[译文] 程序员不应信任任何人, 包括他们自己

1

本文为 《Programmers Should Never Trust Anyone, Not Even Themselves —— Sung Kim》 的中文译文

原文链接 (英文)

程序员应当保持警惕:

  • "我仔细检查过代码了"
  • "代码通过了测试"
  • "代码审核者通过了我的代码"

"所以我的代码正确吗?"

正确地编写代码是困难的, 校验代码的正确性是不可能的.
下面是一部分原因:

  • 通用性: 即使你的代码在当下的环境表现正常,
    它是否能在所有可能的用例、所有的设备、所有的时间都能正常运行?
  • 假通过: 不能通过测试表示代码一定有错误, 但是通过测试不代表代码一定没错误
  • 缺乏确定性: 你可以为你代码的正确性编写一个形式化证明, 但这个证明本身也需要证明正确性.
    校验校验本身的链条永无止境

追求代码绝对正确的行为是愚蠢的, 错误可能潜藏在一个你永远找不到的依赖中.
但我们不应为此感到绝望, 更深入的理解和细致的检查对于降低代码风险是有帮助的.

抽象

什么是 "更深入的理解"? 让我们聚焦于程序员经常提起的一个概念: 抽象.

抽象:

  • 是理解事物如何运作的心理模型
  • 是我们将 事物 A 当作 事物 B 的处理方式
  • 比喻来说:
    • 出现在大脑中的若干数据压缩之后的结果
    • 只见树木, 不见森林
  • 在日常生活中无处不在

"抽象" 这个词有许多种释义.
编程中它常用于表示代码层层实现而隐藏复杂性的方式.
本文中仅会使用这个词在认知学中的抽象概念

抽象的一些例子:

  • 我们看待银行, 将其 当作 是在简单地帮我们将钱保管起来
    • 现实世界中, 银行并不只是简单地将钱保管起来.
      我们的钱并非闲置在金库中, 银行会将大部分存款用于放贷或投资
    • 我们对银行的认知抽象之所以成立,
      是因为银行会持有足够的现金应对大部分取款行为
  • 我们看待时间, 将其 当作 对于所有人而言时间的流速是相同的
    • 每个人的时间会受到他们不同的运动速度和所处位置的不同重力产生细微的差异
    • 绕地运行的全球定位卫星必须每天微调内部时钟约 38 毫秒来应对这种时间差异
      (数据来源 (原文为英文))
    • 我们对时间的认知抽象之所以成立,
      是因为除了一些对于时间准确性有极高要求的工作场景,
      时间的差异性过于微小, 基本可以忽略不计

建立认知抽象的方式之一是细节摘除 (创建对复杂事物的简化观点).
比如, 大部分会开车的人并不了解汽车的内部工作原理,
他们对汽车的理解可以简化为:

  • 点火开关可以用于启动汽车
  • 油门可以使汽车前进
  • 刹车可以使汽车停止
  • 方向盘可以使汽车转向
  • 汽车需要消耗汽油或柴油

了解了上述抽象, 就可以允许大部分驾驶员将汽车开到想去的地方,
而无需了解汽车引擎的工作原理.

当我们使用编程语言时, 编程语言会提供对底层设备的抽象帮助我们操作电脑.

  • 基础语言功能 (诸如循环、条件语句、方法、语句和表达式等) 都是:
    • 对硬件级细节的抽象: CPU 指令、寄存器、标志位和 CPU 架构的细节等
    • 对操作系统的抽象: 调用栈管理和内存管理等
  • 可移植性: 编程语言替我们隐去了不同设备之间的差异
    • 任何编译后的 Java 程序 (比如, 一个 .jar 文件)
      应当 可以运行在任何安装了 Java 运行环境 (比如 JVM) 的设备上
    • 一段 Python 脚本
      应当 可以运行在任何安装了 Pyhton 解释器的设备上
    • 一个 C 语言程序
      应当 可以在任何安装了 C 语言编译器的设备上完成编译并运行

抽象失败

不幸的是, 抽象会失败.

  • 编程语言提供的抽象往往不能提供足够的代码性能.
    为了提升代码性能, 你需要了解硬件层和操作系统层的细节
  • 移植一些用到动态链接库或网络设施作为外部依赖的程序时,
    往往做不到简单地将程序搬到另一个设备上就能直接运行.
    额外的环境配置知识是必须的
  • 汽车拥有者如果只懂得最基础的汽车知识, 很可能就会被困在抛锚的汽车中.
    如果驾驶员不定期更换汽车的润滑油和机油就会缩短引擎的使用寿命

上面提到的驾驶员的认知抽象在短期内 (比如一次简单的驾驶) 是有效的,
但会在 (若干年) 长期中遭遇失败.
Joel Spolsky 将这种失败抽象形容为 "泄漏性的",
并提出了 泄漏性抽象定律 (原文为英文):

所有复杂抽象在某种程度上都是泄漏性的

这与统计学中的一条格言类似:

所有统计学模型都是错误的, 但有些是有用的

当我们编写代码时, 我们总是会使用泄漏性的抽象, 这里是一些例子:

  • 垃圾回收机制替我们免去了手动内存管理的负担
    (除非我们关心延迟抖动)
  • C++ 智能指针让内存更加安全
    (只要你不将任何原始指针储存于其中)
  • 哈希表操作效率更高, 具有 O(1) 复杂度
    (但是数组对于少量数据的组织会具有更高效率)
  • 传引用比传值更快
    (除非有复制消除机制或者诸如 int 整型数据直接储存于 CPU 寄存器的情况)

幸运的是, 很多泄漏性抽象失败时会产生程序崩溃而易于定位问题.
但是也有的泄漏性抽象失败时只会产生未定义行为或性能下降, 这类问题难于辨识和修复.

按下 X 以质疑

所以如果抽象可能产生问题, 我们是否需要放弃认知抽象 (比如去学习汽车 到底 是怎样工作的)?
答案是不. 当你层层向抽象之下挖掘时, 你只会发现更多抽象, 循环往复.

  • 支撑我们对于汽车认知抽象的是对于汽车各个部件的作用
  • 在这之下, 是燃烧的化学反应原理和发动机的工程机械结构
  • 在这之下, 数学和物理学模型支撑了我们的宇宙

这些抽象一路向下, 一直到组成我们逻辑和现实的最基础的公理.

作为程序员, 我们应将自己的知识视为一座由泄漏性抽象和假设构成的纸牌屋.
我们应对一切人和事, 包括我们自己, 保持适度的怀疑态度.

去信任, 但也要校验

程序员需要有一种 "去信任, 但也要校验" 的策略.

这里有一些例子:

  • 信任其他人告诉你的信息, 但是要根据文档说明去校验是否正确
  • 通过尝试反驳来校验自己的观点
    • 当你为代码变动编写对应的测试时, 尝试将这些测试也对未变更的代码使用,
      看看它们是否仍能通过测试, 以此来测试是否存在一些使得代码总能通过测试的错误
    • 你对代码的重构应当不会产生任何额外影响, 所有的测试应该仍能通过.
      另外还要检查你的测试本身是否使用到了你重构之后的代码模块
    • 当你完成对服务的优化并看到了期望中的资源占用优化后,
      检查一下是不是相关资源占用的降低只是因为当前系统的请求量降低了
    • 你提交了代码改动, 第二天服务仍在正常运行,
      检查一下你是否正确完成了更新部署, 以及你提交的代码改动是否包含于此次更新部署
  • 一定要测量代码优化的实际影响.
    "理论上" 更快的代码变更很可能会由于一些底层抽象的细节导致实际上的性能更差

留意对于未知的未知

对于程序员而言最可怕的认知问题是 "未知的未知".

对事物的认知分为下面情况:

  • 你知道某个事物 (你知道)
  • 你知道你不知道某个事物 (你知道你不知道)
  • 你不知道你不知道某个事物 (你不知道你不知道)

这些对于未知的未知是造成抽象失败的根源 (这也是程序员永远无法准确预测一个项目要开发多久的原因).

你可能从来没听过:

  • 清理用户输入
    • 如果你要将用户提供的字符串直接组装到 SQL 查询语句中, 你的服务可能产生 SQL 输入漏洞
  • 字符集编码
    • 你代码处理的所有文本数据一定是基于某种字符集编码 (比如 ASCII、UTF-8 和 UTF-32 等)
    • 对于字符串中字符的任意访问, 取决于文本数据的字符集编码不同,
      会消耗固定时间 (对于 ASCII 等字符集) 或线性增长的时间 (对于 UTF-8 等字符集)
    • 如果使用错误的字符集编码读入文本数据, 你的输出可能也会包含人类无法理解的字符
  • Java 堆空间容量
    • 你的程序可能由于堆空间不足而变慢
    • 你知道去为自己的程序配置更大的堆空间的话就可能修复这个问题

如果你之前从未听说过上述话题, 你可能甚至不会知道自己遇到了由它们产生的问题.

虽然没有万无一失的手段去避免未知的未知, 我们还是应该至少向下检查一层抽象.
尤其在项目需要你学习新知识时, 你总应学习超出项目所用范围的知识,
这可以降低由抽象失败产生的措手不及的问题.

当你学习或用到不熟悉的平台、编程语言、工具、库或技术时:

  • 多多阅读文档, 不仅限于当下用到的范围
  • 去观看视频
    • 就我经验而言, 总结性报告的质量是最高的
  • 去阅读博文
  • 去阅读源码
  • 提升自己对于正在进行工作的抽象的理解
    • 学习编程语言最新添加的特性
    • 了解一遍所有公共方法和库, 不仅仅是你当下用到的部分
    • 浏览一遍 CLI 工具手册中所有的标志位
  • 对于你用到的内容, 向下学习一层抽象
    • 学习编译器优化知识
    • 如果你在部署一个服务, 学习你的编排工具 (比如 K8S)
    • 如果你正在使用 Java, 去了解 JVM 相关知识
    • 如果你正在使用 Python, 去了解 Python 解释器相关知识

结论

抽象是必要的, 它可以帮我们我们更高效地思考;
抽象也是危险的, 它会让我们误以为自己已经掌握了 "足够的" 知识.
肤浅学习的程序员无法应对没有已知解决方案并涉及多个专业领域的复杂任务.

话虽如此, 这篇博文提出的概念需要根据现实情况进行权衡.
显然, 时间紧迫时我们不可能有去学习所有的细枝末节, 况且对于初学者我们本也不能指望就做到面面俱到.
理想应为现实所平衡.

现实也应为理想所平衡, 我们应愿意为了更加深入的学习和校验去多付出一些成本.
这不仅是为了编写正确的代码, 也是为了我们能成长为有能力和原则的软件工程师.