第 3 章:变量与函数(Variables and Functions)
原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 3。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。
变量和函数是几乎所有编程语言都会出现的基本概念。OCaml 对这些概念的处理方式不同于你可能遇到过的大多数语言,因此本章会比较详细地介绍 OCaml 中的变量和函数:从如何定义变量的基础开始,一直讲到带标签参数和可选参数函数中的一些细节。
如果你发现自己被某些细节淹没,尤其是在本章后半部分,请不要气馁。这里的概念很重要,但如果第一次阅读时还没有完全连上感觉,可以在对这门语言的其他部分有更好把握之后,再回到本章。
3.1 变量(Variables)
最简单地说,变量是一个标识符,它的含义绑定到某个特定值。在 OCaml 中,这类绑定通常用 let 关键字引入。可以用下面的语法输入所谓的顶层 let 绑定。注意,变量名必须以小写字母或下划线开头。
let <variable> = <expr>
等我们在第 5 章“文件、模块与程序(Files, Modules, and Programs)”讲到模块系统时会看到,同样的语法也用于模块顶层的 let 绑定。
每个变量绑定都有一个作用域,也就是代码中能够引用该绑定的那一部分。在使用 utop 时,顶层 let 绑定的作用域是当前会话中它之后的全部内容。当它出现在模块中时,它的作用域是该模块中剩余的部分。
下面是一个简单例子:
# open Base;;
# let x = 3;;
val x : int = 3
# let y = 4;;
val y : int = 4
# let z = x + y;;
val z : int = 7
let 也可以用于创建作用域仅限于某个特定表达式的变量绑定,语法如下:
let <variable> = <expr1> in <expr2>
这会先求值 expr1,然后在 variable 绑定到 expr1 求值得到的值的情况下求值 expr2。实际使用时是这样的:
# let languages = "OCaml,Perl,C++,C";;
val languages : string = "OCaml,Perl,C++,C"
# let dashed_languages =
let language_list = String.split languages ~on:',' in
String.concat ~sep:"-" language_list;;
val dashed_languages : string = "OCaml-Perl-C++-C"
注意,language_list 的作用域只是表达式 String.concat ~sep:"-" language_list,它在顶层不可用。如果现在尝试访问它,就会看到这一点:
# language_list;;
Line 1, characters 1-14:
Error: Unbound value language_list
内部作用域中的 let 绑定可以遮蔽(shadow)或隐藏外层作用域中的定义。因此,前面的 dashed_languages 例子也可以写成这样:
# let languages = "OCaml,Perl,C++,C";;
val languages : string = "OCaml,Perl,C++,C"
# let dashed_languages =
let languages = String.split languages ~on:',' in
String.concat ~sep:"-" languages;;
val dashed_languages : string = "OCaml-Perl-C++-C"
这一次,在内部作用域中,我们把字符串列表称为 languages,而不是 language_list,从而隐藏了原始的 languages 定义。但一旦 dashed_languages 的定义完成,内部作用域就关闭了,原始的 languages 定义仍然可用。
# languages;;
- : string = "OCaml,Perl,C++,C"
一种常见惯用法,是使用一系列嵌套的 let/in 表达式构建较大计算的各个组成部分。例如,可以这样写:
# let area_of_ring inner_radius outer_radius =
let pi = Float.pi in
let area_of_circle r = pi *. r *. r in
area_of_circle outer_radius -. area_of_circle inner_radius;;
val area_of_ring : float -> float -> float = <fun>
# area_of_ring 1. 3.;;
- : float = 25.132741228718345
重要的是,不要把一连串 let 绑定与对可变变量的修改混淆。比如,考虑如果我们刻意写出下面这段令人困惑的代码,area_of_ring 会如何工作:
# let area_of_ring inner_radius outer_radius =
let pi = Float.pi in
let area_of_circle r = pi *. r *. r in
let pi = 0. in
area_of_circle outer_radius -. area_of_circle inner_radius;;
Line 4, characters 9-11:
Warning 26 [unused-var]: unused variable pi.
val area_of_ring : float -> float -> float = <fun>
这里,我们在定义 area_of_circle 之后把 pi 重新定义为零。你可能以为这意味着计算结果现在会是零,但实际上函数行为没有改变。原因在于,原始的 pi 定义并没有被修改;它只是被遮蔽了。这意味着后续对 pi 的任何引用都会看到新定义的 pi = 0.,但之前的引用仍然会看到旧定义。不过这里并没有后续再使用 pi,所以把 pi 绑定到 0. 完全没有影响。这也解释了顶层环境为什么会警告我们存在未使用变量。
在 OCaml 中,let 绑定是不可变的。OCaml 中有许多可变值,我们会在第 9 章“命令式编程(Imperative Programming)”中讨论它们,但 OCaml 没有可变变量。
为什么变量不会变化?(Why Don't Variables Vary?)
OCaml 新手的一个困惑来源,是变量不可变这个事实。即使从语言上看,这似乎也很令人意外。变量的重点不就是能够变化吗?
答案是,OCaml 中的变量,以及函数式语言中的变量,实际上更像方程中的变量,而不是命令式语言中的变量。想想数学恒等式 x(y + z) = xy + xz,其中并没有修改变量 x、y 和 z 的概念。它们之所以“变化”,是因为你可以用不同数字实例化这个方程,而方程仍然成立。
函数式语言也是如此。一个函数可以应用到不同输入上,因此它的变量会取不同的值,即使没有任何修改操作。
3.1.1 模式匹配与 let(Pattern Matching and Let)
let 绑定的另一个有用特性,是它支持在左侧使用模式。考虑下面的代码,其中使用了 List.unzip。这个函数会把一个二元组列表转换为一对列表:
# let (ints,strings) = List.unzip [(1,"one"); (2,"two");
(3,"three")];;
val ints : int list = [1; 2; 3]
val strings : string list = ["one"; "two"; "three"]
这里,(ints,strings) 是一个模式,而 let 绑定会给这个模式中出现的两个标识符都赋值。模式本质上是对数据结构形状的描述,其中某些组成部分是要绑定到值的名称。正如第 2.3 节“元组、列表、选项与模式匹配(Tuples, Lists, Options, and Pattern Matching)”中看到的,OCaml 为多种不同数据类型提供了模式。
在 let 绑定中使用模式时,最好使用不可反驳(irrefutable)的模式,也就是相关类型的任何值都保证能匹配该模式。元组和记录模式是不可反驳的,但列表模式不是。考虑下面的代码,它实现了一个函数,用于把逗号分隔列表的第一个元素转换为大写:
# let upcase_first_entry line =
let (first :: rest) = String.split ~on:',' line in
String.concat ~sep:"," (String.uppercase first :: rest);;
Lines 2-3, characters 5-60:
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
[]
val upcase_first_entry : string -> string = <fun>
这个情况在实践中其实不会出现,因为 String.split 总是返回至少包含一个元素的列表,即使传入的是空字符串。
# upcase_first_entry "one,two,three";;
- : string = "ONE,two,three"
# upcase_first_entry "";;
- : string = ""
但编译器并不知道这一点,因此它发出了警告。通常更好的做法是使用 match 表达式显式处理这类情况:
# let upcase_first_entry line =
match String.split ~on:',' line with
| [] -> assert false (* String.split returns at least one element *)
| first :: rest ->
String.concat ~sep:"," (String.uppercase first :: rest);;
val upcase_first_entry : string -> string = <fun>
注意,这是我们第一次使用 assert,它可以用于标记本应不可能发生的情况。第 8 章“错误处理(Error Handling)”会更详细地讨论 assert。
3.2 函数(Functions)
既然 OCaml 是一门函数式语言,函数重要且无处不在就并不令人意外。事实上,函数几乎出现在我们目前看过的每个例子中。本节会更深入地解释 OCaml 函数的工作细节。你会看到,OCaml 中的函数在许多方面都不同于大多数主流语言中的函数。
3.2.1 匿名函数(Anonymous Functions)
我们先看 OCaml 中最基础的函数声明风格:匿名函数。匿名函数是在声明时没有命名的函数。可以使用 fun 关键字声明匿名函数,如下所示:
# (fun x -> x + 1);;
- : int -> int = <fun>
匿名函数的行为与具名函数大体相同。例如,我们可以把匿名函数应用到一个参数上:
# (fun x -> x + 1) 7;;
- : int = 8
或者把它传给另一个函数。把函数传给 List.map 这样的迭代函数,可能是匿名函数最常见的用法。
# List.map ~f:(fun x -> x + 1) [1;2;3];;
- : int list = [2; 3; 4]
你甚至可以把函数塞进数据结构,比如列表:
# let transforms = [ String.uppercase; String.lowercase ];;
val transforms : (string -> string) list = [<fun>; <fun>]
# List.map ~f:(fun g -> g "Hello World") transforms;;
- : string list = ["HELLO WORLD"; "hello world"]
这个例子值得停下来仔细想一想。注意,(fun g -> g "Hello World") 是一个接受函数作为参数的函数,然后它把该函数应用到字符串 "Hello World" 上。List.map 的调用会把 (fun g -> g "Hello World") 应用到 transforms 的元素上,而这些元素本身就是函数。返回的列表包含这些函数应用的结果。
关键要点是:在 OCaml 中,函数是普通值。你可以对函数做任何普通值能做的事情,包括把它们传给其他函数、从其他函数返回它们,以及把它们存储在数据结构中。我们甚至用给其他值命名的同一种方式给函数命名,也就是使用 let 绑定。
# let plusone = (fun x -> x + 1);;
val plusone : int -> int = <fun>
# plusone 3;;
- : int = 4
定义具名函数非常常见,因此 OCaml 为它提供了一些语法糖。所以下面对 plusone 的定义等价于前一个定义:
# let plusone x = x + 1;;
val plusone : int -> int = <fun>
这是声明函数最常见、也最方便的方式。但撇开语法上的便利不谈,这两种函数定义风格是等价的。
let 与 fun(let and fun)
函数和 let 绑定关系密切。从某种意义上说,你可以把函数的参数看作一个变量,它绑定到调用者传入的值。事实上,下面两个表达式几乎等价:
# (fun x -> x + 1) 7;;
- : int = 8
# let x = 7 in x + 1;;
- : int = 8
这种联系很重要,在第 17 章“使用 Async 的并发编程(Concurrent Programming with Async)”讨论单子风格编程时,我们还会再遇到它。
3.2.2 多参数函数(Multiargument Functions)
OCaml 当然也支持多参数函数,例如:
# let abs_diff x y = abs (x - y);;
val abs_diff : int -> int -> int = <fun>
# abs_diff 3 4;;
- : int = 1
你可能会觉得 abs_diff 类型签名中的一串箭头有点难读。为了理解发生了什么,我们用 fun 关键字把 abs_diff 改写成一个等价形式:
# let abs_diff =
(fun x -> (fun y -> abs (x - y)));;
val abs_diff : int -> int -> int = <fun>
这个改写明确显示出,abs_diff 实际上是一个接受一个参数并返回另一个单参数函数的函数,而后者再返回最终结果。因为这些函数是嵌套的,内部表达式 abs (x - y) 既能访问由外层函数应用绑定的 x,也能访问由内层函数绑定的 y。
这种风格的函数称为柯里化函数(curried function)。柯里化以逻辑学家 Haskell Curry 命名,他对编程语言的设计与理论产生了重大影响。解释柯里化函数类型签名的关键,是观察到 -> 是右结合的。因此,abs_diff 的类型签名可以加括号写成:
val abs_diff : int -> (int -> int)
括号不会改变签名的含义,但会让柯里化更容易看出来。
柯里化不只是理论上的好奇点。你可以利用柯里化,通过提供部分参数来特化一个函数。下面的例子创建了 abs_diff 的一个特化版本,用来衡量给定数字与 3 之间的距离。
# let dist_from_3 = abs_diff 3;;
val dist_from_3 : int -> int = <fun>
# dist_from_3 8;;
- : int = 5
# dist_from_3 (-1);;
- : int = 4
对柯里化函数提供一部分参数、从而得到新函数的实践称为部分应用(partial application)。
注意,fun 关键字有自己的柯里化语法,所以下面对 abs_diff 的定义也等价于前面的定义:
# let abs_diff = (fun x y -> abs (x - y));;
val abs_diff : int -> int -> int = <fun>
你可能担心柯里化函数代价高昂,但事实并非如此。在 OCaml 中,用所有参数调用柯里化函数没有额外惩罚。当然,部分应用会有一点额外成本,这并不意外。
柯里化不是 OCaml 中编写多参数函数的唯一方式。也可以把元组的不同部分用作不同参数。因此,我们可以这样写:
# let abs_diff (x,y) = abs (x - y);;
val abs_diff : int * int -> int = <fun>
# abs_diff (3,4);;
- : int = 1
OCaml 也能高效处理这种调用约定。具体来说,它通常不必仅仅为了把参数传给元组风格函数而分配一个元组。不过,这种函数风格不能使用部分应用。
这两种方式之间有一些小权衡,但大多数时候应该坚持使用柯里化,因为这是 OCaml 世界中的默认风格。
3.2.3 递归函数(Recursive Functions)
如果一个函数在自身定义中引用自己,那么它就是递归的。递归在任何编程语言中都很重要,但在函数式语言中尤其重要,因为它是构建循环结构的方式。第 9 章“命令式编程(Imperative Programming)”会更详细讨论,OCaml 也支持 for 和 while 这样的命令式循环结构,但这些结构只有在使用 OCaml 命令式特性时才有用。
要定义递归函数,需要使用 rec 关键字把 let 绑定标记为递归。下面这个函数用于查找列表中第一个连续重复的元素:
# let rec find_first_repeat list =
match list with
| [] | [_] ->
(* only zero or one elements, so no repeats *)
None
| x :: y :: tl ->
if x = y then Some x else find_first_repeat (y::tl);;
val find_first_repeat : int list -> int option = <fun>
模式 [] | [_] 本身是多个模式的析取,也称为 or-pattern。只要任意子模式匹配,or-pattern 就匹配。在这里,[] 匹配空列表,[_] 匹配任意单元素列表。_ 的作用是让我们不必给这个单元素显式命名。
我们也可以使用 let rec 结合 and 关键字定义多个互递归值。下面是一个刻意低效的例子:
# let rec is_even x =
if x = 0 then true else is_odd (x - 1)
and is_odd x =
if x = 0 then false else is_even (x - 1);;
val is_even : int -> bool = <fun>
val is_odd : int -> bool = <fun>
# List.map ~f:is_even [0;1;2;3;4;5];;
- : bool list = [true; false; true; false; true; false]
# List.map ~f:is_odd [0;1;2;3;4;5];;
- : bool list = [false; true; false; true; false; true]
OCaml 区分非递归定义(使用 let)和递归定义(使用 let rec),主要是出于技术原因:类型推断算法需要知道一组函数定义什么时候是互递归的,而这必须由程序员显式标记。
不过这个决定也有一些好处。首先,递归定义,尤其是互递归定义,比非递归定义更难推理。因此,如果没有显式的 rec,你就可以假定一个 let 绑定是非递归的,只能建立在之前的定义之上。
此外,非递归形式也让我们更容易通过遮蔽已有定义来创建一个扩展并取代旧定义的新定义。
3.2.4 前缀与中缀运算符(Prefix and Infix Operators)
到目前为止,我们已经看到函数以两种风格使用:前缀风格和中缀风格。
# Int.max 3 4 (* prefix *);;
- : int = 4
# 3 + 4 (* infix *);;
- : int = 7
你也许没有把第二个例子看作普通函数,但它确实是。像 + 这样的中缀运算符与其他函数真正的区别只在语法上。事实上,如果把中缀运算符放在括号里,就可以把它当作普通前缀函数使用。
# (+) 3 4;;
- : int = 7
# List.map ~f:((+) 3) [4;5;6];;
- : int list = [7; 8; 9]
在第二个表达式中,我们对 (+) 做了部分应用,创建了一个把单个参数加 3 的函数。
如果函数名从一组特殊标识符中选择,该函数在语法上就会被当作运算符。这组标识符包括由以下字符组成的序列:
~ ! $ % & * + - . / : < = > ? @ ^ |
只要第一个字符不是 ~、! 或 $ 即可。
也有少数预先确定的字符串会被当作中缀运算符,包括取模运算符 mod,以及表示“逻辑左移”的 lsl,这是一种位移操作。
我们可以定义或重新定义某个运算符的含义。下面是一个针对 int 二元组的简单向量加法运算符:
# let (+!) (x1,y1) (x2,y2) = (x1 + x2, y1 + y2);;
val ( +! ) : int * int -> int * int -> int * int = <fun>
# (3,2) +! (-2,4);;
- : int * int = (1, 6)
处理包含 * 的运算符时需要小心。考虑下面的例子:
# let (***) x y = (x **. y) **. y;;
Line 1, characters 18-19:
Error: This expression has type int but an expression was expected of
type
float
发生了什么?(***) 根本没有被解释为运算符,而是被读成了注释。要让它正常工作,需要在任何以 * 开头或结尾的运算符周围加上空格。
# let ( *** ) x y = (x **. y) **. y;;
val ( *** ) : float -> float -> float = <fun>
运算符的语法角色通常由其前一两个字符决定,不过也有少数例外。OCaml 手册中有一张明确的表,列出了每类运算符及其关联优先级:precedence and associativity。
这里不展开完整列表,但有一个重要特例值得提及:- 和 -. 分别是整数与浮点减法运算符,它们既可以作为前缀运算符用于取负,也可以作为中缀运算符用于减法。因此,-x 和 x - y 都是有意义的表达式。关于取负还要记住一点:它的优先级低于函数应用。这意味着如果想传入负值,就需要把它放在括号里,如下面代码所示:
# Int.max 3 (-4);;
- : int = 3
# Int.max 3 -4;;
Line 1, characters 1-10:
Warning 5 [ignored-partial-application]: this function application is
partial,
maybe some arguments are missing.
Line 1, characters 1-10:
Error: This expression has type int -> int
but an expression was expected of type int
这里,OCaml 会把第二个表达式解释成等价于:
# (Int.max 3) - 4;;
Line 1, characters 1-12:
Warning 5 [ignored-partial-application]: this function application is
partial,
maybe some arguments are missing.
Line 1, characters 1-12:
Error: This expression has type int -> int
but an expression was expected of type int
这显然没有意义。
下面是标准库中一个非常有用的运算符示例,它的行为关键依赖前面描述的优先级规则。
# let (|>) x f = f x;;
val ( |> ) : 'a -> ('a -> 'b) -> 'b = <fun>
这称为反向应用运算符(reverse application operator)。它的用途一开始并不明显:它只是接受一个值和一个函数,然后把函数应用到该值上。尽管描述听起来平淡无奇,它却很适合对操作进行排序,精神上类似于 UNIX shell 中的管道字符。比如,考虑下面的代码,它会打印出 PATH 中的唯一元素:
# open Stdio;;
# let path = "/usr/bin:/usr/local/bin:/bin:/sbin:/usr/bin";;
val path : string = "/usr/bin:/usr/local/bin:/bin:/sbin:/usr/bin"
# String.split ~on:':' path
|> List.dedup_and_sort ~compare:String.compare
|> List.iter ~f:print_endline;;
/bin
/sbin
/usr/bin
/usr/local/bin
- : unit = ()
不用 |> 也能做到这一点,只要命名中间值即可,但结果会稍微啰嗦一些:
# let split_path = String.split ~on:':' path in
let deduped_path =
List.dedup_and_sort ~compare:String.compare split_path in
List.iter ~f:print_endline deduped_path;;
/bin
/sbin
/usr/bin
/usr/local/bin
- : unit = ()
这里发生的一件重要事情是部分应用。例如,List.iter 接受两个参数:一个要在列表每个元素上调用的函数,以及要迭代的列表。我们可以用所有参数调用 List.iter:
# List.iter ~f:print_endline ["Two"; "lines"];;
Two
lines
- : unit = ()
或者只给它传入函数参数,这会留下一个用于打印字符串列表的函数:
# List.iter ~f:print_endline;;
- : string list -> unit = <fun>
前面的 |> 管道中使用的就是后一种形式。
不过,|> 只有在左结合时才能按预期工作。看看如果尝试使用右结合运算符(例如 ^>)会发生什么:
# let (^>) x f = f x;;
val ( ^> ) : 'a -> ('a -> 'b) -> 'b = <fun>
# String.split ~on:':' path
^> List.dedup_and_sort ~compare:String.compare
^> List.iter ~f:print_endline;;
Line 3, characters 6-32:
Error: This expression has type string list -> unit
but an expression was expected of type
(string list -> string list) -> 'a
Type string list is not compatible with type
string list -> string list
这个类型错误乍看有点令人困惑。发生的问题是,由于 ^> 是右结合的,运算符试图把值 List.dedup_and_sort ~compare:String.compare 传给函数 List.iter ~f:print_endline。但 List.iter ~f:print_endline 期望的输入是字符串列表,而不是函数。
撇开类型错误不谈,这个例子凸显出谨慎选择运算符的重要性,尤其是要注意结合性。
应用运算符(The Application Operator)
|> 被称为反向应用运算符。你可能不会惊讶,还有一个应用运算符:
# (@@);;
- : ('a -> 'b) -> 'a -> 'b = <fun>
当你想避免在复杂表达式上应用函数时写出多层括号,这个运算符很有用。具体来说,可以把 f (g (h x)) 替换为 f @@ g @@ h x。注意,正如我们需要 |> 左结合一样,也需要 @@ 右结合。
3.2.5 使用 function 声明函数(Declaring Functions with function)
另一种定义函数的方式是使用 function 关键字。function 不提供用于声明多参数(柯里化)函数的语法支持,而是内建了模式匹配。下面是一个例子:
# let some_or_zero = function
| Some x -> x
| None -> 0;;
val some_or_zero : int option -> int = <fun>
# List.map ~f:some_or_zero [Some 3; None; Some 4];;
- : int list = [3; 0; 4]
这等价于把普通函数定义和 match 组合在一起:
# let some_or_zero num_opt =
match num_opt with
| Some x -> x
| None -> 0;;
val some_or_zero : int option -> int = <fun>
我们也可以把不同函数声明风格组合在一起。下面这个例子声明了一个双参数(柯里化)函数,并对第二个参数做模式匹配:
# let some_or_default default = function
| Some x -> x
| None -> default;;
val some_or_default : 'a -> 'a option -> 'a = <fun>
# some_or_default 3 (Some 5);;
- : int = 5
# List.map ~f:(some_or_default 100) [Some 3; None; Some 4];;
- : int list = [3; 100; 4]
还要注意,这里使用了部分应用来生成传给 List.map 的函数。换句话说,some_or_default 100 是一个只给 some_or_default 传入第一个参数而创建出来的函数。
3.2.6 带标签参数(Labeled Arguments)
到目前为止,我们定义的函数都按位置指定参数,也就是根据参数传给函数时的顺序来指定。OCaml 也支持带标签参数(labeled arguments),它让你可以按名称标识函数参数。事实上,我们已经遇到过 Base 中使用带标签参数的函数,例如 List.map。
带标签参数以开头的波浪号标记,并且会在要加标签的变量前放置标签名和冒号。下面是一个例子:
# let ratio ~num ~denom = Float.of_int num /. Float.of_int denom;;
val ratio : num:int -> denom:int -> float = <fun>
然后,我们可以用类似约定提供带标签参数。如下所示,参数可以按任意顺序提供。
# ratio ~num:3 ~denom:10;;
- : float = 0.3
# ratio ~denom:10 ~num:3;;
- : float = 0.3
OCaml 还支持标签简写(label punning)。这意味着,如果标签名和正在使用的变量名相同,就可以省略冒号后面的文本。实际上,定义 ratio 时我们已经使用了标签简写。下面展示了调用函数时如何使用简写:
# let num = 3 in
let denom = 4 in
ratio ~num ~denom;;
- : float = 0.75
标签在哪里有用?(Where Are Labels Useful?)
带标签参数是一个意外地有用的特性,值得看看它们会出现在哪些场景中。
解释较长的参数列表(Explicating Long Argument Lists)
当参数数量超过一定程度时,按名称记住参数会比按位置记住更容易。允许在调用点使用这些名称,而且可以按任意顺序使用,会让客户端代码更容易阅读和编写。
给信息量不足的参数类型添加信息(Adding Information to Uninformative Argument Types)
考虑一个创建哈希表的函数。第一个参数是哈希表底层数组的初始大小,第二个参数是一个布尔标志,表示元素被移除时该数组是否会收缩。
val create_hashtable : int -> bool -> ('a,'b) Hashtable.t
这个签名让人很难看出两个参数的含义。但使用带标签参数,就可以让意图立刻清晰起来:
val create_hashtable :
init_size:int -> allow_shrinking:bool -> ('a,'b) Hashtable.t
为布尔值选择好的标签名尤其重要,因为人们常常容易混淆:一个值为 true 到底是启用还是禁用某个特性。
区分相似参数(Disambiguating Similar Arguments)
当函数有多个相同类型的参数时,这个问题最常出现。考虑下面这个用于提取子字符串的函数签名:
val substring: string -> int -> int -> string
这里两个 int 分别是要提取子字符串的起始位置和长度,但仅从类型签名中看不出来。可以通过添加标签让签名更有信息量:
val substring: string -> pos:int -> len:int -> string
这提高了签名和客户端代码的可读性,也让意外交换位置和长度更难发生。
灵活的参数顺序与部分应用(Flexible Argument Ordering and Partial Application)
考虑像 List.iter 这样的函数,它接受两个参数:一个函数,以及一个元素列表,用于在这些元素上调用该函数。常见模式是只给 List.iter 传入函数,从而对它做部分应用,就像本章前面的例子:
# String.split ~on:':' path
|> List.dedup_and_sort ~compare:String.compare
|> List.iter ~f:print_endline;;
/bin
/sbin
/usr/bin
/usr/local/bin
- : unit = ()
这要求我们把函数参数放在前面。
其他顺序也可能有用,或是为了部分应用,或是单纯出于可读性。例如,当使用 List.iter 搭配复杂的多行迭代函数时,如果函数放在后面,也就是位于说明要迭代哪个列表之后,通常更容易阅读。另一方面,当调用 List.iter 时函数很小,但值列表很大且显式写出时,把值放在最后通常更容易阅读。
高阶函数与标签(Higher-Order Functions and Labels)
带标签参数有一个令人意外的陷阱:调用带标签参数的函数时,顺序并不重要;但在高阶上下文中,也就是把带标签参数的函数传给另一个函数时,顺序是重要的。下面是一个例子:
# let apply_to_tuple f (first,second) = f ~first ~second;;
val apply_to_tuple : (first:'a -> second:'b -> 'c) -> 'a * 'b -> 'c =
<fun>
这里,apply_to_tuple 的定义建立了一个预期:它的第一个参数是一个带两个标签参数的函数,标签分别为 first 和 second,并且按这个顺序列出。我们可以用不同方式定义 apply_to_tuple,从而改变带标签参数被列出的顺序。
# let apply_to_tuple_2 f (first,second) = f ~second ~first;;
val apply_to_tuple_2 : (second:'a -> first:'b -> 'c) -> 'b * 'a -> 'c =
<fun>
事实证明,这个顺序很重要。具体来说,如果定义一个参数顺序不同的函数:
# let divide ~first ~second = first / second;;
val divide : first:int -> second:int -> int = <fun>
会发现它不能传给 apply_to_tuple_2。
# apply_to_tuple_2 divide (3,4);;
Line 1, characters 18-24:
Error: This expression has type first:int -> second:int -> int
but an expression was expected of type second:'a -> first:'b ->
'c
但它可以顺利用于原来的 apply_to_tuple。
# let apply_to_tuple f (first,second) = f ~first ~second;;
val apply_to_tuple : (first:'a -> second:'b -> 'c) -> 'a * 'b -> 'c =
<fun>
# apply_to_tuple divide (3,4);;
- : int = 0
因此,在把带标签函数作为参数传递时,需要注意保持带标签参数顺序一致。
3.2.7 可选参数(Optional Arguments)
可选参数就像一种带标签参数,只不过调用者可以选择是否提供它。可选参数使用与带标签参数相同的语法传入,而且和带标签参数一样,可以按任意顺序提供。
下面是一个带可选分隔符的字符串连接函数。这个函数使用 ^ 运算符做成对字符串连接。
# let concat ?sep x y =
let sep = match sep with None -> "" | Some s -> s in
x ^ sep ^ y;;
val concat : ?sep:string -> string -> string -> string = <fun>
# concat "foo" "bar" (* without the optional argument *);;
- : string = "foobar"
# concat ~sep:":" "foo" "bar" (* with the optional argument *);;
- : string = "foo:bar"
这里,函数定义中用 ? 把 sep 标记为可选参数。虽然调用者可以为 sep 传入 string 类型的值,但在函数内部,sep 会被看作 string option,当调用者没有提供 sep 时就是 None。
前面的例子需要一点样板代码,在没有提供分隔符时选择默认分隔符。这是一种足够常见的模式,因此 OCaml 提供了显式语法来设置默认值,让我们可以更简洁地写出 concat:
# let concat ?(sep="") x y = x ^ sep ^ y;;
val concat : ?sep:string -> string -> string -> string = <fun>
可选参数非常有用,但也很容易被滥用。可选参数的关键优势在于,它们让你可以编写带多个参数的函数,而用户大多数时候可以忽略这些参数,只有在明确想调用那些选项时才需要关心。它们也允许你为 API 扩展新功能,而不改变已有代码。
缺点是,调用者可能不知道这里存在一个需要选择的行为,因此可能在不知情的情况下错误地选择默认行为。只有当省略参数带来的简洁性超过对应的显式性损失时,可选参数才真正有意义。
这意味着很少使用的函数不应该有可选参数。一个不错的经验法则是:避免给模块内部函数使用可选参数,也就是那些没有包含在模块接口或 mli 文件中的函数。第 5 章“文件、模块与程序(Files, Modules, and Programs)”会进一步介绍 mli。
显式传递可选参数(Explicit Passing of an Optional Argument)
在底层,带可选参数的函数在调用者没有提供参数时会收到 None,提供参数时会收到 Some。但调用者通常不会显式传入 Some 和 None。
不过有时,显式传入 Some 或 None 正是你想要的。OCaml 允许你用 ? 而不是 ~ 来标记参数,从而做到这一点。因此,下面两行是指定 concat 的 sep 参数的等价方式:
# concat ~sep:":" "foo" "bar" (* provide the optional argument *);;
- : string = "foo:bar"
# concat ?sep:(Some ":") "foo" "bar" (* pass an explicit [Some] *);;
- : string = "foo:bar"
下面两行则是调用 concat 而不指定 sep 的等价方式:
# concat "foo" "bar" (* don't provide the optional argument *);;
- : string = "foobar"
# concat ?sep:None "foo" "bar" (* explicitly pass `None` *);;
- : string = "foobar"
一个使用场景是:你想定义一个包装函数,模仿它所包装函数的可选参数。例如,假设我们想创建一个名为 uppercase_concat 的函数,它与 concat 相同,只是会把传入的第一个字符串转换为大写。可以这样写:
# let uppercase_concat ?(sep="") a b =
concat ~sep (String.uppercase a) b;;
val uppercase_concat : ?sep:string -> string -> string -> string =
<fun>
# uppercase_concat "foo" "bar";;
- : string = "FOObar"
# uppercase_concat "foo" "bar" ~sep:":";;
- : string = "FOO:bar"
按照这种写法,我们被迫单独决定默认分隔符是什么。因此,如果之后改变 concat 的默认行为,还需要记得修改 uppercase_concat 来匹配它。
相反,可以让 uppercase_concat 使用 ? 语法直接把可选参数传递给 concat:
# let uppercase_concat ?sep a b = concat ?sep (String.uppercase a) b;;
val uppercase_concat : ?sep:string -> string -> string -> string =
<fun>
现在,如果有人调用 uppercase_concat 时不提供参数,就会把显式的 None 传给 concat,从而让 concat 自己决定默认行为。
带标签参数和可选参数的推断(Inference of Labeled and Optional Arguments)
带标签参数与可选参数的一个微妙方面,是类型系统如何推断它们。考虑下面这个例子,它用于计算一个二元实函数的数值导数。这个函数接受参数 delta,用于决定计算导数的尺度;接受值 x 和 y,用于决定在哪个点计算导数;还接受函数 f,也就是要求导的函数。函数 f 本身接受两个带标签参数 x 和 y。注意,变量名可以包含撇号,因此 x' 和 y' 只是普通变量。
# let numeric_deriv ~delta ~x ~y ~f =
let x' = x +. delta in
let y' = y +. delta in
let base = f ~x ~y in
let dx = (f ~x:x' ~y -. base) /. delta in
let dy = (f ~x ~y:y' -. base) /. delta in
(dx,dy);;
val numeric_deriv :
delta:float ->
x:float -> y:float -> f:(x:float -> y:float -> float) -> float *
float =
<fun>
原则上,并不明显应该如何选择 f 的参数顺序。由于带标签参数可以按任意顺序传入,看起来它既可以是 y:float -> x:float -> float,也可以是 x:float -> y:float -> float。
更糟的是,让 f 接受可选参数而不是带标签参数也完全一致,这可能导致 numeric_deriv 具有如下类型签名:
val numeric_deriv :
delta:float ->
x:float -> y:float -> f:(?x:float -> y:float -> float) -> float *
float
既然有多个合理类型可选,OCaml 就需要某种启发式规则在它们之间做选择。编译器使用的规则是:优先选择标签而不是可选参数,并选择源代码中出现的参数顺序。
注意,这些启发式规则可能在源码中的不同位置暗示不同类型。下面这个版本的 numeric_deriv 在不同调用中以不同顺序列出 f 的参数:
# let numeric_deriv ~delta ~x ~y ~f =
let x' = x +. delta in
let y' = y +. delta in
let base = f ~x ~y in
let dx = (f ~y ~x:x' -. base) /. delta in
let dy = (f ~x ~y:y' -. base) /. delta in
(dx,dy);;
Line 5, characters 15-16:
Error: This function is applied to arguments
in an order different from other calls.
This is only allowed when the real type is known.
正如错误信息暗示的,如果提供显式类型信息,就可以让 OCaml 接受 f 以不同参数顺序使用。所以下面的代码可以无错误编译,因为我们给 f 加了类型注解:
# let numeric_deriv ~delta ~x ~y ~(f: x:float -> y:float -> float) =
let x' = x +. delta in
let y' = y +. delta in
let base = f ~x ~y in
let dx = (f ~y ~x:x' -. base) /. delta in
let dy = (f ~x ~y:y' -. base) /. delta in
(dx,dy);;
val numeric_deriv :
delta:float ->
x:float -> y:float -> f:(x:float -> y:float -> float) -> float *
float =
<fun>
可选参数与部分应用(Optional Arguments and Partial Application)
在存在部分应用时,可选参数可能有点难想清楚。当然,我们可以部分应用可选参数本身:
# let colon_concat = concat ~sep:":";;
val colon_concat : string -> string -> string = <fun>
# colon_concat "a" "b";;
- : string = "a:b"
但如果只部分应用第一个参数,会发生什么呢?
# let prepend_pound = concat "# ";;
val prepend_pound : string -> string = <fun>
# prepend_pound "a BASH comment";;
- : string = "# a BASH comment"
可选参数 ?sep 现在已经消失,或者说被擦除了。事实上,如果现在尝试传入这个可选参数,会被拒绝:
# prepend_pound "a BASH comment" ~sep:":";;
Line 1, characters 1-14:
Error: This function has type Base.String.t -> Base.String.t
It is applied to too many arguments; maybe you forgot a `;'.
那么 OCaml 什么时候决定擦除可选参数呢?
规则是:一旦传入在该可选参数之后定义的第一个位置参数,也就是既非带标签也非可选的参数,该可选参数就会被擦除。这解释了 prepend_pound 的行为。但如果我们改成把可选参数定义在第二个位置:
# let concat x ?(sep="") y = x ^ sep ^ y;;
val concat : string -> ?sep:string -> string -> string = <fun>
那么应用第一个参数不会导致可选参数被擦除:
# let prepend_pound = concat "# ";;
val prepend_pound : ?sep:string -> string -> string = <fun>
# prepend_pound "a BASH comment";;
- : string = "# a BASH comment"
# prepend_pound "a BASH comment" ~sep:"--- ";;
- : string = "# --- a BASH comment"
不过,如果一次性提供函数的所有参数,那么只有在所有参数都传入之后,才会应用可选参数擦除。这保留了我们在参数列表任意位置传入可选参数的能力。因此,我们可以写:
# concat "a" "b" ~sep:"=";;
- : string = "a=b"
如果某个可选参数后面没有任何位置参数,它就完全无法被擦除,这会导致编译器警告:
# let concat x y ?(sep="") = x ^ sep ^ y;;
Line 1, characters 18-24:
Warning 16 [unerasable-optional-argument]: this optional argument
cannot be erased.
val concat : string -> string -> ?sep:string -> string = <fun>
事实上,当我们提供两个位置参数时,sep 参数没有被擦除,而是返回了一个期待提供 sep 参数的函数:
# concat "a" "b";;
- : ?sep:string -> string = <fun>
正如你所见,OCaml 对带标签参数和可选参数的支持并非没有复杂性。但不要让这些复杂性掩盖这些特性的实用性。标签和可选参数是非常有效的工具,可以让 API 更方便也更安全,值得花力气学习如何有效使用它们。