Download PDF version of this article
这篇以及其他 acmqueue 文章已被翻译成葡萄牙语
葡萄牙语版 Q

OCaml 大众化

为什么您应该学习的下一种语言应该是函数式语言


Yaron Minsky,Jane Street


有时,优雅的实现是一个函数。不是方法。不是类。不是框架。只是一个函数。 - John Carmack


函数式编程是一个历史悠久的古老概念。Lisp 是一种受 Alonzo Church 的 lambda 演算启发的函数式语言,是最早开发的编程语言之一,诞生于计算机时代的黎明。OCaml 和 Haskell 等静态类型函数式语言更新,但其根基深厚——它们的祖先 ML 可以追溯到 70 年代早期 Robin Milner 在 LCF(可计算函数逻辑)定理证明器方面的工作。

函数式编程也具有巨大的影响力。从垃圾回收、泛型到类型推断,许多编程语言设计的 фундаментальные 进步都来自函数式领域,并且在其他语言普及之前几十年就在那里司空见惯。

然而,函数式语言从未真正进入主流。它们最接近主流的时期,也许是在 Symbolics 和 Lisp 机器的时代,但那些日子现在看来已经非常遥远了。尽管函数式编程在过去几年中复兴,但它仍然是一种更多被谈论而不是被使用的技术。

从这个记录中很容易得出结论,函数式语言不具备成功的要素。它们可能对于某些有限的应用有意义,并包含可以导入到其他语言中的有用概念;但命令式和面向对象语言更适合绝大多数软件工程任务。

尽管这种结论很诱人,但它是错误的。我已经在生产环境中使用 OCaml 近十年了,在这段时间里,我确信函数式语言,特别是像 OCaml 和 Haskell 这样的静态类型语言,是非常优秀的通用编程工具——比任何现有的主流语言都更好。它们的应用范围也极其广泛,非常适合小型脚本任务以及大型高性能应用程序。它们不是每项工作的正确工具,但它们已经非常接近了。

转向 OCaml

我使用 OCaml 编程的大部分经验都来自于我在 Jane Street 的工作,这是一家成立于 2000 年的金融公司。九年前,Jane Street 没有人听说过 OCaml。今天,Jane Street 是该语言最大的工业用户,拥有近两百万行 OCaml 代码和 65 名(上次统计)每天使用该语言的员工。解释 OCaml 如此有效工具的最佳方式,可能是从解释这种转变是如何以及为什么发生的开始。要理解这一点,您首先需要了解 Jane Street 是做什么的。

Jane Street 的核心业务是在世界电子市场上提供流动性。它本质上是一个中间人。它不断地在许多不同的交易所为许多不同的证券下订单。每个订单都表达了以给定价格买入或卖出给定证券的意愿,并且总体而言,它们是 Jane Street 服务在市场上的广告。通过这些订单,该公司从需要卖出的人那里买入,并卖给需要买入的人,从买卖价格之间的差额中获利。它一直在价格上与其他试图做同样事情的参与者竞争。

电子流动性提供在技术上是密集的,不仅是因为需要部署的计算资源(需要消耗、分析和实时响应大量数据),而且还因为企业本身的复杂性——交易可以跨越多个交易所、监管制度、证券类别和时区。管理由此产生的复杂性是一项艰巨的任务,需要对软件进行大量投资。

所有这些技术都带有风险。对于一家交易公司来说,没有比部署一段在紧密循环中一遍又一遍地做出错误决定的交易软件更快地自我毁灭的方式了。Jane Street 对这些技术风险的部分反应是,非常重视构建易于理解的软件——可读性强的软件。

阅读代码是公司从我们编写第一行 OCaml 代码之前就采用的风险应对方法的一部分。早期,几位最资深的交易员(包括一位创始人)承诺在核心交易系统投入生产之前,阅读其中每一行代码。这是一项巨大的持续时间投资,反映了对技术风险的高度关注。

我在完成博士学位后的第二年加入了 Jane Street,在那里兼职工作,同时做博士后。我在 Jane Street 的工作重点是交易策略的统计分析和优化,而 OCaml 是我用来完成分析工作的主要工具。为什么选择 OCaml?我在研究生院学过它,并爱上了这门语言。OCaml 非常适合这种快速原型开发工作:高性能,但比 C、C++ 或 Java 编程更快且更不易出错。

我确信我在 Jane Street 的工作只是暂时的,我编写的所有代码都是一次性的,所以我选择最大限度地提高自己的生产力,而不用担心其他人以后是否可以使用这些代码。六个月和 80,000 行代码之后,我意识到我错了:我在 Jane Street 担任了全职职位,并很快开始招聘人员在那里创建一个研究小组。

此时,公司正在寻找构建软件的新方法。公司最初几年支持公司的系统主要用 VBA 和 C# 编写。实际上,核心交易系统本身就是带有大量自定义 VBA 代码的 Excel 电子表格。这是一种快速启动和运行的好方法,但从一开始就很明显,这不是一种可持续的方法。

2003 年,Jane Street 开始用 Java 重写其核心交易系统。重写最终被放弃了,部分原因是生成的代码太难阅读和理解——实际上比被替换的 VBA 更难。这很大一部分原因是 Java 的冗长,但不仅仅是这样。VBA 代码以简洁、直截了当的风格编写,相当容易理解。但不知何故,当用 Java 编写代码时,我们构建了一个类嵌套,当人们想理解在调用给定方法时实际调用了哪段代码时,他们会挠头。大量使用继承的代码尤其难以思考,部分原因是继承会隐藏在抽象边界之下。

2005 年,在研究小组成功的基础上,Jane Street 启动了另一次核心交易系统重写,这次使用 OCaml。第一个原型在三个月内完成,并在那之后的三个月后投入交易。此后,OCaml 在公司中的使用范围不断扩大。如今,它被用于解决公司各个部门的问题,从会计到系统管理,并且这项工作仍在继续增长。近年来,公司的交易部门增加了对该语言的使用,OCaml 培训现在已成为新交易员工课程的标准组成部分。总的来说,向 OCaml 的过渡取得了巨大的成功,产生了比我们原本可以实现的更强大的技术。

为什么选择 OCaml?

这种语言有什么优点使其如此有效?以下是我认为 OCaml 的主要优势的简短摘要。

* 简洁性。 我们在研究方面使用 OCaml 的经验使我们确信,与 Java 或 C# 等语言相比,我们可以用 OCaml 构建更小、更简单、更易于理解的系统。对于一个重视可读性的组织来说,这是一个巨大的胜利。

* 错误检测。 刚接触 OCaml 的程序员经常会对类型系统捕获错误的程度感到惊讶。您得到的印象是,一旦您设法让类型检查器批准您的代码,就不会留下任何错误了。当然,这并不是真的;OCaml 的类型系统对许多错误是无能为力的。然而,类型系统对出奇多的错误是有效的,包括许多通过测试很难发现的错误。

* 性能。 我们发现 OCaml 的性能与 Java 的性能相当或更好,并且与 C 或 C++ 等语言的性能非常接近。除了拥有高质量的代码生成器外,OCaml 还具有增量 GC(垃圾回收器)。这意味着可以调整 GC 以一次执行少量工作,使其更适合电子交易等软实时应用程序。

* 纯粹,主要是。 尽管函数式程序员经常谈论它,但可变状态是编程的基本组成部分,不能也不应该被消除。发送网络数据包或写入磁盘是可变性的示例。完全致力于不变性就等于承诺永远不构建任何真实的东西。

然而,可变状态也有其代价。无突变代码通常更容易推理,使代码库不同部分之间的交互和依赖关系变得显式且更易于管理。OCaml 在这里取得了良好的平衡,使突变变得容易,但使不可变数据结构成为默认设置。一个写得好的 OCaml 系统几乎总是具有可变状态,但该状态受到严格限制。

这些优势中最容易具体证明的也许是简洁性。简洁性的重要性是显而易见的:在其他条件相同的情况下,较短的代码更易于阅读、更易于编写和更易于维护。当然,存在限制:将所有函数名称都缩减为单个字符没有任何好处,但简洁性仍然很重要,OCaml 在保持代码库小型化方面做了很多工作。

OCaml 为我们带来的一个优势是类型推断,它消除了对许多类型声明的需求。这使您获得的代码大致与用 Python 和 Ruby 等动态语言编写的代码一样紧凑。同时,您获得了静态类型的性能和正确性优势。

考虑以下 OCaml 函数 map,用于转换元组的元素。

 
let map f (x,y,z) =
  (f x, f y, f z)

这里,map 被定义为一个带有两个参数的函数:一个函数 f 和一个三元组 (x,y,z)。请注意,f x 是将函数 f 应用于 x 的语法。

现在考虑一下这在 C# 4.0 中会是什么样子。C# 代码虽然功能相同,但看起来很杂乱,真正的结构被语法噪音所掩盖。

 
Tuple<U,U,U> Map<T,U>(Func  <T,U> f, Tuple<T,T,T> t)
{
  return new Tuple<U,U,U>(f(t.item1),  f(t.item2), f(t.item3));
}

简洁性的另一个来源是 OCaml 用于描述类型的表示法。该表示法的核心是代数数据类型的概念。当您的系统包含两种构建新类型的方式时,您就会得到代数数据类型:乘积和求和。

乘积类型是两者中更熟悉的类型。元组、记录、结构和对象都是乘积类型的示例。乘积类型将不同类型的多个值组合成单个值。这些被称为乘积类型,因为它们在数学上对应于构成类型的笛卡尔积。

求和类型对应于构成类型的不相交并集,它用于表达多种可能性。当您同时拥有多个事物(a 和 b 和 c)时,使用乘积类型;当您想要枚举不同的可能性(a 或 b 或 c)时,使用求和类型。求和类型可以在面向对象语言(如 Java)中使用子类进行模拟(尽管有些笨拙),并且它们在 C 中以联合类型的形式出现。但是,大多数语言的类型系统中对以安全方式与求和类型交互的支持非常弱。

图 1 提供了代数数据类型应用的示例。该代码定义了一种类型,用于表示一组基本谓词上的布尔表达式,以及一个用于评估这些表达式的函数。该代码是通用的,适用于基本谓词集,因此这些表达式的主题可以是任何内容,从整数不等式到编译器标志的设置。


 
type 'a expr = | True
              | False
              | And  of 'a expr * 'a  expr
              | Or   of 'a expr * 'a  expr
              | Not  of 'a expr
              | Base of 'a

let rec eval eval_base expr  =
  let  eval' x = eval eval_base x in
  match expr with
  | True  -> true
  | False -> false
  | Base base  -> eval_base base
  | And  (x,y) -> eval' x && eval' y
  | Or  (x,y)  -> eval' x || eval' y
  | Not  x     -> not (eval' x)

Figure 1. Expression  type and evaluator in OCaml

求和类型 expr 由分隔声明的不同分支的竖线表示。其中一些分支,例如 TrueFalse,是简单的标记,与 Java 或 C 中枚举的元素没有本质区别。其他分支,例如 AndNot,具有关联数据,并且该数据在不同情况下有所不同。此类型实际上包含求和和乘积,其中 AndOr 分支包含元组。由分层组合的乘积和求和组成的类型是 OCaml 中常见的强大惯用语。

一个值得注意的语法位是类型变量 'a。类型变量可以使用任何类型实例化,这就是允许代码通用化基本谓词集的原因。这类似于 Java 或 C# 中处理泛型类型的方式。因此,Java 的 <A>List 在 OCaml 中将呈现为 'a list

函数 eval 接受两个参数:expr,要评估的表达式;以及 eval_base,一个用于评估基本谓词的函数。代码是通用的,因为 eval 可以用于任何类型的基本谓词上的表达式,但必须提供 eval_base 才能评估这些基本谓词的真假。函数 eval' 被定义为调用 eval 的递归调用的简写形式,并将 eval_base 作为参数。最后,match 语句用于对表达式的可能结构进行案例分析,在评估基本谓词时调用 eval_base,否则充当数据类型结构上的直接递归。

图 2 显示了相同的代码在 Java 中可能如何呈现。冗长性立即引人注目。添加一个像 And 这样的单个案例在 OCaml 中需要两行(短)代码,而在 Java 中需要八行——而 Java 代码实际上已经非常简洁了。如果您想允许围绕此表达式类型创建其他算法,而这些算法没有内置到类定义中,那么您可能需要使用访问者模式,这将大大增加行数。


 
public  abstract class Expr<T> {

 public interface Evaluator<T> { boolean evaluate(T value); }
 public abstract boolean eval(Evaluator<T> evaluator);

 public class True<T> extends Expr<T> {
   public boolean eval(Evaluator<T> evaluator) { return true; }
 }
 public class False<T> extends Expr<T> {
   public boolean eval(Evaluator<T> evaluator) { return false; }
 }
 public class Base<T> extends Expr<T> {
   public final T value;
   public Base(T value) { this.value = value; }
   public boolean eval(Evaluator<T> evaluator)
   { return evaluator.evaluate(value); }
 }
 public class And<T> extends Expr<T> {
   public final Expr<T> expr1;
   public final Expr<T> expr2;
   public And(Expr<T> expr1, Expr<T> expr2)  {
     this.expr1 = expr1;
     this.expr2 = expr2;
    }
   public boolean eval(Evaluator<T> evaluator) {
     return expr1.eval(evaluator) && expr2.eval(evaluator);
    }
 }
 public class Or<T> extends Expr<T> {
   public final Expr<T> expr1;
   public final Expr<T> expr2;
   public Or(Expr<T> expr1, Expr<T> expr2)  {
     this.expr1 = expr1;
     this.expr2 = expr2;
    }
   public boolean eval(Evaluator<T> evaluator) {
     return expr1.eval(evaluator) || expr2.eval(evaluator);
    }
 }
 public class Not<T> extends Expr<T> {
   public final Expr<T> expr;
   public Not(Expr<T> expr) { this.expr = expr; }
   public boolean eval(Evaluator<T> evaluator)
    { return !expr.eval(evaluator); }
 }
}

Figure 2. Expression  type and evaluator in Java

该语言的另一个需要进一步解释的方面是类型系统捕获错误的能力。不熟悉 OCaml 和相关语言的人(以及一些熟悉的人)经常犯一个错误,即低估类型系统的力量。很容易得出结论,类型系统为您所做的全部工作就是确保您正确传递了参数(例如,您在应该提供 float 的地方提供了 float)。

但它不仅仅如此。即使是类型系统的朴素使用,在捕获错误方面也出奇地好。考虑以下用于去除列表抖动(即删除连续重复项)的 Python 代码。

 
# Removes sequential duplicates, e.g.,
# destutter([1,1,4,3,3,2])  = [1,4,3,2]

def destutter(list):
   l = []
   for i in range(len(list)):
       if list[i] != list[i+1]:
            l.append(list[i])
   return l

这段代码看起来很简单,但它有一个错误:它没有正确处理列表的结尾。以下是修复它的一种方法

 
def destutter(list):
   l = []
   for i in range(len(list)):
       if i + 1 >= len(list) or list[i] != list[i+1]:
            l.append(list[i])
   return l

现在让我们看看在 OCaml 中编写或多或少相同的函数,并且具有或多或少相同的错误时会发生什么

 
let rec destutter l =
 match l with
 | []             -> []
 | x :: y :: rest ->
   if  x = y then destutter (y :: rest)
   else  x :: destutter (y :: rest)

这使用 OCaml 的模式匹配语法来访问列表的元素。这里 :: 是列表构造函数,[] 表示空列表。因此,[] 情况匹配空列表,x::y::rest 情况匹配至少有两个元素 xy 的列表。变量 rest 指的是列表的(可能为空的)剩余部分。

与 Python 示例一样,此代码未能考虑当您到达列表末尾并且只剩下一个元素时会发生什么。但是,在这种情况下,您不是在运行时而是编译时发现问题。编译器给出以下错误

 
File "destutter.ml", line 2, characters 2-125:
Warning 8: this pattern-matching is not exhaustive.
Here is an example of a value that is not matched:
_::[]

缺少的案例 _::[] 是一个包含单个元素的列表。

您可以通过为缺少的案例添加处理程序来修复代码(并满足编译器)

 
let rec destutter l =
 match l with
 | []                -> []
 | x :: []          -> x :: []
 | x :: y :: rest ->
     if x = y then destutter (y :: rest)
     else x :: destutter (y :: rest)

这里的错误是一个微不足道的错误,很容易通过测试发现。但是,类型系统在暴露难以测试的错误方面也同样出色,要么是因为它们仅在奇怪的极端情况下出现,而这些极端情况在测试中很容易遗漏,要么是因为它们出现在难以模拟和彻底执行的复杂系统中。

OCaml 开箱即用就非常擅长捕获错误,但如果您仔细设计类型,它可以做得更多。以以下用于表示网络连接状态的类型为例

 
type connection_state =
| Connecting
| Connected
| Disconnected

type connection_info = {
 state:                   connection_state;
 server:                  inet_addr;
 last_ping_time:          time option;
 last_ping_id:            int    option;
 session_id:              string option;
 when_initiated:          time option;
 when_disconnected: time  option;
}

connection_state 类型是对连接可能处于的三种命名状态的简单枚举;connection_info 是一种记录类型,包含许多描述连接不同方面的字段。请注意,类型末尾带有 option 的字段本质上是可为空字段。(默认情况下,OCaml 中的值保证为非空)。除此之外,此代码与您在 Java 或 C# 中可能编写的代码并没有什么不同。

以下是有关各个记录字段及其相互关系的更多信息

* server 指示连接另一端的服务器的身份。

* last_ping_timelast_ping_id 旨在用作保持活动协议的一部分。请注意,这些字段应同时存在,或同时不存在。此外,它们应仅在 stateConnected 时存在。

* session_id 是一个唯一标识符,每次重新建立连接时都会重新选择。它也应仅在 stateConnected 时存在。

* when_initiated 用于跟踪启动连接尝试的时间,可用于确定何时应放弃连接尝试。这应仅在 stateConnecting 时存在。

* when_disconnected 跟踪连接何时进入 Disconnected 状态,并且应仅在该状态下存在。

如您所见,许多不变量将不同的记录字段联系在一起。维护这些不变量需要真正的工作。您需要仔细记录它们,以免以后绊倒;您需要编写测试来验证不变量;并且您必须继续谨慎,以免在代码演变时破坏不变量。

但是我们可以做得更好。考虑以下重写

 
type connecting   = { when_initiated:  time; }
type connected    = { last_ping  : (time * int) option;
                                session_id: string; }
type disconnected = { when_disconnected: time;  }

type connection_state =
| Connecting   of connecting
| Connected    of connected
| Disconnected of disconnected

type connection_info = {
  state:  connection_state;
  server: inet_addr;
}

我们现在有乘积类型和求和类型的组合,可以更精确地表示连接的允许状态集。特别是,三种状态中的每一种状态都有不同的记录类型,每种类型都包含仅与该状态相关的信息。始终相关的信息(在本例中,只有 server)被推送到顶级记录。此外,我们通过将 last_ping_timelast_ping_id 表示为 last_ping(这是一个可选对),明确表示它们要么都存在,要么都不存在。

通过完成所有这些工作,我们将许多必需的不变量嵌入到类型中。现在不变量是类型的一部分,编译器可以检测并拒绝会违反这些不变量的代码。这比手动维护这些不变量既省力又更可靠。

该示例使用代数数据类型来编码不变量,但 OCaml 还有其他工具可以执行相同的操作。OCaml 的模块系统就是一个例子,它允许您在模块的接口中指定不变量。与大多数面向对象语言不同,OCaml 使表达多个不同类型上的复杂联合不变量成为可能。更一般而言,OCaml 的模块是一种强大的工具,可以将代码库分解为小的、可理解的部分,其中这些部分之间的交互由程序员显式控制。

类型系统捕获错误的能力即使对于小型独立项目也很有价值,但在多个开发人员在长期代码库上协同工作的协作环境中,它真正地大放异彩。除了发现错误之外,类型签名作为一种保证正确的文档,也发挥着令人惊讶的有价值的作用。在不断演变的代码库的上下文中,类型系统强制执行的不变量比约定强制执行的不变量更持久,因为它们不太可能被其他开发人员意外破坏。

局限性

这些都不是说 OCaml 没有缺陷。当然,作为一种少数语言,存在所有相关问题。OCaml 拥有一个伟大的社区,该社区生成了丰富的库集,但该库集合与 Python、C 或 Java 可用的库集合相比,相形见绌。同样,IDE、分析器和调试器等开发工具也存在,但与更主流语言中的同类工具相比,成熟度和功能都大大降低。

OCaml 的另一个限制与并行性有关。OCaml 运行时具有单个运行时锁,这意味着必须使用多个进程才能利用单台机器上的多个内核。在很大程度上,这非常适合我们的开发模型:我们更喜欢消息传递而不是共享内存线程作为并行编程模型,因为它产生的代码更容易推理,并且可以更好地扩展到跨越多台物理机器的系统。然而,在更广泛的 OCaml 世界中可用于执行此类多进程编程的工具仍在成熟中。

但 OCaml 的局限性本质上不是根本性的。它们更多地与实现的细节或语言的流行程度有关,而不是与语言本身有关。最终,这就是我发现最令人困惑的地方。我现在非常确信 OCaml 背后的核心思想非常宝贵,OCaml 本身就是证明,无论其局限性如何,它都是一种非常有效和强大的工具。然而,这些想法仍然顽固地存在于主流之外。

也许这种情况终于即将改变。F# 和 Scala 等语言通过将自己集成到 Dotnet 和 Java 生态系统中,分别将 OCaml 和 Haskell 背后的某些思想带给更广泛的受众。也许 10 年后,我们将不再需要询问为什么这些想法未能被更广泛的世界接受。但没有理由等待。您现在可以将 OCaml 添加到您的工具箱中。

喜欢它,讨厌它?请告诉我们

[email protected]

Yaron Minsky 于 2002 年在康奈尔大学获得计算机科学博士学位,专注于分布式系统。2003 年,他加入 Jane Street,在那里创立了量化研究小组,并自 2007 年以来一直管理那里的技术小组。

© 2011 1542-7730/11/0900 $10.00

acmqueue

最初发表于 Queue 第 9 卷,第 9 期
数字图书馆 中评论本文





更多相关文章

Matt Godbolt - C++ 编译器中的优化
在为编译器提供更多信息方面需要权衡:这可能会使编译速度变慢。链接时间优化等技术可以为您提供两全其美的优势。编译器中的优化不断改进,即将到来的间接调用和虚函数分派方面的改进可能很快就会带来更快的多态性。


Ulan Degenbaev, Michael Lippautz, Hannes Payer - 作为合资企业的垃圾回收
跨组件跟踪是一种解决跨组件边界的引用循环问题的方法。只要组件可以形成具有跨 API 边界的非平凡所有权的任意对象图,就会出现此问题。CCT 的增量版本在 V8 和 Blink 中实现,从而能够以安全的方式有效地回收内存。


David Chisnall - C 不是一种低级语言
在最近的 Meltdown 和 Spectre 漏洞之后,值得花一些时间研究根本原因。这两种漏洞都涉及处理器推测性地执行超出某种访问检查的指令,并允许攻击者通过侧信道观察结果。导致这些漏洞以及其他几个漏洞的功能被添加进来,是为了让 C 程序员继续相信他们正在用一种低级语言编程,而这种情况已经几十年没有发生了。


Tobias Lauinger, Abdelberi Chaabane, Christo Wilson - 你不应该依赖我
大多数网站都使用 JavaScript 库,其中许多库已知存在漏洞。了解问题的范围以及包含库的许多意外方式,只是改进情况的第一步。这里的目标是,本文中包含的信息将有助于为社区提供更好的工具、开发实践和教育工作。





© 保留所有权利。

© . All rights reserved.