Map
这里有两个我们可能想要编写的函数:
(** [add1 lst] adds 1 to each element of [lst]. *)
let rec add1 = function
| [] -> []
| h :: t -> (h + 1) :: add1 t
let lst1 = add1 [1; 2; 3]
val add1 : int list -> int list = <fun>
val lst1 : int list = [2; 3; 4]
(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let rec concat_bang = function
| [] -> []
| h :: t -> (h ^ "!") :: concat_bang t
let lst2 = concat_bang ["sweet"; "salty"]
val concat_bang : string list -> string list = <fun>
val lst2 : string list = ["sweet!"; "salty!"]
这两个函数之间有很多相似之处:
- 它们都与一个列表进行模式匹配。
- 它们在空列表的基本情况下返回相同的值。
- 它们在非空列表的情况下都对尾部进行递归。
事实上,它们之间唯一的区别(除了它们的名称)就是它们对于头元素的操作:添加与连接。让我们重写这两个函数,使这种区别更加明显:
(** [add1 lst] adds 1 to each element of [lst]. *)
let rec add1 = function
| [] -> []
| h :: t ->
let f = fun x -> x + 1 in
f h :: add1 t
(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let rec concat_bang = function
| [] -> []
| h :: t ->
let f = fun x -> x ^ "!" in
f h :: concat_bang t
val add1 : int list -> int list = <fun>
val concat_bang : string list -> string list = <fun>
现在这两个函数之间唯一的区别(除了它们的名称之外)就是辅助函数 f 的主体。在这两个函数之间存在如此微小的差异时,为什么要重复所有那些代码呢?我们不妨将一个辅助函数从每个主函数中抽象出来,并将其作为一个参数。
let rec add1' f = function
| [] -> []
| h :: t -> f h :: add1' f t
(** [add1 lst] adds 1 to each element of [lst]. *)
let add1 = add1' (fun x -> x + 1)
let rec concat_bang' f = function
| [] -> []
| h :: t -> f h :: concat_bang' f t
(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let concat_bang = concat_bang' (fun x -> x ^ "!")
val add1' : ('a -> 'b) -> 'a list -> 'b list = <fun>
val add1 : int list -> int list = <fun>
val concat_bang' : ('a -> 'b) -> 'a list -> 'b list = <fun>
val concat_bang : string list -> string list = <fun>
但现在 add1' 和 concat_bang' 之间实际上没有任何区别,除了它们的名称。它们是完全重复的代码。甚至它们的类型现在也是相同的,因为关于它们的任何内容都没有提到整数或字符串。我们可能会考虑只保留其中一个,并为其想一个好的新名称。一个可能的选择是 transform ,因为它们通过将函数应用于列表的每个元素来转换列表:
let rec transform f = function
| [] -> []
| h :: t -> f h :: transform f t
(** [add1 lst] adds 1 to each element of [lst]. *)
let add1 = transform (fun x -> x + 1)
(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let concat_bang = transform (fun x -> x ^ "!")
val transform : ('a -> 'b) -> 'a list -> 'b list = <fun>
val add1 : int list -> int list = <fun>
val concat_bang : string list -> string list = <fun>
NOTE:
相反
let add1 lst = transform (fun x -> x + 1) lst
以上我们写道let add1 = transform (fun x -> x + 1)
这是另一种高阶的方式,但这是我们在部分应用的伪装下已经了解过的。后一种编写函数的方式部分应用 transform 到其两个参数中的一个,从而返回一个函数。该函数绑定到名称 add1 。
事实上,C++库确实调用等效函数 transform 。但是 OCaml 和许多其他语言(包括 Java 和 Python)使用更短的单词“map”,在数学上的意义是函数如何将输入映射到输出。因此,让我们对该名称进行最后一次更改:
let rec map f = function
| [] -> []
| h :: t -> f h :: map f t
(** [add1 lst] adds 1 to each element of [lst]. *)
let add1 = map (fun x -> x + 1)
(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let concat_bang = map (fun x -> x ^ "!")
val map : ('a -> 'b) -> 'a list -> 'b list = <fun>
val add1 : int list -> int list = <fun>
val concat_bang : string list -> string list = <fun>
我们现在已成功应用了抽象原则:通用结构已被分解出来。剩下的清晰地表达了计算,至少对于熟悉 map 的读者来说,这种表达方式不像原始版本那样迅速显而易见。
副作用
源文本:在 OCaml 的标准库中, map 函数已经存在,名称为 List.map ,但与我们上面发现的实现有一个小差异。首先,让我们看看我们自己实现可能存在的问题,然后再看标准库的实现。
在我们讨论异常时,我们已经看到 OCaml 语言规范通常不指定子表达式的评估顺序,并且当前的语言实现通常从右到左进行评估。因此,以下(相当牵强的)代码实际上导致列表元素以看似相反的顺序打印出来:
let p x = print_int x; print_newline(); x + 1
let lst = map p [1; 2]
val p : int -> int = <fun>
2
1
val lst : int list = [2; 3]
这就是为什么:
- 表达式 map p [1; 2] 的值为 p 1 :: map p [2] 。
- 该表达式的右侧然后被评估为 p 1 :: (p 2 :: map p []) 。 p 应用于 1 尚未发生。
- :: 的右侧再次进行评估,得到 p 1 :: (p 2 :: []) 。
- 然后 p 被应用到 2 ,最后应用到 1 。
这对于那些倾向于认为评估会从左到右进行的人可能会感到惊讶。解决方案是使用 let 表达式,以使函数应用的评估在递归调用之前发生:
let rec map f = function
| [] -> []
| h :: t -> let h' = f h in h' :: map f t
let lst2 = map p [1; 2]
val map : ('a -> 'b) -> 'a list -> 'b list = <fun>
1
2
val lst2 : int list = [2; 3]
这就是为什么这样做有效的原因:
- 表达式 map p [1; 2] 的值为 let h' = p 1 in h' :: map p [2] 。
- 绑定表达式 p 1 被评估,导致 1 被打印并且 h' 被绑定到 2 。
- 身体表达 h' :: map p [2] 然后被评估,导致接下来打印 2 。
这就是标准库如何定义 List.map 的方式。我们应该从现在开始使用它,而不是重新定义这个函数。但很好的是,我们已经发现了这个函数的“从零开始”的方式,如果需要的话,我们可以快速地重新编码它。
这次讨论中更重要的教训是,当评估顺序很重要时,我们需要使用 let 来确保它。什么时候它很重要?只有在存在副作用时。打印和异常是我们目前看到的两种情况。稍后我们将添加可变性。
映射和尾递归
敏锐的读者会注意到 map 的实现不是尾递归的。在某种程度上,这是不可避免的。以下是一种诱人但糟糕的方法来创建它的尾递归版本:
let rec map_tr_aux f acc = function
| [] -> acc
| h :: t -> map_tr_aux f (acc @ [f h]) t
let map_tr f = map_tr_aux f []
let lst = map_tr (fun x -> x + 1) [1; 2; 3]
val map_tr_aux : ('a -> 'b) -> 'b list -> 'a list -> 'b list = <fun>
val map_tr : ('a -> 'b) -> 'a list -> 'b list = <fun>
val lst : int list = [2; 3; 4]
在某种程度上这是有效的:输出是正确的, map_tr_aux 是尾递归的。微妙的缺陷在于子表达式 acc @ [f h] 。回想一下,追加操作在单链表上是线性时间的。也就是说,如果有 个列表元素,那么追加操作需要时间 。因此,在每次递归调用时我们执行一个 操作。并且会有 次递归调用,每个元素都会有一次。这总共是 的工作量,这是 的。因此,我们实现了尾递归,但代价很高:原本应该是线性时间操作变成了二次时间。
为了解决这个问题,我们可以使用常数时间的 cons 操作,而不是线性时间的 append 操作:
let rec map_tr_aux f acc = function
| [] -> acc
| h :: t -> map_tr_aux f (f h :: acc) t
let map_tr f = map_tr_aux f []
let lst = map_tr (fun x -> x + 1) [1; 2; 3]
val map_tr_aux : ('a -> 'b) -> 'b list -> 'a list -> 'b list = <fun>
val map_tr : ('a -> 'b) -> 'a list -> 'b list = <fun>
val lst : int list = [4; 3; 2]
在某种程度上,这是有效的:它是尾递归和线性时间的。这一次不那么微妙的缺陷是输出是颠倒的。当我们从输入列表的前面取出每个元素时,我们将其放在输出列表的前面,但这会颠倒它们的顺序。
NOTE:
要理解为什么会发生逆转,或许可以将输入和输出列表想象成排队的人们:
- 输入:Alice,Bob。
- 输出:空。
然后我们将爱丽丝从输入中移除,并将她添加到输出中:
输入:Bob。
输出:爱丽丝。
然后我们将 Bob 从输入中移除,并将他添加到输出中:
输入:空。
输出:Bob,爱丽丝。
问题在于,对于单链表,我们只能在列表的头部进行操作,并且仍然保持常数时间。我们无法将 Bob 移动到输出的末尾,而不让他经过 Alice 和其他可能站在输出中的人。
因此,标准库将此函数称为 List.rev_map ,即一个(尾递归)映射函数,以相反顺序返回其输出。
let rec rev_map_aux f acc = function
| [] -> acc
| h :: t -> rev_map_aux f (f h :: acc) t
let rev_map f = rev_map_aux f []
let lst = rev_map (fun x -> x + 1) [1; 2; 3]
val rev_map_aux : ('a -> 'b) -> 'b list -> 'a list -> 'b list = <fun>
val rev_map : ('a -> 'b) -> 'a list -> 'b list = <fun>
val lst : int list = [4; 3; 2]
如果您希望以“正确”的顺序输出,那很简单:只需将 List.rev 应用到它上:
let lst = List.rev (List.rev_map (fun x -> x + 1) [1; 2; 3])
val lst : int list = [2; 3; 4]
由于 List.rev 既是线性时间又是尾递归的,这导致了一个完整的解决方案。我们得到了一个线性时间和尾递归的映射计算。代价是需要通过列表进行两次遍历:一次进行转换,另一次进行反转。对于单链表,我们无法做得比这更高效。当然,还有其他实现列表的数据结构,我们最终会涉及到它们。与此同时,请记住,通常我们不必担心尾递归(也就是说,关于堆栈空间)直到列表有 10,000 个或更多元素。
为什么标准库没有提供这个一体化功能?也许如果有足够的理由,将来会提供。但你可能会发现在你自己的编程中,并不怎么需要它。在许多情况下,我们可以不使用尾递归,或者满足于一个反转的列表。
从这次讨论中可以得出的更重要的教训是,递归函数在时间和空间效率之间可能存在权衡。试图使函数更加空间高效(即尾递归)时,我们可能会意外地使其在渐近时间效率上变得不那么高效(即二次而不是线性),或者如果我们足够聪明,可以保持渐近时间效率不变(即线性),但代价是一个常数因子(即处理两次)。
Map in Other Languages
我们之前提到过,映射的概念存在于许多编程语言中。以下是 Python 的一个示例:
>>> print(list(map(lambda x: x + 1, [1, 2, 3])))
[2, 3, 4]
我们必须使用 list 函数将 map 的结果转换回列表,因为为了效率起见,Python 会根据需要生成 map 输出的每个元素。在这里,我们再次看到“何时进行评估?”这一主题再次出现。
在 Java 中,map 是 Java 8 中添加的 Stream 抽象的一部分。由于没有内置的 Java 语法用于列表或流,因此给出一个示例会更加冗长。在这里,我们使用一个工厂方法 Stream.of 来创建一个流:
jshell> Stream.of(1, 2, 3).map(x -> x + 1).collect(Collectors.toList())
$1 ==> [2, 3, 4]
与 Python 示例中一样,我们必须使用某种方法将流转换回列表。在这种情况下,使用的是 collect 方法。