30 秒看懂
在 2010 年 Harvard CS51 课程的演讲中,Jane Street 的 Yaron Minsky 解释了可读性为何对交易软件的正确性至关重要,并展示实用的 OCaml 技巧。他讨论了统一接口、不变量建模、穷尽匹配、错误约定、抽象、副作用和幻影类型。 他的核心判断标准是:一种技巧是否降低了理解和修改代码所需的复杂度。强大的类型技巧有时能提供帮助,有时也会走得太远。
- 1通过可读代码保证正确性交易系统会遇到罕见但影响重大的情况,难以通过测试穷尽。Minsky 主张通过仔细的代码审查来理解系统的意图和内部机制。
- 2标准接口可复用的 OCaml 签名能统一模块约定,让仿函数适用于满足这些约定的不同模块。
- 3让非法状态无法表示连接状态所需的数据应放在对应的变体中;必须一起出现的值可以放进同一个可选的二元组。
- 4用穷尽性辅助重构新增变体时,显式匹配能让编译器指出需要更新的位置;通配符可能掩盖变化。
- 5复杂度是敌人如果高级类型或抽象使代码更难理解,就可能损害它原本想要提高的正确性。
- 6用幻影类型表示能力不出现在运行时表示中的类型参数可以区分只读和可写引用,同时允许多态读取。
要点建议
- 为读者和未来维护者优化长期使用的共享代码。
- 使用统一的模块接口,让约定更容易预测和复用。
- 在可行的情况下设计类型,使非法状态无法表示。
- 编写不含兜底分支的穷尽匹配,让类型变化时编译器指出遗漏的情况。
- 明确表达错误行为,并在保持清晰的前提下提取重复结构。
- 在有助于简化推理时使用纯函数和高级类型技巧,而不是把它们当作绝对目标。
按章节阅读整理后的全文
Jane Street 为什么重视易理解的代码
Minsky 介绍交易公司的约束,以及代码审查在保证正确性方面的作用。他说明了 Jane Street 的工作环境如何影响他的建议。
这场演讲的标题借自 Joshua Bloch 的《Effective Java》。我很喜欢这本书,加入 Jane Street 时还买来送给同事。在工业界使用 ML 大约八年后,我想分享一些编程实践,以及维护真实代码库的更宏观经验。
Jane Street 是一家自营交易公司和电子做市商。我们像处于买卖双方之间的店主,通过大量交易赚取微薄利润。如此大的交易量意味着系统中流动着数十亿美元的名义价值,因此技术风险极为重要。
没有什么比写出在紧密循环中反复做出同一个错误决策的软件更容易让自己陷入灾难。系统越快、范围越大,错误可能造成的影响也越大,所以正确性是我们工作的核心关切。
我们的做法强调写出易于理解的代码,并认真阅读代码。我们也做测试,但我有时觉得我们的流程更像是代码审查驱动的开发。异常金融事件难以测试,而且发生频率可能高于简单的正态分布模型所暗示的水平。
我们希望了解系统的内部机制和意图,而不只是它的正常行为。代码不仅用来让系统完成工作,也是表达系统应该做什么的一种方式。
并非每一段软件都需要同样程度的谨慎。研究原型更需要快速交付;系统管理则有太多潜在故障来源,多层独立检查可能比构建一个极其可靠的可执行程序更重要。
我们的情况也不同于需要集成许多外部系统的团队。我们的软件大多是定制开发的,我们拥有源码并能修改它。我们也招聘计算机科学基础扎实的程序员,因此可以专注于让他们更高效的技巧,而不必先判断每个程序员是否都觉得 ML 容易。
读者与统一接口
长期使用的代码应优先照顾读者。OCaml 模块签名提供一致的约定,也让可复用的仿函数更容易应用。
我的第一条建议来自观察代码作者和读者之间的分歧。随着我的工作从写代码更多地转向阅读别人的代码,我逐渐认为,在共享且长期使用的系统中,读者通常是对的。
读者倾向于追求清晰、简单,以及日后修改系统的能力。大多数真实代码被阅读和修改的次数远多于编写的次数。如果一个脚本写完就扔掉,权衡可能不同;但长期共享的代码应当优先考虑读者。
创建接口,并保持接口统一。在 OCaml 中,.mli 文件约束模块的接口。编写代码时你熟悉所有细节,省略接口可能很方便;但六个月后,连你自己也可能成为那个不记得代码含义的读者。
共同的标准让团队在浏览陌生代码时有可靠的预期。模块签名可以描述可复用的接口组件,而不必与某一个实现绑定。

例如,Comparable 签名可以汇集比较运算符和函数,以及基于该排序的 Map 和 Set 子模块。一个模块可以包含这个签名,并将可比较类型与自己的类型关联起来,尽管语法上需要额外做一些类型统一。
这不只是提出一条约定:如果实现不符合接口,就无法编译。统一接口还让仿函数更有用。仿函数将模块映射为模块,并声明对输入模块的要求和输出模块提供的内容。
模块遵循相同约定时,就能满足这些要求,无须为每个仿函数重新包装。Map 和 Set 模块本身也可以由仿函数生成,因此不需要为每种可比较类型重写相同实现。
让非法状态无法表示
一个连接状态的例子说明记录和变体如何编码不变量。这种方法利用代数数据类型表达普通的分情况分析。
让非法状态无法表示。ML 类型系统能为我们检查有用的性质,但我们仍然可能设计出只能提供薄弱保证的类型。好的类型设计会把实际需要的不变量放进数据结构本身。
考虑一个连接,它有连接中、已连接和已断开三个状态。最初的记录包含状态、服务器、上次 ping 的时间与 ID、会话 ID、连接开始时间和断开时间。这些字段大多是可选的。

使用可选值比在运行时遇到意外的 null 好,但记录仍然允许一些毫无意义的组合。ping 时间应与 ping ID 一起出现;会话 ID 属于已连接的会话;开始时间和断开时间也只适用于特定状态。
可以重新启动的连接尤其容易出问题:旧的启动时间可能残留到错误的状态中。每次创建和更新都必须维持几种未写明的关系,读者因此不得不检查逻辑并记住这些不变量。
重构类型时,把服务器留在公共记录中,把各状态专有的值移入对应的变体。服务器在所有状态下都有意义;连接中、已连接和已断开各自携带与该状态相关的数据。
把 ping 时间和 ID 组合进同一个可选值。两者可以同时不存在,但只要其中一个存在,另一个也必须存在。这样,类型本身就排除了不一致的组合,不再依赖每个调用者记住约定。
这种技巧不能编码所有可能的不变量,有时仍然需要仔细的运行时检查。但代数数据类型可以组合积类型和和类型,表达数量惊人的日常约束。
讲解变体类型时,人们常用二叉树做例子,这可能让它看起来像是专门用于数据结构的功能。它的日常价值在于表示一组不同情况,再显式分析这些情况,尤其是与记录一起使用时。
穷尽匹配与演进
显式列出分支,让编译器找出新增消息类型影响到的位置。兜底分支可能掩盖危险的遗漏。
编写代码时要利用穷尽性检查。OCaml 可以在匹配遗漏某个情况时给出警告;我会将编译器配置为拒绝这样的匹配。重构时,这项检查尤其有用,因为类型演进后它能指出哪些地方需要处理。
假设交易消息有下单、撤单和成交三种类型。一个计算持仓变化的函数对成交返回变化量,对其他情况返回零:下单或撤单本身不会改变持仓。
现在新增一条更正消息:交易所可能告知你之前报告的一笔交易有误。更正也可能改变持仓。如果原来的匹配用通配符处理所有非成交情况,代码仍然能编译,却会悄悄给出错误的更正结果。
如果我们显式列出下单和撤单,新增更正类型就会让匹配不再穷尽。编译器可以指出遗漏的情况。我每天都在使用这些检查;这是我最不愿失去的语言功能之一。
名称、错误和样板代码
限制打开的命名空间,用名称标明错误行为,并抽象重复代码。这些选择有助于读者找到名称的来源并理解失败情况。
少打开模块。打开命名空间能让作者少写一些代码,但打开太多模块会让读者难以判断一个值来自哪里。像 int 这样的名称可能来自参数模块、命令模块,也可能来自标准库。

在一个命令行示例中,我可以去掉大范围的 open,代码仍然不会难以管理。给较长的模块路径设置局部别名,可以让重复引用保持简洁,同时清楚表明其来源。
局部作用域很重要。如果缩写定义在大文件的开头,读者就必须长时间记住它。把别名定义在靠近使用的位置,读者理解后便可以忘掉它。
让常见错误显而易见。找不到某个项目是否属于异常,取决于调用者:在一种情境下很正常,在另一种情境下该项目理应总是存在。因此,返回可选值的函数和抛出异常的函数都可能有用。
统一的命名约定可以区分两者。例如,普通查找可以返回可选值,而名称带 _exn 后缀的版本则在调用处明确提示可能抛出异常。目的在于帮助读者理解错误行为,而不是把它藏起来。
避免样板代码:这些重复或近乎重复的代码遵循共同模式,只在细节上略有变化。开发者常常复制这些模式,因为多处都需要相同结构,或者语言让抽象变得笨拙。
ML 提供了强大的抽象工具,因此重复结构往往表明我们可以提取共同部分。随着代码演进,共同结构可能需要修改,而开发者不太可能记住所有复制出来的位置。
样板代码也很难审查。即使微小差异——例如买入与卖出计算中的正负号——可能极为重要,读者也容易略读看似重复的文本。无论怎样提醒他们注意,都无法完全消除这种人性倾向。
局部函数通常可以提取共同模式,并明确展示变化的部分。结果不一定更短,但读者能看到哪些内容保持不变、哪些内容发生变化,而不必逐块比较几乎相同的代码。
复杂度和副作用
只有能提升清晰度的抽象和静态保证才有价值。纯代码有助于隔离副作用,但可变操作有时更简单或更快。
一位听众问,生成的高阶抽象本身是否可能更难理解。这确实值得担心。大多数重复都可以直接消除,但有些抽象需要复杂的机制,或者需要与类型系统较劲。
强大语言带来的诱惑是:仅仅因为做得到,就不断把更多保证编码进类型。但正确性的敌人是复杂度。如果一位有经验的 ML 开发者都无法理解最终的类型,这种巧妙设计就已经过头了。
学术界有许多漂亮的研究,展示了类型系统能编码多少内容,其中包括非常复杂的例子。它们可能很有趣,却未必适合生产代码。正确的问题是:一种技巧是否让系统更容易理解。
纯函数也需要同样的平衡。没有副作用的代码和不可变数据结构通常更容易推理,但纯粹性不是宗教。局部可变操作有时能让实现更简单,性能敏感的系统也可能需要它。
我们的自动交易系统要处理大量市场数据,必须足够快。程序也必须做事:发送消息、写文件、与外界交互。实用的软件不可能完全避免副作用。
支持纯函数编程的语言让我们能够把副作用隔离在清晰、受控的位置,使其余部分更容易推理,尤其是在并发场景中。如果为了纯粹且足够高效的方案需要引入过多复杂度,使用副作用可能是更好的选择。
用幻影类型实现只读访问
Minsky 构建了一个带只读和读写能力的引用示例。幻影参数支持静态限制和多态读取,无须改变运行时表示。
在提醒大家不要过度使用类型技巧之后,我想展示一种技巧:幻影类型。它让我们加入类型层面的信息,由编译器检查额外性质。用于这些检查的信息不一定需要对应的运行时表示。
从一个简单的整数引用开始,它支持创建、读取和设置。我想要第二个句柄,它可以读取同一个引用,却不能写入。持有原始句柄的人仍可修改值,而只读句柄的持有者可以看到这些变化。
一种直接的做法是创建一个具有更严格签名的独立模块。它的抽象类型使用原始引用实现,但接口只暴露从引用创建句柄和读取的操作,不暴露设置操作。
尽管两个句柄可能表示同一个底层值,它们暴露的类型不同。因此,编译器会阻止只持有只读句柄的代码使用可写接口。这个包装本质上是恒等操作,但调用能否消失可能取决于编译器是否内联。
这个方案的缺点是多态性。由于只读引用和可写引用的类型不同,我无法编写一个简单函数来求任意一种引用列表的和,尽管两者都支持读取。我们希望保留写入限制,同时提供共同的读取操作。
引入两个空类型作为标签,分别表示只读和读写,再为引用添加一个类型参数。这些标签没有可构造的值。它们表达句柄的属性,却不成为底层引用表示的一部分。
参数出现在类型名称中,却没有出现在其实现里:这就是所谓的幻影。引用操作本身很普通,重要的约束存在于模块签名中。
set 操作要求引用带有读写标签,因此只读句柄无法通过类型检查。get 对标签是多态的,可以读取任意一种引用。create 操作也是多态的,直到后续使用施加更具体的约束。
编译器会推断出求和函数只读取引用,因此可以处理任意一种标签。将值翻倍的函数必须调用 set,所以它的输入必须可写。我们不必手动写下所有这些结论。
在一个更大的例子中,以相同方式创建的两个列表,可以因后续用途获得不同类型。把其中一个放入声明为只读的记录字段,另一个传给要求可写引用的函数,就提供了类型推断所需的约束。
如果约束互相冲突,编译就会失败。类型推断不仅仅是读取构造函数的返回类型;信息会在程序中流动,直到编译器协调所有用法。在这个例子中,它既保护了能力边界,又保留了有用的多态性。
这是幻影类型的一种适度用法。更复杂的编码也是可能的,但这里的价值很实际:程序可以区分不同能力,同时共享读取代码。类型索引值和宏等其他技巧,也可以在增加的复杂度有充分理由时提供帮助。
问答:规模与权衡
他讨论了大型代码库中的模块和抽象,也承认了 OCaml 生态及当时界面工具的局限。
在问答中,我想强调模块是构建大型系统的基础。对象通常很自然地抽象一个东西;模块则可以描述一组相关的类型和操作,并说明哪些类型共享、哪些彼此不同。
模块、仿函数、参数多态和高阶函数有助于拆分大型系统。演讲时,Jane Street 的 ML 代码有 100 万到 200 万行——对这个生态来说规模很大,尽管与最大的企业代码库相比仍然不算庞大。
低级语言在大规模开发中同样需要抽象和内存管理。大型 C 库可能构建自己的对象机制和内存管理约定。有些地方转向 C 是合理的,但我们的系统有很大一部分可以用 OCaml 编写。
生态系统确实会带来成本。用户较少意味着库也较少,编写绑定所需的工作更多。2010 年,我不会选择 OCaml 来做 Web 编程或跨平台图形界面,因为当时可用的库还不足以胜任这些任务。
我们的一些界面甚至使用 curses 风格的文本显示。打印和序列化等通用功能如果没有额外技巧,也可能比较麻烦;我们用宏生成其中一部分代码。这些都是实际的权衡,虽然我们的团队已经针对自身需求解决了其中许多问题。
同频道的其他文章
1:34:29EN → 简体中文
1:34:36EN → 简体中文
15:48EN → 简体中文
1:48:21EN → 简体中文
1:20:57EN → 简体中文
55:03EN → 简体中文