Logo
Section 1 / 5

将数据对象分组为元组

学习如何将数据对象分组为成对和元组,并使用模式匹配对其进行解构。

导入

将数据对象分组为元组

学习如何将数据对象分组为成对和元组,并使用模式匹配对其进行解构。

像整数、浮点数、布尔值和字符串这样的原始数据类型虽然有用,但通常不足以满足需求。然而,在建模现实世界的现象时,我们常常需要将多个数据组合起来形成复合数据。以开发一个二维图形软件包为例:要表示一个点,我们可以使用两个独立的 float 数值来表示其 x 和 y 坐标。但这会变得混乱,因为我们需要手动跟踪每个坐标属于哪个点。更好的做法是将 x 和 y 坐标组合成一个复合数据对象(如一对),并将点视为一个整体概念。

...
二维平面上的点

构造元组

OCaml 以及许多其他函数式编程语言提供了元组类型,用于将多个数据对象组合成一个称为 元组 的复合数据对象。
我们通过用逗号分隔并用括号包围值来构造元组。例如,可以创建一个包含两个元素的元组,即一对:

1(42, "Hi FP")

一对中元素的顺序至关重要。例如,("Hi FP", 42) 是一个完全不同的元组。此外,如本例所示,一对中的元素可以是不同类型。在我们的例子中,utop 显示 (42, "Hi FP") 的类型为 int * string。
一般而言,第一个元素类型为 t1、第二个元素类型为 t2 的元组类型表示为 t1 * t2(读作“t1 乘 t2”)。

元组具有固定数量的元素,因此特别适用于那些预先知道要存储元素个数的场景。例如,一对非常适合表示二维点,因为我们需要 x 和 y 两个坐标。我们可以如下定义一个点,其 x 坐标为 2.0,y 坐标为 1.0:

1let p = (2., 1.)

其他可以用一对表示的概念包括:有理数 p/q 和复数 a + bi,其中 a 是实部,b 是虚部。

当然,我们也可以在元组中嵌套元组。例如,可以构造一个第二元素本身也是一个元组的对:

1(1, (2, "FP"))

函数式编程语言 Scheme 利用这一特性,将列表表示为嵌套对的链表。例如,序列 1, 2, 3, 4 在 Scheme 中可表示为 (1, (2, (3, (4, nil)))),其中 nil 表示空列表。而在 OCaml 和 Haskell 中,列表是通过代数数据类型定义的。

元组在需要从函数返回多个值时尤其方便。例如,在进行整数除法时,同时获取商和余数非常有用。我们可以编写一个 div_mod 函数,实现两个整数的整数除法,并将商和余数打包成一对返回。

1(** [print_int_pair (x, y)] 将一对整数打印到控制台。 *) 2let print_int_pair (x, y) = Printf.printf "(%d, %d)" x y 3 4(** [div_mod x y] 返回 x 除以 y 的商和余数组成的配对。 *) 5let div_mod x y = (x / y, x mod y) 6 7(** 打印 [div_mod 5 2] 的结果 *) 8let _ = print_int_pair (div_mod 5 2)

使用模式匹配解构元组

为了让元组真正有用,我们需要能够访问其中的元素。假设我们要定义一个函数 translate_point,它接收一个以配对形式表示的点 (x, y),以及两个平移距离 dx 和 dy。该函数应返回一个新的点,其坐标等于输入点经过平移后的坐标。

...
平移二维点

许多函数式编程语言(包括 OCaml 和 Haskell)提供了一种强大的机制——模式匹配,用于将复合数据分解为其组成部分。OCaml 使用 match 关键字支持模式匹配,我们可以用它来实现 translate_point:

1(** [print_float_pair (x, y)] 将一对浮点数打印到控制台。 *) 2let print_float_pair (x, y) = Printf.printf "(%f, %f)" x y 3 4(** [translate_point p dx dy] 将点 p 沿 dx 和 dy 方向平移。 *) 5let translate_point p dx dy = match p with 6 | (x, y) -> (x +. dx, y +. dy) 7 8(** 打印 [translate_point (2., 1.) 2. 1.] 的结果 *) 9let _ = print_float_pair (translate_point (2., 1.) 2. 1.)

注意: match 将输入点 p 解构为 (x, y),并将坐标值绑定到变量 x 和 y。这使得我们在实现中可以直接访问 x 和 y 坐标。

再看另一个例子:实现 distance_point 函数,计算两点之间的距离。我们知道,两点 (x1,y1)(x_1, y_1) 与 (x2,y2)(x_2, y_2) 之间的距离由公式 (x1−x2)2+(y1−y2)2\sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} 给出。

在 OCaml 中,我们可以使用两次模式匹配来实现 distance_point:

1(** [distance_point p1 p2] 返回两点 p1 和 p2 之间的距离。 *) 2let distance_point p1 p2 = match p1 with 3 | (x1, y1) -> match p2 with 4 | (x2, y2) -> sqrt ((x1 -. x2) ** 2. +. (y1 -. y2) ** 2.);; 5 6(** 打印 [distance_point (2., 1.) (4., 2.)] 的结果 *) 7let _ = print_float (distance_point (2., 1.) (4., 2.))

两次嵌套的 match 模式略显繁琐且不易阅读。幸运的是,OCaml 允许我们一次性进行两个模式匹配。

1let distance_point p1 p2 = match p1, p2 with 2 | (x1, y1), (x2, y2) -> sqrt ((x1 -. x2) ** 2. +. (y1 -. y2) ** 2.)

由于模式匹配非常常用,OCaml 还为此提供了语法糖,允许在 let 绑定或 fun 定义中直接使用。特别是,我们可以非常简洁地定义 distance_point:

1let distance_point (x1, y1) (x2, y2) = sqrt ((x1 -. x2) ** 2. +. (y1 -. y2) ** 2.)

我们还可以改进前面讨论的 translate_point 函数,直接在参数中使用模式匹配来解构点:

1let translate_point (x, y) dx dy = (x +. dx, y +. dy)

欢迎使用上方的代码沙箱尝试这些替代实现。

函数式编程中数据是不可变的

函数式编程的一个关键特征是数据是不可变的。这意味着一旦创建了一个数据对象,就无法更改它。这与命令式编程范式中的可变数据形成对比,后者允许在数据创建后修改其状态。

OCaml 的元组是不可变的。这一点在上一节的 translate_point 函数中体现得非常明显:

1let translate_point (x, y) dx dy = (dx +. x, dy +. y)

当我们平移一个点时,并不会调用某个方法或类似操作来更新其坐标值。事实上,我们根本无法这样做,因为 (x, y) 这对元组是不可变的,不能被修改。相反,我们构造了一个全新的点,其坐标由原点计算得出。

不可变数据有许多优点。首先,它让程序更易于理解:当我们看到一个不可变的数据对象时,可以立即知道它包含什么内容。而可变数据则不然,因为它的状态可能随时间改变。为了推导出可变数据的当前值,我们必须追溯其全部变更历史。另一个优点是线程安全。

不可变数据导致了一种典型的编程风格:函数从输入数据创建全新的数据。这初看起来可能有些别扭,但一旦习惯这种风格,就能充分体会到其所有优势,以及使用起来的愉悦感。

用函数构建抽象

使用列表存储数据序列