对抗软件开发的复杂性

软件世界很复杂,人类对软件的依赖已经超出了人类生产和维护它们的能力,这不仅导致你的手机需要8G内存来保证应用程序的运行,还意味着黑客们总能在钻研足够长时间后找到一个可以利用的漏洞。当人类面临巨大的灾难和文明的崩溃时,信息技术将很难保留下来,因为它们实在太复杂。

本质的复杂性:如果我们不能让它遵循KISS原则怎么办?

KISS原则一向是一个很好的指导,确实给予开发人员很多帮助,但这也仅限于KISS原则有效的场合(此外,把复杂的东西变简单也是有难度的)。无法遵循KISS原则的情况经常出现,这是因为软件固有的复杂性无法被消除。

你可以使用最有表现力的编程语言,精准地运用设计模式,严格遵循先贤们留下的软件开发原则,构建最好的抽象,搭建完美的架构,以及和顶尖水准的合作者共事。软件开发得有条不紊,但依然改变不了一个事实:它需要至少30个源代码文件,使用超过50个依赖,连接数个随时可能发生意外的远程服务。你有一些无法删除的丑陋代码,由ifelse组成,它们的存在只是为了能够肮脏地解决一些特殊情况,从而让软件能够在不纯的现实世界跑起来。更有可能发生的是,你需要实现的需求本身就是变态的,问题域很复杂,它包含着这样那样的怪癖,让它照常运转本就需要开发人员在怒火下付出太多努力。总之,尽管你很想让它遵守KISS原则,但它看着一点也不KISS,即使你往这个蛋糕胚上涂再多奶油,你也遗忘不了它有一部分正在发霉的事实。

转移复杂性不能解决复杂性问题

人们至今为对抗复杂性做出的大部分努力仍然是转移复杂性,比如使用低代码平台或把部分问题交给SaaS解决。毕竟构成复杂性的事物的每一个部分似乎都是必要的,我们无法削减掉任何一部分。遗憾的是,转移复杂性的过程中通常会滋生新的复杂性,最后几乎注定引发崩溃——如果还没发生,那么总有一天会发生,到发生时就为时已晚。

自动化的困境

软件开发本质上是这样一件事:事无巨细地教会计算机做你希望它做的事。尽管软件开发人员已经站在硬件抽象层之上了,但计算机仍然非常愚蠢,无法领会任何暗示,你必须手把手教它们做事。要知道,教人做事有时都很不容易,而教一个像计算机这样真正的白痴做事,就更令人气馁。

需要“强人工智能”

自动化本身的复杂度是人类的工作至今没有被机器取代的真正原因。没有智能的机器需要人工处理大量细节,应付各种情况,这很快就会发展成为人类难以应付的问题。

与复杂性作战的人们最终会发现,解决这个世界级难题需要强人工智能,而在还没有强人工智能的今天,这事实上是指——我们——人件。可人类毕竟不是机械,既不精确,也不耐久,即使我们有按部就班就能解决一切问题的灵丹妙药也不够,它还得符合人体工程学。在这面前,复杂性成为了一个无解的问题,我们从未生活在一个飞机不会坠毁的世界。

小型原型的美好世界

小型原型整个项目可能只有几百行代码,没有自动化测试,代码可能很接近意大利面条,缺乏可维护性。它不是那种会让人在一个感性的夜晚打开阅读后让人不禁落泪,感叹人类智能荣光尚存的优雅小雪花,它只是可以运行。只要代码不是太有原创性,任意一个开发人员总是能够花上一小段时间来理解它,修改它。事实上,它可能比大多数更大更好的项目要容易阅读,因为它更接近人类解决问题时的天真直觉。

不难发现,很多软件本质上只是在一个小型原型上建立起来的充满防御工事的城堡。人们是怎么把事情搞砸的?

代码量更少是一种优势

小型原型的代码量较少,可能只有一两个源代码文件。尽管用代码量作为衡量代码质量的指标是一种经典谬误,但不可否认的是在其他因素相同的情况下,更少的代码确实更可读。

如果1000行代码和50行代码解决了相同的问题,那么恐怕没人会觉得50行代码不够好,这种情况下,那1000行代码显然充斥着太多无谓的抽象。当差距缩小至200行对50行时,开发者会本能地开始怀疑50行代码的版本是否太缺乏重构,询问自己它是不是在以一种缺乏远见的盲目方式解决问题——以至于代码量是如此之少——然而,这就是问题开始的地方。这里有太多的自我怀疑,迫使开发者用更不直观的代码去替换代码量较少的版本,构成这一切的根源仅仅是每个人在新手期犯过的与意大利面条代码有关的错误引起的PTSD。

高标准滋生复杂性

一个非常可悲的事实是,软件的可重用性/可扩展性越高,它就越复杂。让软件可重用/可扩展的本意是避免编写重复的程序,但使用这些程序的成本可能会随着复杂性增加从而抵消其好处。

开发人员可能在一个随机的时刻突然感受到以下痛苦之一,然后通过编码将痛苦转变成另一种类型,以便给未来的自己绊上一脚:

  • 细粒度重用:痛苦,使用起来太困难,很多样板代码。

  • 粗粒度重用:痛苦,难以适应多变的需求。

  • 高可扩展:痛苦,编写扩展需要扭曲或拆分原本干净流畅的代码逻辑。

  • 低可扩展:痛苦,需要复制粘贴代码以修改其实现。

抽象滋生复杂性

抽象是一种转移复杂性的手法。

重量级函数式编程范式在这方面做的工作很多,它们往编程语言里塞入更多原语来解决原语太少产生的复杂性问题:原语太少,意味着更多恼人的手工编码,项目因此变复杂。

但这其实只是把复杂性问题换了一种形式:原语太多,太多原语,难以记忆,难以理解,项目因此变复杂。

最佳实践滋生复杂性

状态机是布尔值开关(隐式状态机)的完美替代品,但状态机比布尔值开关需要更多编码。即使懂状态机的人也在用布尔值开关——对大多数问题来说,它的核心部分以布尔值开关表示更简单——它还没发展到需要用状态机的程度,遵循最佳实践其实是在滋生复杂性。

这是小型原型之所以更好的重要原因之一:当解决问题的方式不符合问题本身的规格时,复杂性就会滋生。这是与人性相悖的,人们在知道更好的方法时会希望把问题一次做好——摒弃低级的做事方法,从而滋生复杂性。

防御性编程滋生复杂性

在一个具有无限计算资源的硬件上,我们可以忽略一段代码里潜在的非语法/非配置错误,重新运行直到它符合编写时预想的那条happy path——直到它成功为止。

遗憾的是,现实世界没有无限的计算资源,失败和重试总是有成本,有时成本还很高昂,甚至要引入人件作为润滑剂。

防御性编程经常与代码注释相辅相成,缺乏注释的防御性代码让人摸不着头脑,拥有注释的防御性代码没人敢删除。

简单问题被复杂的现实需求劫持

理想的软件不必因各种现实需求扭曲代码:

  • 不需要版本之间的兼容性。

  • 不需要记录当下没人需要的日志。

  • 不需要编写代码配合“督战队”的监控。

  • 不需要自动化测试,更不需要几秒内可以运行完的测试。

  • 不需要预见未来,不需要考虑可扩展性。

  • 不需要可审计性,不需要打印报表。

  • 不需要合规性,删除记录就是删除记录。

  • ……

一旦一个项目超出了小型原型的程度,各种需求就层层加码而来,想要维持它的简单性变得越来越难。

美丽被性能竞赛打扰

简单的代码太慢。快速的代码太脏。

更快的硬件允许简单的慢代码存在,直到有其他人开始写快速的脏代码,压榨机能的竞争使参与者陷入一个内卷化的漩涡。一些领域的内卷最终会结束,一些领域的内卷还看不到结束的希望。

如果人们能够接受一个Twitter需要花1分钟来加载新内容的世界,世界会更美好。

康威定律

系统架构受制于通讯方式,通讯带来复杂性,架构带来复杂性。

一个单体架构出现意外故障的原因通常是硬件坏了,很少是通讯问题,因为通讯有限且易于管理,尚在人类理智能够应付的范围以内。一个SOA/微服务架构出现意外故障的原因可以千奇百怪,因为这里有太多通讯步骤,于是需要大量的防御性编程,需要故障转移,执行事务补偿。

诚然,单体架构可能不能很好地像SOA/微服务那样水平扩展,但指数式增加的通讯复杂性成本是否使这笔交易划算仍然是值得商榷的,这也是为什么总是会出现反对微服务的声音。

可消除的复杂性

人体工程学:符合直觉的默认行为

默认值很重要,好的默认值可以大量减少代码,并且不会让人觉得自己错过了什么,因为它让人觉得就应该是那样的。

开发人员有时会觉得默认值是一种不好的做法,因为它似乎会帮助掩盖和忽略应该被人们关注的细节——你怎么知道这个默认值是对的?这种事无巨细的严谨精神很好,足够适合教育计算机这个毫无智能的超级白痴,但没有考虑到人体工程学,没有考虑到让人类面对一个需要详细填充细节的东西可能带来的麻烦。如果一个程序依赖大量人工填充的细节来运行,那么这个程序的臃肿脆弱会让人心烦意乱。

做出稳定性承诺:达到1.0版本

1.0版本意味着内容进入稳定状态,在这些软件里做出重大修改会比以前困难无数倍,所有不兼容的修改都会被延后至下一个主版本。

人们自认为可以把minor版本视作1.0版本,很多项目都这么做,但实际结果像是自欺欺人,因为到处都在狂刷minor版本号。在我看来,没有达到1.0就是没有做出稳定性承诺,没有做出承诺就会导致轻率的举动,在此之上搭建的事物就越不稳定,问题就会有变得复杂化的趋势。一个理想的软件,它的每一个底层都应该比它的上层建筑稳定。

基于语义化版本号的强制性保证在这里有一定作用,但它不是全部:人们有一种本能般的“跟上最新版”的冲动,最重要的是做出“不重新设计已经完善的软件”的承诺,而不是自以为是地用主版本号隔离两个版本。

重新导出:创建prelude

prelude应该被视作是来自用户空间的标准库,它的稳定性应该与标准库相当,但更灵活。

使用prelude最主要的理由是避免重复的导入语句,这是一个经常被忽略的改进。无论是依赖IDE的自动导入,还是手动编写导入语句都会导致代码量增加,从而小小地增加项目的复杂性。由于这种复杂性的影响在表面上如此之小,人们通常不会将它们视作复杂性的一部分,直到你发现自己在每个项目里导入相同的模块,重复使用相同的导入语句,并且每个源代码的文件在编辑器里打开时,导入语句占据了屏幕的三分之一。这种潜移默化的影响足以形成破窗效应,复杂性的堆砌将变得越来越肆无忌惮。

消除依赖

这个世界上充满了草台班子,以至于依赖在很大程度上是不可信任的,只在生产中出现的那些奇怪问题多半由依赖引起,而不是项目代码本身。回顾一下过去10年的新闻,你会发现几乎能出问题的地方全都出过问题,我甚至很难相信下一个10年的情况会有所好转。

阻止人们消除依赖的主要原因是开发成本过高。巨头们往往没有这个问题,它们可以消耗大量的人力物力来研发自己的芯片,自己的编译器,自己的操作系统。当一个大量使用信息技术的企业消除了足够多的依赖时,它的护城河会高到普通人难以想象的地步,以至于基本上不可能因为技术方面的原因而陷入失败。巨头普遍利用这种隐性优势进一步巩固其地位,反垄断法根本解决不了这方面的问题。

关注文件拆分:适时使用大代码文件

在这个行业里足够久,你可能遇见过或至少听说过这样的传奇故事:有一个程序的源码实际上是包含数万行代码的单一文件,在这样的项目里维护和开发更多功能变得非常困难,因为随便修改一处代码都可能导致程序无法运行。

大多数人在这个故事中看到的部分是大代码文件不好,因此人们倾向于将代码拆分到数个小文件里,却忘记了这会带来认知负担,以及真正的问题是写坏代码,而不是代码太长。对于任何注意到代码应该尽可能少地污染全局作用域的程序员来说,将代码拆分到小文件带来的最大优势来自于编辑器内的文件系统导航控件的特性

。而在实际的编码过程中,在大量小文件之间导航经常会变得令人厌烦,你不想在编辑器里打开一个又一个文件,在一个又一个文件里切来换去。在一个理想的世界里,我们不应该同时打开超过2个文件。此外,对于相当一部分现代编程语言来说,拆分小文件会导致很多不必要的导入代码,导致一次又一次重新导入相同的内容,并且很容易创造不必要的代码隔离,导致程序变得越来越繁琐。