第 4 章 带标签的参数

如果您查看标准库中以 Labels 结尾的模块,您会发现函数类型有一些您在自行定义的函数中没有的注解。

# ListLabels.map;;
- : f:('a -> 'b) -> 'a list -> 'b list = <fun>
# StringLabels.sub;;
- : string -> pos:int -> len:int -> string = <fun>

这种形式为 name: 的注解称为标签。它们旨在记录代码,允许更多检查,并为函数应用提供更多灵活性。您可以通过在参数前添加波浪号 ~,在程序中为参数指定这样的名称。

# let f ~x ~y = x - y;;
val f : x:int -> y:int -> int = <fun>
# let x = 3 and y = 2 in f ~x ~y;;
- : int = 1

当您希望为变量和类型中出现的标签使用不同的名称时,可以使用形式为 ~name: 的命名标签。当参数不是变量时,这也适用。

# let f ~x:x1 ~y:y1 = x1 - y1;;
val f : x:int -> y:int -> int = <fun>
# f ~x:3 ~y:2;;
- : int = 1

标签遵循与 OCaml 中其他标识符相同的规则,即您不能使用保留关键字(如 into)作为标签。

形式参数和实参根据其各自的标签进行匹配,标签的缺失被解释为空标签。这允许在应用中交换参数。还可以对任何参数进行部分应用函数,创建剩余参数的新函数。

# let f ~x ~y = x - y;;
val f : x:int -> y:int -> int = <fun>
# f ~y:2 ~x:3;;
- : int = 1
# ListLabels.fold_left;;
- : f:('acc -> 'a -> 'acc) -> init:'acc -> 'a list -> 'acc = <fun>
# ListLabels.fold_left [1;2;3] ~init:0 ~f:( + );;
- : int = 6
# ListLabels.fold_left ~init:0;;
- : f:(int -> 'a -> int) -> 'a list -> int = <fun>

如果函数的多个参数带有相同的标签(或没有标签),它们之间将无法交换,并且顺序很重要。但它们仍然可以与其他参数交换。

# let hline ~x:x1 ~x:x2 ~y = (x1, x2, y);;
val hline : x:'a -> x:'b -> y:'c -> 'a * 'b * 'c = <fun>
# hline ~x:3 ~y:2 ~x:5;;
- : int * int * int = (3, 5, 2)

1 可选参数

带标签参数的一个有趣特性是它们可以被设置为可选的。对于可选参数,问号 ? 替换了非可选参数的波浪号 ~,并且标签也在函数类型中以 ? 为前缀。可以为这些可选参数提供默认值。

# let bump ?(step = 1) x = x + step;;
val bump : ?step:int -> int -> int = <fun>
# bump 2;;
- : int = 3
# bump ~step:3 2;;
- : int = 5

接受一些可选参数的函数也必须至少接受一个非可选参数。决定是否省略可选参数的标准是在函数类型中出现在此可选参数之后的参数的非标签应用。请注意,如果该参数带有标签,则只能通过完全应用函数、省略所有可选参数并省略所有剩余参数的所有标签来消除可选参数。

# let test ?(x = 0) ?(y = 0) () ?(z = 0) () = (x, y, z);;
val test : ?x:int -> ?y:int -> unit -> ?z:int -> unit -> int * int * int = <fun>
# test ();;
- : ?z:int -> unit -> int * int * int = <fun>
# test ~x:2 () ~z:3 ();;
- : int * int * int = (2, 0, 3)

可选参数也可以与非可选参数或未标记参数交换,只要它们同时应用即可。本质上,可选参数不会与独立应用的未标记参数交换。

# test ~y:2 ~x:3 () ();;
- : int * int * int = (3, 2, 0)
# test () () ~z:1 ~y:2 ~x:3;;
- : int * int * int = (3, 2, 1)
# (test () ()) ~z:1 ;;
错误:此表达式的类型为 int * int * int 这不是一个函数;它不能被应用。

这里 (test () ()) 已经是 (0,0,0),不能再应用。

可选参数实际上是作为选项类型实现的。如果您不提供默认值,则可以访问其内部表示 type 'a option = None | Some of 'a。然后,您可以在参数存在或不存在时提供不同的行为。

# let bump ?step x = match step with | None -> x * 2 | Some y -> x + y ;;
val bump : ?step:int -> int -> int = <fun>

将可选参数从一个函数调用传递到另一个函数也可能很有用。这可以通过在应用的参数前添加 ? 来实现。此问号会禁用将可选参数包装在选项类型中的操作。

# let test2 ?x ?y () = test ?x ?y () ();;
val test2 : ?x:int -> ?y:int -> unit -> int * int * int = <fun>
# test2 ?x:None;;
- : ?y:int -> unit -> int * int * int = <fun>

2 标签和类型推断

虽然它们为编写函数应用提供了更高的舒适度,但标签和可选参数存在一个缺点,即它们不能像语言的其他部分那样完全推断出来。

您可以在以下两个示例中看到这一点。

# let h' g = g ~y:2 ~x:3;;
val h' : (y:int -> x:int -> 'a) -> 'a = <fun>
# h' f ;;
错误:此表达式的类型为 x:int -> y:int -> int,但期望表达式的类型为 y:int -> x:int -> 'a
# let bump_it bump x = bump ~step:2 x;;
val bump_it : (step:int -> 'a -> 'b) -> 'a -> 'b = <fun>
# bump_it bump 1 ;;
错误:此表达式的类型为 ?step:int -> int -> int,但期望表达式的类型为 step:int -> 'a -> 'b

第一个案例很简单:将 ~y 然后 ~x 传递给 g,但 f 期望先传递 ~x 然后 ~y。如果我们预先知道 g 的类型为 x:int -> y:int -> int,则可以正确处理此问题,否则会导致上述类型冲突。最简单的解决方法是以标准顺序应用形式参数。

第二个示例更微妙:虽然我们希望参数 bump 的类型为 ?step:int -> int -> int,但它被推断为 step:int -> int -> 'a。由于这两个类型不兼容(在内部,普通参数和可选参数是不同的),因此当将 bump_it 应用于真实的 bump 时会发生类型错误。

我们这里不会尝试详细解释类型推断是如何工作的。只需要理解,上述程序中没有足够的信息来推断 gbump 的正确类型。也就是说,仅通过查看函数的应用方式,无法知道参数是否为可选的,或者正确的顺序是什么。编译器使用的策略是假设没有可选参数,并且应用是按正确顺序进行的。

解决可选参数此问题的正确方法是为参数 bump 添加类型注解。

# let bump_it (bump : ?step:int -> int -> int) x = bump ~step:2 x;;
val bump_it : (?step:int -> int -> int) -> int -> int = <fun>
# bump_it bump 1;;
- : int = 3

在实践中,当使用其方法具有可选参数的对象时,此类问题大多会出现,因此编写对象参数的类型通常是一个好主意。

通常,如果您尝试将类型与预期类型不同的参数传递给函数,编译器会生成类型错误。但是,在预期类型为非标签函数类型并且参数为期望可选参数的函数的特定情况下,编译器会尝试转换参数以使其与预期类型匹配,方法是为所有可选参数传递 None

# let twice f (x : int) = f(f x);;
val twice : (int -> int) -> int -> int = <fun>
# twice bump 2;;
- : int = 8

此转换与预期的语义(包括副作用)一致。也就是说,如果可选参数的应用应产生副作用,则这些副作用会延迟到接收到的函数真正应用于参数时。

3 标签建议

与名称一样,为函数选择标签并非易事。良好的标签是

我们在这里解释在为 OCaml 库添加标签时应用的规则。

用“面向对象”的方式来说,可以认为每个函数都有一个主要参数,即其对象,以及其他与函数操作相关的参数,即参数。为了允许在交换标签模式下通过函数式组合函数,对象将不会被标记。其作用从函数本身就可以清楚地看出。参数用名称标记,以提醒其性质或作用。最好的标签结合了性质和作用。当这不可能时,优先选择作用,因为性质通常由类型本身给出。应避免使用模糊的缩写。

ListLabels.map : f:('a -> 'b) -> 'a list -> 'b list
UnixLabels.write : file_descr -> buf:bytes -> pos:int -> len:int -> unit

当存在多个具有相同性质和作用的对象时,它们都保持不标记。

ListLabels.iter2 : f:('a -> 'b -> unit) -> 'a list -> 'b list -> unit

当不存在首选对象时,所有参数都将被标记。

BytesLabels.blit :
  src:bytes -> src_pos:int -> dst:bytes -> dst_pos:int -> len:int -> unit

但是,当只有一个参数时,它通常保持不标记。

BytesLabels.create : int -> bytes

此原则也适用于返回类型为类型变量的多个参数的函数,只要每个参数的作用不明确即可。当尝试在应用程序中省略标签时,标记此类函数可能会导致尴尬的错误消息,正如我们在ListLabels.fold_left中看到的那样。

以下是一些您将在整个库中找到的标签名称。

标签含义
f要应用的函数
pos字符串、数组或字节序列中的位置
len长度
buf用作缓冲区的字节序列或字符串
src操作的源
dst操作的目标
init迭代器的初始值
cmp比较函数,例如 Stdlib.compare
mode操作模式或标志列表

所有这些都只是建议,但请记住,标签的选择对于可读性至关重要。奇怪的选择会使程序更难维护。

理想情况下,正确的函数名称和正确的标签应该足以理解函数的含义。由于可以使用 OCamlBrowser 或 ocaml 顶层获得此信息,因此仅在需要更详细的规范时才使用文档。


(章节作者:Jacques Garrigue)