type
status
date
slug
summary
tags
category
icon
password
我认为编程中只有两股内在驱动力:
- 尽量减少冗余——理想情况下,每一份知识只定义一次。
- 尽量减少依赖——只有在万不得已时,A 才应依赖 B。
除此之外,其余考量都属于外在现实世界层面,比如领域建模、可用性、进度、平台等。我还认为,大多数所谓的“良好”编程实践,本质上都是在削减冗余、依赖,或两者兼而有之。甚至可以说,一名程序员的好坏,往往可以从 TA 对冗余与依赖的态度上看出来:优秀的程序员痛恨它们,平庸的程序员则无所谓。
如果你觉得这种观点“弱智到过分简化”,请注意,我这里所说的“编程能力”是狭义的——仅指代码质量。我见过一些算法能力惊人、合作精神一流的人,却依旧写出糟糕透顶的代码。我试着分析原因,发现他们的共同点似乎是:对冗余和依赖毫不在意,甚至有点“乐在其中”。也许这听起来仍旧过分简化;就先这样吧——毕竟这并不是我今天讨论的重点。
我要谈的是:当“减少冗余”与“减少依赖”发生冲突时该怎么办。这基本上就是“跨模块复用”的问题:你可以让模块 A、B 都调用一个模块 C 来完成某件事;也可以让它们各自实现一份。你会怎么选?
这个问题有个明显而愚蠢的地方:它围绕着“模块”这个含糊、不正式的概念展开。然而,正因为有“模块”这个维度,才会形成取舍——在模块内部,当然应该复用代码,无可争议。举个例子,同一个模块里要解析两个命令行参数,没人会傻到复制两段一模一样的代码而不是写一个函数吧?
可一旦跨到模块边界:如果两个模块都需要解析命令行,你仍可以把解析逻辑提取出来——但那就得另建一个第三方模块;或者干脆塞进一个“工具库”模块里。就是那个大家昵称为“垃圾桶”的工具库:
- 链接时少不了一堆外部依赖,只因为里面某些“便利函数”需要那些库;
- 配置总是被配错;
- 初始化永远不在正确的时机执行。
没错,你懂的——就是那个工具库。
我觉得,论知识增长,工作年限几乎算不了什么。工作中的学习速度,远远赶不上全职学生的节奏。经验主要起两种作用:要么塑造性格,要么磨灭性格。举个例子:年轻而热情的程序员通常很乐意去写那个“第三方模块”,也不会因为要钻进“工具垃圾桶”而皱眉。但当经验更丰富的同事察觉到他们最新的 “基础设施” 行为、忍不住翻找呕吐袋时,这种场景就揭示了经验既能锤炼,也能腐蚀人的一面——到底是哪一面,我也说不清。
说真的——就拿命令行解析来说吧。你肯定想要统一的选项语法,对吧?
并且有些选项需要接收参数,是吧?
这些参数可以是字符串、布尔值、整数,对吗?
整数可以是十进制也可以是十六进制,对吧?
还可以是用户自定义类型?
每个选项最好都有帮助说明?
还能从这些说明自动生成帮助信息?
甚至生成带属性页的图形界面?
还能从配置文件读取?
还要检查标志或标志组合是否合法,对吧?
当然可以。这都不难,甚至算“微不足道”。(要是你自认聪明,什么都觉得不难——直到因为复杂度失控而彻底失败;并且要承认是复杂度过高导致的失败。前者迟早会发生,后者往往永远不会。)不少人曾把青春中最美好的几个月奉献给了“参数处理”这个问题。比如 XParam,自称“参数处理的终极解决方案”。我上次看,代码量已经超过 1 万行,还自带序列化框架。据说它最初服务的那个项目,只用到了不到 5% 的功能。
澄清:我并不是在嘲笑 XParam 的作者。
原因一:据说他们本身就相当厉害。
原因二:令人羞愧的是,我自己曾写过一个名叫 XLog 的日志库。上次统计时,它也超过 1 万行代码,并且附带了自家的序列化框架。亲身经历告诉我,它所在的项目对这些功能的实际使用率为 0%。痛!
你想知道我现在在各个模块里如何解析命令行参数吗?就像这样:
我特意用 C 举例,因为它处理字符串最别扭——可就算这样,这段代码仍是小菜一碟。
没错,我得不到自动生成的帮助信息;也没有严格的命令行校验。那又怎样?这些只是调试选项,足够用了。这可比让所有东西都依赖一个 1 万行的解析库——或者一个装满“有毒废料”的 5 万行“工具垃圾桶”——要强得多。
模块为何重要?模块边界为何重要?
一个真正的模块应当具备以下特征:
- 简洁且稳定的接口
- 面向对象训练的副作用:人们往往不再追求接口简洁,觉得暴露几十个类、复杂数据结构、仓促设计的扩展钩子也无妨。
- C++ 又带来另一个副作用:“稳定接口”似乎成了自相矛盾。
- 但无论如何,没人能阻止你实现一个既简洁又稳定的接口。
- 文档
- 必须在某处清晰阐释这些简洁稳定接口的语义。
- 优秀的模块还会附带示例代码。
- 如果所谓的“内部模块”缺乏较完整的文档,那它基本就算糟糕——虽然有时难以避免。
- 测试
- 我不认为“给每个类或函数都写单元测试”是必需的。
- 需要测试的是官方模块接口:它们若正常工作,就说明整个模块能正常工作;反之亦然。
- 合理的规模
- 模块通常应控制在 1 K–30 K 行 C++ 代码(若是 4GL 语言,可把数字再除以 4)。
- 更大的模块往往沦为“泥球”。
- 反过来,系统若由海量微小模块拼成,本身也会是一堆泥。
- 所有者
- 想改动模块?说服所有者去改。
- 想修 bug?向所有者报告。
- 如此一来,你就能确定接口背后有人维护一致的心智模型。
- 实验证明,能同时维护这种模型的人数上限是 1。
- 生命周期
- 除紧急修复外,对模块的更改应打包进版本,且发布不宜过于频繁。
- 否则,接口无法做到既经过充分测试,又保持稳定。
够分量吧?
我真想专门搞个“命令行解析模块”吗?我真的打算荣膺这项“尖端技术”的光荣所有者吗?——暂时免了,谢谢。说实话,这主要是我“被折腾坏了”在说话;要不然,这活儿其实既有趣又简单。幸好周围并非人人都像我这么慵懒爱抱怨。瞧,那边那位已经写了一个命令行解析模块,这里这位也整了一个。问题来了:我要不要让自己的代码依赖他们的玩意儿?
难题。先前我因为拒绝“好好”处理命令行解析,名声已经受损;如果下一步还拒绝采用“现有解决方案”,那可真坐实了我“反社会”的个性——团队协作精神何在?但我还是犹豫:那东西真的算一个模块吗?
首先,也是最烦人的问题——谁来负责?你以为小菜一碟,随手就黑了出来,可接下来你愿意维护吗?还是说打算甩给谁?大概你根本没意识到它也需要维护,毕竟“这么简单”。呵呵,你迟早会意识到的——它的确得维护。
我自认有点“卡珊德拉”属性(未卜先知):你什么时候恍然大悟?就在别人对你的代码做出第一个完全蠢爆的改动时。很可能还是把它勾到另一块“基础设施”上,结果形成剪不断理还乱的依赖乱麻。注意到没?热衷搞基础设施的人总觉得自己的模块位居依赖食物链顶端,结果往往引发循环依赖。到那时,你就懂我的担忧了。当然,对我来说已经太晚——我的代码被你的、他的、大家的黏糊“基础设施”缠了个结实。糟糕。
(天真的读者也许会问:命令行解析还能依赖啥?嗬,可多了:序列化包、解析包、彩色终端 I/O 包——用来打印帮助;C++ 还得有个平台相关包在
main()
之前获取 argc/argv
;再来个单例初始化管理包……你问“那是什么鬼”?准备好呕吐袋,去翻翻《Modern C++ Design》吧。)所以——没有所有者的东西,我可不想依赖。我知道这种说辞听着惹人恼火:把焦点从软件本身转移到“人”身上,活像那些技术无能、口口声声“业务优先”的中层经理耍的把戏。那我就澄清一下我的区别:我不仅要求“有人负责”,而且这个人得乐在其中。跟常见的管理假设(虽然不靠谱,但能让管理者心安)相反,我不相信“强行分派”责任:要是所有者本身都不喜欢这模块,那将来的“园丁活”可想而知——保不定荒草丛生,惨不忍睹。
为了把我在此留下的糟糕印象“发扬光大”,让我再用一个并不完美的现实类比——生物进化。
想想活生生的有机体:它们面临严重的“巴别塔”难题——冗余无处不在。听说人类和章鱼的眼睛结构极其相似,可双方都源自一个失明的祖先。别说命令行解析器了——连一整只眼睛(前端硬件算不上简单,后端还有相当复杂的神经系统)都能在各自的进化分支里独立造出来。冗余还能更夸张吗?——然而,它就是行得通,而且显然比“协调所有物种联手进化”来得有效。
冗余确实糟糕:意味着重复投入,甚至导致互操作问题。但依赖更糟。唯一值得依赖的东西,是那种“货真价实的模块”——具有:
- 明确边界和责任归属的所有者;
- 让所有使用者都满意、稳定且简洁的接口。
通常你可以根据问题本身,判断它的解决方案有没有希望演变成“真正的模块”。要是这种可能性不高,就选冗余——而且最好保守一点评估。冗余固然不好,但依赖会让你寸步难行。
我的结论:先斩依赖,再谈冗余。
依赖与冗余:模块化决策实务
一句话摘要:冗余是局部成本,可用人力买单;依赖是系统锁链,可能让全局停摆。遇到两者冲突时,先杀依赖,再控冗余。
目录
- 原则总览
- 何时“宁冗余,勿依赖”
- 模块健康检查表
- Adopt‑or‑Copy 决策树
- 安全复制 / 分叉流程
- 减少冗余副作用的小技巧
- 可逆路径:从复制到共享
- 团队沟通模板
- 结语
1. 原则总览
力量 | 典型失败模式 | 长期代价 |
冗余 | 代码或数据多份拷贝 ➜ 语义分叉 | 重复修 bug、认知混乱 |
依赖 | 模块深耦合 ➜ 变更涟漪 | 全局停摆、版本地狱 |
优先级:在不确定对方是否能给出 可验证 承诺时,宁愿复制 100 行,也不要引 10 行但带 50 个 transitives。
示例:
- 命令行解析:复制 20 行
strcmp
足够;引入 10K LOC CLI 框架却拖进 Boost、fmt、彩色终端依赖。
- 微服务调用:直接 HTTP 调用冗余一点序列化字段;强行共享 proto 定义却导致所有服务同步升级。
2. 何时“宁冗余,勿依赖”
维度 | 红灯信号 | 行动 |
接口稳定 | 需求仍快速变动;三个月内两次 breaking | 复制核心逻辑,待稳定后再抽象 |
维护人意愿 | "谁改谁维护"、贡献者流失 | 保持本地实现,避免绑定 |
发布节奏 | 无 LTS;主干即生产 | 用 shim 隔离,或局部重写 |
依赖扇出 | 引入后再拉 ≥5 个包 | 拆分子进程、复制必需片段 |
回滚成本 | 回滚需链式降级 / 数据迁移 | 优先保持独立实现 |
案例 1:CLI 框架
v1
没有浮点支持,v2
加浮点却废弃旧语法;你的自动化脚本依赖旧行为。若框架无 LTS,复制那 100 行解析更稳。案例 2:日志库
团队 A 想接异步队列,团队 B 只要同步文件输出。共用大而全日志库会逼 B 跟随 A 的复杂度;拆成两份各自维护更省心。
3. 模块健康检查表
项目 | 健康指标 | 说明 & 反例 |
维护人 | ≥1 名自愿 Owner,另有 Backup | 反例:"全组维护" ➜ 没人维护 |
Bus Factor | ≥2 人能合 PR | 单点专家请假即停摆 |
接口体积 | 公共 API ≤20 个符号 | CLI 框架暴露 80+ 模板即红灯 |
发布节奏 | SemVer;重大改动提前两版弃用 | 随心所欲 push main = 红灯 |
依赖层次 | 核心零 mandatory deps | 把颜色输出、序列化拆插件 |
测试覆盖 | 黑箱契约用例 ≥90% 场景 | 只有私有函数单测不算 |
健康示例:开源库
clap
(Rust)核心单文件,插件化颜色、补全;多维护者 + 语义版本。4. Adopt‑or‑Copy 决策树
例子:
- 仅单元测试工具 & 集成测试工具用到 CLI →
E
复制。
- 三个微服务都需同规则且半年不变 →
G
共享模块。
5. 安全复制 / 分叉流程
- Fork 记录
- 保持接口 1:1
方便未来脚本一键替换。
- 集中管理
放
third_party_forks/cli_simple/
,并脚本比较 upstream 差异。- 最小裁剪
5% 代码即可满足需求,删除宏魔法、稀有特性。
6. 减少冗余副作用的小技巧
技巧 | 说明 | 简例 |
代码生成 | 模板 ➜ N 份代码 | go generate , erb , cookiecutter |
统一命名 | 搜索替换方便 | modX_cli_parse_* |
静态链接单文件 | 下游无需再拉依赖 | stb 系列头文件库 |
模块内 DRY, 跨模块 Copy | 保持局部干净,全局松耦合 | 每个微服务内抽工具包, 服务间复制 |
7. 可逆路径:复制 → 共享
- 提炼公共子集
统计 3 份 fork 差异,找 80% 功能交集。
- 引入 Shim 层
内部可切换
LOCAL
或 SHARED
实现。- 双轨期
新代码默认
SHARED
,旧代码编译警告;两版后统一。8. 团队沟通模板
风险声明“若 CLI 库下月破坏接口,我们全组需同步发版,风险高。”替代方案“复制核心 200 行,可 1 人日完成,且不牵涉其他团队。”可逆承诺“CLI 稳定 6 个月后,我负责 2 周内切换回官方实现。”
用 条件 + 风险 而非情绪讨论,让决策客观。
9. 结语
- 冗余与依赖不可避免,但影响半径不同:冗余=局部、人力可解;依赖=全局、系统级锁定。
- 判断标准:对方能否给出 可验证 的长期保障?若否,先复制。
- 有纪律的复制、清晰的可逆路径,能让团队在不确定性中保持机动。