与 Sean Allen 谈软件正确性与供应链安全
Software correctness & supply chain security, with Sean Allen
Antithesis 杰出工程师、Pony 语言核心贡献者 Sean Allen 认为,手动测试是保证正确性最差的方式,应在语言层面禁止非法状态被表达,而非依赖后期 CI/CD 质量门禁。
在我们开始之前,我想分享一些令人兴奋的消息:The Pragmatic Summit 要回归了!这是一场为期一天的活动,将于 2 月 10 日在旧金山举行,距离首届峰会几乎正好一年。我将担任主持,首批几位演讲嘉宾已经确认。有些面孔对老读者和听众来说可能很熟悉!
我对峰会的目标是让它成为与志同道合的工程领导者和工程师,以及充满好奇心、有驱动力的人建立联系的最佳活动。去年的反馈是,正是与会者和高质量的讨论让这场活动脱颖而出。这次我们与组织团队力求呈现同样的体验,包括安排圆桌讨论的空间,以及与演讲嘉宾在其演讲结束后继续交流的机会。The Pragmatic Engineer 团队也会到场。
申请现已开放:本次峰会面向资深及以上级别的工程师和工程领导者,他们正在塑造现代工程团队如何构建软件、交付产品,并扩展规模以应对充满挑战的需求曲线。门票价格为 1,000 美元。你可以查看去年的演讲,或观看一分钟视频摘要。
今年的峰会得以举办,离不开 WorkOS 朋友们的支持,他们协助组织本次活动,并且是独家赞助商。谢谢!
我希望能在那里见到你们中的许多人!现在,进入今天的文章。
Sean Allen 是一位软件工程师,在构建大型分布式系统、流处理和机密计算方面拥有深厚专长。他是为并发而设计的编程语言 Pony 的核心贡献者。Sean 是 Antithesis 的杰出工程师。
我之所以很想与 Sean 交谈,是因为他在微软从事机密计算的工作,以及他在 Pony 语言上的大量工作,还有他对软件正确性的热情——他在 Antithesis 全职从事这方面的工作。
在这次对话中,我们给自己设定了一个挑战:讨论现代软件工程,但一次都不使用“AI”这个词。继续阅读,看看在 2026 年这是否可能……
在本文中,问题由我以粗体字提出,Sean 的话以常规字体呈现。
今天我们涵盖:
软件正确性与为正确性而构建的语言。为什么围绕强类型系统构建的 Meta Language(ML)家族语言,比 Algorithmic Language(ALGOL)家族语言在编译期能捕获更多错误。
还有人关心测试吗?对于一个热衷于正确性的人来说,Sean 出人意料地反测试。他认为手动测试是确保正确性的最差方式,因为你希望非法状态根本无法在语言中被表达——甚至在测试之前就如此。
工作负载与正确性。比单元测试之类更稳健的测试方式,是定义可以在测试期间多次执行的“工作负载”。一个常见的工作负载示例是 The Bank Test,用于对数据库隔离保证进行压力测试。
质量到底是不是一道“关卡”?人们很容易把质量检查想象成把劣质挡在门外的封闭关卡。Sean 对此提出反驳;他说,重要的是从一开始就考虑质量,并采用能对正确性提供反馈的验证技术来构建。相比在开发流程后期才添加 CI/CD 质量“关卡”,他更倾向于前者。
技术债只有在你必须偿还时才会造成伤害。别再害怕技术债了,因为你往往根本不需要偿还它!Sean 说,当一个系统已经变得流行时,在重写过程中技术债被忽视是很常见的。
供应链安全没有好的解决办法。供应链攻击——即某个包被攻击者劫持,并在某台机器或服务上执行恶意代码——极难防御,因为拥有真正强健防御能力的语言(如‘E’)并不流行,而流行的语言则假定生态系统是可信的。Sean 说,不存在单一或简单的解决办法。
垃圾回收很慢——但不一定非得如此。概述了垃圾回收为何对运行时性能造成毁灭性影响,以及 Sean 如何在 Pony 语言中构建出一种可预测的高性能方案。
慢下来,才能快起来。反直觉的建议:花更多时间在前期做设计并让所有人达成一致,然后你就能快速推进!
你也可以在 YouTube 上观看这段对话:
一个有趣的细节:Sean 和我发现我们拥有同一个第一份全职雇主。我们职业生涯初期都在一家名为‘Sense/Net’的公司工作过,尽管它们是位于不同国家的完全独立的公司。两家的创始人一定都是基础赛博朋克小说——威廉·吉布森的《神经漫游者》的粉丝,‘Sense/Net’这个名字就来自那里。
注:Antithesis 是播客赞助商,但本次深度访谈并非由他们赞助。我只是在 AI 智能体产出比以往任何时候都多的代码、从而让比以往任何时候都多的 bug 有机会被发布到生产环境的背景下,越来越多地思考软件正确性问题。我想向一位在该领域经验丰富的工程师多了解一些这方面的情况。
1. 软件正确性 & 为正确性而构建的语言
Gergely:你如何定义“软件正确性”?
Sean:“这取决于我的程序是否做了我期望它做的事。我倾向于用‘不变量’来谈论这些事情,这是一个更正式的规范术语。不变量是对我的软件会做到人们期望它做到的事情的一种保证。但问题在于:在很多情况下,人们事先并不知道他们期望自己的软件做什么!他们知道它不工作时是什么样,但他们未必知道它正常工作意味着什么。
‘正确’的软件能实现其业务目的,并且不会在凌晨两点因为故障把任何人吵醒。对我来说,这就足够正确了。”
你对“优美代码”的个人定义也与此相呼应。
Sean:“我在CoRecursive 播客上谈到 Ruby 中优美代码这个话题时。我当时并不真正明白人们说‘优美代码’是什么意思。最终,我想出了自己的定义:优美的代码服务于它被编写出来的目的,并且不会在凌晨两点把任何人吵醒。对我来说,这就是优美的代码!”
在你职业生涯早期,你用 Ada 语言编程。这教会了你什么?
Sean:“当我学习编程时,我决定选一门语言并投入一段时间。最终我选定了 Ada 和 C。
Ada 在很多方面是由美国国防部的需求驱动的:一种规范极其严格、不会让你写出错误程序的语言。NASA 和美国国防部正是这门语言的目标用户。Ada 至今仍然存在,而 Ada 公司推销时的话术是‘那么,你喜欢 Rust,想不想要更多的正确性保证?’
我的一大遗憾是,我当时无法让程序在 Ada 中编译通过,因为那时我对编程了解不多。太令人沮丧了!
与此同时,我能让 C 程序编译通过,但它们总是发生段错误!多年来,我培养出了一种直觉,知道如何在 C 中做事才能让程序不发生段错误而崩溃。我有一整套正确编写并发 C 和 C++ 的规则。尽管我变得非常严谨,但我是人,还是会搞砸。
在正确性方面,C 几乎什么都没给我。它只是一把大枪,我把它对准自己的脚,一次又一次地扣动扳机。回想起来,我明白我的 Ada 程序无法编译的原因在于它们从根本上就是内存不安全的。因此,Ada 拒绝编译,因为程序最终会导致内存损坏、崩溃或数据竞争。”
那 ML 家族的语言,比如 OCaml 呢?
Sean:“说到编程语言,有几个家族:
ALGOL 家族:命令式编程的基础。这些是具有局部变量的块结构语言。Python、Perl、Ruby、C 和 C++ 等语言都属于这一家族。
ML 家族:ML 语言围绕强类型系统构建。Standard ML、OCaml 和 F# 属于这一家族。有趣的事实:Rust 是当今最著名的受 ML 影响的语言。Rust 的求和类型和模式匹配来自 ML。
求和类型表示‘这个值可以是这些事物之一’,有点像 Java 中的枚举。你把它与模式匹配结合,然后再与穷尽匹配结合。假设我有一个求和类型,它可以是五种不同的事物之一。我不使用 if 语句,而是对它是哪一种进行模式匹配。例如,这里有一个用 Rust 表达的求和类型:
求和类型的美妙之处在于:假设你对一个求和类型做了修改,从五种值类型变成六种。一旦你这么做,编译器会告诉你代码库中每一处没有处理第六种类型的地方——当与穷尽匹配结合时!换句话说,穷尽匹配把每一个‘忘记处理新情况’的 bug 变成了编译错误。使用这样的语言是一种极其美妙的体验。”
2. 有人在乎测试吗?
你写过‘没人在乎测试。我不重视测试’,但从一个关心正确性的人口中听到这句话很奇怪。
Sean:“我写那句话是因为人们把 Antithesis 看作一家测试公司,而我不这么看。我认为它是一家软件验证与正确性公司——这非常不同。
测试是你为了验证东西能正常工作而做的事,适用于你没有更好办法验证的情况。最糟糕的测试类型是手动测试。你可以把它自动化,让自动化测试越来越精细,但你仍然是在开发的后期阶段做事。你应该在进入测试之前尽可能多做。
使用 ML 语言时,你通常可以在类型系统中表示有哪些状态,以及允许哪些转换。一旦这些就位,你就能确保东西永远不会转换到你意料之外的状态。能做到这一点非常了不起!
在实践中,当你试图弄清楚系统是否能工作时,你并不会从头到尾思考整个系统。你只会思考类型系统不允许你表达的那些东西。这就是‘让非法状态不可表示’背后的理念。”
唱个反调,在测试驱动开发(TDD)中你是先写测试的,所以那算是提前做测试,不是吗?
Sean:“我喜欢 TDD。我认为它是编写优秀 API 的最佳机制之一,因为你在主动审视你将如何使用这个东西。
另外,TDD 社区热爱 katas(小型编程练习)。其中一种练习是结对进行:一个人写测试,另一个人写让测试通过的实现。写实现的人应该做尽可能最简单的事。
当人们和我一起做这些 katas 时,他们很快发现我把‘写尽可能少的代码来通过测试’理解得非常字面。我会把答案硬编码进实现里,让他们的基于示例的测试通过。例如,如果唯一的测试是‘2 + 2 等于几?’,我就直接返回硬编码的‘四’。
在 TDD katas 中你很快就会接触到基于属性的测试。kata 不应该结束,直到写测试的人对实现满意为止,所以要想‘打败’一个像我这样字面理解的人,唯一的办法就是开始随机化输入。一旦你随机化输入,你就进入了基于属性测试的领域,因为你生成的输入覆盖的空间比你在示例中能想到的更大。”
说句实话,如果是我在写测试,我可能会觉得你有点混蛋!
Sean:“把这话说得再脏一点,那就是人们经常对我说的话!
在一个所有工程师都做这个练习的地方,和我结对的人出来会说他们是唯一没完成的人。但他们也会说,‘我觉得我现在写测试会好得多。’
我们的 katas 中测试的很多东西,在有更强编译器的情况下你根本不需要测试。多年来,我喜欢动态语言;Smalltalk 仍然是我最喜欢的语言之一,我写过很多 Ruby、Perl 和 Python。而且我告诉自己我不需要编译器的帮助。”
最终,我意识到没有太多编译器的帮助也能应付,因为我主要处理小型系统,可以把所有东西都记在脑子里。当我在更大的公司、和更多人一起开始处理更大的系统时,我开始看重一些东西,比如能够删除一个类,并让编译器告诉我每一个会因此出错的地方。”
3. 工作负载与正确性
你之前说过,很多人在测试策略中并没有“工作负载”。什么是工作负载?
Sean:“大多数人的基于属性的测试示例都很简单:
反转一个列表
→ 然后再反转一次
→ 确认你得到了最初的那个列表。
然而,如果你想将基于属性的测试应用于分布式系统或数据库,那么工作负载是以下内容的组合:
输入:由你创建以驱动你的代码
验证:你用来验证事物正常工作的任何手段
例如,让我们以一个具有三种操作的键值存储为例。工作负载是以下内容的组合:
输入:GET、PUT 和 DELETE 操作
验证:确定有多少个键、键的范围是什么,以及键及其值可以有多大
在阅读关于基于属性的测试时,很容易认为随机性会替你完成测试。例如,假设你随机选择 GET、PUT 和 DELETE 操作之一。这样做的话,你不会触发代码中许多有趣的地方,因为在一次 PUT 之后,你很可能在附近执行一次 DELETE。连续执行 10,000 次 PUT——将你的存储推向需要换出到磁盘、垃圾回收或重新分片的区域——的概率极小。把这一切都交给运气,就像醉酒走路:人们走不了多远。”
有什么特定的工作负载可以帮助测试?
Sean:“有一种工作负载常用于测试 ACID 数据库中的隔离性保证,而这是你在数据库中想要保证的最重要的一件事。它被称为‘银行测试’:
让我们看看这个设置:
系统中有 10,000 美元
你把这 10,000 美元分到 10 个账户中。每个账户有 1,000 美元。
工作负载在一个事务内将钱从一个账户转移到另一个账户
在运行执行账户间转账的工作负载时,你有另一个组件不断查询系统中的总金额。如果隔离性保证成立,它应该看到的唯一值就是 10,000。
如果你的数据库是好的,你永远不应该看到奇怪的失败。但如果我们谈论的是分布式数据库,并且你在一个引入故障的环境中运行它——比如节点之间的网络故障或杀死节点——那么各种有趣的 bug 就会开始冒出来。Kyle Kingsbury 通过他在分布式系统安全性研究方面的 Jepsen 工作让这一点闻名;这与我们在 Antithesis 所做的工作一脉相承。
一个简单的工作负载会检验一个核心不变量:在这个例子中,不变量是‘我永远不应该看到发生在另一个事务内部的操作所产生的影响。’
那么,在有工作负载的情况下,你设定规则,然后让事情自行发展?而在这种 Bank Test 中,我们从不指定转账的金额或时间:它只描述转账是如何发生的?
Sean:“正是如此。对于已经存在一段时间并经过严格测试的软件,像银行测试这样的东西不应该暴露出 bug。但一旦你让它经历 Jepsen 或 Antithesis 类型的体验,bug 就会疯狂地冒出来。这也是生产环境中一直发生的那种体验,软件工程师 Lorin Hochstein 在他的事故分析中对此做了极其出色的记录。”
4. 质量到底是不是一道“关卡”?
你认为质量不是一道关卡。“关卡”这个概念建立在一种信念之上,即质量是通过把坏东西挡在外面来维持的——但优秀团队保持优秀并不是靠这种方式。你是什么意思?
Sean:“这是我更愿意谈论正确性而不是测试的一个重要原因。
来源:The Pragmatic Engineer · newsletter.pragmaticengineer.com