你从入职第一天起就要应对复杂代码。

若是还未遇到过无法理解的程序,那说明你编程的年头还不够长。在行业里,要不了多久你就会碰到让人发懵的混乱代码:巨兽、面条工厂、来自地狱的遗留系统。我曾接手过一个程序,它的前任在听说要增加一个分量不轻的新特性时,选择了辞职。(我并不怪他。)

软件系统的复杂度是不可避免的。有些问题就是很难,它们的解决方案很复杂。然而,你在软件中找到的大多数复杂度是我们自己造成的。在《The Mythical Man-Month》(人月神话)[Bro95]里,Fred Brooks将复杂度的两个来源分成必然(necessary)复杂度和偶然(accidental)复杂度。

这里有一种区分必然复杂度和偶然复杂度的思考方法:什么复杂度是问题域固有的?假设你面对的是一个日期/时间处理代码散落各处的程序。在处理时间时,存在一些必然复杂度:每月的天数不同,必须考虑闰年,等等。但多数我碰到的程序充斥着大量与处理时间相关的偶然复杂度:用不同格式保存的时间,加减时间的新奇(同时也是充满Bug的)方法,不一致的时间打印格式,说都说不完。

复杂度的死亡螺线

编程时常会遇到这种情况:产品代码库中的偶然复杂度渐渐压倒必然复杂度。情况在某一时刻会自我放大,我称这种现象为复杂度的死亡螺线,如图1所示。

驯服复杂代码_代码

图1 复杂度的死亡螺线

问题1:代码规模

构建产品时,它的代码规模最终将远超任何在学校或消遣项目中所遇到的。行业中的代码库的度量结果从成千到上百万代码行(Line of Code, LOC)不等。

John Lions在《Lions’ Commentary on UNIX 6th Edition》一书中写道:单个程序员能够理解和维护的程序大小的实际限制规模是1万行代码。于1975年发布的UNIX第6版的规模大约是9000行代码(不算机器特定的设备驱动程序)。

相比而言,Windows NT在1993年有4百万~5百万行代码。10年后,Windows Server 2003配备了2000名开发人员和2000名测试人员,他们管理多达5千万行代码。大多数行业项目并不像Windows那样巨大,但它们也都轻易地跨过了Lions设定的1万行代码的警戒线。这样的规模意味着公司内部没有人能理解整个代码库。

问题2:复杂度

随着代码规模的增长,最初想法的概念优雅性消失了。曾经对于车库中两个小伙水晶般清澈的想法变成了大批开发人员艰难跋涉其中的阴暗沼泽。

复杂度并不是代码规模的必然产物。大型代码库完全有可能被拆分成许多模块,其中每个模块都有清晰的用途、优雅的实现和为人熟知的与邻近模块的交互。

然而,即使设计良好的系统也会在它们变大时变得复杂。一旦没有一个人可以理解整个系统,这时多个人必须去理解系统中自己那部分—且没有人的理解跟其他人是完全一样的。

问题3:Bug

产品复杂度飙升,Bug也就不可避免地出现了。这是注定的—就算是伟大的程序员也不是完人。但每个Bug并非生而平等:高度复杂系统里的那些Bug尤其难觅踪迹。总是听到程序员说:“真搞不懂,伙计,系统刚刚崩溃了。”欢迎来到这糟糕的调试世界!

问题4:快速修补

问题并不在于产品是否有Bug—它肯定有,关键在于工程团队在出现Bug之后如何响应。在推出产品的压力之下,大多数程序员经常求助于快速修补。

快速修补是给问题打补丁,而非解决其根本原因。甚至常常不寻找根本原因。

程序员:在试图往网络队列中放入一个任务(job)且队列在10秒内无响应时,程序崩溃了。

经理:重试队列操作100次。

根本原因是什么?天知道,只要重试次数够多,你就可以掩盖任何问题。但如车身修补一样,某一位置的霸道胶水(Bondo)比实际残留的车本身部件还要多。

更难找的问题发生在补丁并没有解决问题根本原因的时候,问题通常根本没有消失—它只是转移到别处。在前面的对话中,重试100次可能很好地掩盖了问题,但万一需要101次重试怎么办?经理只是随便捏了个数字,这种膏药式修补只会让问题更难查。

沿着“快速修补”上行,我们现在得到了一个增加代码规模的完整闭环。

走向清晰

提起复杂的反面,人们通常会想到简单。但由于领域的必然复杂度,我们并不是总能写出简单的代码。应对复杂更好的方法是清晰。你是不是明白自己的代码要做什么?

明确两点会有助于我们减少软件偶然复杂度:清晰思考和清晰表达。

清晰思考

在分析问题的原因时,我们试图做出像“保存时间的方式应该只有一种”这样的清晰陈述。那为何UNIX C代码里还混杂着像time_t、struct timeval和struct timespec这样的结构呢?那并不是太清晰。

如何调和你的清晰陈述和UNIX计时功能的复杂度?你需要隔离复杂度,或将其抽象到单个模块中。在C里,这可能是结构体和操作它的函数;在C++里,它会是一个类。模块化设计让程序的其余部分可以用一种清晰的方式推导时间,而不用了解系统计时功能的内部机制。

一旦能将时间作为程序的一个单独模块进行对待,你也就能证明你的计时机制的正确性。完成这一工作的最佳方式就是单独测试,但是同行评审或书写规格说明也行。当一组逻辑是隔离的而不是内嵌在一大段代码体内时,它的测试和严格证明要容易得多。

清晰表达

随着你清晰地思考模块并将它与其余程序隔离,最终程序也就能更清晰地表达它的用途。处理问题域的代码应该真正专注于问题域。

将辅助代码抽出放入自己的模块之后,剩余逻辑读起来应该越来越像问题域的规格说明(虽然有更多分号)。

让我们看看前后对比。我已经无数次看到过如下这种C++代码: