重构:聊聊代码的复杂性

写代码这么多年,我大部分时间都在和代码复杂性较劲。早在 20120年我就写下了这份思考,一直存于本地没有对外发布。后来再读,很多观点依旧没有变化,整理出来,供大家参考。

复杂从哪里来

代码的复杂性可以拆成两层:业务本身的复杂性,以及代码结构带来的额外复杂性。

对于具备一定规模的系统,程序员的工作不只是完成功能,更重要的是消除结构带来的额外复杂度,让代码结构在各个抽象层级,和真实业务逻辑对齐,也就是常说的:写出来的代码,就是你想表达的业务。

业务逻辑变复杂,大多来自业务迭代发展,这是客观现实,开发只能接受并提供支撑。当然如果你同时承担产品角色,可以从产品层面对需求做取舍权衡,但这不在本文讨论范围。

抛开业务固有的复杂度,真正折磨人的,是代码结构越跑越乱。

根源往往不在于某个惊天动地的大 bug,而在于开发者对代码结构的把控能力不足。很多不起眼的小问题,比如方法内充斥大量临时变量、方法参数过多,开发者察觉不到隐患。随着业务迭代,这些小问题会持续堆积。

等到某天代码变得难修改、难复用的时候,很多人不会选择重构,而是本能地打补丁:多加一个参数、新增一堆 if 分支处理特殊场景,甚至直接复制粘贴旧代码改几行继续用。

这里重新厘清重构的定义:在不改变代码对外行为的前提下,修改内部结构。把握住这个核心,“重构改出 bug” 这个误解就不攻自破。

可以用打绳结来做比喻:一个个不起眼的小坏味道,就像绳子上打的小结。打补丁的行为,就是在旧绳结之上再打更大的结,结上叠加结。最后整个系统变成一团乱麻。此时所有人都能看见代码难以维护,但已经很难追溯问题源头。

如果缺少拆解小结的能力,面对一团乱麻一般的代码,开发者通常只有两种选择:

  1. 全部推倒重写。但如果没有学会处理小坏味道的能力,新写的代码依旧会一步步重新打结,再次走向腐化。
  2. 继续硬着头皮打补丁,在烂摊子之上继续堆砌逻辑。

代码腐化有一个很隐蔽的特点:错误的小决策,往往要很久之后,才会暴露出肉眼可见的恶果。当下没有负面反馈,开发者就意识不到问题,自然不会主动去做改进。所有大型屎山,都是无数微小坏味道日积月累堆出来的。不存在某一个单独的、一眼就能发现的重大缺陷,等到问题彻底爆发,已经牵一发而动全身。

这也是为什么版本越往后,代码越难读懂:除去业务本身变重,大量被忽视的结构问题持续累积,才是核心原因。

复杂本质上是记忆负担。当理解一段逻辑,需要同时记住的信息超过人脑短期记忆的上限,我们就会感受到复杂。

如何看待代码坏味道

很多人会走向两个极端:要么死记硬背死板的数字标准,比如 “方法不能超过 10 行、参数不能超过 2 个”;要么走向另一个极端:既然做不到完全消除,那就干脆放任不管。

但坏味道只是触发器,不是铁律。大量临时变量、过长的方法、过多参数,这些是信号,不是判定问题的硬性标准。看到这些现象,要结合上下文分析,判断在当前场景下这到底是不是问题,而不是拿一套固定规则一刀切。编程是高强度脑力劳动,很多时候无法简单非黑即白。

但现实里不少团队却试图把编程变成体力劳动,追求统一死板的标准,希望可以低成本招聘 “编码机器”。可代价就是,混乱的代码,后续维护要耗费更多脑力。

如何解决代码的复杂性

巨大的代码烂摊子,本质是一堆细碎小问题叠加出来的。我们不可能用一招就一次性彻底解决,只能逐个解决每一个构成泥潭的微小问题。

解决这些微小问题的工具,就是各类重构手法。很多程序员对此不屑一顾,觉得这些手法处理的场景太简单,对付不了真实项目的庞杂系统。

《重构》这本书,不应该只读前面几章。后面大量的重构手法案例,核心作用,就是训练我们对坏味道的嗅觉,在大脑里建立触发器。如果没有经过这套训练,真正碰到问题的时候,你甚至识别不出那是问题,自然不会翻开这本书去找解法。

训练到位之后,你写出来的代码模块职责清晰,绝大多数方法都足够短小,不同语言会有浮动,但普遍不会很长。大量简短、解耦的代码片段,阅读负担自然会下降。

这里会引出一个争议:什么叫代码直观? 不少开发者认为层层调用、多抽象的代码不够直观,把所有逻辑平铺展开才叫好读。这背后是大家对于 “复杂” 的理解差异,这是另外一个值得探讨的话题。

重构实践中的现实问题

1. 没有自动化测试,还能不能重构?

社区有一个很普遍的观点:重构必须先写自动化测试,没有测试就不能重构。 这个说法容易劝退很多人。很多遗留项目本身就没有测试,大家就会陷入死循环:不敢重构,也不会去补测试,最后只能摆烂,坐等未来整体重写。
重构分两种场景:

  • 边写边重构:开发新功能时持续整理代码,不让坏味道堆积,天然不容易形成烂代码。
  • 遗留代码重构:历史代码已经腐烂,本身就极难编写测试。指望在一团乱麻之上先补完全套自动化测试再动手重构,现实中很难落地。

重构引入 bug,大多不是重构这件事本身带来的,而是两种人为失误:

  1. 重构的时候顺手修改业务逻辑,这已经不属于重构;
  2. 重构动作没有做完整,比如重命名方法,漏改了部分调用点。

重构的核心是小步前进。提取几行代码成为独立方法、重命名变量、把一段逻辑迁移到另一个类,这些都是重构。每一步改动都很小,保证外部行为完全不变。没有自动化测试,可以依靠人工验证,重构完成之后,代码本身也会变得更加容易编写测试。我们不必同时跨过 “写测试” 和 “重构” 两道门槛,可以分步来。当然自动化测试永远是加分项,能极大提升重构的信心。

2. 对象建模:把条件前置到构造阶段

社区讨论中也提到另一种思路,用来降低调用时的记忆负担:尽量把依赖放到构造函数初始化,让对象构造完成之后,它的属性全部是无条件有效的。

类比数学里的对象:只要我成功构造出一个交换群,它就天然具备交换律,不需要后续调用的时候再做条件判断。映射到业务代码,比如Purchase = (Customer, Product),只要对象实例构建成功,它的属性就全部有效。

对应的工程写法就是 Service Class 模式,把原本 Model 上的复杂方法,封装成独立服务类,依赖全部传入 initialize 构造器,之后调用 run 方法执行业务。好处是依赖一目了然,代码搜索也很方便。

但这不是万能教条。如果方法本身很短,逻辑简单,直接写在原有类中也完全合理。不要走向 “所有方法都不能带参数” 的极端,要结合场景权衡取舍。

3. 什么时候不适合重构

如果需求本身就混乱、自相矛盾,业务还没有稳定,此时重构收益很低,甚至弊大于利。PM 的职责之一,就是过滤矛盾混乱的需求。代码复杂度很大一部分,来自需求衍生出来的状态与状态流转。重构本质就是对抗系统的熵增。当维护这套混乱系统的成本,已经超过完整重写成本的时候,就代表这个项目已经走到终点。

同时,重构不是一个大版本才做一次的专项工作,它是持续的行为。每一次迭代新增功能,都可以顺带做局部重构。但这件事能否落地,离不开团队文化,如果团队只看功能交付,不接纳持续重构,开发者也很难开展。

写在最后

很多开发者默认 “代码屎山” 是软件开发的常态。但如果我们愿意从小的坏味道入手,小步持续重构,就不必一直活在对屎山的绝望里。
复杂的系统不是一夜之间诞生的,防微杜渐,才是避免系统彻底腐化最好的方式。