---
name: coding-style
description: Use when 编写、生成、修改、编辑、重构或评审任何代码——涵盖实现功能、修 bug、写脚本、写测试、性能优化、代码迁移、重命名、加减参数或函数、修改函数签名、定义类型、处理函数间数据流、JSON/YAML 反序列化、code review / PR review。任何语言中任何会产出或改动代码的任务（write / edit / refactor / review code）都要加载本 skill，即使用户没有提到风格、规范或最佳实践。只读调查不加载：单纯 debug、定位代码、定位问题、读代码理解逻辑等不改代码的任务不需要加载。
---

# 编码风格（shared）

语言无关的原则写在本文件；语言特定的实现细则在对应语言的 skill 里，**写或改哪种语言，就同时加载哪个 skill**：

| 语言 / 场景                                   | Skill                                |
| --------------------------------------------- | ------------------------------------ |
| Python                                        | `coding-style-python`                |
| TypeScript / JavaScript                       | `coding-style-typescript-javascript` |
| Go                                            | `coding-style-golang`                |
| C++                                           | `coding-style-cpp`                   |
| 跨语言 FFI / 镜像结构（pybind11、Python↔C++） | `coding-style-ffi`                   |

每条规则都说明**为什么**这么要求——理解动机后，遇到规则没覆盖的灰色地带才能自己判断。

---

## 1. 函数间数据传递：用显式类型，不用 Any / 裸字典 / 裸元组

### 核心原则

函数边界是代码里最容易发生"形状漂移"的地方。一个函数返回一个无类型的字典，下游函数从里面取几个字段——改的人 A 加了个字段，改的人 B 改了某个键名，调用方默默拿到运行时错误，或者拿到一个语义已经变了的数据。类型检查器帮不上忙，IDE 补全靠猜，读代码的人得跳回定义处才能知道这个字典里到底有什么。

所以：**函数之间传递结构化数据时，用带命名字段的显式类型容器（Go 的 struct、Python 的 dataclass、TS 的 interface……），不要用 `Any`、裸字典、裸元组这类无类型载体。**

**裸字典唯一合适的领域，是无界 key 对应相同的数据类型**——也就是 Go 里用 `map[K, V]` 的场景。如果一个场景我们不会在 Go 里用 `map[K, V]` 来表达（那是一组已知字段、类型各异的记录，Go 会用 struct），在任何语言里也不该用裸字典来表达。

这条规则约束的是"跨越函数边界的数据"。函数内部的局部变量用什么类型，不在这个 skill 的管辖范围内——内部用什么字典做缓存、用什么临时变量都行，只要它不作为参数传出去、不作为返回值传出去。

### 为什么这样做

**类型安全是契约，不是装饰。** 一个 `dict[str, Any]` 风格的参数向调用方承诺了什么？什么都没承诺。一个命名字段的容器参数承诺了"我有这几个字段，类型分别是……"。类型检查器能在编译期（或 CI）就发现你把 `quantity` 当字符串用了。裸字典做不到。

**形状漂移是真实发生的 bug 来源。** 当数据以裸字典形式穿过三四个函数，中间任一环节加键、改键名、改语义，下游都会静默出错。typed 容器让这种改动要么是一次显式、可被 review 的字段增删，要么直接是类型错误。

**可读性。** 读到 `process(trade)` 且类型是裸字典时，你得打开 `process` 的实现才知道参数里有什么、返回什么。读到 `process(trade: Trade): TradeResult` 这样的签名时，签名本身就回答了这个问题。函数签名是代码里最常被扫到的文档，让它说人话。

**重构安全。** 重命名容器字段时，IDE 和类型检查器能定位所有引用；重命名字典里的键时，只能靠 grep + 祈祷。

**与类型检查工具链协同。** 这条原则的价值依赖项目在用类型标注（至少函数签名上有）。如果项目根本不跑类型检查，typed 容器的价值会打折——但可读性和防漂移的收益仍然在。不要为了用 typed 容器而在没类型检查的项目里强行引入；先看项目现状。

### 合理的例外

规则约束的是"函数间结构化数据传递"，不是所有字典 / Any 的使用。下面这些场景不适用：

- **纯字典语义的数据：** 真的在做"键→值"映射且键空间是开放/动态的（缓存、计数器、词频统计）。判断标准就是会不会在 Go 里写成 `map[K, V]`：会，才用字典；不会（一组已知字段、类型还可能不同），就不用。
- **与外部边界交互：** 解析 JSON、读 CSV、对接 HTTP API，拿到无类型的原始数据是正常的。关键是在进入你自己的函数边界之前，把它解析成 typed 容器（见第 3 节），后续函数间传递用 typed 容器。
- **Any 作为类型擦除/渐进式标注：** 在给老代码逐步加类型的过渡期，Any 可以作为占位，但应尽快收敛，不要让它成为函数签名的长期状态。
- **确实无法静态确定类型：** 极少数元编程、动态代理场景。真实业务代码里几乎不会遇到，别拿这个当借口。

判断标准始终是同一条：**这段数据会不会穿过函数边界、会不会被多个函数依赖其形状？** 会 → 用 typed 容器；不会 → 随便。

### 重构现有代码

把已有的裸字典传递代码改成 typed 容器时，按这个顺序做，降低出错面：

1. **先找边界，不急着改内部。** 找出作为函数参数或返回值的裸字典 / 元组 / Any，挑一条数据流（比如"订单从 fetch → enrich → persist"）作为目标，一次改一条流。
2. **定义容器。** 根据现有代码里实际用到的键，定义容器类型。字段类型从现有用法反推，拿不准的先用 Any 占位，不要凭空猜类型。
3. **改返回方先，调用方后。** 先让数据的生产端返回 typed 容器，这样类型检查器能立刻帮你发现漏改的访问点。
4. **跑类型检查 + 测试。** 每改完一条流就跑一次类型检查和相关测试。类型错误会精确指出还没改的访问点，测试会兜底行为正确性。
5. **别顺手重构无关代码。** 只改数据传递相关的部分。看到别的可以改的地方记下来，单独处理。

如果函数层级很深、字典穿透了很多层，优先改最外层的公共函数签名——内部函数可以暂时继续收字典，因为它们不暴露给外部，影响面小，可以后续慢慢收敛。

容器选型（可变性、命名元组、不可变默认值等）和类型检查器的具体配置见对应语言的 skill。

---

## 2. 数据合法性检查外推到系统边界

把数据合法性的检查推到系统的**边界**（外部输入进入内部的那一层），内部代码默认拿到的数据已经是合法的，不再重复校验。并且，尽量让构造出来的对象本身就持有合法数据，而不是先构造一个"可能不合法"的对象、再在别处修正它。

### 为什么这样做

- **检查散落 = 检查遗漏。** 同一个约束在十个函数里各检查一遍，改约束时要改十处，漏一处就是 bug。把检查集中到边界，内部只有一个权威入口，约束变更只改一处。
- **重复检查是噪声。** 内部函数反复检查同一个已经在边界验证过的约束，掩盖了函数的真实职责，读代码的人分不清哪些检查是"可能真的会出错"、哪些是"防御性冗余"。
- **"先构造非法再修正"是不安全的。** 先造出一个语义上不该存在的非法对象、再调用 fix-up 修正：在修正真正运行之前，非法对象是可被任何人拿到的——重构、并发、提前 return 都会让它泄漏出去。这类"先脏后净"的写法破坏了"对象总是合法"这一基本保证。
- **让非法状态不可表达。** 如果类型本身能保证合法（用枚举代替魔法字符串、用字面量联合类型/newtype 限定取值、用专门类型代替裸标量），那么很多检查在构造时就一次性完成，后续代码连写错的机会都没有。这是比"检查"更强的保证。

### 什么是"系统边界"

边界是"外部、不受信任的数据进入你代码"的位置：

- 输入解析层：HTTP 请求体、命令行参数、配置文件、JSON/YAML 反序列化。
- 跨语言/跨进程入口：C++ 经 FFI 回调进 Python、另一个服务发来的消息。
- 外部 API 响应、数据库读出的原始行——它们承诺的 schema 不一定真的成立。
- 读的文件、用户上传的内容。

在这些位置做一次完整的校验（合法性、取值范围、类型、必填、关联约束），通过后产出**合法的、有类型的内部对象**。从这之后，内部函数间的数据传递就不必再怀疑数据形状——第 1 节"函数间传递用 typed 容器"正是为这一步服务的。类型标注 + 边界校验 = 内部免检。

### 怎么做

**边界校验覆盖两个场景，产出方式不同。**

**场景一：把已经构造好的结构化数据在类型上确定下来。** 数据已经存在（外部 JSON、API 响应、反序列化结果），工作是把它的类型确定下来。有成熟校验库时，从原始数据一步解析成 typed 对象，不手写逐字段检查——校验库自动处理缺字段、类型转换、嵌套结构和错误聚合，比手写可靠（手写解析缺字段静默用默认、类型不匹配静默通过、错误信息零散）。具体写法见 ref：Python 用 pydantic，TS 用 zod / typebox。

**场景二：自己拼接结构化数据。** 数据没有现成形态，需要自己从多个来源取值、做转换后拼装成对象。正确姿势是"先逐个构造字段值，最后拼装成对象"：先在边界函数里用局部变量逐个构造/校验每个值，最后一次性拼装成对象——中间任何一步都不出现"可能不合法的对象"。每个值的校验独立成步，错误发生在各自字段的构造点；最后一行只是纯组合，不可能产出非法对象，中间变量还能被类型检查器单独把关。完整示例见 ref。

**能由类型保证的，就不要靠运行时检查。** 类型能表达的约束（取值集合、字段存在性、nullable vs 非 null）尽量交给类型系统——用枚举、字面量联合类型或专用 newtype 代替魔法字符串和裸标量，让非法取值"构造不出来"，在调用点就是类型错误；类型表达不了的（范围、跨字段关联约束、业务规则）集中在边界校验里。两类加在一起，让"构造出来即合法"成为内部代码可以依赖的事实。具体写法见 ref。

**不要"先构造非法再修正"。** 构造时数据可能非法、指望后面调用一个 normalize/fix-up 修正——万一没调用到、提前 return、并发呢？修正要么发生在各字段的构造点（场景二），要么发生在边界校验一步（场景一），不存在"先构造一个脏对象"的中间态。

### 什么时候在内部也检查

不是所有检查都只该在边界做。内部再次检查是合理的，当且仅当：

- **不变量可能被内部逻辑破坏。** 如果某个内部操作有可能把对象弄成不合法状态，那么操作后检查是必要的——但更好的做法是让操作本身就不可能产出非法状态（用"产出新对象"表达状态变化，而不是就地改坏再校验）。真要检查，说明类型没把约束写死，值得考虑能不能用更强的类型。
- **安全/权限关键路径。** 涉及鉴权、资金、破坏性操作的关键校验，即使边界查过，内部在真正执行前再确认一次是合理的纵深防御。这不是"重复校验同义约束"，而是"高代价动作前的最后一道关"。
- **跨信任域的内部边界。** 模块 A 和模块 B 之间如果有"谁的代码都可能改这个对象"的耦合，那它们之间也是一条边界，值得校验。理想情况下应通过类型/封装消除这种耦合，而不是靠校验兜底。

除此之外，内部代码对"已经从边界进来的、有类型的对象"重复校验同一约束，应当视为冗余——把它删掉，或把它上推到边界/类型里。

---

## 3. JSON/YAML 反序列化分两层：原始 schema + 验证后模型

从 JSON/YAML（配置文件、API 响应）反序列化时，不要手写大段 `raw["xxx"]` 逐字段构造目标对象的代码。把过程拆成两层：

1. **原始 schema**：用成熟校验库从原始 dict 解析。这一层只做"形状对齐 + 基础类型校验"，不做业务转换。声明哪些字段看 schema 归谁控制，见下。
2. **验证后模型**：如果加载时还需要转换（合并多个来源、派生字段、补默认值、语义校验），定义一个独立的内部类型，用类型安全的代码从原始 schema 转换成它。

### 为什么分两层

- **不要手写大量逐字段构造。** 逐字段 `raw["a"]`、`raw.get("c", default)` 的解析代码冗长、易错、缺字段时行为靠 `get` 的默认值悄悄变化，而且没有任何 schema 线索。成熟校验库用类型声明形状，自动处理缺字段、类型转换、嵌套结构、错误聚合，改动 schema 时只改类型声明。
- **原始 schema = 我们依赖的外部形状的可追踪记录。** 外部数据（尤其是配置文件、第三方 API）有独立的演进生命周期，它的形状不一定等于你内部想用的形状。把"我们依赖的那部分外部形状"固化成一个类型，而不是散落在各处解析逻辑中，改动时才有唯一可对账的地方。加一个可空可选字段，就能清楚地表达"这个字段是新加的、外部可以没有、不破坏向后兼容"——兼容性意图直接体现在类型上。
- **转换层让外部形状与内部形状解耦。** 外部 schema 因为兼容性要保留历史字段、要允许宽松取值；内部模型为了好用要强类型、要派生字段、要合并多个来源。两层分开，外部 schema 演进不影响内部模型（只要转换函数跟上），内部模型重构不影响外部 schema（只要转换函数跟上）。转换函数是唯一的桥，改起来集中、可 review。
- **用类型安全的代码写转换，不用字典拼接。** 原始 schema 已经是 typed 对象，转换函数是 `RawConfig -> InternalConfig`，全程操作对象字段、有类型标注、能被类型检查器校验。比在 dict 之间拼凑再构造要安全得多。

### 原始 schema 声明哪些字段：看 schema 归谁控制

第 1 层该声明哪些字段，只取决于一件事：**这份数据的 schema 是我们定义的，还是上游控制的。** 配置文件、API 响应、消息、数据库行都只是这两种归属的具体实例——规则跟着归属走，不跟着数据来源的名字走。

判断问句：**这份字段清单要由谁负责、由谁向人解释？**

**我们控制的 schema**——配置文件、我们自己定义并文档化的消息格式、写出去再读回来的持久化数据。这份清单是我们的承诺，所以**声明要完整**：支持的每一项都写出来，写配置的人拿它当功能列表，读代码的人拿它当"外部能保证提供什么"。声明了却不读的字段不要写，那是对外谎称"支持这一项"。

**上游控制的 schema**——第三方 API 响应、别的服务发来的消息、不由我们决定的数据库行。我们只是消费方，所以**只声明代码真正读取的字段**，其余一概不写。真实响应动辄几十个键：签名、内部 ID、上游昨天刚加的字段。全抄下来等于替别人维护一份会腐烂的文档，而且**多声明的字段会变成脆性来源**——上游改了一个我们从不读的字段（换类型、变 nullable、直接删掉），整个对象解析抛错，我们把上游每一次 schema 变动都变成自己的故障面。附带的好处是，这一层成了"我们依赖上游什么"的清单，review 时一眼看清。

两种归属共用的规则：

- **未声明的键靠解析库默认忽略，不为此写任何配置。** 这个默认值对两边都恰好正确：上游一定会加我们从不读的字段；配置文件里的多余键要么键名拼错了，要么属于同一份文件里别的组件的段落，都轮不到 schema 来否决。给配置那一层配"未知键报错"看着能抓拼写错误，代价是把"还没升级的旧代码读带新键的配置"也变成启动失败——灰度、回滚、多个组件共用一份配置都依赖这个方向；而拼错的键本来就会以"某个行为没按预期发生"的形式暴露在运行日志里，比让进程起不来便宜得多。真要做未知键诊断，写成显式的 warning 检查，不要写成 schema 的硬约束。
- **字段少不等于可以省校验。** 声明出来的字段仍按第 2 节"数据合法性检查外推到系统边界"写约束；"可选"仍然表现为有默认值。
- **新增可选字段必须带默认值。** 否则旧配置文件会因为缺这个 key 而解析失败，破坏向后兼容。反过来，想从"可选"改成"必填"也要谨慎——原本可不带的旧数据会突然变非法。

确有特殊需求时才偏离这两种默认做法，且把偏离的理由写进注释：

- **我们控制的配置需要读进来、改几个键再原样写回**（配置文件的读写往返工具）：这时才需要显式持有未知键，不要为了"能存住"就逐个猜字段。更常见的解法是让每个组件只读写自己那一段，不做整文件往返。
- **上游数据要全字段落盘 / 审计**（数据本身就是产品）：直接保留原始 dict 或原始 bytes，别把上游全部字段抄成类型——那还是一份会腐烂的文档，只是多了一层转换。
- **就是要主动探测上游新增字段**：显式配置"未知键报错"，并在注释里写明这是"上游一动我就报错"的有意选择。
- **跨语言镜像结构不属于这两种归属**：那是我们自己两侧维护、两侧都读的契约，必须严格 1:1，写法见 `coding-style-ffi`。

### 校验库当引擎，模型是长期契约

校验的价值集中在"从原始数据到 typed 对象"这一步；对象一旦构造出来，后续传递靠的是语言原生的类型系统和普通容器。选校验库和用法时，优先"校验完产出/对应普通类型"的形态，不要让校验库的模型基类侵入整个数据模型——那是把外部 schema 的包袱带进内部。

各语言的具体取舍见对应语言的 skill：Python 优先 stdlib dataclass + pydantic `TypeAdapter`（不用 `BaseModel`，字段级配置用 `Annotated` 注入），见 `coding-style-python`；TS 里 zod / typebox 的 schema 本身就是类型来源，见 `coding-style-typescript-javascript`。

---

## 4. 公共 API：参数放宽，返回收紧

公共 API（会被其他模块、其他人调用的函数、方法、类）遵循一条不对称原则：**参数用能满足需求的最宽松类型，返回值用最精确的类型。**

- **参数从宽。** 声明参数时只要求"这个函数真正需要的东西"：只需要遍历就收 `Iterable` / `Sequence` 而不是 `list`，只需要两个字段就收只含这两个字段的接口/Protocol 而不是完整实现类型，Go 里收 `io.Reader` 而不是 `*os.File`。调用方手里有什么就该能直接传进来，不为满足你的签名做无谓转换。
- **返回收紧。** 返回值用调用方能获得最多信息的具体类型：返回具体容器、具体 dataclass / struct，而不是宽泛接口、基类或弱类型。调用方要对返回值做最少的检查、得到最多的保证。

### 为什么这样做

- **方向不对称是本质的。** 参数位置上，类型是"我们向调用方提的要求"——要求越少，API 越好用，能传的东西越多；返回位置上，类型是"我们向调用方给的承诺"——承诺越具体，调用方能做的越多、要防的越少。把两者写反（参数收窄、返回放宽）等于"要求多、给得少"，两头都吃亏。
- **返回类型是调用方的依赖，参数类型是调用方的自由。** 返回太宽（接口、`Any`、裸字典），调用方被迫类型断言、字段猜测，实现一变调用方跟着炸；返回具体类型，实现内部怎么变，只要返回的契约还在，调用方无感。参数太窄（收 `list` 其实只需要 `Iterable`），每个调用方都为你的实现细节买单。
- **和第 1 节的关系：放宽 ≠ 放弃类型。** 参数从宽指的是"最小结构要求"（最小接口、宽容器类型、Protocol），不是 `Any` / 裸字典——传进来的数据仍然是有类型的，只是要求的形状最小。返回收紧也正是第 1 节"函数间传递用 typed 容器"在 API 边界上的体现。
- **内部私有函数不受此约束。** 这条原则约束的是有外部调用方的公共 API；模块内部的私有函数跟内部约定走，参数类型贴近实际用法即可，不必为假想的灵活性引入接口。

### 各语言的典型形态

- **Go：** 经典表述 "accept interfaces, return structs"——参数收小接口，返回具体 struct。
- **Python：** 参数收 `Iterable[T]` / `Sequence[T]` / `Protocol`，返回具体 dataclass、具体容器；返回 `list[T]` 而不是 `Iterable[T]`（除非真是惰性序列）。
- **TypeScript：** 参数收最小 interface / 宽联合类型，返回具体类型，不返回 `object` / 宽泛 Record。

语言侧细则在对应语言的 skill 里补充。
