程序员永远不应该相信"抽象“


程序员应该有偏执心。

  • “我仔细检查了代码”
  • “代码通过了测试”
  • “审阅者批准了我的代码”

“那么我的代码正确吗?”

正确编写代码很困难,而且验证代码正确性是不可能的。以下是一些原因:

  • 普遍性:即使你的代码一次运行正确,它在所有情况下、所有机器、所有时间都运行正确吗?
  • 误报:测试失败表明存在错误,但通过测试并不保证没有错误。
  • 缺乏确定性:您可以为代码的正确性编写正式证明,但现在您一定想知道该证明是否正确。您需要证明该证明。这种验证链永远不会结束。

追求代码正确性的确定性是愚蠢的。错误可能隐藏在您永远找不到的依赖项中。但我们不应该绝望。我们仍然可以通过更好的理解和尽职调查来降低错误的风险。

抽象
什么是“更深的理解”?

让我们集中讨论一下程序员经常提到的理解的一个方面:抽象。

抽象是……

  • 事物运作方式的心理模型
  • 大脑中发生的数据压缩(可能是有损的,也可能是无损的)的结果
  • 在日常生活中随处可见

“抽象”一词有很多含义。

  • 在编程中,它也可以指隐藏复杂性的代码层。
  • 这篇文章只讨论认知意义上的抽象。

抽象的例子:

  • 我们将一群树视为一片森林。
  • 我们认为银行存款就是银行为我们存储的钱。
    • 事实上,银行不只是储存我们存入的钱。它借出/投资了人们存入的大部分钱。我们的钱不会闲置 在金库里。
    • 我们的银行余额实际上只是一本记录我们可以提取多少钱的账簿。
  • 我们总是假设时间对于每个人来说都以相同的速度流逝。
    • 时间膨胀会根据每个人/物体的速度和所受的重力大小稍微改变他们的时间流逝。
    • 围绕地球运行的 GPS 卫星必须每天调整其时钟约 38 微秒,以适应时间膨胀(来源)。

形成抽象的一种方法是删除不必要的细节。

例如,大多数开车的人对汽车的内部工作原理不太了解。他们对汽车的看法可以归结为:

  • 点火启动汽车
  • 加速器使汽车行驶
  • 刹车使汽车停下来
  • 车轮转动汽车
  • 汽车需要汽油/柴油

了解了上述抽象概念,就无需了解汽车发动机的内部工作原理。

大多数司机只具备汽车的这些工作知识,就可以开车去他们需要去的地方。

当我们使用编程语言时,它提供了抽象,使我们无需了解计算机的内部工作原理即可操作计算机。

  • 基本语言特性(如循环、if 条件、函数、语句和表达式)都是抽象,它们隐藏了以下内容:
    • 硬件级别的详细信息:CPU 指令、寄存器、标志以及特定于 CPU 架构的详细信息……
    • 操作系统级细节:调用堆栈管理、内存管理……
  • 可移植性:语言抽象了我们关注不同机器之间的差异的需要。
    • 任何已编译的 Java 程序(例如 jar 文件)都应该能够在任何具有 Java 运行时环境(即 JVM)的机器上运行。
    • Python 脚本应该能够在任何具有 Python 解释器的机器上运行。
    • 如果机器有 C 编译器,那么C 程序应该能够在任何机器上编译并运行。

抽象泄露
不幸的是,抽象会失败。

  • 如果您关心代码性能,语言抽象是不够的。要加快代码速度,您需要了解硬件级和操作系统级的详细信息。
  • 移植具有外部依赖项(如动态库或网络要求)的程序并不那么简单。它们不能简单地移动到另一台机器并运行。需要额外的设置和知识。
  • 只知道最基本知识的车主最终可能会陷入汽车抛锚的境地。如果驾驶员不定期更换汽车的润滑油/机油,则会缩短发动机的使用寿命。

抽象在短期内运行良好,但从长期来看会失效。

Joel Spolsky 将这种失效的抽象描述为“泄露”,并提出了抽象泄露定律

  • 所有普通的抽象在某种程度上都是有漏洞的。

这与统计学中的格言类似:
  • 所有模型都是错误的,但有些是有用的。

当我们编写代码时,我们总是使用漏洞抽象。以下是一些随机示例:

  • 垃圾收集消除了担心内存管理的负担(除非我们关心延迟抖动)
  • C++ 智能指针使内存安全(只要你不存储任何原始指针)
  • 哈希表速度很快,因为它们具有 O(1)操作(但对于较小的数组,速度更快)。
  • 通过引用传递比通过值传递更快(除了复制省略的情况和适合 CPU 寄存器的值,如 int)

幸运的是,许多漏洞抽象在失败时会导致代码崩溃,因此很容易解决。

然而,有些漏洞抽象可能只会产生未定义的行为或性能下降,这些行为更难识别和修复。

那么,如果抽象可能会带来问题,那么我们是否应该尝试在不考虑抽象的情况下理解一个主题(了解汽车的真正面貌)?
不。当你深入抽象时,你只会发现更多的抽象。
这就像乌龟在不断下沉。

  • 我们对汽车的抽象基础在于对每个部件的用途的理解。
  • 在这之下,燃烧化学和发动机机械工程
  • 在这之下,是模拟宇宙力量的数学/物理学

这些抽象层不断深入,直到我们触及关于逻辑和现实的最基本公理。

作为程序员,我们应该把我们的知识看作是一个由漏洞百出的抽象和假设组成的纸牌屋。
我们应该对一切事物、任何人,包括我们自己,都保持适度的怀疑态度。

信任但要验证
程序员应该有“信任,但要核实”的政策。

这里有些例子:

  • 相信人们告诉你的信息,但要用文件来验证
  • 通过尝试反驳来检验你的信念。
    • 您为代码更改编写了测试,并且第一次尝试就通过了。尝试在没有更改的情况下运行测试,看看它们是否仍能通过。它们可能存在导致测试总是通过的错误。
    • 您已重构了本应为无操作的代码。所有测试仍通过。请检查以确保确实有任何测试运行您重构的代码。
    • 您优化了服务,并且看到了资源利用率的预期下降。请检查以确保您的服务当前不只是处理的请求较少。
    • 您已提交代码更改,第二天发现服务中没有出现任何问题。请检查以确保当天已推出并且您的代码已包含在内。
  • 优化代码时务必衡量影响。由于抽象层较低所揭示的因素,看起来“理论上”更快的代码更改最终可能会变得更慢。

警惕未知的未知数
对于程序员来说,最可怕的认识论问题是“未知的未知”。
有…

  • 你知道的事情(即“已知”)
  • 你知道你不知道的事情(“已知的未知数”)
  • 你甚至不知道你不知道的事情(“未知的未知数”)

这些未知的未知数是抽象失败的根源(也是程序员永远无法准确预测一个项目需要多长时间的原因)。
你可能从未听说过……

  • 净化用户输入
    • 如果您使用用户提供的字符串作为 SQL 查询的一部分,您的服务可能会通过 SQL 注入受到黑客攻击。
  • 字符编码
    • 您的代码处理的任何文本数据都必须使用您的代码期望/支持的字符编码(例如,ASCII,UTF-8,UTF-32等)。
    • 根据字符编码,随机访问文本缓冲区中的字符可能需要恒定时间(对于 ASCII)或线性时间(对于 UTF-8)。
    • 如果您尝试使用错误的字符编码读取文本数据,可能会输出难以理解的字符。
  • Java 堆大小
    • 您的程序可能会因堆内存不足而变慢。
    • 如果您知道为 Java 程序配置更大的最大堆大小,则可以解决此问题。

如果您之前没有听说过这些主题,您甚至可能不知道自己已经陷入了它们的陷阱。

当未知未知因素出现时,没有万无一失的方法可以将其捕获,但我们应该检查至少一个抽象层来寻找它们。特别是当一个项目需要学习新东西时,你应该总是学习比你需要的更多的东西。这样做可以降低因抽象失败而感到惊讶的风险。

当学习/使用不熟悉的平台/语言/工具/库/技术时:

  • 阅读比最低限度要求更多的文档
  • 看视频
    • 在我看来,会议演讲质量最高
  • 阅读博客文章
  • 阅读源代码
  • 加深对必须处理的抽象概念的理解
    • 了解您的编程语言最近添加的功能
    • 通读图书馆的所有公共功能,而不仅仅是你正在使用的功能
    • 浏览 CLI 工具手册页中的所有标志
  • 学习至少比你需要的低一个抽象层
    • 了解编译器的优化
    • 如果你正在运行服务,请了解你的编排平台(例如:kubernetes)
    • 如果你使用 Java,请了解 JVM
    • 如果您使用 Python,请了解 Python 解释器。


概括:
以下是重点:

  1. 程序员应该采取“信任但要验证”的方法,质疑假设,并从多个来源验证信息。
  2. 文章强调了测试和验证的重要性,建议程序员应该:
    • 运行不做任何更改的测试,以确保它们并非总是通过
    • 使用适当的测试验证代码重构
    • 优化代码时衡量影响
    • 确认代码部署及其包含在发布中
  • “未知的未知数”的概念 - 程序员不知道他们不知道的事情。这可能导致抽象失败和项目评估困难。
  • 建议程序员在使用新技术时要学习更多的知识,包括:
    • 阅读大量文档
    • 观看会议演讲
    • 检查源代码
    • 理解至少低于必要水平的一层抽象


    结论
    抽象是必要的,因为它能让我们高效地思考,但抽象也是危险的,因为它可能会让我们觉得自己知道的 "足够多"。肤浅学习的程序员无法在没有已知解决方案、涉及多个专业领域的高难度项目中取得成功。

    也就是说,这篇博文提出的理想需要与现实保持平衡。显然,我们不可能在匆忙中花时间学习每一件小事。此外,我们也不能指望初学者能做到如此透彻。理想应该与现实世界的考虑相平衡。