diff --git a/src/ocaml_programming/ch4_beyond_lists.md b/src/ocaml_programming/ch4_beyond_lists.md index 4a69c37..21572b9 100644 --- a/src/ocaml_programming/ch4_beyond_lists.md +++ b/src/ocaml_programming/ch4_beyond_lists.md @@ -1 +1,119 @@ # Beyond Lists + + + +像 map 和 fold 这样的功能并不局限于列表。它们对几乎任何类型的数据收集都是有意义的。例如,回想一下这棵树的表示: + +``` +type 'a tree = + | Leaf + | Node of 'a * 'a tree * 'a tree +``` + +``` +type 'a tree = Leaf | Node of 'a * 'a tree * 'a tree +``` + +## Map on Trees + +这很容易。我们只需要在每个节点上将函数 f 应用于值 v 。 + +``` +let rec map_tree f = function + | Leaf -> Leaf + | Node (v, l, r) -> Node (f v, map_tree f l, map_tree f r) +``` + +``` +val map_tree : ('a -> 'b) -> 'a tree -> 'b tree = +``` + +## Fold on Trees + +这个稍微难一点。让我们为 'a tree 开发一个类似于我们在 'a list 上的 fold_right 的折叠功能。一种思考 List.fold_right 的方式是列表中的 [] 值被 acc 参数替换,每个 :: 构造函数被 f 参数的应用所替换。例如, [a; b; c] 是 a :: (b :: (c :: [])) 的语法糖。因此,如果我们用 0 替换 [] ,用 ( + ) 替换 :: ,我们得到 a + (b + (c + 0)) 。沿着这些思路,这是我们可以重写 fold_right 的一种方式,这将帮助我们更清晰地思考一下: + +``` +type 'a mylist = + | Nil + | Cons of 'a * 'a mylist + +let rec fold_mylist f acc = function + | Nil -> acc + | Cons (h, t) -> f h (fold_mylist f acc t) +``` + +``` +type 'a mylist = Nil | Cons of 'a * 'a mylist +``` + +``` +val fold_mylist : ('a -> 'b -> 'b) -> 'b -> 'a mylist -> 'b = +``` + +算法是相同的。我们所做的只是改变了列表的定义,使用用字母字符编写的构造函数,而不是标点符号,并改变了 fold 函数的参数顺序。 + +对于树,我们希望将 acc 的初始值替换每个 Leaf 构造函数,就像它在列表中替换 [] 一样。我们希望每个 Node 构造函数被操作符替换。但现在操作符将需要是三元的而不是二元的 - 也就是说,它需要接受三个参数而不是两个 - 因为树节点有一个值,一个左子节点和一个右子节点,而列表的 cons 只有一个头部和一个尾部。 + +受到这些观察的启发,这里是树的折叠函数: + +``` +let rec fold_tree f acc = function + | Leaf -> acc + | Node (v, l, r) -> f v (fold_tree f acc l) (fold_tree f acc r) +``` + +``` +val fold_tree : ('a -> 'b -> 'b -> 'b) -> 'b -> 'a tree -> 'b = +``` + +如果将该函数与 fold_mylist 进行比较,您会注意到它们几乎是相同的。第二个模式匹配分支中只多了一个递归调用,对应于在该类型定义中 'a tree 的一个额外出现。 + +我们可以使用 fold_tree 来实现我们之前看到的一些树函数: + +``` +let size t = fold_tree (fun _ l r -> 1 + l + r) 0 t +let depth t = fold_tree (fun _ l r -> 1 + max l r) 0 t +let preorder t = fold_tree (fun x l r -> [x] @ l @ r) [] t +``` + +``` +val size : 'a tree -> int = +``` + +``` +val depth : 'a tree -> int = +``` + +``` +val preorder : 'a tree -> 'a list = +``` + +为什么我们选择 fold_right 而不是 fold_left 进行这个开发?因为 fold_left 是尾递归的,这是我们永远无法在二叉树上实现的。假设我们首先处理左分支;然后我们仍然必须在返回之前处理右分支。因此,在对一个分支进行递归调用后,总会有剩余的工作要做。因此,在树上, fold_right 的等价物是我们所能期望的最好的。 + +我们用来推导 fold_tree 的技术适用于任何 OCaml 变体类型 t : + +- 编写一个递归 fold 函数,该函数接受 t 的每个构造函数的一个参数。 +- 该 fold 函数与构造函数匹配,在遇到任何类型为 t 的值时会递归调用自身。 +- 使用 fold 的适当参数来组合所有递归调用的结果,以及在每个构造函数中不属于 t 类型的所有数据。 + +这种技术构建了一种称为 catamorphism 的东西,又称为广义折叠操作。要了解更多关于 catamorphisms 的信息,请参加范畴论课程。 + +## Filter on Trees + +这可能是最难设计的一个。问题是:如果我们决定过滤一个节点,那么我们应该怎么处理它的子节点? + +- 我们可以对子节点进行递归。如果在筛选它们后只剩下一个子节点,我们可以将其提升为其父节点的位置。但如果两个子节点都保留下来,或者一个都没有呢?那么我们就必须以某种方式重塑树形结构。如果不了解树应该如何使用——也就是说,它代表什么样的数据,我们就会陷入困境。 +- 相反,我们可以完全消除子节点。因此,过滤节点的决定意味着修剪以该节点为根的整个子树。 + +后者易于实施: + +``` +let rec filter_tree p = function + | Leaf -> Leaf + | Node (v, l, r) -> + if p v then Node (v, filter_tree p l, filter_tree p r) else Leaf +``` + +``` +val filter_tree : ('a -> bool) -> 'a tree -> 'a tree = +``` diff --git a/src/ocaml_programming/ch4_currying.md b/src/ocaml_programming/ch4_currying.md index aced99d..5e12cd6 100644 --- a/src/ocaml_programming/ch4_currying.md +++ b/src/ocaml_programming/ch4_currying.md @@ -1 +1,64 @@ # Currying + +我们已经看到,一个接受类型为 t1 和 t2 的两个参数并返回类型为 t3 的值的 OCaml 函数具有类型 t1 -> t2 -> t3 。在 let 表达式中函数名后面我们使用两个变量: + +``` +let add x y = x + y +``` + +``` +val add : int -> int -> int = +``` + +定义一个接受两个参数的函数的另一种方法是编写一个接受元组的函数: + +``` +let add' t = fst t + snd t +``` + +``` +val add' : int * int -> int = +``` + +不使用 fst 和 snd ,我们可以在函数定义中使用元组模式,从而导致第三种实现: + +``` +let add'' (x, y) = x + y +``` + +``` +val add'' : int * int -> int = +``` + +使用第一种风格编写的函数(带有类型 t1 -> t2 -> t3 )称为柯里化函数,而使用第二种风格(带有类型 t1 * t2 -> t3 )的函数称为非柯里化函数。比喻地说,柯里化函数更“辛辣”,因为您可以部分应用它们(这是您无法对非柯里化函数做到的:您无法传入一对的一半)。实际上,“柯里”一词并不是指香料,而是指逻辑学家 [Haskell Curry](https://en.wikipedia.org/wiki/Haskell_Curry)(他是极少数以名字和姓氏命名编程语言的人之一)。 + + +有时候你会遇到一些库提供了一个非柯里化版本的函数,但你想要一个柯里化版本来在自己的代码中使用;或者反过来。因此,了解如何在这两种函数之间进行转换是很有用的,就像我们在上面使用 add 时所做的那样。 + +您甚至可以编写一对高阶函数来为您进行转换: + +``` +let curry f x y = f (x, y) +let uncurry f (x, y) = f x y +``` + +``` +val curry : ('a * 'b -> 'c) -> 'a -> 'b -> 'c = +``` + +``` +val uncurry : ('a -> 'b -> 'c) -> 'a * 'b -> 'c = +``` + +``` +let uncurried_add = uncurry add +let curried_add = curry add'' +``` + +``` +val uncurried_add : int * int -> int = +``` + +``` +val curried_add : int -> int -> int = +``` diff --git a/src/ocaml_programming/ch4_exercises.md b/src/ocaml_programming/ch4_exercises.md index eba3257..bb25380 100644 --- a/src/ocaml_programming/ch4_exercises.md +++ b/src/ocaml_programming/ch4_exercises.md @@ -1 +1,126 @@ # Exercises + +大多数练习的解决方案都可以[获得](https://github.com/cs3110/textbook-solutions)。2022 年秋季是这些解决方案的首次公开发布。尽管康奈尔大学的学生已经可以获得这些解决方案几年了,但更广泛的传播将揭示可以进行改进的地方是不可避免的。我们很乐意添加或更正解决方案。请通过 GitHub 进行贡献。 + + +## Exercise: twice, no arguments [★] + +考虑以下定义: + +``` +let double x = 2 * x +let square x = x * x +let twice f x = f (f x) +let quad = twice double +let fourth = twice square +``` + +使用 toplevel 确定 quad 和 fourth 的类型。解释为什么 quad 不是以语法形式编写为接受参数的函数,但其类型显示它实际上是一个函数。 + +## Exercise: mystery operator 1 [★★] + +以下运算符是做什么用的? + +``` +let ( $ ) f x = f x +``` + +提示:调查 square $ 2 + 2 与 square 2 + 2 之间的差异。 + +## Exercise: mystery operator 2 [★★] + +以下运算符是做什么用的? + +``` +let ( @@ ) f g x = x |> g |> f +``` + +提示:研究 String.length @@ string_of_int 应用于 1 , 10 , 100 等。 + +## Exercise: repeat [★★] + +将 twice 概括为一个函数 repeat ,使得 repeat f n x 将 f 应用于 x 共 n 次。也就是说, + +- repeat f 0 x yields x +- repeat f 1 x yields f x +- repeat f 2 x yields f (f x) (which is the same as twice f x) +- repeat f 3 x yields f (f (f x)) +- and so on. + +## Exercise: product [★] + +使用 fold_left 编写一个函数 product_left ,用于计算一组浮点数的乘积。空列表的乘积是 1.0 。提示:回想一下我们在讲座中如何用一行代码实现 sum 。 + +使用 fold_right 编写一个函数 product_right ,用于计算一个浮点数列表的乘积。相同的提示适用。 + +## Exercise: terse product [★★] + +你能将对产品练习的解决方案简化到多少?提示:每个解决方案只需要一行代码,且不需要使用 fun 关键字。对于 fold_left ,你的函数定义甚至不需要显式地接受一个列表参数。如果你使用 ListLabels ,对于 fold_right 也是同样的情况。 + +## Exercise: sum_cube_odd [★★] + +编写一个函数 sum_cube_odd n ,计算介于 0 和 n 之间(包括 0 和 n )所有奇数的立方和。不要编写任何新的递归函数。而是使用函数式编程中的 map、fold 和 filter,以及管道化讨论中定义的 ( -- ) 运算符。 + +## Exercise: sum_cube_odd pipeline [★★] + +重写函数 sum_cube_odd 以使用管道运算符 |> 。 + +## Exercise: exists [★★] + +考虑编写一个函数 exists: ('a -> bool) -> 'a list -> bool ,使得 exists p [a1; ...; an] 返回列表中至少有一个元素满足谓词 p 的情况。也就是说,它的评估结果与 (p a1) || (p a2) || ... || (p an) 相同。当应用于空列表时,它的评估结果为 false 。 + +写出三个解决这个问题的方法,就像我们之前所做的那样: + + - exists_rec 必须是一个递归函数,不能使用 List 模块 + - exists_fold ,使用 List.fold_left 或 List.fold_right ,但不使用任何其他 List 模块函数,也不使用 rec 关键字 + - exists_lib ,使用除 fold_left 或 fold_right 之外的任何 List 模块函数的组合,并且不使用 rec 关键字。 + + +## Exercise: account balance [★★★] + +编写一个函数,给定一个代表借方的数字列表,从账户余额中扣除它们,最后返回余额中剩余的金额。编写三个版本: fold_left , fold_right ,以及一个直接的递归实现。 + +## Exercise: library uncurried [★★] + +这是 List.nth 的非柯里化版本: + +``` +let uncurried_nth (lst, n) = List.nth lst n +``` + +以类似的方式,编写这些库函数的非柯里化版本: + +- List.append +- Char.compare +- Stdlib.max + +## Exercise: map composition [★★★] + +展示如何用一个调用 List.map 仅一次的等效表达式替换任何形式为 List.map f (List.map g lst) 的表达式。 + +## Exercise: more list fun [★★★] + +编写执行以下计算的函数。 您编写的每个函数都应该使用 List.fold , List.map 或 List.filter 中的一个。 要选择使用哪个,请考虑计算正在做什么:组合、转换或过滤元素。 + +- 找出字符串列表中长度严格大于 3 的元素。 +- 将 1.0 添加到浮点数列表的每个元素。 +- 给定一个字符串列表 strs 和另一个字符串 sep ,生成包含 strs 的每个元素以 sep 分隔的字符串。例如,给定输入 ["hi";"bye"] 和 "," ,生成 "hi,bye" ,确保不会在结果字符串的开头或结尾产生额外的逗号。 + +## Exercise: association list keys [★★★] + +回想一下,关联列表是一种字典的实现,它是一对列表,我们将每对中的第一个组件视为键,第二个组件视为值。 + +编写一个函数 keys: ('a * 'b) list -> 'a list ,该函数返回一个关联列表中唯一键的列表。由于它们必须是唯一的,在输出列表中不应出现多次相同的值。输出值的顺序不重要。您能使解决方案多么紧凑和高效?您能在一行中以线性对数空间和时间完成吗?提示: List.sort_uniq 。 + +## Exercise: valid matrix [★★★] + +数学矩阵可以用列表表示。在行主表示中,这个矩阵 + +[ [1; 1; 1]; [9; 8; 7]] + +将其表示为列表 [[1; 1; 1]; [9; 8; 7]] 。让我们将行向量表示为 int list 。例如, [9; 8; 7] 是一个行向量。 + +一个有效的矩阵是一个至少有一行、至少有一列,并且每列具有相同行数的 int list list 。有许多 int list list 类型的无效值,例如, + +- [] +- [[1; 2]; [3]] diff --git a/src/ocaml_programming/ch4_filter.md b/src/ocaml_programming/ch4_filter.md index 8cc4cd0..5e6a32a 100644 --- a/src/ocaml_programming/ch4_filter.md +++ b/src/ocaml_programming/ch4_filter.md @@ -1 +1,148 @@ # Filter + + + +假设我们想要从一个列表中筛选出偶数或奇数。以下是一些函数来实现这一目的: + +```ocaml +(** [even n] is whether [n] is even. *) +let even n = + n mod 2 = 0 + +(** [evens lst] is the sublist of [lst] containing only even numbers. *) +let rec evens = function + | [] -> [] + | h :: t -> if even h then h :: evens t else evens t + +let lst1 = evens [1; 2; 3; 4] +``` + +``` +val even : int -> bool = +``` + +``` +val evens : int list -> int list = +``` + +``` +val lst1 : int list = [2; 4] +``` + +```ocaml +(** [odd n] is whether [n] is odd. *) +let odd n = + n mod 2 <> 0 + +(** [odds lst] is the sublist of [lst] containing only odd numbers. *) +let rec odds = function + | [] -> [] + | h :: t -> if odd h then h :: odds t else odds t + +let lst2 = odds [1; 2; 3; 4] +``` + +``` +val odd : int -> bool = +``` + +``` +val odds : int list -> int list = +``` + +``` +val lst2 : int list = [1; 3] +``` + + +函数 evens 和 odds 几乎是相同的代码:唯一的关键区别是它们应用于头元素的测试。因此,就像我们在上一节中对 map 所做的那样,让我们将该测试提取为一个函数。让我们将该函数命名为 p ,缩写为“predicate”,这是一种测试某事是真还是假的花哨方式: + +``` +let rec filter p = function + | [] -> [] + | h :: t -> if p h then h :: filter p t else filter p t +``` + +``` +val filter : ('a -> bool) -> 'a list -> 'a list = +``` + +现在我们可以重新实现我们最初的两个函数: + +``` +let evens = filter even +let odds = filter odd +``` + +``` +val evens : int list -> int list = +``` + +``` +val odds : int list -> int list = +``` + +这些是多么简单!多么清晰!(至少对熟悉 filter 的读者来说。) + + +## 过滤器和尾递归 + +与 map 一样,我们可以创建 filter 的尾递归版本: + +```ocaml +let rec filter_aux p acc = function + | [] -> acc + | h :: t -> if p h then filter_aux p (h :: acc) t else filter_aux p acc t + +let filter p = filter_aux p [] + +let lst = filter even [1; 2; 3; 4] +``` + +``` +val filter_aux : ('a -> bool) -> 'a list -> 'a list -> 'a list = +``` + +``` +val filter : ('a -> bool) -> 'a list -> 'a list = +``` + +``` +val lst : int list = [4; 2] +``` + +再次发现输出是反向的。在这里,标准库做出了与 map 不同的选择。它内置了反转到 List.filter ,实现方式如下: + +``` +let rec filter_aux p acc = function + | [] -> List.rev acc (* note the built-in reversal *) + | h :: t -> if p h then filter_aux p (h :: acc) t else filter_aux p acc t + +let filter p = filter_aux p [] +``` + +``` +val filter_aux : ('a -> bool) -> 'a list -> 'a list -> 'a list = +``` + +``` +val filter : ('a -> bool) -> 'a list -> 'a list = +``` + +为什么标准库在这一点上对 map 和 filter 有所不同?很好的问题。也许只是从来没有对一个 filter 函数有过时间效率是一个常数倍更好的需求。或者这只是历史意外。 + +## 其他语言中的过滤器 + +再次,过滤器的概念存在于许多编程语言中。在 Python 中是这样的: + +```python +>>> print(list(filter(lambda x: x % 2 == 0, [1, 2, 3, 4]))) +[2, 4] +``` + +在 Java 中: + +```java +jshell> Stream.of(1, 2, 3, 4).filter(x -> x % 2 == 0).collect(Collectors.toList()) +$1 ==> [2, 4] +``` diff --git a/src/ocaml_programming/ch4_fold.md b/src/ocaml_programming/ch4_fold.md index c77ec8f..7cdcc07 100644 --- a/src/ocaml_programming/ch4_fold.md +++ b/src/ocaml_programming/ch4_fold.md @@ -1 +1,495 @@ # Fold + +map 函数为我们提供了一种逐个转换列表中每个元素的方式。filter 函数为我们提供了一种逐个决定是保留还是丢弃列表中每个元素的方式。但这两个函数实际上只是逐个查看单个元素。如果我们想要以某种方式结合列表的所有元素呢?这就是 fold 函数的作用。事实证明,它有两个版本,我们将在本节中学习。但首先,让我们看一个相关的函数——实际上不在标准库中——我们称之为 combine。 + +## 合并 + + + +再写两个函数: + +```ocaml +(** [sum lst] is the sum of all the elements of [lst]. *) +let rec sum = function + | [] -> 0 + | h :: t -> h + sum t + +let s = sum [1; 2; 3] +``` + +``` +val sum : int list -> int = +``` + +``` +val s : int = 6 +``` + +``` +(** [concat lst] is the concatenation of all the elements of [lst]. *) +let rec concat = function + | [] -> "" + | h :: t -> h ^ concat t + +let c = concat ["a"; "b"; "c"] +``` + +``` +val concat : string list -> string = +``` + +``` +val c : string = "abc" +``` + +与我们在使用map和filter进行类似练习时一样,这些函数共享许多共同的结构。这里的区别是: + +- 空列表的情况返回不同的初始值, 0 vs "" +- 非空列表的情况使用不同的运算符将头元素与递归调用的结果结合起来, + vs ^ 。 + +那么我们可以再次应用抽象原则吗?当然可以!但这次我们需要分解出两个参数:一个用于这两个差异中的每一个。 + +首先,让我们只提取出初始值: + +``` +let rec sum' init = function + | [] -> init + | h :: t -> h + sum' init t + +let sum = sum' 0 + +let rec concat' init = function + | [] -> init + | h :: t -> h ^ concat' init t + +let concat = concat' "" +``` + +``` +val sum' : int -> int list -> int = +``` + +``` +val sum : int list -> int = +``` + +``` +val concat' : string -> string list -> string = +``` + +``` +val concat : string list -> string = +``` + +现在 sum' 和 concat' 之间唯一真正的区别是用于将头部与尾部的递归调用组合的运算符。该运算符也可以成为我们称之为 combine 的统一函数的参数: + +``` +let rec combine op init = function + | [] -> init + | h :: t -> op h (combine op init t) + +let sum = combine ( + ) 0 +let concat = combine ( ^ ) "" +``` + +``` +val combine : ('a -> 'b -> 'b) -> 'b -> 'a list -> 'b = +``` + +``` +val sum : int list -> int = +``` + +``` +val concat : string list -> string = +``` + +一种思考 combine 的方式是: + +- 列表中的 [] 值被 init 替换,而 +- 每个 :: 构造函数都被 op 替换。 + +例如, [a; b; c] 只是 a :: (b :: (c :: [])) 的语法糖。因此,如果我们用 0 替换 [] ,用 (+) 替换 :: ,我们得到 a + (b + (c + 0)) 。这将是列表的总和。 + + +再次,抽象原则让我们得到了一个非常简单而简洁的计算表达。 + +## Fold Right + + + +combine 函数是一个实际 OCaml 库函数的基本思想。为了实现这一点,我们需要对我们目前的实现进行一些更改。 + +首先,让我们重新命名一些参数:我们将把 op 更改为 f ,以强调我们实际上可以传入任何函数,而不仅仅是像 + 这样的内置运算符。我们将把 init 更改为 acc ,通常代表“累加器”。这样就得到了: + + + +``` +let rec combine f acc = function + | [] -> acc + | h :: t -> f h (combine f acc t) +``` + +``` +val combine : ('a -> 'b -> 'b) -> 'b -> 'a list -> 'b = +``` + +其次,让我们做一个明显动机较弱的改变。我们将隐式列表参数与 init 参数进行交换: + +``` +let rec combine' f lst acc = match lst with + | [] -> acc + | h :: t -> f h (combine' f t acc) + +let sum lst = combine' ( + ) lst 0 +let concat lst = combine' ( ^ ) lst "" +``` + +``` +val combine' : ('a -> 'b -> 'b) -> 'a list -> 'b -> 'b = +``` + +``` +val sum : int list -> int = +``` + +``` +val concat : string list -> string = +``` + +以这种方式编写函数会稍微不太方便,因为我们不再能够利用 function 关键字,也不能在定义 sum 和 concat 时利用部分应用。但算法上没有任何改变。 + +我们现在拥有的是标准库函数 List.fold_right 的实际实现。我们剩下要做的就是更改函数名称: + +``` +let rec fold_right f lst acc = match lst with + | [] -> acc + | h :: t -> f h (fold_right f t acc) +``` + +``` +val fold_right : ('a -> 'b -> 'b) -> 'a list -> 'b -> 'b = +``` + +为什么这个函数被称为“fold right”?直觉是它的工作方式是从右到左“折叠”列表的元素,使用运算符结合每个新元素。例如, fold_right ( + ) [a; b; c] 0 导致表达式 a + (b + (c + 0)) 的评估。括号从最右边的子表达式向左关联。 + +## 尾递归和组合 + +既不是 fold_right 也不是 combine 是尾递归的:在递归调用返回后,仍然需要在应用函数参数 f 或 op 时进行工作。让我们回到 combine 并将其重写为尾递归。所需的只是更改 cons 分支: + +``` +let rec combine_tr f acc = function + | [] -> acc + | h :: t -> combine_tr f (f acc h) t (* only real change *) +``` + +``` +val combine_tr : ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a = +``` + +细心的读者会注意到 combine_tr 的类型与 combine 的类型不同。我们很快会解决这个问题。 + +现在函数 f 应用于递归调用之前的头元素 h 和累加器 acc ,从而确保在调用返回后没有剩余的工作需要完成。如果这看起来有点神秘,这里是两个函数的重写,可能会有所帮助: + +``` +let rec combine f acc = function + | [] -> acc + | h :: t -> + let acc' = combine f acc t in + f h acc' + +let rec combine_tr f acc = function + | [] -> acc + | h :: t -> + let acc' = f acc h in + combine_tr f acc' t +``` + +``` +val combine : ('a -> 'b -> 'b) -> 'b -> 'a list -> 'b = +``` + +``` +val combine_tr : ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a = +``` + +请密切关注每个版本中 acc' ,即新的累加器的定义: + +- 在原始版本中,我们使用头元素 h 来拖延。首先,我们将所有剩余的尾元素组合在一起得到 acc' 。然后我们才使用 f 将头部合并进去。因此,作为 acc 的初始值传递的值最终对于 combine 的每次递归调用都是相同的:它一直传递到需要的地方,即列表的最右边元素,然后在那里被使用一次。 + +- 但在尾递归版本中,我们通过立即将 h 与旧累加器 acc 折叠在一起来“预先处理”。然后我们将其与所有尾元素折叠在一起。因此,在每次递归调用时,作为参数传递的值 acc 可能是不同的。 + +结合的尾递归版本对求和(和连接,我们省略了)非常有效 + +``` +let sum = combine_tr ( + ) 0 +let s = sum [1; 2; 3] +``` + +``` +val sum : int list -> int = +``` + +``` +val s : int = 6 +``` + +但是减法可能会发生一些令人惊讶的事情: + +``` +let sub = combine ( - ) 0 +let s = sub [3; 2; 1] + +let sub_tr = combine_tr ( - ) 0 +let s' = sub_tr [3; 2; 1] +``` + +``` +val sub : int list -> int = +``` + +``` +val s : int = 2 +``` + +``` +val sub_tr : int list -> int = +``` + +``` +val s' : int = -6 +``` + +这两个结果是不同的! + +- 使用 combine 我们计算 3 - (2 - (1 - 0)) 。首先我们将 1 合并,然后 2 ,最后 3 。我们从右到左处理列表,将初始累加器放在最右边。 +- 但是使用 combine_tr 我们计算 (((0 - 3) - 2) - 1) 。我们从左到右处理列表,将初始累加器放在最左边。 + + +在加法中,我们处理列表的顺序并不重要,因为加法是可交换和结合的。但是减法不是,所以两个方向会得出不同的答案。 + +实际上,如果我们回想一下我们将 map 设为尾递归时,这并不应该太令人惊讶。然后,我们发现尾递归会导致我们以与同一函数的非尾递归版本相反的顺序处理列表。这就是这里发生的事情。 + + +## Fold Left + +我们的 combine_tr 函数也在标准库中以 List.fold_left 的名称存在: + +``` +let rec fold_left f acc = function + | [] -> acc + | h :: t -> fold_left f (f acc h) t + +let sum = fold_left ( + ) 0 +let concat = fold_left ( ^ ) "" +``` + +``` +val fold_left : ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a = +``` + +``` +val sum : int list -> int = +``` + +``` +val concat : string list -> string = +``` + +我们再次成功应用了抽象原则。 + +## Fold Left vs. Fold Right + +让我们回顾一下 fold_right 和 fold_left 之间的区别: + +- 它们根据它们的名称以相反的顺序组合列表元素。函数 fold_right 从右到左组合,而 fold_left 从左到右进行。 +- 函数 fold_left 是尾递归的,而 fold_right 不是。 +- 函数的类型是不同的。 + +关于最后一点,很难记住这些类型是什么!幸运的是,我们总是可以询问顶层: + +``` +List.fold_left;; +List.fold_right;; +``` + +``` +- : ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a = +``` + +``` +- : ('a -> 'b -> 'b) -> 'a list -> 'b -> 'b = +``` + +要理解这些类型,请查找它们各自的列表参数。这会告诉你列表中值的类型。然后查找返回值的类型;这会告诉你累加器的类型。从那里,你可以推断出其他一切。 + +- 在 fold_left 中,列表参数的类型为 'b list ,因此列表包含类型为 'b 的值。返回类型为 'a ,因此累加器的类型为 'a 。知道这一点,我们可以推断第二个参数是累加器的初始值(因为它的类型为 'a )。我们可以推断第一个参数,即组合运算符,将累加器值作为其自己的第一个参数(因为它的类型为 'a ),将列表元素作为其自己的第二个参数(因为它的类型为 'b ),并返回一个新的累加器值。 + +- 在 fold_right 中,列表参数的类型为 'a list ,因此列表包含类型为 'a 的值。返回类型为 'b ,因此累加器的类型为 'b 。知道这一点,我们可以推断第三个参数是累加器的初始值(因为它的类型为 'b )。我们可以推断第一个参数,即组合运算符,将累加器值作为自己的第二个参数(因为它的类型为 'b ),将列表元素作为自己的第一个参数(因为它的类型为 'a ),并返回一个新的累加器值。 + +> TIP: +> +> 你可能会想为什么这两个 fold 函数的参数顺序不同。很好的问题。事实上,其他库确实使用不同的参数顺序。在 OCaml 中记住这一点的一种方法是,在 fold_X 中,累加器参数放在列表参数的 X 位置。 + +如果您发现很难跟踪所有这些参数顺序,标准库中的 [ListLabels](https://ocaml.org/manual/5.2/api/ListLabels.html) 模块可以提供帮助。它使用带标签的参数为组合运算符(称为 f )和初始累加器值(称为 init )命名。在内部,实现实际上与 List 模块完全相同。 + +``` +ListLabels.fold_left;; +ListLabels.fold_left ~f:(fun x y -> x - y) ~init:0 [1;2;3];; +``` + +``` +- : f:('a -> 'b -> 'a) -> init:'a -> 'b list -> 'a = +``` + +``` +- : int = -6 +``` + +``` +ListLabels.fold_right;; +ListLabels.fold_right ~f:(fun y x -> x - y) ~init:0 [1;2;3];; +``` + +``` +- : f:('a -> 'b -> 'b) -> 'a list -> init:'b -> 'b = +``` + +``` +- : int = -6 +``` + +请注意,在上述两个 fold 的应用中,由于它们的标签,我们能够以统一的顺序编写参数。然而,我们仍然需要注意将列表元素与累加器值作为组合运算符的哪个参数。 + +## A Digression on Labeled Arguments and Fold + +我们可以编写自己版本的 fold 函数,该函数将为组合运算符的参数加上标签,因此我们甚至不必记住它们的顺序: + +``` +let rec fold_left ~op:(f: acc:'a -> elt:'b -> 'a) ~init:acc lst = + match lst with + | [] -> acc + | h :: t -> fold_left ~op:f ~init:(f ~acc:acc ~elt:h) t + +let rec fold_right ~op:(f: elt:'a -> acc:'b -> 'b) lst ~init:acc = + match lst with + | [] -> acc + | h :: t -> f ~elt:h ~acc:(fold_right ~op:f t ~init:acc) +``` + +``` +val fold_left : op:(acc:'a -> elt:'b -> 'a) -> init:'a -> 'b list -> 'a = + +``` + + +``` +val fold_right : op:(elt:'a -> acc:'b -> 'b) -> 'a list -> init:'b -> 'b = + + +``` + +但这些功能并不像它们看起来那么有用: + +``` +let s = fold_left ~op:( + ) ~init:0 [1;2;3] +``` + + +``` +File "[17]", line 1, characters 22-27: +1 | let s = fold_left ~op:( + ) ~init:0 [1;2;3] + ^^^^^ +Error: This expression has type int -> int -> int + but an expression was expected of type acc:'a -> elt:'b -> 'a +``` + +问题在于内置的 + 运算符没有标记参数,因此我们无法将其作为组合运算符传递给我们的带标签函数。我们必须定义自己的带标签版本 + +``` +let add ~acc ~elt = acc + elt +let s = fold_left ~op:add ~init:0 [1; 2; 3] +``` + +但现在我们必须记住, ~acc 参数将成为 add 的左手参数。这并没有比我们一开始必须记住的要好多少。 + +## 使用 Fold 实现其他函数 + +折叠是如此强大,以至于我们可以用 fold_left 或 fold_right 来编写许多其他列表函数。例如, + +``` +let length lst = + List.fold_left (fun acc _ -> acc + 1) 0 lst + +let rev lst = + List.fold_left (fun acc x -> x :: acc) [] lst + +let map f lst = + List.fold_right (fun x acc -> f x :: acc) lst [] + +let filter f lst = + List.fold_right (fun x acc -> if f x then x :: acc else acc) lst [] +``` + +``` +val length : 'a list -> int = +``` + +``` +val rev : 'a list -> 'a list = +``` + +``` +val map : ('a -> 'b) -> 'a list -> 'b list = +``` + +``` +val filter : ('a -> bool) -> 'a list -> 'a list = +``` + +在这一点上,开始有争议,是更好地使用折叠来表达上面的计算,还是使用我们已经看到的方法。即使对于经验丰富的函数式程序员来说,理解折叠的作用可能比阅读天真的递归实现更耗时。如果您查阅标[准库的源代码](https://github.com/ocaml/ocaml/blob/trunk/stdlib/list.ml),您会发现 List 模块内部没有一个是使用折叠来实现的,这或许是对折叠可读性的一种评价。另一方面,使用折叠确保程序员不会意外地错误地编写递归遍历。对于比列表更复杂的数据结构,这种健壮性可能是一种胜利。 + +## Fold vs. Recursive vs. Library + +我们现在已经看到了三种不同的编写操作列表的函数的方法: + +- 直接作为一个递归函数,该函数模式匹配空列表和 cons +- 使用 fold 函数 +- 使用其他库函数。 + + +让我们尝试使用这些方法来解决问题,这样我们就能更好地欣赏它们。 + +考虑编写一个函数 lst_and: bool list -> bool ,使得 lst_and [a1; ...; an] 返回列表的所有元素是否都是 true 。也就是说,它的评估结果与 a1 && a2 && ... && an 相同。当应用于空列表时,它的评估结果为 true 。 + +以下是编写此类函数的三种可能方式。为了清晰起见,我们为每种方式赋予略有不同的函数名称。 + +``` +let rec lst_and_rec = function + | [] -> true + | h :: t -> h && lst_and_rec t + +let lst_and_fold = + List.fold_left (fun acc elt -> acc && elt) true + +let lst_and_lib = + List.for_all (fun x -> x) +``` + +``` +val lst_and_rec : bool list -> bool = +``` + +``` +val lst_and_fold : bool list -> bool = +``` + +``` +val lst_and_lib : bool list -> bool = +``` + +所有三个函数的最坏运行时间与列表的长度成线性关系。但是: + +- 第一个函数, lst_and_rec 的优点在于它不需要处理整个列表。一旦在列表中发现一个 false 元素,它将立即返回 false 。 +- 第二个函数, lst_and_fold ,将始终处理列表的每个元素。 +- 至于第三个功能 lst_and_lib ,根据 List.for_all 的文档,它返回 (p a1) && (p a2) && ... && (p an) 。因此,与 lst_and_rec 一样,它不需要处理每个元素。 diff --git a/src/ocaml_programming/ch4_pipelining.md b/src/ocaml_programming/ch4_pipelining.md index 1494aa2..0b935ca 100644 --- a/src/ocaml_programming/ch4_pipelining.md +++ b/src/ocaml_programming/ch4_pipelining.md @@ -1 +1,87 @@ # Pipelining + +假设我们想要计算从 0 到 n 的数字的平方和。我们该如何做呢?当然(数学是最佳的优化形式),最有效的方式是使用一个封闭形式的公式: n(n+1)(2n+1) / 6 + +但是让我们想象一下,你忘记了那个公式。在一种命令式语言中,你可以使用一个 for 循环: + +``` +# Python +def sum_sq(n): + sum = 0 + for i in range(0, n+1): + sum += i * i + return sum +``` + +在 OCaml 中,等价的(尾)递归代码将是: + +``` +let sum_sq n = + let rec loop i sum = + if i > n then sum + else loop (i + 1) (sum + i * i) + in loop 0 0 +``` + +``` +val sum_sq : int -> int = +``` + +在 OCaml 中产生相同结果的另一种更清晰的方法是使用高阶函数和管道操作符: + +``` +let rec ( -- ) i j = if i > j then [] else i :: i + 1 -- j +let square x = x * x +let sum = List.fold_left ( + ) 0 + +let sum_sq n = + 0 -- n (* [0;1;2;...;n] *) + |> List.map square (* [0;1;4;...;n*n] *) + |> sum (* 0+1+4+...+n*n *) +``` + +``` +val ( -- ) : int -> int -> int list = +``` + +``` +val square : int -> int = +``` + +``` +val sum : int list -> int = +``` + +``` +val sum_sq : int -> int = +``` + +函数 sum_sq 首先构造一个包含所有数字 0..n 的列表。然后它使用管道运算符 |> 将该列表传递给 List.map square ,该操作对每个元素进行平方。然后将结果列表通过 sum 进行管道传递,该操作将所有元素相加。 + +您可能考虑的其他选择有些更加丑陋: + +``` +(* Maybe worse: a lot of extra [let..in] syntax and unnecessary names to + for intermediate values we don't care about. *) +let sum_sq n = + let l = 0 -- n in + let sq_l = List.map square l in + sum sq_l + +(* Maybe worse: have to read the function applications from right to left + rather than top to bottom, and extra parentheses. *) +let sum_sq n = + sum (List.map square (0--n)) +``` + +``` +val sum_sq : int -> int = +``` + +``` +val sum_sq : int -> int = +``` + +与原始的尾递归版本相比,所有这些方法的缺点是它们浪费空间——是线性的而不是常数的——并且需要更多的时间。因此,就像在编程中经常发生的情况一样,代码的清晰度和效率之间存在权衡。 + +请注意,低效并不是由于管道操作符本身,而是由于需要构建所有那些不必要的中间列表。因此不要认为流水线处理本质上是不好的。事实上,它可以非常有用。当我们到达关于模块的章节时,我们将经常在那里学习的一些数据结构中使用它。 diff --git a/src/ocaml_programming/ch4_summary.md b/src/ocaml_programming/ch4_summary.md index ac9323c..b1aa920 100644 --- a/src/ocaml_programming/ch4_summary.md +++ b/src/ocaml_programming/ch4_summary.md @@ -1 +1,35 @@ # Summary + + +本章是本书中最重要的章节之一。它没有涵盖任何新的语言特性。相反,我们学习了如何以可能是新的、令人惊讶的或具有挑战性的方式使用一些现有特性。高阶编程和抽象原则是两个概念,将帮助您成为任何语言中更好的程序员,而不仅仅是 OCaml。当然,各种语言在支持这些概念的程度上有所不同,有些语言在编写高阶代码时提供的帮助明显较少,这也是我们在本课程中使用 OCaml 的原因之一。 + + +地图、过滤器、折叠和其他函数式正逐渐被广泛认可为优秀的计算结构化方式。部分原因在于它们将对数据结构的迭代与对每个元素进行的计算分离开来。诸如 Python、Ruby 和 Java 8 等语言现在已经支持这种迭代方式。 + +## 术语和概念 + +- Abstraction Principle 抽象原则 +- accumulator 累加器 +- apply 应用 +- associative 关联的 +- compose 组成 +- factor 因素 +- filter 过滤器 +- first-order function 一阶函数 +- fold 折叠 +- functional 功能性 +- generalized fold operation 广义折叠操作 +- higher-order function 高阶函数 +- map 地图 +- pipeline 管道 +- pipelining 流水线化 + +## 进一步阅读 + +- Introduction to Objective Caml -- Objective Caml 简介,第 3.1.3 章,第 5.3 章 +- OCaml from the Very Beginning -- OCaml 从零开始,第 6 章 +- More OCaml: Algorithms, Methods, and Diversions --《更多 OCaml:算法、方法和娱乐》,第 1 章,约翰·惠廷顿著。这本书是《OCaml from the Very Beginning -- OCaml 从零开始》的续集。 +- Real World OCaml --- 现实世界的 OCaml,第 3 章(请注意,这本书的 Core 库与标准库的 List 模块不同, map 和 fold 的类型也与我们在这里看到的不同) +- “Higher Order Functions”,《函数式编程:实践与理论》第 6 章。Bruce J. MacLennan,Addison-Wesley,1990 年。我们对高阶函数和抽象原则的讨论受到了这一章的启发。 +- “编程能否摆脱冯·诺依曼风格?一种函数式风格及其程序代数。”约翰·巴克斯(John Backus)1977 年图灵奖演讲的详细形式,作为一篇发表的[文章](https://dl.acm.org/doi/pdf/10.1145/359576.359579)。 +- 《[斯坦福哲学百科全书](http://plato.stanford.edu/entries/logic-higher-order/)》中的“二阶和高阶逻辑”。