回想起来,我的职业生涯有大半的时间都在有意无意的死磕这个问题。2016 年左右,开始觉得有必要写下来,因为总觉得不写下来的话,到该说的时候脑子里有许许多多的想法,临时却又说不出来什么。本文写于 2016-08-06,写得挺早,一直没有发在社区,放着好多年。现在看起来,来来回回也没改动多少,发出来大家随缘看看。
代码当中包含的复杂性,我分为两部分来看:业务逻辑本身的复杂性,和代码结构的复杂性。
对有点规模的系统程序来说,程序员本应该做的工作除了完成功能之外,就是消除代码结构额外的复杂性,让系统代码整体的结构趋向于跟业务逻辑在各个抽象层面上一致——即人们常说的:你所写的代码就是你想要表达的业务。
业务逻辑变得复杂,很多时候是业务发展的需要,程序员只有接受,并为业务提供支持。当然如果你身兼数职,可以以产品经理的身份在产品的角度优化一下,但这不是本文讨论的范围。抛开业务本身的复杂度,剩下的就是代码结构的复杂度了。
那么代码结构为什么总是变得越来越复杂呢?主要是跟程序员控制代码结构复杂度的能力有关。能力不足的程序员很容易经常犯些小毛病——例如在一个方法内部出现了临时变量,或者代码中出现了一个带了 2 个参数的方法(注 [^1])——但他本身并没有意识到这是个问题。代码随着业务的变化和增加,这种小毛病就会越积累越多。
到了某一时刻,程序员会发现以前某些代码很难修改或者重用,多数情况下,能力不足的程序员这时候意识不到那些小毛病的存在,会很本能地选择对以前的代码做一种打补丁式的修改(比如多传个参数、多加个条件分支来处理一下特殊情况),或者干脆把代码大片复制出来做些小修改,而不是重新组织它——也就是我们常说的“重构”。
程序员们经常把“重构”这个词挂在嘴边,但遇到问题他们的选择要么是打补丁,要么干脆全部重写,真正做重构的真的很少。来复习一下“重构”一词的定义:在不改变代码外在行为的前提下,对代码进行修改,以改进程序的内部结构。这里的关键,是“改变内部结构”、“不改变外在行为”。理解了这一点之后,“重构把代码改出 bug”这样的认知错误自然就不存在了。
跑题了,言归正传。打个比方。想像一下,假设上面我所提到的程序员,在一开始经常犯的那些小错误,是在一根绳子上打了的一个小结。这种小结打多了之后,一整根绳子上就都是绳结。程序员对之前的问题代码打补丁的动作可以看成在之前的小结上打了一个更大的结,结上再打结……如此发展下去,最终系统必然是一团错综复杂的结。这时候,问题本身终于显而易见了——代码无比复杂,修改十分困难,但产生这问题的根本原因还是像迷一样难以被发现。
因为“解小结”的能力不足,所以才最终造成了这么一团大结。同样的因为“解小结”的能力一直不足,他们不知道应该从最外面的结开始解,解开一个结之后,才能再解这个结下面更多的小结(重构的过程真的是这样,你会发现首先要把代码用一种重构手法整理完之后,才能够再用另一种重构手法对解开的代码进行进一步的整理)。他们要么直接把这一团东西扔了重写一份——但是没有训练出“解小结”的能力的话,未来继续重新开始打结大概率是免不了的;要么继续痛苦地在这玩意上面继续打结。
代码之所以不容易写好,是因为许多错误的小决策往往要很久以后才能让人看到显而易见的问题。 没有即时的负面反馈,不够细心的人们就发现不了问题,“问题”对他们来说不存在,“改进”自然就无从谈起。 复杂难用的系统都是由小毛小病慢慢积累而来,最终才表现成为显而易见的大问题,但是每个小毛小病多数人看不见,临时用打补丁的方式不讲究地处理一下在他们看来似乎也没什么坏的影响。他们没有意识到 防微杜渐才是防止大病形成的唯一办法。 问题最终很明显的表现出来之后他们也往往不知道根源到底在哪里——其实根源都在之前做决定的每一步的细节里,没有一个单独存在的、明显的大问题(因为如果有,那早就被发现和解决了)——而正是因为这样,最终的问题往往都很难解决,你总是,总是会发现牵一发要动全身。
所以代码的版本越高越难以被读懂,除了业务变得复杂了以外,我觉得主要的原因在于此。
提出问题,找到了原因,然后就得解决。
复杂是由简单组合而成的,巨复杂的代码结构问题本质上也都是些简单的问题组合成的。最终的那个复杂无比的问题不可能只用一种简单的手段一次性解决,只能针对组成整个大问题的那些小问题一个一个来解决。
那么解决这些不起眼的小问题的手段是什么?就是许多程序员不屑于练习的,被他们声称解决的问题过于简单,被他们认为无法用于解决实际项目当中超级复杂的系统问题的,同样不起眼的一个个重构手法。这些重构手法全在《重构》这本书后面的每一个示例里,我认为这本书的正确读法是把每个重构手法以及每一句示例代码都认真的阅读和理解一遍,搞清楚重构前的代码问题在哪,为什么那算得上是个问题,那个问题应该如何解决,解决这种问题本质上所运用的该编程语言的特性和原理是什么。
可能有人读了前五章之后,认为剩下的部分可以先放着,等遇到问题了再来读。但矛盾的地方在于,从第六章起之后的这部分容,很重要的一个作用就是训练程序员对各种细微 bad smell 的嗅觉,在大脑里对各种 bad smell 设置 trigger。未经训练的程序员在真遇到问题的时候,并不认为自己遇到了问题,因为那些问题就像这些重构手法一样,是如此的不起眼,最后自然也就不会再次打开这本《重构》。
当程序员控制代码结构复杂性的能力训练得足够好了,你会发现他写的代码模块职责清晰,大多数方法的代码行数真的不超过 10 行(这个数字用不同的 OOP 语言会有所不同),同时也会发现,原来写出短小的方法和类不是手段和目的,只是一种结果。系统里全是如此短小不互相纠缠的代码,还会有人觉得难读吗?
这里可能又可以引发出另一个讨论:到底什么样的代码才叫“直观”?什么是代码的可读性?似乎有不少程序员觉得层层调用的代码很不“直观”,把逻辑完全展开,平铺直叙的代码才叫“直观”。这背后隐含的问题是“到底什么是‘复杂’”,这又是另一个话题了。我知道其实很多人不习惯跟类似“什么是‘复杂’”这样看似简单、每个人好像都知道,但认真解释起来还是需要一些思考的概念死磕。
上面是我最近在思考的东西。
注 [^1]:并不是所有的临时变量都是不好的,也并不是所有参数多于 1 个的方法就是有问题的。这里是故意把“大量临时变量”中的“大量”给省去了,把“多个参数”写成了“2 个参数”。因为不在具体的上下文当中,“大量”和“多个”根本没个标准。至于如何判断,可以仔细阅读《重构》。
这里想要表达的是,所有的 bad smell 都是一种反模式,所谓“模式”就是很容易被人发现的一种有规律的定式,一旦遇到就要动脑子分析,和具体的规模(无论是“2 个”还是“很多个”,“5 行”还是“很多行”)无关。但很多程序员倾向于总结出非此即彼的固定标准,因为具体问题具体分析太费脑子。所以:临时变量既然不可能完全消除,就完全不需要消除;方法参数既然不可能永远不超过 1 个那就可以是 5, 6, 7, 8 甚至 10 多个;方法行数既然不能全部减少到 5 行那就说明 100 来行也是没问题的。最终失去了对 bad smell 的警觉。
而训练有素的程序员会把这些模式当作“触发器”,时时刻刻警觉着分析这些现象在当前的情况下到底算是个问题还是算正常情况。训练有素的程序员知道很多东西不是非此即彼,不可以一概而论,编程本身是一项脑力劳动很大程度上也是因此吧。
但是多数情况下我们似乎都倾向于追求和谐的统一,把编程变为体力劳动,只是因为那看起来更不需要思考。或者用有些人的话来说,可以更容易招聘到“不会思考的下等编码机器”。只可惜采用这种“不需要思考”的方式写代码造成结果却往往是需要更多的思考,因为混乱的代码结构维护起来一定比条理清晰的代码费脑得多。