svdu

软件开发中的心智负担

我常常喜欢将软件工程项目与土木工程项目视作相同的东西:客户是业主,领导是监理审计单位,产品经理是设计单位,开发是施工单位。互联网上的很多梗图都将开发团队视作上述三者的对立方,这和现实里的工程差不多,即施工单位在整个项目中属于“弱势群体”。

当然,这并不是这篇文章讨论重点。本文实际上是以“施工”的角度出发,谈一谈软件工程项目中的一个难点——心智负担。

什么是心智负担?

简单来说心智负担是指完成一项工作所需要占用的大脑资源,包括但不限于你所拥有的知识、记忆力以及逻辑思维和数学能力。

举个简单的例子,有一个100x100的抽屉的柜子,以及一个放着各种各样形状和各种大小的积木的篮子,然后依次给你一些纸条,上面有着许多的操作让你执行,执行完毕之后给你下一张。

例如:

  1. 从篮子里面选出3个小号蓝色圆柱形积木,其中的一号积木放到有红色的三角积木的柜子里面,如果有红色三角积木,则将其放在别处,二号积木放到空柜子,三号积木放到大号绿色方形积木往左数上方第三个柜子里面。完成后需要把所有柜子合上。
  2. 把上一条指令中的一号小号蓝色圆柱形积木放到三号积木的右下方第五个柜子里。
  3. 把柜子里面所有的拱形积木拿出来,并把柜子合上。
  4. 把上一条指令中取出拱形积木的柜子里面放入紫色中号圆锥积木,并把柜子合上。
  5. ......

计算机的工作原理和工作内容大致也是如此:获得输入、执行操作、进行输出;从篮子里取出积木,按照条件找到对应的柜子,然后把积木放进去。

但是如果把这个工作交给人脑来处理呢?一万个柜子,假设里面已经放了不少积木了,那么这些操作可能就变得非常复杂了,每一次操作我们可能需要记住上一次开合了哪些柜子,但注意,我这里使用的是“可能需要”,因此为可能之后的纸条上并不会让你用到之前你所记忆的柜子。但是因为你不知道到底需不需要,所以你还是必须记在脑子里。

这就是心智负担的一种简单体现,当然,程序员所要面对的问题复杂度更高一些,并且所面对的并不是柜子和积木这类具象的东西,而是一堆抽象的根本不存在的1和0的信息。

心智负担的另一个特点即它是动态的,可能一开始这份工作的心智负担并不算高,如果柜子一开始全部是空的,那么这些纸条上的操作都显得无比轻松。但是随着纸条越来越多,柜子里面的积木越来越多,你所需要记忆的在某个纸条中开合过的柜子越来越多,心智负担会逐步提升。

超出心智负担会使得我们无法工作吗?就上面的例子来看,并不会,对于信息行业从业者来说也不会。但是这意味着你会花更多的时间,来处理一些本来就很简单的事情上——把上一个打开过的柜子找到,把里面的积木拿出来,把新的积木放进去。但是这些“愚笨的”解决问题的方法,会消费掉你有限的“脑力”,最终让你变得疲惫。这或许也是 AI 的意义吧。虽然它现阶段没法替代脑力劳动者,但是会释放更多的“脑力”,让劳动者更加轻松。

如果你是非计算机技术人员,那么你的阅读大概也就到此为止了。因为接下来的内容将涉及到大量的专业术语。

工具的心智负担

现代软件开发离不开工具,毕竟不会有人从烧沙子开始编写计算机程序。软件开发工具由于工具的开发的需求、时代、人员水平等客观原因,不仅仅会在趁手与否有所不同,也会对使用工具的人造成不同的心智负担。

下面是两个典型的例子:

内存管理

内存管理的心智负担主要出现在 C/C++ 这类需要手动管理内存的语言上,这类语言为了最大限度的去使用内存,将内存管理工作交付给了开发者决定。

每一块内存有申请就必须有释放,假设是某个业务功能中临时申请的内存,则更有必要在业务功能结束后释放掉内存。如果没有正确释放,则会导致这块内存再之后可能再也用不了了,最终导致运算资源被浪费从而产生“内存泄漏”。

那么我们简单的考虑,是不是只要在函数开头申请在末尾释放就可以了呢?对于简单的需求来说或许如此,但当你的业务需求变得复杂,又有多人一起参与其中,那么内存的释放可能就不一定是在末尾进行了。

// The allocated data should be free outside
void doSomething(char **input)
{
  char *data = malloc(typeof(char) * 32);
  // The code you written
  // ...
  // ...
  *input = data;
}

当然,如果涉及到并发和数据竞争,那么内存管理的心智负担会更大。

变量

对于某些编程语言来说,变量也是一个会产生心智负担的东西,这主要出现在没有块作用域而且没有常量的语言中。通常来看似乎还好,但是当代码变得具有一定复杂度时,保证已经声明过的变量不会在各种分支和循环中不被修改,变成了一件非常难做到的事情。

以 JavaScript 为例,JS 的 var 关键字变量是函数级作用域,并且这个关键字可以直接复写已经声明的变量。

function doSomething() {
  var i = 3;
  for (var i = 0; i < 10; ++i) {
    // do something
  }
  return i; // now `i` is 10
}

这也是为什么,JS 在后面的规范中引入了 constlet 两个修正性的关键字。但是 var 的历史遗留问题会导致当维护一个比较旧并且整体架构比较糟糕的系统时,心智负担会变得巨大无比。

状态的心智负担

对于刚入门编程的人来说,“状态”这一概念是很难理解的。无论大家对于程序的定义是怎么样的,是”算法加上数据结构“,还是”接口加上实现“,但是这些都是对于程序还没有运行时的描述。程序一旦开始运行,上述的这一切都会被转换为对于状态的控制。

函数式编程是一种独特的、纯粹的编程范式,其核心在于函数的概念是与数学中的函数的概念对应的。例如 f(x) = x + 1 这个函数,一个给定输入 x,必然会产生一个输出 x + 1 。那么我们就可以将 f(x) 的结果视作是对于 x 的一次变换,即 x 作为状态经过函数 f 的转变。

回到现实的编程中,我们绝大多数的函数也都是有输入和输出的,虽然你的输入不一定是从函数的参数里面来的,有可能是从数据库或者是传感器采集到的真实世界的数据;输出也不一定是函数的输出,有可能是输出到数据库或者某个电子屏幕上。但是总归我们的函数一定会有输入和输出。毕竟一个啥都不干的函数,确实没啥存在的必要(通常来看是如此,但是现实世界中只有输出的情况也有很多。)

现实中的软件不可能只有一个状态,光是一个函数中我们可能就要维护若干个状态(变量),我们需要根据这些状态来决定其他状态的变换。那么在这种情况下,如果一个状态的维护出现问题,例如它在输入时就不是我们想要的,或者说它在变换的过程中变成了一个我们没有预料到的情况,那么会出现什么问题呢?——短期来看,绝大多数的现实中的软件并不会因为出现了一到两个错误状态就崩溃,但是错误的状态就犹如一个癌细胞,如果不及时处理它就会不断扩散,最终导致系统崩溃。

简单且实际的例子:

假设一个银行系统中,用户A向用户B转账100元。系统更新了用户A的余额减少。但因某种原因没能更新用户B的余额增加(可能是程序设计错误,甚至有可能是太阳风暴的影响),这个错误状态会导致资金凭空消失。随着越来越多的转账操作,系统的账目平衡会越来越混乱,最终可能需要停机修复并手动对账。

尽管现代化的开发工具,以及程序员、测试人员和QA人员会尽所有努力让代码和系统可以处理所有的错误状态,但是人的大脑始终是有限的,并且人类和计算机都很难去处理现实世界的真实随机,毕竟谁也避免不了宇宙射线造成的数据位反转。

时至今日面对状态问题,最有效的解决办法只有两个(通俗地讲,因为我也不知道到底该用什么专业术语):

整体成功、整体失败

以我们上面的例子来说明一下这个解决办法。

假设用户A向用户B转账100元。
假设用户A扣减余额成功,但是用户B扣减余额失败。
那么这个时候用户A扣减余额的操作将会被撤销(回滚)。
用户A向用户B转账的操作则会失败。

虽然用户A向用户B转账对于用户来说一个操作,但是对于系统和开发人员来说,其实是两步操作,即先从一个账户上面扣款,然后再加到另一个账户上。

整体成功、整体失败的策略可以面对绝大多数现实业务场景中可能会出现的问题。它能够尽可能地确保状态之间的一致性,确保所有状态都是正确的。但是这只能确保状态系统内不会出现不同的错误,它并不能避免因为外部输入问题导致的状态错误。

Let it crush (任其崩溃)

有一个笑话是说“测试工程师进入一家酒吧点了碗炒饭,然后酒吧爆炸了”。这就是外部输入问题导致的状态错误,对于这种问题,如果是和用户交互的部分实际上反而是最简单的,因为从用户界面、数据处理再到数据库,层层把关,能够杜绝绝大多数输入错误的问题。

但是如果输入输出是外部世界呢?例如从一个传感器获取数据数据,假设这个传感器坏了会怎么样。

没有经验的开发人员可能会设定一个缺省值,如果设备损坏读取不到值之后,那么就会用这个缺省值作为条件进行处理。但是这个处理方法有一个缺陷,即这个缺省值不是传感器的正常值范围。

正确的做法实际上是,在整个模块上一级增设一个低耦合的监视模块/系统(Supervisor),所有子模块、子系统的崩溃都不会影响到监视模块/系统。而如果传感器坏掉之后,那就让整个模块甚至子系统都直接崩溃掉就好了。如果监视模块/系统的策略允许,则尝试重启它,甚至也可以直接放着不管也好。

新手程序员会觉得开发语言提供的异常是一个很棘手的东西,因为它会导致整个系统停机。但实际上在绝大多数情况下,哪怕没有监视模块/系统,让系统直接崩掉也好过带病运行。

开发环境的心智负担

如果说上述两者都是开发过程中客观存在不可避免的心智负担,那么本节所要讨论的“框架与风格的心智负担”则是完全人为的结果。

现代的高级的开发环境,通常可以使用许许多多的工具来辅助我们的开发,包括但不限于:语言特性、开发工具的功能(IDE)、外部工具。在这些工具的加持下,绝大多数的信息系统的开发都是一件不太难的事情,也不会让我们的心智负担超载,而是维持在一个平均大多数人都能够处理的水平。

但这些工具毕竟都是可选项,一些所谓的经验人士会非常“先验主义”地想当然地认为老方法已经足够了,而不会在项目中使用它们,甚至禁止开发团队的成员使用它们。然而实际上,正确地做法应该是根据项目的需要,从已有的成熟的工具中选择最适合这个项目的,为开发团队成员带来良好的开发体验。

例如我的一个真实案例,我此前某家公司的领导禁止使用 JavaScript 中的 Array.mapArray.filter 的方法,而是青睐于使用 for 循环:

const a = [1, 2, 3, 4];

// Bad
var b = [];
for(var i = 0; i < a.length; ++i) {
  b.push(a[i] + 1);
}

这会有什么问题呢?结合上述此前讲述的两点来看,一是存在工具的心智负担——多了两个变量 b 和 i;二是状态的心智负担——循环内部的下标取值则是一个状态上的心智负担。

而正确的做法是什么呢?

const b = a.map(x => x + 1);

它不仅仅减少了开发者的心智负担,循环时的状态不需要手工去维护,另一方面代码也足够简洁,并不会凭空出现一个之后可能会重复用到的变量。

当然,一个好的框架也会减少用户的心智负担。绝大多数系统中都有操作鉴权的这个动作,通常是鉴定用户是否具备某个权限是否可以执行对应的操作。一个好的框架会提供给开发人员足够多的工具可以在各个颗粒度下进行控制,而坏的框架会增加开发人员的心智负担,让人难以下手。

一个简单的例子,通过注解来实现重新定义路由以及将控制器级的认证和鉴权,并可以排除某一个操作的鉴权操作。

#[Authorize]
class DashboardController {

    // GET /dashboard/public-info
    #[AllowAnonymous]
    function getPublicInfo() {
    }

    // GET /dashbaord/datalist
    #[Get('/dashboard/datalist')]
    function getList() {
    }
}

而一个糟糕的架构,处理这个问题则会变得无比复杂,甚至,可能根本做不到。

// 主要鉴权在 BaseController 里面
class Dashboard extends BaseController {

    // 不知道要改动其他什么地方才能让这个控制器方法不参与鉴权
    function publicInfo() {
      if (!request()->isGet()) {
          // ...
      }
    }

    // 不能给路径进行别名操作
    function datalist() {
      // 只能在函数内部判断是不是想要的 HTTP 方法
      if (!request()->isGet()) {
          // ...
      }
    }
}

有人会反驳“虽然这样写麻烦一点,但是它性能好啊。”可惜现实世界里,人类对于计算机的性能并不敏感,绝大多数的普通人对于300毫秒左右的延迟都没有感觉,但是300毫秒对于计算机网络来说,都是一个比较慢的水平了。而上述两个例子中的错误的示范,所带来的性能优势,可能根本人类根本感知不到。但是对于开发人员来说,痛苦的时间可能就不只是300毫秒了。

从入门走向资深

如果说初级开发人员只需要保证自己负责功能是正确的,那么高级开发人员,则需要从更加宏观的视角去确保整个系统的正确性。

高级开发人员,或者所谓的架构设计人员,不仅仅应该考虑系统的性能、正确性、可靠性等问题,同时也应该考虑为其他开发人员带来的开发体验(Development Experience)。如何设计一个框架,让开发人员能够快速开发,在低心智负担的场景下完成复杂的功能,这对于一个高级开发人员来说是一个非常重要的能力。当然就算你不需要设计一个框架,仅仅是你设计出来的库和方法,也应该尽可能让使用它的人用起来感到舒适和轻松。

当然,从入门走向资深,并不是说一定要变成了高级开发人员才需要做到上面这些要求。哪怕只是你自己一个人使用,或者说场景足够单一,你也应该尽可能将一个模块设计地足够简单,让自己哪怕在两个月后看见调用这个模块时,也不会有疑惑:“这到底什么玩意儿”。

不过,随着经济下行越来越多的需求是存量的,初级开发人员不能接受到新的需求,或者新的项目也是基于老项目的修改,这些情况下,初级开发人员所能获得的成长有限,而在实际开发过程中,所需要面对的心智负担也不用低。而开发人员面对高心智负担的庞然大物时,处理问题的方法会退回到一种能用就行的状态,而这样的结果会导致后面处理起来更加棘手。而高心智负担的软件项目,会随着维护和开发时的心智负担过高,而变得不可维护。

从现在走向未来

为什么更早之前的计算机软件开发没有心智负担问题呢?那时的软件开发多以自主研发的商业软件为主,每一家公司内部都有自己的解决方案,而这些问题也因为当时是以闭源为主,没有任何可以对比的情况,所以未能充分的体现出来。而到了如今,软件开发是以开源软件为核心,优质与劣质可以得到充分的比较了,心智负担才得以体现。

实际上,现代软件开发工程,使用成熟和便利的工具,搭配上人工智能,开发人员的心智负担会越来越低(当然存量的屎山项目除外),可以以更高的效率产出更多的代码。而不会使用这些现代化工具和人工智能的人,只会被时代所淘汰,就如同马车一样。

祝愿大家都能轻松编码!