软件工程随感

一些基本概念与问题

Posted by John Mactavish on February 14, 2020

写在前面

程序设计和软件工程上有一些经常碰到的问题。这一领域里存在着一些口口相传的就像是什么武林秘籍一样的东西, 当你在论坛之类的地方交流的时候总会有一些似乎经验更丰富的开发者试图用这些秘籍来教育新手, 诸如 “一件东西只做一件事”,“越简单越有效”,“代码越短质量越高”。今天就来深入探讨一下这些问题。

代码的长短与软件的质量的关系

他们之间当然没有必然的关系。如果非要讨论他们之间的关系,那就必须放在一定的前提下。

经验丰富的开发者编写的更短的代码

这种情况下质量确实会更高。水平有限的开发者往往不能很好地表达他们的思想,甚至 API 使用不熟 写出了一些南辕北辙的代码。但是老道的开发者并不是在所有的地方都比新手开发者更有质量。 一个常见的例子就是

while(i < 100) bucket[i++] = count;

这类的过分追求简便的写法。新手开发者往往根本不知道还可以有这么神奇的操作,他们看到那些所谓的高手这样写 之后甚至就开始模仿起来。事实上这种写法非常糟糕。i++ 是一个有副作用的操作;它应该是一个语句, 结果这家伙竟然还有返回值。你不仅可以用它来赋值,甚至还可以再利用 C 里面没有布尔值的缺陷把它用于

if (i++) {…}

一行代码表达多个意思就会提高阅读时候的开销。我每次看到这种东西的时候都不得不去想自增操作本身是干什么, 然后这个语句又在干什么。直到我熟练了,形成了一个没什么卵用的条件反射。

开发者更加注重代码质量

如果开发者水平相似,那么更加注重代码质量的人写出来的代码未必就更短。一个很简单的例子就是空行。 把一个 scope 里面的代码按照逻辑用空行隔开,然后在每个空行隔开的 block 上面加一行注释, 讲解这个 block 干了什么;这样一来看代码的时候甚至不需要去关心 block 内部的逻辑, 对于几百行代码我们只要知道他分了几块,这几块分别干了什么事就好了;对于 block 内部的临时变量, 你也很清楚他们不会在其他 block 当中用到,减小了阅读代码时脑子里面变量追踪的个数。

很显然,因为我增加了空行和注释,代码变长了但是更清晰了,质量也更高。同样的道理也适用于 抽象出函数,进行更高层次的重构等有可能会增加代码的行数的情况。

使用了其他的语言而导致代码变短

这个情况就比较微妙了。有些语言比如 Java 相比一些动态语言,和一些设计思想相当不同的语言,比如 Haskell 明显比较啰嗦。但是正如我上面所说,简洁的代码可能行数更长,表意清晰的语言也可能比较啰嗦。 这当然不是说越啰嗦越好,这就是走向了另一个极端;到底好不好分析一下就知道了。我们引用一个原则, don’t pay for what you don’t need

我们来看看一门啰嗦的语言里面的东西是不是都是必要而且经常需要的。访问限制总是需要的吧,这是隔离原则, 可以保证程序结构的抽象性与模块化。我们看看其他语言做了什么,比如 Go ,变量首字母大写默认为 public , 小写是 private 。确实省事,但是这样好像也有问题。把命名规范和语言特性绑在一起让人觉得不是很舒服。 但是我们也能从中发现一个规律,好的东西总是要有的,问题是要如何把它表示出来。 你可以把它明确地写出来,像 Java 一样一个 method 可以有好多个修饰符;或者你可以用一些约定俗成的东西 把它藏起来。好像省了事,但是你总要为有用的东西付出代价。那么代价去哪里了?有一些语言设计就把代价 转嫁给了开发者。为了代码看上去简单很多,语言把一些特性设计成约定俗成的东西。 诸如 “我们其实是面向对象的,只不过你不用把 class 每次都写出来,你不写的话我们默认是在一个 全局 class 内”,“没有专门设计枚举,但是你可以这样写。。。然后这样。。。你看,最后 效果和枚举是一样的”,“类型强制转化不用明确写出来,我们有一个默认的转换关系,你只需要把它记下来”。 这就造成了其他语言的开发者很难一眼就看懂这个语言的代码,因为它把很多信息都藏了起来, 你不去专门地学习这门语言,你就不知道它约定俗成了多少东西。Java 在这方面表现就很好, 不管你第一语言是什么,你总是一眼就能看出来 Java 代码在干什么。

还有一种策略就是直接把这些好的特性给阉割掉。比如动态语言削弱了类型系统。一些小白经常会说什么, 这个语言定义变量不用指定类型多舒服呀,我不用费脑子去想类型这些麻烦的东西了。 但是你会发现计算机科学没有银弹,开销基本上是守恒的;没有类型系统、类型检查,写时一时爽,调试火葬场。 当然,不要误会我的意思,阉割未必就是不好的。当你在某种场景下,某些功能变成不需要的了, 那还是按照我上面提过的原则, don’t pay for what you don’t need ,阉割掉一些东西还是可以的。 比如说写自动化脚本的时候,我们只是想表达一个很简单的顺序逻辑,所以我们根本不需要面向对象系统; 如果出现了错误肯定是哪里出了问题,脚本也不可能去解决,所以错误、异常处理系统也可以阉割, 遇到错误直接当场 crash 就好。

结论

说了这么多,那到底如何评价代码的质量?简单的办法就是看花费在这个代码上的开销,比如时间开销; 那就一定要算上开发、后续维护、测试和修改、重构所有的时间开销,而不能片面地追求开发时候写得快。 通过上述条件计算的开销小的代码未必很短,但一定是简单清晰的。

测试驱动开发

测试驱动开发是软件工程当中一个很重要的概念。但是很多人对此持有教条主义观点,流传甚广的一个言论就是 每写一个函数就要马上补上单元测试。

分析一种说法的时候我们要先考虑,它到底想解决什么样的问题。我们来看看测试驱动开发到底想解决什么样的问题。 一个大型软件或系统的开发周期可能会非常长,代码的规模也非常大。开发团队有可能人数众多,那么我们很自然 地就会把整个系统分成几个模块,然后每个小团队乃至每个人负责一部分。那么最后如何进行交接呢? 很显然我们要保证每个模块的正确性,所以我们需要测试。另外开发周期太长的话,很可能会使我们的开发者丧失信心。 如果系统的每一个部件出错的概率是百分之一,这个系统有一万个部件,那最终会出问题的概率是相当大的。 就好像是生产一辆汽车,生产轮胎的不能保证高速行驶下轮胎总不会爆,生产车门的不保证车门总是能够 正常地开启关闭,生产刹车的也不保证刹车总是灵的,敢问生产车辆的还敢让这辆车出厂吗。所以我们需要 在进行大规模开发的时候保障开发者的信心。那我们就每做一步便跟上相应的测试,让大家知道 我们是有进展的,这样一砖一瓦地就可以把整个系统构建起来。

下面我们来看看一步一个测试这种说法。首先是对于个人向团队提交的自己编写的模块,这当然要测试,否则 别人怎么会有信心来使用你的模块。但是对于完全由你自己编写的代码,完全可以等你把自己的部分全部做好了再写测试。 毕竟这个信心是给你自己的,如果你对自己写的东西有把握,你完全可以先搁置测试。简单来说, 每写一个函数就写它的测试函数这种行为是没有必要的。不仅没必要,过于频繁地写测试还会造成问题。 一个很大的问题就在于写测试也是要花费时间的,你写的测试其实就已经变成了沉没成本。 设计阶段是有可能出现程序设计错误的。这个时候重写代码是需要勇气的,如果这个代码还带有测试那就更难了。 万一你要删除或者彻底重写某些模块,那么接口都变了,测试条件也变了,你的测试代码有可能直接没用了。 考虑到这些你还敢改变原有的糟糕设计吗,还是说缝缝补补继续呢。测试本质上是一种限制手段, 适度的限制是有意义的,但你也要当心不要被它束缚住手脚。

然后就是测试覆盖率的问题。我发现一个东西一旦可以通过数字化的标准来进行衡量,不论它能带来多大的好处, 副作用往往都是巨大的。测试覆盖率这种东西有了之后,有些人就开始变得非常较真,比如说“你覆盖率怎么这么低”, “你这个人怎么不写测试,那你的代码一定很差”……诸如此类。

对于测试,我最后给出一点自己的建议。在个人的层面上,不管你做的是大型项目当中的一个部分, 还是你完全个人开发的项目,尽量不要着急写测试。一个系统总可以分成几个更小的模块,如果你是从一些互不相干的 小模块入手,可以先不要给它们写测试。等到你要写一些有依赖关系的东西的时候再对被依赖的模块动手。 在系统的层面上,测试是必要,个人交付的模块必须有测试。但是也请记住测试也不是银弹,因为组合数爆炸的原理, 你只能通过测试说明程序有 BUG 但不能通过测试得出它没有 BUG。最后不要太较真测试覆盖率,带测试的代码 未必质量高,优雅的代码也不靠测试来证明。

不完美、不统一的世界

我小时候总有一点完美主义的倾向,这甚至导致了我在上初中的时候产生了严重的强迫症, 具体表现为我希望在上课的时候能够百分之一百聚精会神地听老师讲课。为了做到这一点, 我必须抛弃所有的杂念。但这显然是不可能的,就好像是我让你千万别想粉红色的大象,那么请告诉我你现在想到了什么。 毫无疑问是粉红色的大象了。人的思维应当是一个非常精妙的东西了吧,但就是这样,人也无法保证百分之一百的专注。 所谓的 pure 在世界上的其他地方更是一种妄想了。所谓的百分之一百基本上是不存在的,没有纯金也没有纯氧。 我觉得世间的很多东西都处于一种奇妙的平衡当中。在我前面所提到的语言设计、测试中都有这样的道理。 当你试图在开发上图快的时候,你就要想想这样是不是会让代码的阅读和调试变得困难。

而且世界是不统一的。我曾经想在信号与系统当中把傅里叶变换与拉普拉斯完全统一。如果你也学过信号与系统, 我大概能猜到你在想什么,但我说的可不是公式推导上的那个统一。我指的是他们各自所有方法集的统一。 比如说信号流图之类,三种变换都有,就很统一。但是几种变换与时域之间的对应关系看上去就一点都不统一, 朱莉准则之类的东西在傅里叶变换里面明显就没有对应的方法。我在试图统一它们的时候就遇到了极大的阻力。 后来我才想明白了,一切其实都很简单,这就是矛盾的个性,世界当然不会是完全统一的。 如果傅里叶变换的方法集和拉普拉斯、Z 变换是完全重合的,那么后面两种变换不就没有存在的必要了吗。 这给我带来的启示就是不要试图去统一所有的东西。我们经常会把新的东西与我们以前学的东西做对比, 这样当然有助于学习;但是如果对应不上,那就把它当成是新的知识点。比如说学 Haskell 的时候, 你就不要试图把命令式语言的流程控制之类的东西对应过去,因为它根本就没有,这就是函数式编程的个性。

我最后想说的是,计算机科学没有银弹没有任何的语言、方法、理论能够在解决已有的问题的同时不引入新的问题。 就好像是对于分布式系统,你既要一致性,又要高可用性,还想着分区容错性,这是无法实现的。 你希望完全没有副作用,理解代码确实相对变简单了,但是因为你没有办法保存状态, 你就不得不采用一些非常 HACK 的方法,去解决其他语言当中很简单的问题(比如 Haskell 中的 Monad)。 总是有人提出一些新的概念,比如 NoSQL、敏捷开发、微服务等等,在看到它们带来的好处的同时也一定要知道 这一切必然都是有代价的。就好像是圆神的契约,你享受了五十年的繁荣,那么现在是时候为此付出代价了。


最后附上GitHub:https://github.com/gonearewe