第 26 章 “尾部模构造函数”程序转换

(在 OCaml 4.14 中引入)

注意:此功能被认为是实验性的,其接口可能会在语言的未来几个版本中根据用户反馈而演变。

考虑这个 List.map 函数的自然实现

let rec map f l = match l with | [] -> [] | x :: xs -> let y = f x in y :: map f xs

这个实现的一个众所周知的限制是递归调用 map f xs 不在 尾部 位置。运行时需要记住在调用返回一个值 r 后继续执行 y :: r,因此这个函数在每次递归调用中都会消耗一定量的调用栈空间。 map f li 的栈使用量与 li 的长度成正比。对于在配置了有限栈空间的系统上的大型列表来说,这是一个正确性问题 - 令人讨厌的 Stack_overflow 异常。

# let with_stack_limit stack_limit f = let old_gc_settings = Gc.get () in Gc.set { old_gc_settings with stack_limit }; Fun.protect ~finally:(fun () -> Gc.set old_gc_settings) f ;;
val with_stack_limit : int -> (unit -> 'a) -> 'a = <fun>
# with_stack_limit 20_000 (fun () -> List.length (map Fun.id (List.init 1_000_000 Fun.id)) );;
在评估期间出现堆栈溢出(循环递归?)。

在这个 map 的实现中,递归调用发生在一个不是程序中的 尾部 位置的位置,而是在一个本身位于 尾部 位置的数据类型构造函数应用中。我们说这些由尾部位置和构造函数应用组成的位置是 尾部模构造 (TMC) 位置 - 为了简洁起见,我们有时写成 尾部模构造

可以对程序进行重写,使尾部模构造位置变成尾部位置;在这个转换之后,上面 map 的实现变成了 尾递归,因为它只消耗恒定的栈空间。OCaml 编译器在需要时实现这个转换,在要转换的函数上使用 [@tail_mod_cons][@ocaml.tail_mod_cons] 属性。

let[@tail_mod_cons] rec map f l = match l with | [] -> [] | x :: xs -> let y = f x in y :: map f xs
# List.length (map Fun.id (List.init 1_000_000 Fun.id));;
- : int = 1000000

这个转换只提高了尾部模构造位置的调用,它不会提高不符合这个片段的递归调用

(* 不起作用:加法不是数据构造函数 *) let[@tail_mod_cons] rec length l = match l with | [] -> 0 | _ :: xs -> 1 + length xs
警告 71 [unused-tmc-attribute]: 此函数标记为 @tail_mod_cons,但从未在 TMC 位置应用。

当然,可以在包含一些尾部模构造位置递归调用和一些其他任意位置调用的函数上使用 [@tail_mod_cons] 转换。只有尾部调用和尾部模构造调用会发生在恒定的栈空间内。

通用设计

此功能作为显式程序转换提供,而不是隐式优化。它是注释驱动的:用户需要通过在程序中使用属性添加注释来表达他们的意图,并在任何模棱两可的情况下被要求这样做。

我们预计它主要被需要获得其程序栈消耗行为的一些保证的高级 OCaml 用户使用。我们的建议是在所有不应消耗任何栈空间的调用点上使用 [@tailcall] 注释。 [@tail_mod_cons] 扩展了可以在其上注释为尾部调用的函数集,帮助在更多情况下建立栈消耗保证。

性能

获得 List.map 的尾递归版本的标准方法是使用累加器来收集输出元素,并在遍历结束时反转它。

let rec map f l = map_aux f [] l and map_aux f acc l = match l with | [] -> List.rev acc | x :: xs -> let y = f x in map_aux f (y :: acc) xs

这个版本是尾递归的,但它明显比简单的非尾递归版本慢。相比之下,尾部模构造转换提供了一个实现,即使在小型输入上也能与原始版本具有相当的性能。

评估顺序

请注意,尾部模构造转换会影响评估顺序:被转换到尾部位置的构造函数参数将始终最后评估。考虑以下示例

type 'a two_headed_list = | Nil | Consnoc of 'a * 'a two_headed_list * 'a let[@tail_mod_cons] rec map f = function | Nil -> Nil | Consnoc (front, body, rear) -> Consnoc (f front, map f body, f rear)

由于 [@tail_mod_cons] 转换,对 f frontf rear 的调用将在 map f body 之前评估。特别是,这可能与未注释版本的评估顺序不同。(构造函数参数的评估顺序在 OCaml 中未指定,但许多实现通常使用从左到右或从右到左。)

这种对评估顺序的影响是尾部模构造转换必须由用户显式请求而不是作为自动优化应用的原因之一。

为什么是尾部模构造?

其他程序转换,特别是转换为延续传递样式 (CPS),可以使所有函数尾递归,而不是只针对一小部分。提供对更不一般的尾部模构造的内置支持的一些原因如下

注意:OCaml 调用栈大小

在 OCaml 4.x 及更早版本中,字节码程序会尊重 stack_limit 运行时参数配置(如上例中使用 Gc.set 设置),或 OCAMLRUNPARAM 变量的 l 设置。原生程序会忽略这些设置,只尊重操作系统原生栈限制,如 Unix 系统上的 ulimit 设置。大多数操作系统默认情况下运行的栈大小限制相对较低,因此非尾递归函数上的栈溢出是一个常见的编程错误。

从 OCaml 5.0 开始,原生代码不再使用原生系统栈来进行 OCaml 函数调用,因此它不受操作系统原生栈大小的影响;原生程序和字节码程序都尊重 OCaml 运行时的自身限制。运行时限制被设置为比大多数操作系统原生栈高得多的默认值,限制至少为 512MiB,因此在实践中堆栈溢出应该少得多。默认情况下仍然存在栈限制,因为它仍然有助于快速捕获循环非尾递归函数的错误。如果没有栈限制,就必须等到整个内存被栈消耗完才能使程序崩溃,这可能需要很长时间并使系统无法响应。

这意味着 尾部模构造 转换在 OCaml 5 上不太重要:它在某些情况下确实显著提高了性能,但在大多数情况下并不需要用于基本正确性。

1 消除歧义

构造函数的多个参数可能是对尾部模-cons 函数的递归调用。转换只能将其中一个调用转换为尾部调用。编译器不会做出隐式选择,而是要求用户提供明确的消除歧义。

考虑这种类型的语法表达式(假设一些预先存在的表达式变量类型 var

type var (* 一些预先存在的变量类型 *) type exp = | Var of var | Let of binding * exp and binding = var * exp

考虑对变量的 map 函数。直接定义在 Let 构造函数的参数中包含两个递归调用,因此它被拒绝为模棱两可。

let[@tail_mod_cons] rec map_vars f exp = match exp with | Var v -> Var (f v) | Let ((v, def), body) -> Let ((f v, map_vars f def), map_vars f body)
错误: [@tail_mod_cons]: 此构造函数应用程序可能以几种不同的方式进行 TMC 转换。请通过在应进行尾递归调用的调用中添加显式 [@tailcall] 属性,或在不应转换的调用中添加 [@tailcall false] 属性来消除歧义。此调用可以添加注释。此调用可以添加注释。

为了消除歧义,用户应该在应该转换为尾部位置的递归调用中添加一个 [@tailcall] 属性。

let[@tail_mod_cons] rec map_vars f exp = match exp with | Var v -> Var (f v) | Let ((v, def), body) -> Let ((f v, map_vars f def), (map_vars[@tailcall]) f body)

请注意,结果函数不是尾递归函数,对 def 的递归调用将消耗堆栈空间。但是,表达式树往往是右倾斜的(许多 Let 按顺序排列,而不是嵌套在彼此内部),因此将对 body 的调用置于尾部位置是对朴素定义的有趣改进:如果我们假设 Let 构造的嵌套深度有限,它将提供有限的堆栈空间消耗。

使用冲突注释时也会出现错误,要求将两个构造函数参数置于尾部位置。

let[@tail_mod_cons] rec map_vars f exp = match exp with | Var v -> Var (f v) | Let ((v, def), body) -> Let ((f v, (map_vars[@tailcall]) f def), (map_vars[@tailcall]) f body)
错误: [@tail_mod_cons]: 此构造函数应用程序可能以几种不同的方式进行 TMC 转换。只有一个参数可以成为 TMC 调用,但多个参数包含明确标记为尾递归调用的调用。请通过查看和修复冲突注释来解决冲突。此调用已明确添加注释。此调用已明确添加注释。

2 危险:超出尾部模-cons

由于尾部模-cons 转换的性质(有关转换的介绍,请参见第 ‍26.3 节)

源程序中尾部位置的调用如果从尾部模-cons 函数到非尾部模-cons 函数可能会变为非尾部调用这一事实令人惊讶,转换将对此发出警告。

例如

let[@tail_mod_cons] rec flatten = function | [] -> [] | xs :: xss -> let rec append_flatten xs xss = match xs with | [] -> flatten xss | x :: xs -> x :: append_flatten xs xss in append_flatten xs xss
警告 71 [unused-tmc-attribute]: 此函数标记为 @tail_mod_cons,但从未在 TMC 位置应用。 警告 72 [tmc-breaks-tailcall]: 此调用位于 TMC 函数的尾部模-cons 位置,但被调用的函数本身没有专门用于 TMC,因此调用不会转换为尾部调用。请为被调用的函数添加 [@tail_mod_cons] 属性,或为此调用添加 [@tailcall false] 属性以使其非尾部特性明确。

这里 append_flatten 辅助函数没有用 [@tail_mod_cons] 注释,因此调用 append_flatten xs xssflatten xss 不会是尾部调用。这里正确的修复方法是将 append_flatten 注释为尾部模-cons。

let[@tail_mod_cons] rec flatten = function | [] -> [] | xs :: xss -> let[@tail_mod_cons] rec append_flatten xs xss = match xs with | [] -> flatten xss | x :: xs -> x :: append_flatten xs xss in append_flatten xs xss

append_flatten 是同一个递归组的非尾部模-cons 函数时,也会出现相同的警告;使用尾部模-cons 转换是单个函数的属性,而不是整个递归组的属性。

let[@tail_mod_cons] rec flatten = function | [] -> [] | xs :: xss -> append_flatten xs xss and append_flatten xs xss = match xs with | [] -> flatten xss | x :: xs -> x :: append_flatten xs xss
警告 71 [unused-tmc-attribute]: 此函数标记为 @tail_mod_cons,但从未在 TMC 位置应用。 警告 72 [tmc-breaks-tailcall]: 此调用位于 TMC 函数的尾部模-cons 位置,但被调用的函数本身没有专门用于 TMC,因此调用不会转换为尾部调用。请为被调用的函数添加 [@tail_mod_cons] 属性,或为此调用添加 [@tailcall false] 属性以使其非尾部特性明确。

同样,修复方法是将 append_flatten 也进行专门化。

let[@tail_mod_cons] rec flatten = function | [] -> [] | xs :: xss -> append_flatten xs xss and[@tail_mod_cons] append_flatten xs xss = match xs with | [] -> flatten xss | x :: xs -> x :: append_flatten xs xss

非递归函数也可以用 [@tail_mod_cons] 注释;这通常对递归函数的局部绑定很有用。

错误版本

let[@tail_mod_cons] rec map_vars f exp = let self exp = map_vars f exp in match exp with | Var v -> Var (f v) | Let ((v, def), body) -> Let ((f v, self def), (self[@tailcall]) body)
警告 51 [wrong-tailcall-expectation]: 预计尾部调用 警告 51 [wrong-tailcall-expectation]: 预计尾部调用 警告 71 [unused-tmc-attribute]: 此函数标记为 @tail_mod_cons,但从未在 TMC 位置应用。

推荐修复

let[@tail_mod_cons] rec map_vars f exp = let[@tail_mod_cons] self exp = map_vars f exp in match exp with | Var v -> Var (f v) | Let ((v, def), body) -> Let ((f v, self def), (self[@tailcall]) body)

在其他情况下,使被调用的函数成为尾部模-cons 既没有好处,也不可能:例如,它是一个函数参数(转换只适用于对已知函数的直接调用)。

例如,考虑对二叉树的替换函数

type 'a tree = Leaf of 'a | Node of 'a tree * 'a tree let[@tail_mod_cons] rec bind (f : 'a -> 'a tree) (t : 'a tree) : 'a tree = match t with | Leaf v -> f v | Node (left, right) -> Node (bind f left, (bind[@tailcall]) f right)
警告 72 [tmc-breaks-tailcall]: 此调用位于 TMC 函数的尾部模-cons 位置,但被调用的函数本身没有专门用于 TMC,因此调用不会转换为尾部调用。请为被调用的函数添加 [@tail_mod_cons] 属性,或为此调用添加 [@tailcall false] 属性以使其非尾部特性明确。

这里 f 是一个函数参数,而不是直接调用,当前的实现是严格的一阶,它不支持尾部模-cons 参数。在这种情况下,用户应该通过使用 (f[@tailcall false]) v 来表明他们意识到对 f v 的调用实际上不在尾部位置。

type 'a tree = Leaf of 'a | Node of 'a tree * 'a tree let[@tail_mod_cons] rec bind (f : 'a -> 'a tree) (t : 'a tree) : 'a tree = match t with | Leaf v -> (f[@tailcall false]) v | Node (left, right) -> Node (bind f left, (bind[@tailcall]) f right)

3 转换细节

要使用此高级功能,了解函数转换会产生目标传递风格的专门函数会有所帮助。

回顾我们的 map 示例

let rec map f l = match l with | [] -> [] | x :: xs -> let y = f x in y :: map f xs

以下是转换后的程序的伪 OCaml 表示形式的描述:某些操作无法用 OCaml 源代码表达。(转换实际上发生在 OCaml 编译器的 Lambda 中间表示形式上。)

let rec map f l =
  match l with
  | [] -> []
  | x :: xs ->
    let y = f x in
    let dst = y ::{mutable} Hole in
    map_dps f xs dst 1;
    dst

and map_dps f l dst idx =
  match l with
  | [] -> dst.idx <- []
  | x :: xs ->
    let y = f x in
    let dst' = y ::{mutable} Hole in
    dst.idx <- dst';
    map_dps f xs dst' 1

map 的源版本被转换为两个函数,一个直接风格版本,也称为 map,以及一个目标传递风格版本 (DPS),称为 map_dps。目标传递风格版本不会直接返回结果,而是将结果写入由两个附加函数参数 dst(一个内存块)和 i(内存块中的位置)指定的内存位置。

源调用 y :: map f xs 被转换为创建一个可变块 y ::{mutable} Hole,其第二个参数是一个未初始化的hole。然后将该块作为目标参数传递给 map_dps(偏移量为 1)。

注意, map 不会递归调用自己,而是调用 map_dps。然后, map_dps 递归调用自己,以尾递归方式。

mapmap_dps 的调用 _不是_ 尾调用(这是我们将来可以改进的地方);但是此调用仅在调用 map f l 时发生一次,第一个元素之后的列表元素都由 map_dps 在恒定堆栈中处理。

这解释了“跳出尾部模态构造”的细微差别。考虑我们之前涉及 flattenappend_flatten 之间相互递归的示例。

let[@tail_mod_cons] rec flatten l =
  match l with
  | [] -> []
  | xs :: xss ->
    append_flatten xs xss

append_flatten 的调用,在语法上出现在尾部位置,其转换方式取决于函数是否有目的地传递样式版本可用,即它本身是否标注为 [@tail_mod_cons]

(* if append_flatten_dps exists *)
and flatten_dps l dst i =
  match l with
  | [] -> dst.i <- []
  | xs :: xss ->
    append_flatten_dps xs xss dst i

(* if append_flatten_dps does not exist *)
and rec flatten_dps l dst i =
  match l with
  | [] -> dst.i <- []
  | xs :: xss ->
    dst.i <- append_flatten xs xss

如果 append_flatten 没有目的地传递样式版本,则该调用将被转换为非尾调用。

4 当前限制

纯粹的语法标准

就像一般的尾调用一样,尾部模态构造位置的概念纯粹是语法上的;一些简单的重构会将调用移出尾部模态构造位置。

(* 预期工作 *) let[@tail_mod_cons] rec map f li = match li with | [] -> [] | x :: xs -> let y = f x in y :: (* 此调用位于 TMC 位置 *) map f xs
(* 不再可优化 *) let[@tail_mod_cons] rec map f li = match li with | [] -> [] | x :: xs -> let y = f x in let ys = (* 此调用不再位于 TMC 位置 *) map f xs in y :: ys
警告 71 [unused-tmc-attribute]: 此函数标记为 @tail_mod_cons,但从未在 TMC 位置应用。
局部,一阶变换

当一个函数使用尾部模态构造进行转换时,会生成两个定义,一个提供直接样式接口,一个提供目的地传递样式版本。但是,并非所有在尾部模态构造位置调用该函数的调用都会使用目的地传递样式版本并变为尾调用

例如,考虑调用 Option.map foo x:即使 fooOption.map 的定义中位于尾部模态构造位置,该调用也永远不会变为尾调用。(即使对 Option.map 的调用位于 Option 模块内,情况也是如此。)

通常,此限制对于递归函数来说不是问题:来自外部模块或高阶函数的第一个调用将消耗堆栈空间,但尾部模态构造位置中进一步的递归调用将被优化。例如,如果 List.map 被定义为尾部模态构造函数,则来自 List 模块外部的调用在尾部位置时不会变为尾调用,但 List.map 定义中的递归调用位于尾部模态构造位置,并且确实变为了尾调用:处理列表的第一个元素将消耗堆栈空间,但所有后续元素都以恒定空间处理。

在更复杂的情况下,这些限制可能是问题,在这种情况下,函数之间发生相互递归,其中一些函数没有标注为尾部模态构造,或者在不同的模块中定义,或者间接调用,例如通过函数参数。

对元组函数的非精确调用

OCaml 对“元组”函数进行了一个隐式优化,这些函数接受一个作为元组的单个参数:let f (x, y, z) = ...。使用元组文字参数(如 f (a, b, c))对这些函数的直接调用将通过直接传递参数来调用“元组”函数,而不是为它们构建元组。其他调用,无论是间接调用还是传递更复杂元组值的调用(如 let t = (a, b, c) in f t)都被编译为“不精确”调用,这些调用会通过包装器。

[@tail_mod_cons] 转换支持元组函数,但只会优化尾部位置的“精确”调用;对元组文字以外内容的直接调用不会变为尾调用。用户可以手动解包元组以强制调用为“精确”:let (x, y, z) = t in f (x, y, z)。如果对调用是否可以被尾部模态构造优化有任何疑问,可以使用 [@tailcall] 属性对被调用函数进行标注,如果转换不可行,则会发出警告。

let rec map (f, l) = match l with | [] -> [] | x :: xs -> let y = f x in let args = (f, xs) in (* 此不精确调用无法尾部优化,因此将发出警告 *) y :: (map[@tailcall]) args
Warning 51 [wrong-tailcall-expectation]: expected tailcall