第一个10,000行代码是在工作的头半年里面写完的,其中绝大部分是C++代码,而C++代码中大部分是在重构某个工具。这里就说一说我在写这10,000行代码的过程中学到的软件工程的一些知识:包括设计、测试、遗留代码、代码审查、版本控制。

我之前并没有软件工程方面的背景,即使是计算机科学相关的内容,我也仅限于能够写码,以及对数据结构与算法有基本的了解1。包括面向对象在内各种设计模式完全是一窍不通,而软件开发方面的工程步骤也是不了解多少。过去六个月,因为我各种经验都缺乏,所做的项目比较琐碎,没有什么复杂的涉及。虽然感觉缺少了影响,但是也还是学到了不少东西。

代码审阅

代码提交之前需要互相审阅,这是工作中最让我受益匪浅的一环。尤其是我现在作为一个菜鸟,经常能够从前辈的审阅意见中学到很多知识,包括如何写好的代码,好的注释,好的设计,好的思维方式。牛逼的前辈经常能洞察到的被我疏忽的逻辑漏洞,他们的审阅意见因此预防了很多可能产生的幺蛾子(bug)。

我记得我第一次接触到代码审阅是在2015年的实习。当时我就发现,能在代码审阅中感受到一个程序员的经验和智慧。现在也是,有些审阅人草草了事,有些审阅人斤斤计较。而真正牛逼的审阅人往往可以一针见血地指出我的程序中的漏洞,给出意见的时候主次分明,态度友善,看得我心服口服。

测试

测试,各种测试:单元测试,集成测试,负载测试。我开始工作的这半年里,一半的代码量都是在写测试。可以说,我是在用写测试的方式来学习C++,而不是通过写业务逻辑。经常一次代码提交,业务逻辑也就写了100多行,但是附带的测试可能写了300多行。而且我写的测试还不一定覆盖了所有的逻辑,也不一定覆盖了所有的代码。

写测试真的是一个技术活。尤其是我发现自己需要维护的代码中,很多以前的测试写得非常糟糕。要不就是只考虑一种情况,要不就是只考虑正常的、符合预期的逻辑链,而不考虑可能引起错误的逻辑分支。最糟糕的情况那就是不写测试了。

不写测试的原因也有很多种。其中最有趣的问题是:要不要为测试代码写测试?很多时候,测试代码是直接靠人眼和人脑进行推理的,这些代码是没有其他的代码进一步保证其正确性的。然而,只要是人在做事情,人就一定会犯错。但是,如果要为测试代码写测试的话,新写的代码要不要继续被更多的代码测试?如此递推下去可不行。

所以,我们对于单元测试就直接人脑推理,不用其他代码了。不过,集成测试和负载测试通常是需要用独立的测试工具来实现的。这些测试工具本身的正确性,至少应该由一定的单元测试来检验。不然,就会沦落到我现在的处境:我们有一个3000行的测试工具,内部代码70%都没有被测试过。结果现在想淘汰或者想修改这个工具都非常困难。

另外,测试驱动的开发(Test-Driven Development)还是蛮有用的,尤其是在代码重构和代码淘汰的时候(泪流满面,都是教训)。

版本控制,回撤,前推

版本控制实在是太重要了。这一点学术界做的非常非常不好。现在不仅仅有Git这类的对代码的版本控制,Dropbox、Google Docs之类的软件对很多类型的文件和数据都有了版本控制。真是好事!

版本控制的好处不仅仅在于能够看到数据修改的历史,还在于能够在各种版本之间切换,从而帮助新产品的迭代。另外,我觉得版本控制也使得安全性增加,开发者们也能感受到更强的心理安全感。比如,如果新的代码出现了问题,代码库可以及时回撤(Roll back)到之前稳定的版本。与此同时,开发者可以回过头来分析到底是出现了什么问题。代码更新,问题解决之后,写的代码又可以前推(Roll forward)到代码库中。如果新版本没问题,开发继续;如果又有问题,大不了就重来一次。而且,只要主版本是稳定的,不同的开发者之间一般互不耽误。

本来我以为单元测试覆盖得足够好,我应该不会需要经历回撤这种操作。但是没想到过去的半年中还是回撤了三次。幸亏我们有健全的版本控制系统,所以也算是有惊无险。不然,即使我们有“不责难(Blameless)”的文化,我也不会大胆地去写代码。

另外,不同的版本控制软件的不同思路也很有趣。比如,Git和Perforce对于代码版本组织的不同决策就很有意思。虽然现在主要是Git的天下,但是某些巨大的代码库不能够用Git的逻辑来控制版本,而是选择了Perforce的变体,让我第一次对版本控制所面临的规模扩展性问题有了认识。规模大起来,真是连细小琐碎的事情都显得复杂。

历史遗留代码

过去半年,我算是真正见识到了技术债(tech debt)。我现在主攻的问题之一就是重构一个3000多行、测试不够、设计混杂的工具。这个项目我已经写了四个月了,现在还没有完成一半。原因之一是我的C++基础本来就薄弱;另一个原因就是这段代码实在是太乱了。我看我们组没有人真的愿意花精力理清其中的逻辑。但是这是一个我们每天都要使用的重要工具啊!我也是服气的。于是,我本着借此机会学习C++的想法开始修改这段代码。硬骨头,真难啃。不过,这还只是我们代码库中比较小的问题了。在我们庞大的代码库中,不知道有多少这样的历史遗留问题没有得到妥善安置。但问题是,没什么人愿意花时间精力来清理这些债务。毕竟这是一件费力不讨好事情:不一定被同事认可你做出的贡献,也不会成为你的升职之路的垫脚石。

遗留代码的另一个问题就是:公司内部工具的用户界面,真的是一个丑啊。很多界面都是十几年没改过了。虽然我理解后端程序员不太关心这个,但是很多时候,界面已经丑到影响工作效率的地步了。结果我发现,面对这些问题,大家的方式就跟面对所有技术债一样,强行通过时间来熟悉和适应。这应该是我觉得工作中最不合理的一部分了。

软件工程设计

作为一名新手,过去10,000代码解决的问题都很琐碎。我也没有太多的机会深入学习软件设计。日常的工作,其实对设计模式和分布式计算的知识要求也不高。所以,这些知识看起来还是得业余时间多看书才能学习。至于算法设计,那更是从来没看见过。想到这一点,还是有一些遗憾的。

话虽如此,经验还是教会了我一些知识的。

  • 设计要把界面(interface)和实现(implementation)分开 : 这个道理说起来很容易,但是真正做起来还真是需要从代码实践中学习。很多时候,我觉得两者分离得已经挺不错的了,结果代码审阅的时候还是被指出实现手段没有被封装好。
  • 异常处理 : 作为一个不允许使用exception的公司,处理异常的手段可能是不太一样。不过话说回来,我也没有用exception处理异常的经验,所以禁止使用exception对我来说倒是影响不大。经过这半年的折腾,我觉得:
    1. 早死早超生。如果程序刚刚启动没多久就有错误,干脆早点报错终止程序,不要等到抛传了一系列错误之后,时间过去了,结果还是没有办法恰当处理错误,该死的还是要死。
    2. 即使病入膏肓,也要把问题浮出表面在死。在大型项目中,有些异常是深藏在代码深处的,经历了一层套一层的函数传递。如果这个时候见到异常就终止程序,反而不是一个好选择。一方面失去了重试的机会,另一方面也不好调试。比如我写的主程序,结果深藏的一个并没有那么重要的小库出现了异常,这点问题可能对主程序并不重要,没了数据主程序还能跑。如果这个小库导致的异常就直接终止程序,那就得不偿失了。我有一个项目,做的就是把藏得较深的报错终止逻辑去掉,然后把错误状态一层一层传回主程序,让主程序决定是继续还是去死。
    3. 优雅地处理错误。其实异常处理最难的不是传递错误状态,也不是抛出一些exception,而是思考如何优雅地处理错误,从异常中恢复,然后继续代码的逻辑。要做到这个,往往比抛出异常需要更多的脑力和逻辑思维。抛出异常是好,但是如果把抛出异常当做甩锅的手段,把问题都留给其他的开发者,这对整个开发团队的效率也会有负面的影响的。

  1. 我对数据结构与算法的了解也主要来自于中学计算机竞赛。本科以及之后就没有再系统地学习过这一方面的知识了。虽然之前的知识斩掉一般公司的面试题没什么大问题,但算法和数据结构毕竟只是码农工作中很小的一部分。 ↩︎