当写代码、改代码和重构代码的成本开始下降,过去为了减少重复、避免修改、提前抽象而形成的软件设计习惯,是否还应该保持同样的权重?从 DRY、代码复用、OCP 和 YAGNI,重新审视 AI Coding Agent 时代的软件设计成本。
这个系列文章想讨论什么问题
过去一两年,AI Coding Agent 的进化速度非常快。我的使用方式,也从最早“让 AI 帮我写一个方法、补一段代码”,逐渐发展绝大部分编码工作都直接交给 Agent 完成。这个过程中,我开始遇到一个以前没有认真思考过的问题:一些过去按照传统软件工程思想精心设计出来的架构、抽象和扩展机制,在和 Coding Agent 协作时,反而会变成额外的理解成本、修改成本,甚至是人与 Agent 之间的沟通成本。
经历过几次类似的情况之后,我开始重新审视一些过去几乎不会质疑的经验判断:我们为什么要遵循这些传统的软件工程原则?它们当初究竟是在解决什么问题?这些问题到了 AI Coding 时代,仍然同样昂贵吗?如果软件开发底层的成本结构正在发生变化,那么过去被我们视为“最佳实践”的设计方式,是不是也应该重新计算一次它们的收益和代价?
和一些不同领域的朋友讨论之后,我越来越觉得,这并不是某一条原则究竟“对不对”的问题,而是一个值得系统讨论的话题。所以我想通过一系列文章,把这些过去习以为常的软件设计思想重新拿出来审视一遍。
当然,这个系列里会有不少来自个人实践的主观判断。这个系列也理应顺应同行的讨论以及技术的发展去更新,是随着对事物理解和技术发展一起迭代更新的鲜活的有生命的文字。我更想把这些问题抛出来,和不同项目、不同技术栈、不同 AI Coding 实践经验的开发者一起讨论:
当越来越多的代码开始由 AI 生产,我们究竟应该怎样设计和组织软件,才能用更低的开发成本、更少的不必要复杂度,把真正可用、可维护的软件做出来?
目录
- 一、Slack 的一次“去复用”实践
- 二、DRY 真正要消灭的,也许不是重复代码
- 三、代码复用从来都不是免费的
- 四、Coding Agent 改变的,是这笔账里最重要的一个变量
- 五、OCP 与 YAGNI:还要不要那么早为未来设计?
- 六、从 Design for Reuse,到 Design for Change
- 总结
本文想讨论什么
作为一个“训练有素”的软件工程师,在 AI Coding 时代到来之前,我也逐渐形成了一整套关于“好代码”的判断标准:不要重复自己,尽量复用;稳定代码尽量少改;为未来可能出现的变化提前留出扩展空间。
DRY(Don't Repeat Yourself,不要重复自己)、OCP(Open/Closed Principle,开闭原则)、YAGNI(You Aren't Gonna Need It,不要为尚未出现的需求提前设计),以及围绕代码复用形成的一系列设计思想,都深刻影响了我过去对软件设计的判断。
但现在回过头来看,这些原则并不是凭空产生的。它们背后其实都对应着一个非常现实的成本结构:
写代码很贵,修改代码更贵,而大规模重构尤其贵。
正因为如此,我们才愿意通过复用减少重复实现,通过抽象降低未来的修改范围,通过 OCP 尽量避免反复进入稳定代码,也会提前为未来可能出现的变化设计扩展点。很多我们今天认为理所当然的“好设计”,其实都在以不同方式降低未来修改软件的成本。
而 AI Coding Agent 正在改变的,恰恰是这个成本结构。当搜索代码、批量修改、接口迁移、补充测试乃至大规模重构都开始变得越来越便宜时,我们有必要重新审视一下:过去为了减少重复、避免修改和提前适应未来而付出的复杂度,今天是否仍然值得?
所以这篇文章并不是要否定 DRY、代码复用或者 OCP,而是想讨论一个更基础的问题:
当代码生产和代码修改的成本发生变化以后,我们是否应该从“Design for Reuse”逐渐转向“Design for Change”——不再把“少写代码、尽量复用”本身当成目标,而是更加关注一个系统能否以更低的成本应对未来真实发生的变化?
一、Slack 的一次“去复用”实践
2017 年,Slack 做了一个非常符合传统软件工程直觉的决定。
当时 Slack 的 iOS、Android、桌面等客户端分别维护自己的代码。不同平台经常需要重复实现相同或相似的业务逻辑,同一个 bug 可能要在几个客户端里分别修复,甚至连一些看似基础的行为,在不同客户端之间都可能出现差异。
于是 Slack 开始开发一套跨平台的 C++ 库 LibSlack,希望把同步、缓存以及部分公共业务逻辑集中在同一个地方。
从传统软件工程的角度看,这几乎是一个标准答案。
既然几套客户端做的是同一件事情,为什么要写三遍、四遍?如果能够把公共逻辑放在同一个地方,既可以减少重复代码,也可以保证不同客户端行为一致。以后出现 bug,只需要修改一份代码,看起来无论从 DRY、代码复用还是维护成本上,都更加合理。
但几年之后,Slack 逐渐停止了 LibSlack 的开发。
原因也很现实。桌面客户端因为缓存策略、构建系统等原因,并没有真正采用这套共享库;Windows Phone 又逐渐退出历史舞台,真正共享 LibSlack 的主要只剩下 iOS 和 Android。更重要的是,不同平台之间的差异开始越来越明显。它们拥有不同的生命周期、工具链、性能要求和平台能力,一份统一实现并不天然意味着更简单。
Slack 最终选择重新让不同客户端拥有各自的实现,再通过一致性测试去保证关键行为不会偏离。
这个案例真正有意思的地方,不在于 Slack 最后证明了“复用不好”,而是它把两个过去经常被绑定在一起的问题拆开了:
行为保持一致,并不意味着代码必须共享同一份实现。
这其实是一个很重要且容易混淆的问题。
我们过去经常默认:如果几个地方需要保持一致,那么最可靠的方式就是让它们使用同一份代码。但 LibSlack 的例子说明,“行为的一致性”和“实现的一致性”并不是一回事。共享代码是一种保证一致性的手段,但不是唯一手段,更不是天然成本最低的手段。
而在 AI Coding Agent 开始参与大量代码修改之后,这个区别可能会变得越来越重要。
二、DRY 真正要消灭的,也许不是重复代码
DRY 的全称是 Don't Repeat Yourself,通常翻译成“不要重复自己”。但在实际开发中,它经常被进一步简化成一个更加直观的判断:不要写重复代码。
所以,当两个地方出现十几行相似实现时,一个训练有素的软件工程师很容易产生条件反射:这段代码是不是应该抽出来?
但仔细想一下,真正危险的重复,很多时候并不是“代码长得一样”,而是同一份知识出现了多个版本。
假设系统里存在一条业务规则:订单支付超过 30 天以后,不能直接退款。
现在这个“30 天”分别存在于退款服务、前端页面、客服后台和某个定时任务里。某一天业务规则从 30 天修改成了 45 天,开发者修改了其中三个地方,却漏掉了第四个。
这时候真正的问题并不是“我们多写了四份判断逻辑”,而是同一个系统内部出现了两个互相矛盾的业务事实。
这是一种知识上的重复。它危险的地方在于,同一个事实失去了唯一、权威的来源。
但另外一种情况并不完全一样。
例如退款流程和售后换货流程,现在碰巧有十几行代码非常相似。它们都需要检查订单状态、读取用户信息、记录操作日志,于是从代码层面看,两个流程存在明显的重复。
可它们今天相似,并不意味着它们表达的是同一个业务概念,也不意味着半年之后,它们还会沿着同一个方向变化。
这种重复更多只是实现上的重复。
过去,我们很容易把这两种情况放在一起:只要代码看起来相似,就认为应该抽象、应该复用。但“代码长得一样”和“它们本质上是同一件事情”,其实是两个完全不同的判断。
Sandi Metz 曾经总结过一种很常见的演化路径:两个地方出现重复,于是开发者提取了一个公共抽象;后来新的需求和原来的抽象“差一点”,于是增加一个参数;再来一个场景,又多一个条件;随着调用方越来越多,原来那个为了简化系统而存在的抽象,逐渐充满特殊情况。
最后,重复代码确实消失了,但系统得到的是一个越来越难理解、越来越不敢修改的公共模块。
这也是为什么“错误的抽象往往比重复更加昂贵”这个观点一直很有生命力。
回过头来看 Slack 的 LibSlack,也可以从这个角度理解。不同客户端的确需要共享某些业务规则,也确实需要保持行为一致,但这并不意味着它们必须共享所有实现细节。
Slack 最终仍然需要一个 Single Source of Truth——一个关于“什么行为才是正确的”的统一定义,但它不再坚持 Single Implementation。
换句话说:
知识上的重复,并不总等于统一实现。
有些东西必须只有一个真相,但并不一定只能有一份实现代码。
三、代码复用从来都不是免费的
“写一次,用十次”,大概是代码复用最有吸引力的描述。
它让复用看起来像一笔稳赚不赔的交易:代码更少,bug 只需要修一次,新功能也不需要到处重复实现。相比于几份相似代码散落在不同地方,一份公共实现似乎天然更容易维护。
但这里容易忽略一件事情:复用本身也在建立关系。
假设两个业务模块原本各自拥有一段 50 行的逻辑。我们把它们抽成一个公共模块之后,确实消灭了几十行重复代码,但两个原本相互独立的业务模块,也从此开始依赖同一个接口、同一套行为和同一个变化节奏。
A 模块出现一个特殊需求,公共模块可能因此需要增加一个参数;公共模块一旦修改,B 模块又必须重新确认自己有没有受到影响。如果第三个、第四个调用者继续加入,这个公共模块就需要同时照顾越来越多不同的需求。
所以:Reuse is also a form of coupling——复用本身也是一种耦合。
当然,这并不意味着耦合一定不好。
如果十个地方表达的是同一条税务规则、同一种权限模型或者同一个通信协议,那么它们本来就应该一起变化。此时通过同一份实现建立耦合,反而可以有效避免知识分叉。
真正麻烦的是另一种情况:我们仅仅因为两段代码在今天看起来相似,就推断它们未来也应该一起变化。
很多项目里的“万能组件”就是这样慢慢出现的。一开始两个调用方很相似,所以抽成公共模块;第三个调用方有一点差异,于是增加一个参数;第四个场景希望跳过某一步,于是增加一个 boolean flag;第五个场景又需要在中间插入一段逻辑,于是再增加 callback 或 hook。
几年之后,重复代码确实没有了,但留下的是一个所有人都需要理解、所有修改都可能影响很多调用方、最终谁也不愿意轻易碰的公共组件。
所以衡量复用是否真正有价值,重要的不是“减少了多少代码”,而应该是:
它有没有降低未来变化的总成本?
如果十个调用方本来就会因为同一个业务规则而一起变化,那么共享实现可以降低变化成本。
但如果几个业务场景只是当前看起来相似,未来却拥有不同的变化原因,那么强行复用,可能只是用更少的代码换来了更大的修改半径。
从这个角度看,Slack 后来采取的一致性测试方案就很有意思。它没有放弃“一致”,而是把一致性的来源,从“必须共享一份实现”逐渐转移到了“共享关于正确行为的定义”。
不同客户端可以拥有不同代码,但只要在相同条件下满足同一套行为要求,它们依然可以保持一致。
也就是说,真正需要被共享的,很多时候不是每一行代码的重复实现,而是关于正确行为的知识或者行为本身。
四、Coding Agent 改变的,是这笔账里最重要的一个变量
如果实现重复并不总是坏事,为什么过去的软件工程仍然如此强调复用?
一个非常现实的原因是:人维护重复代码真的很贵。
假设同一个变化涉及二十个地方。一个开发者首先需要找到这二十个位置,然后判断哪些真正受到影响,再逐一修改,检查有没有遗漏,更新相关测试,最后确认这些修改没有引入新的问题。
这里面相当一部分成本,并不是来自真正困难的业务判断,而是来自搜索、定位、机械修改和验证。
过去我们之所以愿意建立大量 abstraction,一个很重要的目的就是提前消除这些未来成本:今天多付出一点设计和理解上的复杂度,以后变化时只需要改一个地方。
在人工编码时代,这往往是一笔很划算的交易。
但 Coding Agent 正在改变的,恰恰是这个变量。
现在的 Agent 已经可以做跨文件搜索、分析调用关系、批量修改接口、完成 API migration、执行大规模重构,再根据编译和测试反馈继续修正。过去一个开发者可能需要半天甚至几天去完成的机械性修改,现在越来越可能变成一次 Agent 任务。
这当然不意味着重复代码已经没有成本,也绝不意味着以后可以随意复制粘贴。
Agent 一样可能遗漏,一样可能误解业务语义。在复杂系统里,如果缺少清晰的约束和测试,Agent 也可能非常高效地产生错误。
但真正值得注意的是:
修改代码的边际成本正在下降。
过去,五份简单实现意味着未来大概率需要人工修改五次。为了避免这五次修改,我们愿意提前创造一个公共 abstraction。
但如果未来 Agent 可以一次找到这五份实现,理解它们局部的差异,分别完成修改,再通过测试进行验证,那么“保留五份简单、局部、容易理解的实现”和“维护一份高度通用、但需要理解大量上下文的公共抽象”之间,哪一个长期成本更低,就不再像过去那么显而易见。
这也是我目前觉得最值得重新计算的一笔账。
实现重复,可能正在从一种几乎必须被消灭的结构性风险,重新变成一种可以权衡的工程成本。
而与此同时,错误 abstraction 的成本并没有同步下降。
一个错误的抽象,仍然会隐藏真实的业务边界;它依然会扩大一次修改的影响范围;未来无论是人还是 Agent,在修改之前都必须先理解这套复杂的通用模型。
某种意义上,过去我们是在用 abstraction 去解决一个非常昂贵的问题。
但如果那个问题本身正在变得便宜,那么 abstraction 就需要重新证明自己的价值。
五、OCP 与 YAGNI:还要不要那么早为未来设计?
OCP(Open/Closed Principle,开闭原则)强调“对扩展开放,对修改关闭”。简单来说,就是希望系统增加新能力时,尽可能通过增加新的实现完成,而不是反复进入已经稳定的代码中修改。
YAGNI(You Aren't Gonna Need It)则从另一个方向提醒我们:不要因为未来“可能”出现某个需求,就在今天提前把它设计出来。
这两个原则并不真正冲突,但在实际项目里,我们经常会不自觉地偏向前者。
比如一个后台系统现在只有一个需求:把报表导出成 Excel。
按照一种非常典型的设计思路,我们可能很快就会想到:以后说不定还会支持 PDF、CSV,所以最好不要直接写一个 Excel 导出功能,而是先定义一个通用的 Exporter 接口,再实现一个 ExcelExporter。为了未来方便扩展,我们甚至可能继续加上 Factory、Registry 或其他插件机制。
从传统工程经验来看,这种设计并不奇怪。
问题在于,当系统里只有 Excel 这一个真实实现时,我们其实并不知道所谓“通用导出”究竟应该长什么样。
我们可能会假设所有导出格式都有标题、表头、行和样式,于是按照 Excel 的模型设计一个看起来很通用的接口。但等 PDF 需求真正出现时,我们才发现 PDF 更关心页面布局、分页和排版;等 CSV 出现时,又会发现 CSV 根本没有“样式”这个概念,它更多只是字段和原始数据。
回过头看,当初那个“通用的 Exporter”,很可能只是:
把 Excel 的特点包装成了一个看起来通用的 abstraction。
这里真正的问题,不是多写了几个 interface,也不是抽象本身有什么错误,而是当我们只有一个真实实现的时候,其实很难知道未来真正稳定的共同点是什么。
所谓“为未来设计”,很多时候并不是在总结已经存在的规律,而是在猜未来会长什么样。
等第二个、第三个真实需求出现之后,情况反而完全不同。此时我们已经知道 Excel、PDF、CSV 到底有哪些真实差异,也开始看见它们之间哪些东西值得共享,哪些东西应该保持独立。
这个时候产生的 abstraction,往往会比只有一个实现时想象出来的 abstraction 更接近真实问题。
过去我们不愿意等,原因也很现实:以后再重构太贵了。
所以即使现在信息不足,我们也愿意提前猜,希望用一点今天的复杂度,换取未来少动代码。
而 Coding Agent 让另外一种路线开始变得更加可行:先按照今天真实存在的需求,写出一个足够简单、清晰的 Excel 导出;等 PDF、CSV 真的出现以后,再根据真实差异,让 Agent 帮我们完成结构调整和大规模重构。
这也是为什么我认为,YAGNI 在 AI Coding 时代的权重可能反而会提高。
它并不是鼓励我们“不设计”,而是在提醒我们:
延迟一个设计决策,本身也是一种价值,因为未来的我们会拥有更多真实信息。
过去,延迟 abstraction 最大的问题,是未来重构成本太高。如果这部分成本开始下降,我们就有更多资格等到真正的 pattern 出现以后再抽象。所以 AI 最终未必会让软件里出现更多 abstraction。恰恰相反,它也可能让我们更敢于晚一点抽象。
YAGNI的原则不止会更多应用在软件开发的层面,我认为对于数据库的设计也会有更深远影响,这点后续会再开一篇文章来讨论。
六、从 Design for Reuse,到 Design for Change
回到文章最开始 Slack 的案例。
LibSlack 最初并不是一个错误决定。在当时的条件下,Slack 的确面对多个客户端重复实现同一套逻辑、不同平台行为不一致的问题。共享代码是一个非常合理的工程选择。
后来环境变化了。真正使用这套共享库的平台减少了,不同客户端之间的差异变得更加明显,共享 implementation 带来的成本开始超过它带来的收益,于是 Slack 又调整了设计。就像几年前很多用uniapp的团队再研发到后期还是转为用原生语言开发一样。
这恰恰说明了一件事情:
好的软件设计,也许从来都不是找到一个“永远正确”的 abstraction,然后再也不修改它。
设计本身就是对当前成本结构、需求和未来变化的一次判断。
我们过去特别强调 复用性,是因为代码生产和重复修改的成本都很高。在这样的环境里,通过复用减少未来修改次数,是一种非常自然的工程优化。
但现在 Agent 正在降低其中一部分成本。于是我越来越倾向于认为,比 Reuse 更稳定的设计目标,应该是另一个词:
Changeability——系统应对变化的能力。
当真实需求发生变化时,这个系统是不是容易理解?一次修改能不能被限制在合理的范围里?修改之后能不能容易地验证正确性?如果原来的 abstraction 不再适用,我们能不能以较低的成本把它拆掉,再建立新的边界?
从这个视角回头看,DRY、代码复用、OCP 和 YAGNI 都不应该被当成某种绝对正确的教条。
它们只是解决不同问题的工具。
当十个地方表达的是同一份业务知识时,DRY 仍然极其重要;当几个模块确实应该随着同一个业务原因一起变化时,代码复用可以显著降低成本;当系统已经存在稳定的扩展边界时,OCP 仍然是非常有价值的设计思想。
但当未来本身还不明确的时候,YAGNI 也许应该获得比过去更高的优先级。
Agent 时代真正需要改变的,并不是简单地把某些传统原则判定为“过时”,而是重新调整这些原则之间的权重。
以后再看到两段相似代码时,我们或许不应该条件反射地问:“这里是不是违反了 DRY?”
更值得问的是:它们表达的是不是同一份知识?它们未来是否真的会因为同一个原因而一起变化?把它们抽到一起,是在减少未来的修改成本,还是仅仅让今天的代码看起来更加整洁?如果今天暂时允许一点实现重复,等真正出现稳定模式后再重构,它的代价到底还有多高?
这些问题比“有没有重复代码”更难回答,但也更接近软件设计真正应该优化的东西。
因为随着代码越来越便宜,我们可能会越来越清楚地看到:
真正昂贵的从来不只是代码本身,而是错误的边界、错误的耦合,以及那些已经失去现实基础,却因为重构成本太高而一直没人敢碰的 abstraction。
过去,我们经常问的是:
怎样才能少写一点代码?
而在 AI Coding Agent 时代,也许更值得问的是:
怎样才能让未来真实发生的变化更便宜?
这两件事情看起来很像,却并不是一回事,而后者会更需要软件工程师对业务的熟悉度来做出判断(这个后面也会再开一篇文章讲一下我认为的AI时代软件工程师的素质要求)。
总结
如果你在短平快的时代已无法阅读长篇大论的文章,那可以看这里。我目前的理解和判断是:
- DRY(Don't repeat yourself) 真正应该避免的,是知识和业务事实的重复,而不只是代码长得相似。
- 代码复用并不是免费的。复用减少了重复实现,但同时也会建立新的耦合关系。
- Coding Agent 正在降低搜索、修改和重构代码的成本,因此实现重复的长期代价需要重新评估。
- 当未来重构的成本下降以后,YAGNI(You Aren't Gonna Need It) 的权重可能会上升,我们可以更晚一点做抽象。
- 比“最大化代码复用”更稳定的软件设计目标,也许是 Changeability——让系统能够以较低成本应对真实变化。
这并不是说传统软件工程原则已经过时。
相反,我更愿意把它理解成:AI Coding Agent 正在改变这些原则赖以成立的成本结构。身处 AI Coding 时代,我们需要做的不是抛弃过去的软件工程经验,或者以原有的软件工程原则作为教条,而是重新审视它们的适用边界、收益和优先级。对复用性的追求要逐步迁移到Changeability——系统应对变化的能力。
这也是这个系列后面几篇文章想继续讨论的问题。