# 10000行代码后对软件工程的思考

*工作头半年写完第一个10,000行代码后，对代码审阅、测试、版本控制、历史遗留代码和软件设计的思考与经验总结。*

- 作者: Feitong Yang (https://www.feitong.phd/about)
- 发布日期: 2019-02-01
- 原文链接: https://www.feitong.phd/zh/essays/10k-code-zh
- 主题: technical, career

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

我之前并没有软件工程方面的背景，即使是计算机科学相关的内容，我也仅限于能够写码，以及对数据结构与算法有基本的了解<Note>我对数据结构与算法的了解也主要来自于中学计算机竞赛。本科以及之后就没有再系统地学习过这一方面的知识了。虽然之前的知识斩掉一般公司的面试题没什么大问题，但算法和数据结构毕竟只是码农工作中很小的一部分。</Note>。包括面向对象在内各种设计模式完全是一窍不通，而软件开发方面的工程步骤也是不了解多少。过去六个月，因为我各种经验都缺乏，所做的项目比较琐碎，没有什么复杂的涉及。虽然感觉缺少了影响，但是也还是学到了不少东西。

## 代码审阅

代码提交之前需要互相审阅，这是工作中最让我受益匪浅的一环。尤其是我现在作为一个菜鸟，经常能够从前辈的审阅意见中学到很多知识，包括如何写好的代码，好的注释，好的设计，好的思维方式。牛逼的前辈经常能洞察到的被我疏忽的逻辑漏洞，他们的审阅意见因此预防了很多可能产生的幺蛾子(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`，而是思考如何优雅地处理错误，从异常中恢复，然后继续代码的逻辑。要做到这个，往往比抛出异常需要更多的脑力和逻辑思维。抛出异常是好，但是如果把抛出异常当做甩锅的手段，把问题都留给其他的开发者，这对整个开发团队的效率也会有负面的影响的。

