同一份代码里,"用户"有时叫 user、有时叫 member、有时叫 account;"状态"有时叫 status、有时叫 state、有时叫 phase。每个名字在当时都合理,因为它们各自出现在不同的模块里——而当它们开始互相调用时,读者必须在三套词汇之间做翻译。
命名漂移很少是"取名字不认真",而是缺少一个"词汇表"作为单一来源。更棘手的是:漂移是渐进的,没有人做过"错误的决定"。每一步都合理,合起来才不可读——所以它很难在评审里被拦,因为没有一处可以指责。
而这个词表在项目初期往往不存在,等到需要它时,已经有三套名字了。
1. 漂移的四种成因
| 成因 | 表现 |
|---|---|
| 多人独立命名 | 同概念不同词 |
| 翻译差异 | member/account/user |
| 领域演变 | 旧名沿用,语义已变 |
| 外部系统影响 | 数据库用词与代码不一致 |
第三项最隐蔽:一个字段叫 type 而它的含义已经从"类型"变成"权限级别",但名字没改——因为改名要动的地方很多。
第四项在跨系统时常见:数据库列名 user_flag、接口字段 status、代码字段 state,三处指的是同一件事,而新人必须查三次才知道。第一项(多人独立命名)在团队扩张时集中爆发:五个新人各写一个模块,五套词汇就出现了,而他们各自都觉得自己用的是行业惯例。
2. 漂移的代价在哪
| 代价 | 说明 |
|---|---|
| 阅读成本 | 每处都要翻译 |
| 搜索失效 | 搜 user 找不到 member |
| 重复实现 | 以为没有,另写一份 |
| 变更遗漏 | 改一处忘另一处 |
第四项(变更遗漏)有个具体形态:加了一个状态值,只改了 A 模块的判断,B 模块的 switch 落到默认分支——而默认分支通常是"当作正常处理",于是错误被静默吞掉。
第二项是最实在的:改名之前,"我要改所有用户相关逻辑"这件事根本无法用一次搜索完成。于是改动靠记忆,而记忆会漏。
第三项更贵:因为搜不到,所以以为不存在,于是同一段逻辑被实现两次,行为还不一致。
3. 词汇表的建立方式
| 做法 | 成本 |
|---|---|
| 一张 Markdown 表 | 极低 |
| 代码里的常量/枚举 | 低 |
| 类型定义(单一模型) | 中 |
| 领域模型统一(DDD) | 高 |
第一项最实用:一页纸,三列(概念、代码中的名字、禁止使用的同义词)。词汇表的维护成本要压到最低:放在仓库里、随代码评审更新、不追求完整。追求"一次写全"的词汇表往往写不完,而"只登记已经出现过分歧的概念"的词汇表可以持续长大。
它不需要工具支持,就能让评审时有依据。
第三项更进一步:让概念在类型系统里只出现一次,于是拼写错误与"另起一个名字"在编译期就会被发现。
// 词汇表(节选)
| 概念 | 代码名 | 禁用同义词 | 说明 |
|---|---|---|---|
| 平台用户 | user | member, account | 有登录能力的主体 |
| 组织成员 | member | user(在组织上下文) | 属于某组织,无独立登录 |
| 生命周期状态 | status | state, phase | 枚举:ACTIVE/FROZEN/CLOSED |
| 权限级别 | role | type, level | ADMIN/STAFF/GUEST |
4. 命名的三条硬规则
| 规则 | 目的 |
|---|---|
| 同一概念只有一个词 | 可搜索 |
| 一词只表示一个概念 | 无歧义 |
| 缩写只在词汇表里登记 | 可读 |
第一项(一词一义)在重构时最容易破:把一个类改名成更通用的名字,却没有检查它的语义是否也跟着变宽了。通用名一旦被复用,第二次复用就会带来歧义。
第二项比第一项更容易被违反:status 在 A 模块指"订单状态"、在 B 模块指"用户状态",两个都对,但混用时必须靠上下文判断。
第三项的失控很常见:cfg、conf、config、setting 四种缩写同时存在,而新人无法判断它们是否有区别。
// 反例:同一概念多种拼写
class UserCfg { } // 配置用 Cfg
class AccountConfig { } // 同类东西用 Config
class MemberSetting { } // 又用 Setting
// 正例:一个后缀,一种含义
class UserConfig { }
class AccountConfig { }
class MemberConfig { } // 至少读者知道"这三者同层"
词汇表还有一个"自动校验"的用法:把概念名做成常量或枚举,让"另起名字"在编译期失败。比如统一用一个 Status 枚举而不是各模块自己的字符串常量——于是"字符串写错"这类问题从运行时错误变成编译错误。
// 反例:各模块自己写字符串
if (order.getStatus().equals("ACTIVE")) { } // 拼错不报错
// 正例:单一枚举,拼错即编译失败
if (order.getStatus() == OrderStatus.ACTIVE) { }
5. 改名的正确做法
| 步骤 | 动作 |
|---|---|
| 1 建词汇表 | 确定目标名 |
| 2 加别名(过渡) | 旧名保留,委托新名 |
| 3 逐模块替换 | 每批可验证 |
| 4 删别名 | 确认无引用 |
第二项是关键:一次性全局改名风险高,而"加一层别名"能让新旧共存,逐步迁移。改名还有一类"看不见的引用"必须专门搜:字符串里的类名(反射、序列化)、配置键、SQL 里的列名、接口字段名、日志与指标名。前四项错了功能失效,第五项错了会"看起来正常但监控断掉"。
第四项不能省:别名保留太久会变成事实标准,于是"两种名字都对"的知识留在团队里,而不是代码里。
// 改名的过渡期(三周)
第 1 周:词汇表 + 新增 UserService.getUser(旧 AccountService.getAccount 委托)
第 2 周:调用方切到新名(按模块分批,每批一次提交)
第 3 周:删旧方法(先搜字符串引用,再删)
// 每批都可回滚;别名存在时间短,不固化
漂移的"检测"可以做成一次扫描:把同一个概念的所有候选词列出来,统计每个词出现的模块数。若同一概念分散在三个以上模块且用词不同,那它就是漂移最严重的地方,也是收益最大的治理点。
// 粗略统计候选词出现在多少个文件里(以"用户"为例)
for w in user member account customer; do
printf '%-10s %s files
' "$w" "$(grep -rli "$w" src/ | wc -l)"
done
// 四个词都有几十个文件 → 该建词汇表;只用一个 → 无需处理
6. 排错:五种"命名导致的困惑"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 搜不到已有的实现 | 同概念不同词 | 建词汇表 |
| 改一处漏另一处 | 无单一来源 | 统一名字再改 |
| 新人反复问同一问题 | 词汇不一致 | 词汇表进文档 |
| 同名字段语义不同 | 一词多义 | 加限定词(order_status) |
| 缩写看不懂 | 未登记 | 缩写入表 |
第三项(新人反复问)是词汇表最直接的收益指标:同一个问题被问三次以上,说明这个词该进表了。所以词汇表应当由"提问记录"驱动,而不是由架构师一次设计。
第四项的正解是"加限定词"而不是"换一个更花的名词":order_status 与 user_status 比 status 与 stage 清楚得多。第五项(缩写)有个实用上限:团队里超过三个人看不懂的缩写,就该写全。缩写省的是打字时间,花的是所有人的阅读时间。
7. 边界:什么情况不必强求统一
| 场景 | 为什么 | 做法 |
|---|---|---|
| 与外部系统对接的 DTO | 对方命名固定 | 只做映射,不改名 |
| 数据库列名 | 改名成本高 | 用映射层隔离 |
| 第三方库类型 | 无法改 | 适配层 |
| 一次性脚本 | 生命周期短 | 随意 |
最后一条与"AI 辅助编码"有关的边界:助手会用"它见过的常见命名"补全,而这些名字来自成千上万个项目,不一定与本项目的词汇表一致。所以词汇表还有一个新用途——贴进提示里,作为命名约束。
判断口径一句话:同一个概念会不会被两个以上的模块用到。会,就必须统一;只在一个模块内,命名只是局部风格。
8. 自测
- 命名漂移的四种成因里,哪一种最隐蔽?为什么"旧名沿用、语义已变"比"多人独立命名"更难发现?
- "搜索失效"为什么是最实在的代价?它如何导致重复实现?
- 词汇表三列分别是什么?为什么"禁用同义词"这一列比"正确名字"更有用?
- 三条硬规则里,哪一条更容易被违反?
status在多个模块里分别表示不同含义该怎么修? - 改名的四个步骤里,为什么"加别名"是关键?别名保留太久会发生什么?
- 判断"是否需要统一命名"的那个问题是什么?