30 秒看懂
Yaron Minsky 解释了 Jane Street 使用 OCaml 背后的观点:交易系统必须足够易懂,工程师才能推理罕见但后果重大的事件。他认为代码应优先照顾读者和未来的维护者,同时承认其他工作也需要快速制作原型。 在《Effective ML》讲座的第一部分,他展示了统一的模块接口、编码连接状态不变量的数据类型、穷尽模式匹配,以及让 API 调用处更清晰的局部命名方式。
- 1优先考虑读者可读代码既有助于保证正确性,也有助于修改:在长期运行的系统中,代码被阅读和修改的次数通常远多于编写次数。
- 2统一接口可复用的签名组件规范操作,让模块能够配合要求共同能力的仿函数使用。
- 3让非法状态无法表示把连接状态专有的数据移入对应变体,并将必须一起出现的值组合起来。
- 4穷尽匹配显式列出分支,让编译器在新增变体时指出遗漏;通配符则可能悄悄掩盖遗漏。
- 5少打开模块大范围导入能缩短名称,却可能模糊其来源。局部别名既保持简洁,也让引用清楚明确。
- 6为调用处设计带标签的参数在函数使用位置说明位置、长度和布尔值等参数的作用。
要点建议
- 为必须阅读和修改代码的人设计长期使用的共享代码。
- 统一的模块签名提供可预测的约定,并支持可复用的仿函数。
- 使用变体和记录,把状态专有的数据与其有效的状态关联起来。
- 显式列出匹配分支,让编译器在类型演进时指出需要更新的位置。
- 限制大范围打开模块,并保持别名的局部作用域,以明确名称的来源。
- 当值的类型本身不能说明其作用时,使用带标签的参数。
按章节阅读整理后的全文
交易工作塑造的视角
课程介绍之后,Minsky 解释了建议背后的工作环境。交易系统需要正确性,研究工作也需要快速迭代。
每场演讲都有自己的观点,而观点受到作者所处环境的影响。作为读者或听众,你应该问一问这些约束是什么。我的演讲叫《Effective ML》,模仿 Joshua Bloch 的《Effective Java》:后者汇集了一些关于如何写出更好程序的小观察。
我很欣赏 Bloch 的建议,包括限制可变性,以及在适当时优先使用组合而非继承。初读这本书时,我正在帮助 Jane Street 的同事编写 Java。在生产环境中使用 ML 大约九年后,我积累了自己的经验。
Jane Street 是电子流动性提供商。它像处于买卖双方之间的杂货店一样,主要通过交易中的微小价差获利,而不是押注某种商品未来的价格。利润率很薄,因此交易量很重要。
每天有数十亿美元通过我们的系统。一个小小的软件错误,如果在紧密循环中反复发生,很快就可能酿成灾难,尤其是许多高效系统在复杂市场中交易时。因此,正确性极为重要。
正确性并不是我们唯一关心的事。有些软件的失败成本很高,值得格外谨慎;另一些工作则更需要快速交付。我们的研究基础设施需要快速实验和制作原型,我们也在这些应用中使用 OCaml。
我们的环境不同于围绕许多外部工具包构建网站或桌面界面的公司。我们的大部分代码由内部开发,可以自行修改。这样的控制程度影响了我们的技术选择。
我们把代码视为表达意图的方式,也视为完成工作的工具。我们需要审视代码的意图并推理其行为。仅靠测试无法覆盖所有异常情况,尤其是罕见金融事件的发生频率高于简单正态分布模型所暗示的水平时。
为了在这些情况下保持稳健,我们需要从第一性原理出发,推理程序可能做什么。这要求代码足够简单,让人能够理解。这个考虑影响了我接下来的所有建议。
为读者写代码
Minsky 认为清晰的代码有助于程序员推理正确性并演进系统。长期使用的代码会被反复阅读和修改。
随着我的工作从主要编写自己的代码转向更多阅读别人的代码,我的偏好也发生了变化。作者往往重视编写时的便利;读者则重视理解时的便利。对于共享且长期使用的系统,我的经验法则是读者是对的。
简单有助于读者在脑中形成一个非形式化的论证,判断代码是否正确。它也有助于团队日后修改系统。在生产环境中,代码可能在原作者写完很久之后仍被反复阅读和修改。
创建统一接口
OCaml 模块签名让约定更加明确。可复用的签名组件为模块提供一致的能力,并支持仿函数。
由此引出一个具体做法:创建接口,并保持接口统一。OCaml 将 .ml 文件中的实现与 .mli 文件中的签名分开。当项目看起来是临时的,省略接口可能显得方便。
我刚加入 Jane Street 时就这么做过,以为代码最终会被丢弃。大约写了 8 万行后,它却仍在使用。回头看,没有接口是这份寿命远超预期的代码中既令人尴尬又代价高昂的问题。
好的接口还应遵循共同模式。对列表或数组执行 map 操作,应使用一致的名称和大体一致的签名。这种规律能让程序员在遇到新模块时形成有用的预期,也让协作更容易。
可以用可复用的接口组件来促进约定:把签名中的标准部分包含进许多模块。例如,Comparable 组件可以定义 compare、派生运算符和 min/max 函数,以及使用该排序的 Map 和 Set 子模块。

包含这个组件后,模块就有了标准的比较接口。如果实现违反约定,就无法编译。其他组件可以描述字符串转换或哈希等能力,并在模块中使用约定俗成的类型 t。
一致性也让仿函数更易使用。一个要求模块可比较且可转换为字符串的仿函数,可以接受任何满足这些标准接口的模块,而不必为每个输入专门编写包装。
让非法状态无法表示
具有多个可选字段的连接状态记录隐藏着不变量。把状态专有数据移入变体并组合相关字段,可以让某些不变量由结构保证。
另一条原则是让非法状态无法表示。刚接触 ML 的程序员有时只把类型检查器看成阻止程序运行的约束。更好的做法是把类型系统当作工程工具,用来表达不变量并帮助未来的读者和维护者。
这些约束可以防止初次实现时的错误、传达假设,也能防止多年后出现无效修改。当忘记代码如何工作的人就是未来的你自己时,它们尤其有帮助。
考虑网络协议中的连接信息。原始记录包含连接状态、服务器地址、上次 ping 的时间和 ID、会话 ID、启动时间以及断开时间。许多字段是可选的,因为这些信息并非在每个状态下都可用。

可选值明确标示了可能缺失的情况,而不是将每个对象引用都视为可能为 null。这有所帮助,但相互独立的可选值仍然允许不一致的组合。ping 时间通常应与 ping ID 一起出现;连接建立后应当存在会话 ID;其他时间戳只在特定状态下有意义。
这些关系没有写进类型时,每个转换记录的函数都必须记住并维护它们。注释和谨慎是必要的,但仍然容易出错。我们可以重组数据,让其中一些检查不再依靠手工完成。

为连接中、已连接和已断开状态分别定义数据,让每个变体携带适合该状态的信息。服务器在所有状态下都有意义,因此保留在外层连接记录中。依赖状态的信息则移到对应分支。
上次 ping 仍然是可选的,但时间和 ID 变成一个可选元组。它们现在要么同时存在,要么同时不存在。数据结构捕获了两者的关系,不再要求每个调用者同步两个独立字段。
这种方法不能编码所有不变量。有时函数仍然需要显式检查和仔细的文档。但代数数据类型在表达普通程序中的许多重要关系方面,效果好得令人惊讶。
基本构件是积类型和和类型。记录把这个和那个组合起来;变体表示这个或那个。将这两种形式分层组合,可以得到丰富的表达方式,描述程序数据及其有效情况。
编写穷尽的代码
交易消息示例展示了类型变化时通配符如何掩盖遗漏。显式匹配让编译器指导更新。
一个相关技巧是为穷尽性编写代码。OCaml 会检查 match 表达式,并对遗漏、重复或不可能的情况发出警告。这些检查有助于初始正确性,而当类型变化时,它们作为重构辅助尤其有用。
如果编写匹配时向编译器隐藏某些情况,就会破坏这种保护。应当考虑编译器提供了哪些保证,并组织代码来保留这些保证。
设想发送给交易所的消息:订单表达交易意愿,撤单撤回订单,成交报告已完成的交易。计算持仓变化的函数应对成交返回变化量,对订单和撤单返回零。

现在新增更正消息,因为交易所可能修改此前的成交报告。更正可能改变持仓。如果原函数处理成交,并用通配符让其他所有情况返回零,它仍然可以编译,但对更正给出的答案现在可能是错误的。
函数可能距离类型定义很远,因此修改类型的程序员未必会注意到它。如果原来的匹配显式列出订单和撤单,而不是使用兜底分支,新增更正就会让匹配不再穷尽,编译器便能指出需要更新的位置。
避免通配符并非绝对规则,但匹配末尾的下划线应该让你停下来想一想。在大多数情况下,显式列出各个分支能在程序演进时提供更好的保护。
名称、作用域和调用处
打开多个模块会模糊名称来源。局部别名和带标签的参数可以让代码更易阅读,同时避免过度冗长。
少打开模块。打开模块会把其名称带入当前命名空间,让表达式更短,但这是用明确性换取简洁。打开多个模块后,读者可能不知道某个函数或值来自哪里。
编辑器工具可以帮助定位定义,我们也有这样的工具。即便如此,清晰的代码不需要读者把鼠标悬停在每个名称上也能阅读;由于它较少依赖周围上下文,也更容易移动。额外文字的成本不一定像最初看起来那么大。
我的例子是 Unix 命令 true 的增强实现,使用命令行库解析退出码和消息。原始代码打开了 Core、Command 和 Flag。int 和 string 等名称是来自 Flag 的值,用于描述如何解析参数,但在代码中很难一眼看出它们的来源。
我不必在顶部打开这些模块,而可以为 Command.Flag 使用局部模块别名,再限定相关名称。这样既能明确来源,也不必每次写出完整模块路径。

局部别名的生命周期也很短。读者的工作记忆有限:名称在小作用域中引入时,他们知道何时可以不用再记住它。变量名也适用同样的原则:短作用域可以使用短名称;长期存在的名称则应包含足够的含义,以便日后提醒读者。
一位听众问到示例中的波浪号。它们标记带标签的参数。当类型不能说明值的作用时,Jane Street 经常使用带标签的参数,例如字符串范围的位置和长度,或含义可能不明确的布尔标志。
设计 API 时,要看它在调用处读起来如何,而不只是看接口或文档。读者常常只看眼前的源代码。清晰的标签可以告诉他们参数的含义,不必再去别处查找。
OCaml 还支持标签简写:如果变量与标签同名,写出标签就可以表示传入该变量。这让语法保持简洁,也鼓励不同上下文使用一致的名称,从而形成更连贯的代码库。
同频道的其他文章
1:34:29EN → 简体中文
1:34:36EN → 简体中文
15:48EN → 简体中文
1:48:21EN → 简体中文
1:20:57EN → 简体中文
55:03EN → 简体中文