【译】【OCaml函数式编程教程】第三章:建立数据模型

1,166 阅读11分钟

III.建立数据模型

1. 一点理论

到目前为止,我们只使用了简单的值,这些值也只使用了OCaml中基本的类型。但是当我们想要实现那些真正的程序时,我们需要更复杂的类型,而不仅仅是int或者string。例如,假使您想创建一个用于收发消息的应用程序。您可以使用简单的string来建立此消息的模型。但是如果您希望将其储存在储存器中或者对发信人的姓名进行操作时要怎么办?想要对这条消息以及其发件日期进行操作时呢?

当由于这些信息量的增加使得情况变得复杂了起来,我们不再能使用这些简单的类型建立模型。如果想要编写一个将这十几个变量当做参数的函数的话,又并不是很容易实现。尤其是当你想再新增一项时,这会使得我们要在全文中重新添加一个变量。

幸运的是,OCaml允许我们创建自己的数据类型。在本教程总,我们会学到四种方式来创建新类型:

  • 同义类型;
  • 乘积类型;
  • 加法类型;
  • 结构类型(作为与原课程相比的附加项);

2. 同义类型

创建我们自己的类型的第一种方法就是创建同义类型。目标就是为已经存在的类型创建一个“别称”。语法如下:

type NAME = TYPE

NAME就是所取别称的名字,TYPE就是类型。在OCaml中,类型名称只能是小写字母,如果需要分隔不同的词,可以使用_将其分隔(我们会写成my_type,而不是myType)。 您可以使用原始类型来运行同样的函数以及运算符,这两个类型是可以相互替换的。

这种类型的意义虽然看起来并不大,但是这类同源类型可以使得我们的代码相较于同样的类型来说更易于阅读,您可以为它们赋予另外的意义。 比较下面两个例子:

(* 没有使用同义类型 *)

let s (d : float) (t : float) : float = d /. t
(* 函数的类型为 float -> float -> float *)
(* 我们从定义同义类型开始 *)
type speed = float
type time = float
type distance = float

(* 然后将它们运用于函数中 *)
let s (d : distance) (t : time) : speed = d /. t
(* 这个函数的类型为 distance -> time -> speed, 这样会清楚很多 *)

1628774438486.png

有了同义类型,我们弄混和颠倒值的可能性就会减少。

但这显然不是我们最感兴趣的创建新类型的方式,同样也不是最实用的方式。

3. 乘积类型

我们现在来看另一种类型的类型 (🤔️) :乘积类型。 我们也称之为多元组或元组(这是在Python中的名字)。我们只需要将数个不同类型的值组合在一起。通过类比数学中的集合的乘积,它们被称为乘积类型。

为了定义一个乘积类型,我们用*来分隔组合中不同类型的值。比如,如果我们要建立一天中时刻的模型,我们需要用到三个int(一个对应小时,一个对应分钟,一个对应秒),我们写做:

int*int*int

一般来说,为了更方便的使用新定义的类型,我们会为这些类型定义一个同义词。

type time = int * int * int

为了创建这个类型的值,我们使用括号将其组合并用逗号来分隔(和在Python中一样):

type time = int * int * int

(* 这个常数的类型为time,也就是int * int * int,这两种是等价的 *)
let end_of_class = (16, 45, 0)

除了构建这种类型的值,您还可以“解构”它们(或“分解”它们,取决于您的精密程度)。为了实现这一点,我们使用“复合”let,以及元组中不同的元素:

(* let end_of_class的类型为 int * int * int  *)

let (hours, minutes, seconds) = end_of_class
(* hours,minutes,seconds为三个新的常数,类型为int *)

另请注意,在构造或解构这些类型时,括号实际上是可选的。但是通常情况下为了更清楚以及避免避免歧义,我们会加上括号。于我们以及于OCaml的编译器而言,都会更好一些。

解构方法也可以直接在函数的参数中使用:

let add_a_hour (hour, minutes, seconds: time) = (hour + 1, minutes, seconds)

我们也可以在模式匹配中使用解构的方法:

let meal (hour: time): string =
  match hour with
  | (7, _, _) | (8, _, _) -> "Breakfast"
  | (12, 0, 0) -> "Lunch"
  | (16, 30, 0) -> "Snack"
  | (19, 30, 0) -> "Dinner"
  | (h, _, _) -> (string_of_int h) ^ "hours? It's not time to eat!"

同样的,在比较中该方法也行得通。我们可以在if中使用带有乘积类型的值:

(* 一组姓名+密码 *)
type identifiers = string * string

let message_secret id =
  if id = ("Philippe Poutou", "ilovecreps") then
    "Hello comrade Poutou !"
  else
    "Incorrect name or password."

4. 加法类型

毫无疑问,在OCaml中用这种方式来定义类型是最实用也是最常见的,在函数式编程中也是如此。这次,我们会使用不同的形式来描述类型。 我们将其值限制在一个有限合集中,每一个值都有不同的含义。这些构建不同类型的方法被称为constructor

定义加法类型的一般语法如下:

type NAME = CONSTRUCTORS_1 |CONSTRUCTORS_2

我们使用|来分隔不同的constructor。这里,我只例举了两个,但是我们可以想写多少就写多少。它们的名字要使用大写字母开头,同时我们也会使用大写字母来分隔constructor名字中不同的词:比如我们写做MyConstructor,而不是My_constructor

我们来看一个小例子:

type size = Small | Medium | Large
type garnish = Vegetarian | Chicken | Beef
type sauce = Cheese | Algerian | Curry | Harissa | Samurai | Blanche

type tacos = size * garnish * sauce

这里我们定义了三个加法类型:sizegarnish以及saucetacos是乘积函数)。 加法类型的优点是,我们不可能创建除了已定义的constructor以外的值:用以上我们已定义的类型,我们不可能拥有一个‘XXL’号的tacos1

为了创建这个类型的一个值,我们使用其中一个builder的名字;

let my_sauce_favorite = Curry

我们也可以在枚举类型的值中使用匹配模式:

let meat_price (m: meat): float =
  match m with
  | Vegetarian -> 4.5
  | Chicken -> 5.0
  | Beef -> 6.0

在等式中也同样适用,因此我们可以使用if/else

let price_size (s: size): float =
  if size = Small then
    4.0
  else if size = Medium then
    5.0
  else
    5.5

需要注意的是,我们没有规定每个类型的定义必须单独一行。但是如果有很多的constructor的话,还是建议在每个|前换行:

type region =
  | AuvergneRhoneAlpes
  | Bretagne
  | GrandEst
  | HautsDeFrance
  | IleDeFrance
  | NouvelleAquitaine
  | Occitanie
  | ProvenceAlpesCoteDAzur
(* 以及等等,我不知道所以的地名,但是你们理解就好 *)

关联数据和constructor

我们还可以选择将信息和constructor关联在一起。为了能够更精确描述信息们的类型,我们在constructor的名字后使用关键词ofof后面可以接我们的目标类型。如果要使用这个constructor创建新的值的话,我们可以在constructor的名字后面加上括号,并在括号中指出与之关联的值。

举个例子,如果我们想要为一个可能会出错的的函数的结果建立枚举类型,我们可以这样做:

type resultat =
  | Ok of float (* 我们将float与builder OK关联 *)
  | Error

(* 我们可以使用这个类型来实现一个不会崩溃的除法函数。
 * 如果出现错误,它将会返回Error,处理除法失败的情况也是必要的 *)
let div (x : float) (d : float) : resultat =
  if d = 0.0 then
    Error (* Division by 0 impossible *)
  else
    Ok(x /. d) (* 就是在这里使用与数据关联的builder *)

我们同样也可以将这类constructor的值进行解构,因此我们可以使用匹配模式:

let my_division = div 5. 2.

(* my_division的类型为resultat, 所以我们要通过操作才能得到它含有的值。
 * 我们已经考虑到了错误的情况,因此我们可以确定我们的程序不会崩溃。 *)
match my_division with
| Ok(res) -> "5除以2会得到: " ^ (string_of_float res)
| Erreur -> "我们没有办法实现这个除法!"

我们与constructor关联的类型当然可以更复杂:我们可以使用加法类型、乘积类型等类型。

加法类型、枚举类型以及代数类型间的不同

加法类型有时也称为枚举类型或代数类型。在实际情况中并没有太大区别,但如果在INF201的测验中被问到了这样的问题,您需要知道:

  • 如果您的类型只是“简单”constructor(没有关联值),则它是枚举类型;
  • 如果它至少有一个带有关联值的constructor,则它是加法类型。

5. 附加:结构类型

这种创建类型的方法并不在原本的课程范围内,但这依然是一种有效的方法。

结构类型看起来与乘积类型很相似,因为这两种方法都是将数个值组合在一起。区别在于, 我们给每个值一个名称,以便能够更容易地识别它们。我们依然是在关键词type后面加上我们想要赋予它的名字以及一个等号。

然后,我们使用一个大括号,之后就可以赋予不同的值(也称作“字段”)名字以及类型来定义它们。我们可以使用:来分隔名字和类型,使用分号来分隔不同的值。

我们用建立联系方式的模型(比如说是一个消息应用)来举个例子:

type contact = {
  name : string;
  firstname : string;
  age : int;
  telephone : int * int * int * int * int; (* 五个分隔开的数字比起一长串数字更易于阅读 *)
  email : string;
}

为了创建这种类型的值,我们使用一个大括号,并在字段的名称和值之间输入一个等于号。之后我们要在每一个定义过的名称之后写入数据。

举个例子:

let manon = {
  name = "Sélon" ;
  firstname = "Manon" ;
  age = 18 ;
  telephone = (06, 06, 66, 66, 06) ; (* 这个是假的,不要给这个号打电话,谢谢 *)
  email = "manon.selon@gmail.com" ;
}

我们同样可以以这种方式来解构结构类型的值:

let { name; firstname; age  } = manon
(* 我们现在有三个新的常数:name,firstname以及age *)

值得注意的是,在结构过程中我们不必列出目标字段中所有的值。

我们还可以使用其它的名字,语句如下:

let { name = name_of_manon ; age = age_of_manon } = manon
(* 我们得到两个新的常量:nom_of_manon以及age_of_manon *)

由于我们可以解构这些值,同样地我们也可以匹配它们:

let iscalled_manon (cont : contact) : bool =
  match cont with
  | { firstname = "Manon" } -> true
  | _ -> false

我们也可以使用VALEUR.CHAMP单独读取一个字段中的值:

let full_name (cont : contact) : string =
  cont.firstname ^ " " ^ cont.name

6. 课后练习

这里有一些练习来验证你是否已经理解了类型这一章节。

我们定义了这些类型:

(* 我们建立了课程的模型 *)

type ue =
  | Inf201
  | Inf203
  | Mat201
  | Mat203
  | Phy201
  | Phy202
  | break

type schedule = int * int (* 小时和分钟 *)
type course = schedule * schedule * ue (* 开始时间、结束时间以及课程UE *)

以下表达式的类型是什么(只接受最“简单”的版本)?

MAT201
<答案点我>
    ue
((13, 30), (15, 00), Inf201)
<答案点我>
    course
(13, 30, 00)
<答案点我>
    int*int*int
'Z'
<答案点我>
    char
(15,32)
<答案点我>
    int*int 或 schedule

为了是代码正确运行,_____中要填什么?

type tram = A | B | C | D | E
type transport =
  | Bus of int (* 线路编号 *)
  | Tram of ______
  | ______
  | ______

let name_tram (t : tram) : string =
  match t with
  | A -> "A"
  | B -> "B"
  | C -> "C"
  | D -> "D"
  | E -> "E"

let name_transport (tra : transport) : string =
  match tra with
  | Bus(line) -> "Line " ^ (string_of_int line) ^ " of bus"
  | Tram(t) -> "Tram " ^ (name_tram t)
  | Car -> "Car"
  | Bicycle -> "Bicycle"
<答案点我>
    tram

然后是

<答案点我>
    Car

最后是

<答案点我>
    Bicycle

我们现在要为音乐收藏建立模型。为此,我们要创建以下类型:musicgenrealbumtype_album(单曲、EP 或专辑)。你可以做任何你想做的,但是要做尽可能完善的种类。如果需要,您还可以添加更多类型(例如artist)。

<参考答案>
type genre =
  | Classic
  | Electro
  | KPop
  | Pop
  | Rap
  | RnB
  | Rock

type artist = string (* 歌手的名字 *)

type type_album = Single | EP | Album

type album = artiste * type_album * string (* string为专辑名称 *)

type music = string * genre * album * artist

(* 如果你想的话,我们同样可以使用结构类型来定义专辑和音乐 : *)
type album = {
  alb : album;
  alb_type : type_album;
  name : string;
}

type music = {
  title : string;
  genre : genre;
  alb : album;
  art : artiste;
}

我建议您使用函数来继续操作这些类型。

  • same_album:两首音乐在同一张专辑中。
  • same_genre:两首音乐属于同一种类。
  • long_title:如果标题很长(超过20个字母)。我们可以使用String.length : string -> int函数。
<参考答案>
let same_album (a : music) (b : music) : bool =
  let (_, _, alb_a, _) = a in
  let (_, _, alb_b, _) = b in
  alb_a = alb_b

let same_genre (a : music) (b : music) : bool =
  let (_, genre_a, _, _) = a in
  let (_, genre_b, _, _) = b in
  genre_a = genre_b

let long_title (m : music) : bool =
  let (title, _, _, _) = m in
  (String.length title) > 20

(* 如果您使用了结构类型,可以这样做: *)
let same_album (a : music) (b : music) : bool =
  a.alb = b.alb

let same_genre (a : music) (b : music) : bool =
  a.genre = b.genre

let long_title (m : music) : bool =
  (String.length m.title) > 20

Footnotes

  1. 遗憾的是,或许我们应该修改一下size的定义...