同样是写代码开发软件,有人自称「程序员」,也有人自称「软件工程师」。真要抠字眼的话,这两者之间有怎样的区别呢?Google 今年出版的新书 Software Engineering at Google (SWE Book) 在第一章开头就提出:「编程」和「软件工程」是两回事。
Software engineering is programming integrated over time.
「编程」指生产代码的活动,而「软件工程」则是在此基础上再添加时间这一维度。正如三维的立方体不等同于二维的正方形,距离不等同于速度,「软件工程」也不等同于「编程」。
要区分一种软件开发的模式是更接近于「编程」还是「软件工程」,我们可以问这样一个问题:这份软件的预期生命周期有多长?或者说这份软件需要被维护到什么时候为止?一般而言,这些活动会更接近于「编程」:
而像 Google 搜索或是 Linux 内核这样的项目,它们的生命周期可以认为是无限期的。
在动辄以年为单位的预期生命周期下,改动是不可避免的。一个软件系统即使没有需求上的变动,也必然会由于时间的流逝而面临不得不做的改动,比如:
这就使得我们在软件工程中永远要考虑代码的可持续性/可维护性。
现代软件工程几乎无一例外都是团队合作的结果。随着规模的扩大,团队组织、项目结构、政策、流程、实践等等都需要跟着 scale。经验告诉我们,一些东西比较容易 scale,而另一些则更为困难。SWE Book 从多个方面进行了具体介绍1,而本文主要关注作为个人可以做的。
在软件工程中做决策也更为有挑战性:风险和机会成本更高、做决策时信息不充分,对问题的理解永远不够全面并且在时刻变化,等等。
对这些区别的认知可以为我们提供一个关于「软件工程师应该怎么做」的框架。
对个人而言,可持续化开发大致就是要实践笔者以前提到过的 Strategic Programming。摘录相关段落如下:
... 就像着眼全局的战略家,能持之以恒地投入资源从而在完成开发目标的同时逐步地、系统性地提升整个 codebase 的可维护性、可拓展性以及测试覆盖率等。
在信息不充分的前期,积累一些技术债务是不可避免的。学会定期偿还这些债务以保持系统的可持续开发,则是成为高效的工程师的关键。
... 一些具体操作建议:
- 新代码必须有测试。对于缺乏测试的旧代码,改动时的第一步永远应该是添加测试。
- 在改动一个模块之前,如果观察到代码质量不理想,可以考虑先做一次重构。当然,良好的测试覆盖是我们能够充满信心地重构的先决条件。
- 充分利用各类代码分析工具,定期发起 Fixit 来提高代码性能和可读性、优化 build 规则、改进易错代码、补充注释、处理 todo、清理 dead code、迁移 deprecated API、拆分变得过于庞大的类和文件等等。
- 如果开发的痛点是由不够理想甚至错误的 design 所导致的,在寻找 workaround 作为短期解决方案的同时,也要开始思考、规划和投入资源在重构或是迁移到更正确的 design 上。
Code is read much more often than it is written.
代码是写给别人(现在的 peer)看的,也是写给自己看的,但归根结底还是写给别人(未来的维护者)看的。在做代码改动时,我们要时刻提醒自己:code review 在很大程度上是 peer 之间的一种交流方式,而不是一种单纯的 bug 预防机制或是一种集体分担(逃避?)代码出错的责任的形式主义。
明确这样的心态以后,下面是一些具体的建议:
有人说,写(高级语言)代码的本质就是在给(机器语言)0 和 1 写注释。类似地我们也可以说,之所以讲 Naming 是计算机科学中最困难的两件事情之一2,就是因为它的本质是在给一堆对人脑而言毫无意义的内存地址赋予合适的名字,从而让人能跟得上机器的思路。但是机器可以在记住成千上万个变量的地址和值的同时用难以置信的速度做五花八门的运算,相比之下人脑则是高度不可靠的。当我们说一份代码难以维护,其最直观的表现就是给试图阅读和修改它的人带来很高的认知负荷(Cognitive load)。
能正确地写出思维复杂度高却又缺乏注释和文档的代码(或者说,只有自己看得懂的代码),在某种意义上或许也算是一种成就,但长远来看绝不是一个好主意3:不仅你的 peer 会看不懂,几个月的你大概率也会看不懂。在自己踩过几次坑之后,笔者的经验是:
接手一份代码并让它运行起来是相对比较容易的,但这并不意味着今后维护这份代码所必需的知识也会自动跟着转移5。任何有实际应用场景的代码,在某种意义上都只是将我们在其对应的问题领域所积累的知识形式化地表示出来的介质。对代码的 ownership 是表面的,对知识的 ownership 才是关键的。
一个简单的原则:多问,多写。
SWE Book 中提出的 shifting left, aka getting feedback early (and often):代码挂在 integration test 上永远要比挂在 production 上好;单元测试能捕捉到的 bug 往往要比手动测试中发现的 bug 复现起来更容易;在写代码之前意识到 assumption 的错误自然要比写完代码以后再去改来得轻松……
动手开发之前多思考和验证 assumption 的正确性、在 design 上多征求 peer 的意见、多依赖静态分析工具、细心写单元测试、认真对待 code review……坚持做好这些事情,永远有意想不到的回报。
我们在思考 trade-off、做决策的时候会去权衡各种成本。有些成本是很显然的,而另一些就因为种种原因不那么好量化。经验告诉我们,要想做出更好的决策,永远先做难的事情。
假设要搭建一个新的系统,其中大部分组件你都很熟悉,但其中有一个模块要用到你没有接触过的工具/语言等。出于惰性,这时候人往往倾向于先把手到擒来的部分完成。但其实我们应该首先花时间去加深对不熟悉的模块的了解,必要的话还可以动手写个简单的 prototype。如果不这样做的话,你会很难去准确地估计整个系统开发的成本和可能的风险。
这可能也是二八原则的一种体现:一个项目中 20% 的部分需要我们花 80% 的时间去事先研究,并且它们对于项目是否能够低风险完成贡献了 80% 的影响。
对「编程」和「软件工程」、「程序员」和「软件工程师」这些表达方式做区分,绝不是为了当语言警察。除了在法律上混淆不同的头衔可能带来问题之外,我们可以随意混用这些表达,只要它们在语境下是合适的。类似地,使用「码农」这类表达也无伤大雅;这也不是 gatekeeping。任何人都应当被欢迎进入业界工作或是发表自己的意见,不论他们的实际头衔是什么,也不论他们对自己的定位是「程序员」还是「软件工程师」。
做这样的区分,是因为即使这种区分不被所有人同意,但只要是在某个明确的定义下,的确会存在两个或更多相关却有所不同的领域。它们有着不同的约束条件、价值追求和最佳实践。有些工具和做法在一个领域很有效,在其他领域可能就不那么有价值了。
许多人的职业道路都是从学校或者小作坊进入大公司或是非早期小公司,在这个过程中大概率会经历从「程序员」到「软件工程师」的角色转变。SWE Book 和本文所讨论的区分,是为了建立对「软件工程的本质是什么」这个问题的思考框架,从而为这样的角色转变提供理论支撑和切实可行的建议。