Published on

《A Philosophy of Software Design》心得 I — 写出复杂度低的软体

目錄

还记得刚入软体工程师这行时,在熬过了前期熟悉公司程式库,脱离了只能改改文案、写一些简单的函式后,我终于被分派到一张估时三天的任务。那时我心里想「终于是我好好发挥,证明自己能写出点东西的时候」。在经过两天多的努力,我总算发出了 PR (pull request)。因为自己有先测试过,所以觉得应该没问题,可以顺利通过 code review 吧 (?)

结果我完全大错特错!那次的 code review 收到将近二十处的评论。没错,快要二十个评论!其中不乏「这边为什么需要这个?」、「为什么这里这样写?」公司里严谨的资深前辈在 Gitlab 上的提问。在收到这些反馈后我重新思考,发现的确我提交的第一个版本,充满了冗余的程式,或是可以被精简的逻辑,甚至有着我自己没有意识到,但却会影响到其他程式的地方。

好险当时有前辈的把关,现在回头看,要是当时没有 code review 直接让我写的那些程式进到程式库中,不知道会对整体程式库的健康程度损害多少 😓。不过也是在那次的 code review,我看到了自己与资深工程师的距离。

最近在读史丹佛大学教授 John Ousterhout 前几年出的 《A Philosophy of Software Design》一书,有系统地归纳软体设计常见的问题。这时我才想到当年的我,虽然我写出来的东西,功能上没有问题,但是在「复杂度 (complexity)」上却大有问题!

什么是复杂度 (complexity)?

谈到软体开发的复杂度,有几种看待的角度,举例来说演算法很常会用时间复杂度 (time complexity) 以及空间复杂度 (space complexity) 来衡量演算法的效能。不过这篇文章想探讨的复杂度,不是时间与空间复杂度,而是软体设计有多易懂与易改。

在《A Philosophy of Software Design》一书当中,作者 John Ousterhout 谈到,如果某段程式没办法轻易让其他人读懂,那就是太复杂了;如果要修改某段程式,需要同时修改到许多其他地方,那也是太复杂了;或是要修某段程式码,却在改的过程引出另一个 bug,这也代表原本的写法复杂度太高。

假如你也有过类似以下的经验大概会更有感 — 自己写的程式码明明怎么看都没问题,但却仍是报错,最后一路往下追,才发现是因为某段历史遗迹 (legacy code) 导致 (于是脑中怒骂前人怎么埋了个炸弹)。这种隐藏式炸弹,可说是软体设计复杂度太高的极致表现 (当然这种极致是我们必须尽可能避免的)。

反过来说,假如你写的程式别人一看就懂、改了不会影响到其他程式码、改了不会引发其他错误,那么恭喜你,你写的程式复杂度是低的。在软体设计有个原则叫松散耦合 (loosely-coupled),许多人也会说「低耦合」,这是指软体之间的关联性低 (换句话说改了 A,不会影响到 B)。当遵照低耦合原则,通常也会设计出复杂度低的软体。

复杂度过高的三大病症

《A Philosophy of Software Design》有进一步谈到三个复杂度过高的表征,当写程式时遇到下面三种状况,就是需要警觉的时候!

改动时需要大量改动

试想有一个网站有十多个页面,页面中都有用到某一个主题色,而在第一版的设计中,设计师请前端工程师把主题色设成亮红色,但是到了第二版,设计师觉得不行,要暗红色会比较好看。假如这个主题色,是被分别写到十多个页面中将近五十个元件中,那么当要改动颜色时,就需要分别去改五十多个地方。 像是下面这样,如果要改颜色,要去三个页面分别改:

// A 页面中
<Button color="#ff5050"/>
// B 页面中
<Box backgroundColor="#ff5050" />
// C 页面中
<SnackBar color="#ff5050" />

若要解决这个问题,可以把主题色抽出来,让我们只需改一个地方,就能全都改:

// 定义好主题色,让其他地方饮用
// 之后只要改主题色,其他地方都会改,因此只需改一个地方即可
const primary = "#ff5050"
// A 页面中
<Button color={primary}/>
// B 页面中
<Box backgroundColor={primary} />
// C 页面中
<SnackBar color={primary} />

让其他开发者认知负担过重

这边的认知负担是指「事先需要知道多少先备知识」,而认知负担过重则代表,其他开发者如果想要读懂某段程式码,会需要先了解的先备知识过高。打个比方来说,假如用一台微波炉之前,要先去了解微波炉背后的物理原理,或是需要先了解微波炉在制造过程的细节,那么多数人应该都不会知道该怎么用一台微波炉。这就是所谓认知负担过重。

不过好加在,现代的微波炉设计简单到小朋友都能够轻易操作,这种让操作者不须知道太多先备知识,可以说是好设计的典范。同样的道理,在设计程式时,如果设计的让其他协作者,需要花很多时间去了解,那这时的复杂度就会是过高的。

举实际的例子来说,许多程式语言会需要处理记忆体泄漏 (memory leak) 的问题,假如你写了某个函式,在使用时需要额外有步骤再去释放记忆体,这时使用该函式的人,很可能会因为忘了释放记忆体,而造成记忆体泄漏;然而,如果能把该部分的程式设计成,用的人不用处理记忆体释放,而是程式会自动做掉那块,那么对用的人来说,就会比较简单 (换句话说,复杂度比较低)。

不知道不知道 (Unknown unknown)

或许你也有遇过,你改了某段程式,然后在 QA 时被反应说有地方没有照规格改;收到反应后你进一步去查,才发现有个本来不知道要改的地方,原来需要改。这种「不知道自己不知道」的状况,多数时候也意味着软体的复杂度过高。

以前端工程近年来常有的双平台语言来说 (例如 React Native 与 Flutter),因为 iOS 与 Android 可能在某些设定上不太一样,因此如果 API 设计的不好,就会导致踩到意想不到的坑。以我自己写过的 React Native 来说,用某些 API 时,有时在某些地方会需要去判断作业系统,在依据作业系统去做特别的调整。这时候若没有详细读文件,很可能就会踩了坑而不自知。然而复杂度低的设计,会是让使用套件的人,不用去担心这些细节,不再需要担心「我会不会漏掉什么没注意」。

这边提供一个我踩过的坑,是 React Native 提供的 LayoutAnimation API,假如在 Android 平台就必须额外加上以下判断。假如没有细读文件,很可能会漏掉。这很容易让开发者陷入「不知道自己不知道」的问题中。

if (Platform.OS === 'android') {
  if (UIManager.setLayoutAnimationEnabledExperimental) {
    UIManager.setLayoutAnimationEnabledExperimental(true)
  }
}

如何降低软体设计的复杂度

希望以上有让大家更了解软体复杂度相关的概念。读到这里相信很自然会问的下个问题是「该如何降低软体设计时的复杂度呢」。谈到降低软体开发的复杂度,最直觉的方式就是把程式写得简单好懂,把可能会出现的极端状况排除,这样就能避免自己或别人在未来开发时,不小心踩到过去写的程式的坑。

除了把程式写得更简单一点,另一个更常见的方式,就是透过模组化的方式 (modulization),把程式拆成各个模组,模组之间彼此独立 (最理想的状况是完全不影响彼此),这能让用某个模组的开发者,不用担心自己做了什么,导致其他模组炸掉。这种方式又叫模组化设计 (modular design)。

模组化设计是个不小的主题,在下一篇我将统整 《A Philosophy of Software Design》一书谈到的,如何透过模组化设计来降低软体设计复杂度的实际方法。感谢读到这里的大家,我们下次见~