第 1 次课:OCaml 语法介绍
本学期我们将使用 Objective Caml(OCaml)编程语言。OCaml 是一种函数式语言,而不是命令式语言;这两类语言之间的关键区别在于执行模型—程序执行的方式。命令式(或过程式)语言,如 C 和 Java,基于改变执行机器状态的命令。例如,赋值语句(在 C 和 Java 中)明确地改变了某个内存位置存储的值。相比之下,函数式语言基于评估表达式以产生值。OCaml 提供了构建正确、可理解和大型表达式的良好方法。本讲座的重点是理解表达式。
对于实际应用,使用表达式进行编程可能看起来有些牵强。毕竟,应用通常不会“计算”任何东西;相反,它们让你可以“做”某事。在课程的后期,我们将介绍副作用的概念:在评估这个时,同时做那个—例如,评估一个表达式,并(作为副作用)在屏幕上显示一个窗口。但目前,我们将生活在一个纯净的世界中,一个没有副作用的世界。
要开始学习 OCaml,您应该通过编写玩具表达式来开始使用 OCaml 语言。与大多数编程语言一样,Ocaml 有一个编译器,可以在源文件上运行以生成目标文件。但要真正理解表达式,有一种更好的与 OCaml 编译器交互的方法,称为OCaml 顶层系统。这个系统有点像一个非常复杂的计算器—您通过键入表达式与它交互,它通过评估该表达式并告诉您结果值来回应。
重要提示:在以前的学期中,这门课程是用 SML 教授的,这是一种与 OCaml 非常相似的语言。它们有共同的起源和几乎所有的语法。为了帮助理解以前年份的笔记,您可能希望查阅SML vs. OCaml页面,该页面提供了两者之间差异的详细列表。
如果您在笔记中注意到任何 SML/OCaml 的差异,请告诉我们。
启动 OCaml 顶层系统。 课程资源页面上有安装 OCaml 的说明。安装好 OCaml 后,您可以通过在终端(即 Unix shell 或 Windows 控制台)中键入ocaml
来启动交互式会话。(3110 虚拟机上安装了名为utop
的增强版本的ocaml
。)您将获得一个顶层提示符“#”,表示编译器已准备好接受表达式并评估它们。要退出顶层系统,请输入您平台的文件结束字符(Unix 上为 Ctrl-D,Windows 上为 Ctrl-Z + Return)。
使用 OCaml 顶层系统。 输入一个表达式(可能跨多行,之后第一行之后将有一个次级提示符“=”),然后键入 ;;
并按 Enter 键。注意,;;
不是要评估的表达式的一部分。它只是向编译器指示你已经完成了输入表达式的工作,并且你已经准备好进行评估。在评估表达式之前,编译器将对其进行类型检查。
提示:顶层系统在读取输入时对某些事情非常挑剔,只有当它确切地位于顶层提示符处时才会接受退出请求——即,如果没有其他内容已经输入。当存在疑问时(特别是如果输入似乎表现得很奇怪),按下 Ctrl-C 以中断并将提示重置为正常状态可能是有用的。
提示:将表达式放入文件中可能会很有用,这样你就不必一遍又一遍地输入它们。只需使用编辑器编辑文件,然后你就可以使用操作 #use "file";;
将其加载到 OCaml 中,该操作的行为就好像这些表达式已经输入到了顶层提示符(别忘了分号!)。关键问题是文件应该存储在哪里。操作 #use
默认在当前工作目录中查找文件。要更改当前工作目录,请使用操作 #cd "*path*";;
其中 path 是你想要进入的路径。要将目录添加到 OCaml 查找文件的目录列表中,请使用操作 #directory "*path*";;
。使用 Unix 约定(即使在 Windows 上):路径分隔符是“/”。
提示:更多关于顶层系统的详细信息可以在OCaml 手册页面找到。
基本表达式和类型
表达式求值为 值。值可以根据其类型进行分类:
类型 | 示例值 |
---|---|
整数 | 0 ,1 ,2 ,-1 ,-2 |
布尔 | true ,false |
浮点数 | 3.141592, 1.618034 |
字符串 | "Hello world!","xyzzy" |
字符 | 'A','Z' |
让我们开始看一下简单表达式,具体语法如下:
e ::= c | unop**e | e1binope2 | ife1thene2elsee3 | (e)
其中 c 是常量(上述值描述的值),unop 是一元操作,binop 是二元操作。我们使用 巴科斯-诺尔 形式(BNF),一种计算机语言语法的常见符号,来表示这个语法。**注意给教师:**表达式的 BNF,以及后来的声明的 BNF,应该放在黑板的一侧,并且在本节进行时应该一直保留在那里,因为我们将随着本节的进行添加表达式类型和声明类型。
一元操作符包括:
操作符 | 含义 |
---|---|
- | 取一个 int(或一个 float)并返回其负值 |
not | 取一个布尔值并返回其否定 |
二元操作符包括:
操作符 | 含义 |
---|---|
+ ,- ,* , / | 取两个整数并返回它们的和、差、积或商 |
+. ,-. ,*. , /. | 接受两个浮点数并返回它们的和、差、积或商 |
mod | 接受两个整数并返回它们的整数余数 |
> ,>= ,< ,<= | 接受两个整数(或两个浮点数)并返回它们的比较结果 |
= | 接受两个值并返回它们是否相等 |
^ | 接受两个字符串并返回它们的连接成一个新字符串 |
表达式通过求值规则转换为值。简单表达式的求值规则为:
-
常量已经是值了。
-
一元和二元运算符首先将它们的参数求值为值,然后执行操作。
-
条件语句 if e1 then e2 else e3 将 e1 评估为一个值(类型为 bool):如果它是
true
,则评估 e2,如果它是false
,则评估 e3。
只有当操作数的类型一致时,操作符的求值才有意义。例如,+
操作符在两个操作数都是整数时被定义,但是将整数加到布尔值上是没有意义的。因此,进行类型检查以确保表达式有意义,这需要给每个表达式指定一个类型。OCaml 如何确定表达式的类型?
每个常量都有一个类型(42
的类型为 int
,true
的类型为 bool
,等等)。运算符也有类型(我们以上给出的是非正式的);在 OCaml 语法中,这些类型如下:
- : int -> int (* or *) float -> float not : bool -> bool + : int -> int -> int +. : float -> float -> float > : int -> int -> bool (* or *) float -> float -> bool ^ : string -> string -> string
每个操作符都指定它接受的值的类型以及它返回的值的类型。加法操作符 +
接受一个 int
,另一个 int
,然后返回一个 int
。
现在我们可以给出确定表达式类型的规则。这些规则体现的原则是:
表达式的类型总是其结果的类型。
-
对于表达式 unop e,如果 e 的类型为 t1,并且 unop 的类型为 t1
->
t2,那么 unop e 的类型为 t2。 -
对于表达式 e1 binop e2,如果 e1 的类型为 t1,并且 e2 的类型为 t2,并且 binop 的类型为 t1
->
t2->
t3,那么 e1 binop e2 的类型为 t3。 -
对于条件语句 if e1 then e2 else e3,如果 e1 的类型为
bool
,并且 e2 和 e3 都具有相同的类型 t,那么条件语句本身的类型为 t。- 为什么 e2 和 e3 必须具有相同的类型?从编译器的角度来看,任一分支都可能被执行。如果执行 then 分支,则结果值的类型将与 e2 的类型相同。同样,else 分支产生的值与 e3 的类型相同。因此,条件语句的类型必须以某种方式概括 e2 和 e3 的类型。确保这种可能性的一种安全方式是要求两个分支返回相同的类型。
如果表达式不满足这些规则中的条件,则无法为该表达式指定类型。它不会通过类型检查,编译器会将其拒绝作为程序。这可以防止对无意义表达式的求值。这样做很重要,因为这种编程错误可能会导致运行时错误。
声明
值可以被命名。这不是一种表达形式,而是向编译器指示您正在声明一个新名称。
d ::= letid = e
例如,让pi = 3.141592
是一个声明。声明告诉编译器评估表达式e,生成一个值,并将该值绑定到名称id。在后续表达式中,id可以用来引用该值。因此,我们扩展了表达式的语法:
e ::= … | id
评估id意味着查找它绑定的值是什么。id的类型是它绑定的值的类型。
声明持续到遇到同名的另一个声明(遮蔽—即替换—先前的声明)。我们还可以引入仅在表达式评估期间持续的局部声明。
e ::= … | let dine
要评估 let 表达式,首先评估声明d,然后评估e,考虑声明中的绑定。
问题:letdine的类型是什么?答案:e的类型,因为表达式的类型是结果的类型。
在这一点上,我们可以编写大型表达式,但不能轻松地重用表达式。因此,我们引入函数。函数声明是一种新的声明形式:
d ::= … | let id ((x1:t1), …, (xn:tn)):t = e
在这种新形式的声明中,e被称为函数id的主体。例如,let square (x:int) : int = x * x
是一个函数定义,其主体是x * x
。函数的类型类似于运算符的类型。例如,square
的类型是int -> int
。
这里有一个带有两个参数的函数示例:
let max2 ((a:int),(b:int)):int = if a>b then a else b
其类型是(int * int) -> int
。参数类型由*
分隔。请注意,在此语法中,*
不表示整数乘法。
提示:不一定需要为函数注释类型。大多数情况下,OCaml 可以从函数的定义中推断出函数的类型。例如,我们可以将max2
重写如下:
let max2 (a,b) = if a>b then a else b
请注意,我们不必在参数周围写内部括号,因为我们省略了它们的类型注释。OCaml 为max2
推断的类型现在变得相当神秘:'a * 'a -> 'a
。暂时将'a
视为表示“任何类型”。由于int
是任何类型的一个示例,我们仍然可以在int
上使用max2
。
陷阱:正如您很快会注意到的,调试一个无法通过类型检查的程序可能非常困难。很难弄清楚为什么编译器为那个表达式推断出这个类型。解决方法:使用类型注释来捕捉类型错误!
超级提示:始终为函数注释类型是非常好的做法。我们通常要求您在编程作业中这样做。
我们甚至可以有一个带有三个参数的函数:
let max3 ((a:int),(b:int),(c:int)):int = max2(max2(a,b),c)
其类型是(int * int * int) -> int
。
你可能会想知道为什么接受两个参数的内置运算符的类型,比如+
,看起来和max2
的类型不太一样。我们稍后会讨论这个原因(大约两节课后)。
对于递归函数,需要使用let rec
:
d ::= … | `let rec id ((x1:t1), …, (xn:tn)):t = e
这与普通函数声明相同,但在函数体内使用的任何 id 都将绑定到函数本身的值。
我们如何使用函数?我们引入一个新的表达式称为 函数应用:
e ::= … | id (e1,…,em)
我们如何对函数应用 f (e1,…,em) 进行类型检查?如下所示:
-
如果 f 的类型是 (t1 * … * tn)
->
t,并且 -
如果传递给函数的参数数量与函数期望的参数数量相同,即 m 等于 n,并且
-
如果 e1 的类型是 t1,e2 的类型是 t2,依此类推,en 的类型是 tn,
-
那么 f (e1,…,em) 的类型是 t。
因此 square (10)
类型检查通过,但 square (true)
不通过。
如何评估函数应用 f (e1,…,em)?评估 e1,…,em 到值 v1,…,vm,然后用 x1 绑定到 v1,…,xm 绑定到 vm 来评估 f 的主体。所以 square (10+10) --> square (20) --> 20*20 --> 400
。一周后我们会看到更多关于评估的细节。现在,我们只想给你一个关于简单情况的直觉。
函数绑定可以在 let 表达式中使用,以获得一个局部函数(类似于 Pascal,不同于 C)。例如:
let fourth (y:int) : int = let square (x:int):int = x * x in square (square (y))
第二讲:OCaml 程序的语法和评估
主题摘要:
-
OCaml 语法
-
表达式、项、类型和值
-
错误
-
评估和重写规则
-
命名空间和作用域
-
限定标识符和库
OCaml 语法
在上一次讲座中,您应该已经看到了 OCaml 的一些简单表达式和声明形式。这个语言片段的语法可以总结如下:
语法类 | 语法变量和语法规则 | 示例 |
---|---|---|
标识符 | x, f | a , x , y , x_y , foo1000 , … |
| 常量 | c | …-2
, -1
, 0
, 1
, 2
(整数)1.0
, -0.001
, 3.141
(浮点数)
true
, false
(布尔值)
"hello"
, ""
, "!"
(字符串)
'A'
, ' '
, '\n'
(字符) |
一元运算符 | u | - , not |
---|---|---|
二元运算符 | b | + , * , - , > , < , >= , <= , ^ , != , … |
项 | e ::= x | c * | u e | e[1] b e*[2] |if * e then e* else * e | let d[1] and …and d[n]in e | e ( e[1], …, e[n]*) | foo , -0.001 , not b , 2+2 |
声明 | d ::= *x = e | * * f *( x[1], …, x[n]): t = e | one = 1 square(x:int):int = x*x |
类型 | t ::= int | float | bool | string | char | t[1]* …* t[n]-> t | int , string , int->int , bool*int->bool |
一个 OCaml 程序,像任何其他语言一样,由各种 表达式 组成。上表描述了如何构造其中一些表达式。也就是说,它指定了 OCaml 的一些语法。其中一些表达式,比如标识符、常量和运算符,我们仅通过示例来描述。这些表达式都是单个 标记。其他表达式,比如项、声明和类型,由 语法规则 描述。这些规则以称为 BNF(巴科斯-瑙尔形式)的形式编写。每个规则描述了构建特定类型的表达式的各种方法,用竖线分隔。例如,一个项可以是一个标识符、一个常量、任何一元运算符 u 后跟任何表达式 e(u e)、任何两个项 e[1] 和 e[2] 之间用任何二元运算符 b 分隔,等等。注意,我们使用字母 u 表示任何一元运算符,字母 e 表示任何项。这些都是 语法变量 或 元变量 的示例。语法变量不是 OCaml 程序变量;它只是某种语法结构的通用名称。例如,x 可以是任何标识符,e 可以是任何表达式。我们有时在语法变量上加上下标以帮助我们将它们区分开(如上所示),但这并非必需。
OCaml 解释器允许在提示符处输入项或声明。我们可以将程序看作仅仅是一个 OCaml 表达式,尽管后面我们会发现它更复杂。
程序错误
仅仅因为一个表达式具有合法的语法并不意味着它是合法的;这个表达式还必须是类型正确的。也就是说,它必须仅使用与其类型相符的表达式。我们稍后会更详细地看一下表达式是什么意思。通常,把类型想象成一组可能的值(通常是无限集)是很有用的。我们将看到 OCaml 有一个功能强大、富有表现力的类型系统。
更一般地说,OCaml 中的表达式可以出错的方式有很多,就像在英语中一样:
-
语法错误:
let 0 x =
;“斯波特跑看见” -
类型错误:
"x" + 3
;“看到斯波特跑了” -
语义错误:1/0;“无色绿色的想法狂暴地睡觉”(良好的语法,不连贯的语义)
-
更一般的错误:正确计算错误答案的 OCaml 程序,“警官,您不敢给我开罚单!”
现在,我们如何编写表达式和声明呢?这里是一个简单函数的声明,它计算给定整数的绝对值:
let abs (x : int) : int =
if x < 0 then -x else x
或者,也可以这样写
let abs : int -> int =
function x -> if x < 0 then -x else x
或者更简洁地说,
let abs = fun x -> if x < 0 then -x else x
每个表达式和声明都有一个类型和一个值。当你在 OCaml 顶级环境中键入一个表达式或声明时,它会报告表达式的类型和值。如果我们在 OCaml 提示符下键入abs
的定义,然后跟着;;
,告诉 OCaml 解释器现在应该评估这个表达式,它会回应
*val abs : int -> int = <fun>*
这意味着我们刚刚将名字abs
绑定到一个类型为int -> int
的函数上。
示例
这是一个确定其参数是否为素数的函数。这个函数的类型是int -> bool
。
Turn on Javascript to see the program.
关于这个程序有几点需要注意。首先,注意到递归辅助函数noDivisors
的使用,它被声明在函数isPrime
内部。这个函数使用let rec
来定义,因为它是递归的。在命令式语言中,这个函数会使用循环来写,但是使用一个恰当命名的辅助函数比通用的循环更易读。声明的作用域是声明本身的主体和跟在in
后面的表达式;它在其他地方是不可用的。
这是一个找到给定浮点数的平方根近似值的函数。它基于这样一个事实:对于任何正数x和g,数字g和x/g位于 sqrt(x)的两侧。这是因为它们的乘积是x。
Turn on Javascript to see the program.
这个示例展示了很多东西。首先,你可以声明局部变量,比如delta
和局部函数,比如goodEnough
、improve
和tryGuess
。注意,“内部”函数(比如improve
)可以引用“外部”变量(比如x
)。还要注意,后面的声明可以引用前面的声明。例如,tryGuess
同时引用了goodEnough
和improve
。实际上,后面的声明都在前面的in
表达式内部。
如果您将上面的squareRoot
声明键入 OCaml 顶级,它会响应:
*val squareRoot : float -> float* = <fun>
表示您声明了一个变量(squareRoot
),其值是一个函数(<fun>
),其类型是从浮点到浮点的函数。所有函数定义的内部结构都是隐藏的;从外部来看,我们只知道它的值是一个简单的函数float -> float
。特别地,函数tryGuess
在squareRoot
之外没有定义:
# tryGuess;;
*Characters 0-8: tryGuess;; ^^^^^^^^ Error: Unbound value tryGuess*
在键入函数后,您可能会尝试在浮点数(如 9.0)上使用它:
# squareRoot 9.0;;
*- : float = 3.0000000013969839*
OCaml 已经评估了表达式squareRoot 9.0
并打印了其值(3.0000000013969839
)和其类型(float
)。
目前,我们对将此表达式键入 OCaml 时确切发生了什么有一个不精确的概念。我们很快将有更精确的理解。
如果您尝试将squareRoot
应用于没有浮点类型的表达式(例如整数或布尔值),那么您将收到一个类型错误:
# squareRoot 9;;
*Characters 11-12: squareRoot 9;; ^ Error: This expression has type int but is here used with type float*
在表达式中使用插入符号(^^^
)来指示错误表达式。
限定标识符和库
限定标识符的形式为x.y,其中x是一个模块标识符。示例包括String.length
、List.map
和String.sub
。与 Java 中的包和类一样,在 OCaml 中,限定标识符允许一组名称被分组在一个单独的代码模块中。
评估
OCaml 提示符允许您键入一个术语或将变量绑定到一个术语的声明。它评估术语以产生一个值:一个不需要进一步评估的术语。我们也可以将值v定义为一个语法类。目前,我们可以认为值与常数相同,尽管我们将看到它们有更多的内容。
运行 OCaml 程序只是评估一个术语。当我们评估一个术语时会发生什么?在一种命令式(非函数式)语言中,比如 Java,有时我们会想象有一个正在执行的“当前语句”的概念。这对于 OCaml 来说不是一个很好的模型;最好将 OCaml 程序视为以与评估数学表达式相同的方式进行评估。例如,如果你看到一个表达式像(1+2)3,你知道你首先评估子表达式 1+2,得到一个新的表达式 33。然后你评估 3*3。OCaml 评估的方式与此相同。在每个时刻,OCaml 评估器获取最左边的不是值的表达式,并将其重写(或简化)为一些更简单的表达式。最终整个表达式都是一个值,然后评估停止:程序完成。或者可能表达式永远不会简化为一个值,在这种情况下,您会得到一个无限循环。
OCaml 内置了许多重写规则,远远超出了简单的算术。考虑 if 表达式。它有两个重要的重写规则:
if true then *e*1 else *e*2 --> *e*1
if false then *e*1 else *e*2 --> *e*2
如果评估器遇到一个 if 表达式,它首先尝试将条件表达式简化为 true 或 false。然后它可以应用这里的两条规则中的一条。
替换
使用重写规则也会对let
表达式进行评估。它的工作原理是首先评估所有的绑定。然后,这些绑定被替换到let
表达式的主体(in
之后的表达式)中。例如,下面是使用let
的一系列评估步骤:
let x = 1+4 in x*3
--> let x = 5 in x*3
--> 5*3
--> 15
函数调用是最有趣的情况。当调用函数时,OCaml 会进行类似的替换:它将传递的参数值替换到函数的主体中。考虑评估abs(2+1)
:
abs (2+1)
--> abs 3
--> if 3 < 0 then -3 else 3
--> if false then -3 else 3
--> 3
这是一个简单的开始,介绍了如何思考评估问题;我们将在接下来的几堂课上进一步讨论评估问题。
第三讲:元组、记录和数据类型
元组
在 OCaml 中,每个函数都接受一个值并返回一个结果。例如,我们的squareRoot
函数接受一个浮点数值并返回一个浮点数值。始终接受一个参数并返回一个结果的优势在于语言非常统一。稍后,我们将看到,当涉及将旧函数组合成新函数时,这给我们带来了很多好处。
但看起来我们可以编写接受多个参数的函数!例如,我们可以写成:
let max1 (r1, r2) : float =
if r1 < r2 then r2 else r1
max1 (3.1415, 2.718)
看起来max1
接受两个参数。事实上,max1
接受一个2 元组(也称为有序对)作为参数。
一般来说,n 元组是一个有序的n个值的序列,用括号括起来,用逗号分隔,如(expr, expr, …, expr)。例如,(42, "hello", true)
是一个 3 元组,它包含整数42
作为其第一个分量,字符串"hello"
作为其第二个分量,布尔值true
作为其第三个分量。注意n可以为 0,这给出了空元组()
。这在 OCaml 中称为"unit"。
当您在 OCaml 中调用一个接受多个参数的函数时,您可以将参数传递给一个元组。例如,当我们写:
max1 (3.1415, 2.718)
我们将 2 元组(3.1415, 2.718)
传递给函数max1
。我们也可以写成:
let args = (3.1415, 2.178)
这将使我们能够编写
max1 args (* evaluates to 3.1415 *)
n 元组的类型写作t[1]*
…*
t[n]。例如,上面 args 的类型是float * float
。这种表示法基于数学中的笛卡尔积(即平面是 R² = R * R)。
类似地,3 元组(42, "hello", true)
的类型为int * string * bool
。请注意,max1
的类型为(float * float) -> float
,表示它接受一个参数(包含两个浮点数的 2 元组)并返回一个结果(一个浮点数)。
结合我们迄今所见,我们可以写出基本类型的语法如下:
t ::=
int
|float
|bool
|string
|char
| t[1]*
…*
t[n] | t[1]->
t[2] |(
t)
这有一些棘手的部分。最重要的两点是->
的优先级低于*
,因此类型(float * float) -> float
和float * float -> float
是完全相同的类型。第二点是->
是右结合的,这意味着
t[1]
->
t[2]->
t[3] 和 t[1] -> (t[2]->
t[3])
是相同的。这将在讨论高阶函数时出现。
您可以使用fst
和snd
运算符提取元组的前两个分量,它们分别检索元组的第一个和第二个元素。然而,对于元组的其他元素没有类似的函数。
因此,例如,我们可以将 max 函数重写如下:
let max1 (pair : float * float) : float =
if (fst pair) < (snd pair) then (snd pair) else (fst pair)
而这与第一个定义完全等价。这强调了 max
真的只接受一个参数 – 一个浮点数对。但是当然,它比第一个定义稍微不那么可读。我们可以通过声明局部值 r1
和 r2
并将它们绑定到对的适当组件来更接近第一个定义:
let max1 (pair : float * float) : float =
let r1 = fst pair in
let r2 = snd pair in
if r1 < r2 then r2 else r1
模式匹配元组
这样做会稍微好一些,因为我们避免了一遍又一遍地重新计算相同的表达式。但是,仍然不如我们第一个定义的 max 那样简洁。这是因为第一个定义使用模式匹配来隐式解构 2-元组并将组件绑定到变量 r1
和 r2
。您可以在 let
声明或函数定义中使用模式匹配来解构元组。元组模式始终为 (
x[1]:t[1], x[2]:t[2],…, x[n]:t[n])
的形式。模式匹配(无论是在 let
还是函数声明中)是从元组中提取元素的推荐方法。它通常更有效,并且几乎总是更简洁且更易读。例如,这是另一个使用 let
声明中的模式来解构对的 max 版本:
let max1 (pair : float * float) : float =
let (r1, r2) = pair in
if r1 < r2 then r2 else r1
在上面的示例中,let
声明将对与元组模式 (r1, r2)
匹配。这将 r1
绑定到对的第一个组件 (fst pair)
,r2
绑定到对的第二个组件 (snd pair)
。当您编写一个使用元组模式的函数时,就像 max 的原始定义中一样,也会发生类似的情况:
let max1 (r1, r2) : float =
if r1 < r2 then r2 else r1
在这里,当我们使用对 (3.1415, 2.718)
调用 max 时,元组与模式 (r1, r2)
匹配,r1
绑定到 3.1415
,r2
绑定到 2.718
。正如我们后面将看到的那样,OCaml 在许多地方使用模式匹配来简化表达式。
假设我们想要在单个函数中提取两个数字的最小值和最大值。使用元组,这很容易:我们只需返回一个包含两个结果的元组。使用 let,我们也可以方便地将两个结果分别提取到不同的变量中:
let minmax (a, b) : float * float =
if a < b then (a, b) else (b, a)
let (mn, mx) = minmax (2.0, 1.0)
这将 mn
绑定到 1.0
,mx
绑定到 2.0
。minmax
的类型是 (float * float) -> (float * float)
,我们可以省略括号,因为在写类型表达式时 *
的优先级高于 ->
。
总结:
-
OCaml 中的每个函数都接受一个参数并返回一个结果。
-
表达式
(
e[1],
…,
e[n])
的值是一个 n-元组。 -
元组类型看起来像 t[1]
*
…*
t[n] -
函数
fst
和snd
提取 2-元组的第一个和第二个组件 -
let``(
x[1]:t[1], x[2]:t[2],…, x[n]:t[n])
= e 匹配表达式 e 的值(e 必须是一个 n-元组)与元组模式(
x[1]:t[1], x[2]:t[2],…, x[n]:t[n])
并将模式中的标识符绑定到元组的适当组件。 -
let
y(
x[1]:t[1], x[2]:t[2],…, x[n]:t[n])
= e是一个接受n-元组作为参数并将该元组与元组模式(
x[1]:t[1], x[2]:t[2],…, x[n]:t[n])
匹配,然后使用这些绑定评估e的函数y的声明。
记录
记录类似于元组,因为它们是用于保存多个值的数据结构。但是,它们与元组不同,因为它们携带一个无序的带标签值集合。记录表达式的形式为**{**
x[1]=
e[1];
…;
x[n]=
e[n]**}**
,其中标识符x是标签。然而,在创建记录之前,你必须使用type
关键字为其类型命名。要声明记录类型,类型必须用大括号括起来,并且每个字段必须被赋予自己的名称和类型。例如,以下声明了一个account
类型:
type account = {first:string; last:string; age:int; balance:float}
一旦你声明了类型,你就可以创建该类型的记录。例如,给定account
类型的声明,表达式
{first="John"; last="Doe"; age=150; balance=0.12}
是一个具有四个名为first
、last
、age
和balance
的字段的记录。你可以通过使用exp.id
从记录中提取字段,其中exp
是记录,id
是你想要提取的字段。例如,对上面的记录应用.age
会得到 150,而应用.balance
会得到 0.12。
创建记录时,给出字段的顺序并不重要。因此,记录
{balance=0.12; age=150; first="John"; last="Doe"}
等同于上面的例子。当你在 OCaml 顶层输入其中一个记录时,它会根据类型声明中的顺序将字段排序为规范顺序:
# let pers = { last = "Doe";
age = 150; balance = 0.12; first = "John" };;
*val pers : account = {first = "John"; last = "Doe"; age = 150; balance = 0.12}*
记录的类型写作**{**
x[1]:t[1]; x[2]:t[2]; … ; x[n]:t[n]**}**
。
就像你可以使用模式匹配来提取元组的组件一样,你也可以使用模式匹配来提取记录的字段。例如,你可以写:
let {first=first; last=last; age=age; balance=balance} = pers
OCaml 的回应是:
*val first : string = "John" val last : string = "Doe" val age : int = 150 val balance : float = 0.12*
从而将标识符first
、last
、age
和balance
绑定到记录的各个组件。你还可以编写参数为记录的函数,使用记录模式。例如:
let full_name {first=first; last=last; age=age; balance=balance} : string =
first ^ " " ^ last (* ^ is the string concatenation operator *)
调用full_name
并将记录pers
传递给它会得到"John Doe"
作为答案。
我们可以将元组视为记录的简写。特别地,元组表达式(3.14, "Greg", true)
类似于记录表达式{1=3.14; 2="Greg"; 3=true}
。因此,在某种意义上,元组只是记录的语法糖。
总结:
-
在创建记录之前,你必须使用
type
声明记录类型。 -
记录表达式的形式为**
{
x[1]=
e[1];
x[2]=
e[2];
…;
x[n]=
e[n]}
**。 -
记录类型的形式为
**{**
x[1]:t[1]; x[2]:t[2]; … ; x[n]:t[n]**}**
。 -
你可以通过写e
.
x来从记录中提取字段,其中x是字段的名称。 -
你可以使用形式为
**{**
x[1]=id[1]; x[2]=id[2]; … ; x[n]=id[n]**}**
的模式匹配记录。
下周我们将介绍更多种类的类型(数据类型)和更多的模式匹配结构。
简单数据类型和匹配表达式
数据类型用于两个基本目的,我们将通过示例描述。数据类型声明的第一个示例如下:
type mybool = Mytrue | Myfalse
这个定义声明了一个新类型(mybool
)和两个构造器(Mytrue
和Myfalse
)来创建mybool
类型的值。换句话说,在将这个定义输入到 OCaml 之后,我们可以使用Mytrue
和Myfalse
作为mybool
类型的值。实际上,这些是mybool
类型的唯一值。因此,数据类型的一个目的是引入新类型到语言中,并引入创建此新类型值的方法。实际上,内置的bool
类型简单地定义为:
type bool = true | false
请注意,数据类型定义很像 BNF 语法。例如,我们可以认为 bool 由true
或false
组成。当我们开始构建语言的实现时,我们将会充分利用 OCaml 中的这种内置语法。
**旁注:**逻辑连接和逻辑析取的运算符如下:
exp ::= … | e1 && e2 | e1 | | e2
请注意,and
并不是用于逻辑连接的,尽管它是一个关键字。它们看起来像二元运算符;但是,它们不同于中缀函数,因为所有其他二元运算符都会评估两个表达式。这两个逻辑构造具有称为短路评估的特殊功能。如果可以通过评估左侧表达式来确定逻辑公式的结果,则右侧表达式将保持未评估状态。
另一个数据类型声明的示例如下:
type day = Sun | Mon | Tue | Wed | Thu | Fri | Sat
这个声明定义了一个新类型(day
)和七个该类型的新构造器(Sun
–Sat
)。例如,我们可以编写一个将数字映射到一周中的某一天的函数:
let int_to_day (i : int) : day =
if i mod 7 = 0 then Sun else
if i mod 7 = 1 then Mon else
if i mod 7 = 2 then Tue else
if i mod 7 = 3 then Wed else
if i mod 7 = 4 then Thu else
if i mod 7 = 5 then Fri else Sat
这一系列if
表达式测试值i
相当繁琐。更简洁的写法是使用match
表达式:
let int_to_day (i : int) : day =
match i mod 7 with
0 -> Sun
| 1 -> Mon
| 2 -> Tue
| 3 -> Wed
| 4 -> Thu
| 5 -> Fri
| _ -> Sat
match
表达式类似于诸如 Java 或 C 等语言中的switch
语句。在上面的示例中,我们对(i mod 7
)的值进行分析,并将其与一组数字模式进行匹配(即,0, 1, 2 等)。最后一个模式是通配符,可以匹配任何值。在 Java 中,我们可以将上述内容写成类似于以下的形式:
switch (i % 7) {
case 0: return Sun;
case 1: return Mon;
case 2: return Tue;
case 3: return Wed;
case 4: return Thu;
case 5: return Fri;
default: return Sat;
}
这样就可以将整数映射到日期。那么将日期映射到整数呢?
let day_to_int (d : day) : int =
match d with
Sun -> 0
| Mon -> 1
| Tue -> 2
| Wed -> 3
| Thu -> 4
| Fri -> 5
| Sat -> 6
使用match
表达式,我们技术上不需要if
表达式形式。特别是,形式为if
exp1 then
exp2 else
exp3的表达式等价于:
match *exp1* with
true -> *exp2*
| false -> *exp3*
实际上,事实证明,通过数据类型的一般形式和 case 表达式,我们可以编码许多似乎内置在语言中的东西。这是一件好事,因为它简化了我们需要考虑的特殊形式的数量。
总之:
-
type
id = id1 | id2 | id3 | … | idn声明了一个新类型(id1),具有n 个数据构造器(id1 id2 id3 … idn)。 -
match
expwith
pat1 -> exp1 | pat2 -> exp2 | … | patn -> expn 评估 exp,然后依次将其与模式匹配。也就是说,首先尝试第一个模式(pat1),如果匹配成功,则评估相应的表达式(exp1)。如果匹配失败,则继续下一个模式 pat2,依此类推。 -
到目前为止,模式可以由整数(例如,12,~4)、作为变量的标识符(例如,
x
)、元组模式、记录模式或作为数据构造函数的标识符(例如,Sun
,Mon
,true
等)组成。 -
if
-表达式是对match
-表达式的语法糖。
代数数据类型和更多模式匹配:
记录(或元组)在逻辑上类似于“和”。例如,类型为 int * float * string
的元组是一个包含 int
和 float
和 string
的对象。数据类型,在最一般的形式上,用于定义“或”类型–当某物需要是一种类型或另一种类型时。特别是,假设我们想要定义一个包括 int
或 float
类型元素的新类型“number”。这可以通过以下数据类型定义在 OCaml 中完成:
type num = Int_num of int | Real_num of float
这个声明给了我们一个新类型(num
)和两个构造函数 Int_num
和 Real_num
。Int_num
构造函数以 int
作为参数并返回一个 num
,而 Real_num
构造函数以 float
作为参数并返回一个 num
。通过这种方式,我们可以创建两种其他类型的(不相交的)联合类型。
但是我们如何使用类型为 num
的值呢?我们不能对其应用诸如 + 之类的操作,因为 + 仅对 int
定义。为了使用类型为 num
的值,我们必须使用模式匹配来解构它。例如,以下函数计算两个 nums 的最大值:
let num_to_float (n : num) : float =
match n with
Int_num i -> float_of_int i
| Real_num r -> r
let max1 (n1, n2) : num =
let r1 : float = num_to_float n1 in
let r2 : float = num_to_float n2 in
if r1 >= r2 then Real_num r1 else Real_num r2
这个策略很简单:将数字转换为浮点数,然后比较这两个浮点数,返回较大的那个。为了使返回值是一个 num
(而不是一个 float
),我们必须将结果放在一个 Real_num
构造函数中。我们可以写成这样:
let max2 (n1, n2) : num =
let r1 : float = num_to_float n1 in
let r2 : float = num_to_float n2 in
Real_num (if r1 >= r2 then r1 else r2)
在这里,我们用 Real_num
构造函数包装了整个 if
-表达式。这是将 if
视为表达式而不是语句的一个优势。
注意,在函数 num_to_float
中,我们使用了一个 match
-表达式来确定数字 n 是整数还是浮点数。模式 Int_num i
只有当 n 是使用 Int_num
数据构造函数创建的时候才匹配 n,而 Real_num r
只有当它是用 Real_num
数据构造函数创建的时候才匹配 n。此外,请注意在 Int_num i
案例中,我们将由数据构造函数承载的基础整数绑定到变量 i
上,并且这在表达式 float_of_int i
中使用。类似地,Real_num r
模式提取由数据构造函数承载的基础浮点值,并将其绑定到 r
上。
因此,例如,调用num_to_float (Int_num 3)
匹配第一个模式,将i
绑定到 3,然后返回float_of_int i
= float_of_int 3
= 3.0
。调用num_to_float (Real_num 4.5)
无法匹配第一个模式,但可以匹配第二个模式,将r
绑定到 4.5,然后返回r
= 4.5
。
这里是关于数字最大值的另一种定义。
let rec max2 (n1, n2) : num =
match (n1, n2) with
(Real_num r1, Real_num r2) -> Real_num (max r1 r2)
| (Int_num i1, Int_num i2) -> Int_num (max i1 i2)
| (_, Int_num i2) -> max2 (n1, Real_num (float_of_int i2))
| (Int_num i1, _) -> max2 (n2, Real_num (float_of_int i1))
请注意,在max2
中的match
表达式匹配了数字n1
和n2
的元组。因此,案例表达式中的所有模式都是元组形式。例如,模式(Real_num r1, Real_num r2)
只有当两个数字都是浮点数时才匹配。
在第三和第四种模式中,我们使用了一个“通配符”(或默认)模式。例如,第三种模式(_, Int_num i2)
匹配的条件是第一个数字可以是任何值,但第二个数字必须是整数。在这种情况下,我们只需将整数转换为浮点数,然后递归调用自身。同样,第四种模式(Int_num i1, _)
第一个数字是整数,第二个数字可以是任何值。在这种情况下,我们将第一个数字转换为浮点数,然后递归调用自身。
现在假设我们用两个整数调用max2 max2 (Int_num 3, Int_num 4)
。看起来好像这匹配了最后三种情况中的任何一种,那么我们应该选择哪一个呢?答案是我们按顺序尝试匹配。因此第二种模式会成功,其他模式甚至不会尝试。
另一个问题是,我们如何知道每种情况都有一个案例呢?例如,假设我们不小心写成:
let rec max3 (n1, n2) : num =
match (n1, n2) with
(Int_num i1, Int_num i2) -> Int_num (max i1 i2)
| (_, Int_num i2) -> max3 (n1, Real_num (float_of_int i2))
| (Int_num i1, _) -> max3 (n2, Real_num (float_of_int i1))
现在没有考虑n1
和n2
都是浮点数的情况。如果你在 OCaml 中输入这个,它会抱怨模式匹配不是穷尽的。这很好,因为它告诉你你的代码可能会失败,因为你忘记了一个情况!一般来说,我们不会接受有不穷尽警告的代码。也就是说,你必须确保你永远不会提交没有覆盖所有情况的代码。
如果我们添加了太多的情况会发生什么?例如,假设我们写成:
let rec max2 (n1, n2) : num =
match(n1, n2) with
(Real_num r1, Real_num r2) -> Real_num(max r1 r2)
| (Int_num i1, Int_num i2) -> Int_num (max i1 i2)
| (_, Int_num i2) -> max2 (n1, Real_num (float_of_int i2))
| (Int_num i1, _) -> max2 (n2, Real_num (float_of_int i1))
| (_, _) -> n1
然后 OCaml 会抱怨最后一个匹配情况未被使用(即永远不会被执行)。这很棒,因为它告诉我们有一些无用的代码,我们应该将其删除或重新检查为什么它永远不会被执行。再次强调,我们不会接受有冗余模式的代码。
那么 OCaml 类型检查器如何确定模式匹配是穷尽的,没有死代码呢?原因是模式只能测试有限数量的事物(模式中没有循环),测试相当简单(例如,这是一个Real_num
还是一个Int_num
?)并且给定类型的数据构造函数集是封闭的。也就是说,在定义数据类型之后,我们不能简单地向其添加新的数据构造函数。请注意,如果可以的话,那么每个模式匹配都可能是不穷尽的。
起初,这似乎是语言的一个缺点。添加新的构造函数是经常发生的事情,就像在 Java 程序中经常添加新的子类一样。在 OCaml 中的区别在于,如果您向数据类型声明添加了新的数据构造函数,那么编译器将通过“匹配不完全”错误告诉您需要检查或更改代码的位置。这使得模式匹配成为程序维护和演化的无价工具。
有时,通过限制编程语言,我们可以获得一些力量。在 OCaml 的模式匹配子语言中,设计者限制了可以执行的测试集,以便编译器可以自动告诉您需要查看代码的位置,以使其与您的定义同步。
第二次课:测试与调试
调试是最后的手段
许多程序员,尤其是在入门课程中,可能会认为编程主要涉及调试的任务。因此,值得退后一步思考一下在调试之前发生的一切。
-
防止错误的第一道防线是让它们不可能发生。
通过选择使用保证*内存安全性(即除了通过有效的指针*(或引用)访问该内存区域外,不能访问内存的任何部分)和*类型安全性*(即不会以与其类型不一致的方式使用任何值)的语言来编程,可以消除整个类别的错误。例如,OCaml 类型系统可以防止缓冲区溢出和无意义的操作(如将布尔值添加到浮点数中),而 C 类型系统则不能。
-
防止错误的第二道防线是使用能找到它们的工具。
有自动化源代码分析工具,比如FindBugs,它可以发现 Java 程序中许多常见类型的错误,以及SLAM,它用于发现设备驱动程序中的错误。计算机科学的子领域形式方法研究如何使用数学来指定和验证程序,即如何证明程序没有错误。我们将在本课程的后期研究验证。社交方法,如代码审查和配对编程,在发现错误方面也很有用。IBM 在 1970 年代至 1990 年代的研究表明,代码审查是发现错误最有效的工具;请参阅下面的附录。
-
防止错误的第三道防线是立即让它们可见。
错误越早出现,诊断和修复就越容易。如果计算继续进行超出错误点,则进一步的计算可能会掩盖故障真正发生的地点。源代码中的断言使程序“快速失败”和“大声失败”,以便错误立即出现,程序员确切地知道在源代码中要查找的位置。
-
防止错误的第四道防线是广泛的测试。
你如何知道一段代码是否有特定的错误?编写能够暴露该错误的测试,然后确认你的代码不会在这些测试中失败。单元测试适用于相对较小的代码片段,比如单个函数或模块,在你开发该代码的同时编写这些测试尤为重要。这些测试应该自动化运行,这样如果你破坏了代码,你就能尽快发现。(这实际上又是防御手段 3。)
-
防止错误的第五和最后一道防线就是调试,如你所知。
调试实际上是程序员的最后手段。
在本次课的其余部分,我们将看看第 3 到第 5 道防线。
防御手段 3:断言
Assertions
模块包含一个函数assert_true:bool -> unit
,如果传递true
作为参数,则返回单位,如果传递false
,则引发异常。例如:
open Assertions
let t = assert_true true
let inc x = x+1
let u = assert_true ((inc 0) = 0) (* raises an exception *)
(open Assertions
行使我们能够简单地将声明在Assertions
内部的任何函数f
称为f
,而不是Assertions.f
。)
如果我们将此代码保存在文件test.ml
中,然后运行cs3110 compile test.ml; cs3110 run test.ml
,我们会看到异常被触发:
Fatal error: exception Assertions.Assert_true("false is not true")
Raised at file "assertions/assertions.ml", line 4, characters 18-49
Called from file "test.ml", line 4, characters 8-33
Error (exit code 2)
在课程后面,我们会看到如何声明、触发和处理异常。现在,知道Assertions
使用它们就足够了。
Assertions
模块包含许多其他我们可以用来编写断言的函数。您可以在模块的 GitHub 存储库中阅读文档。
防御 4:OCaml 中的单元测试
该课程开发了自己的单元测试框架,它是分布在虚拟机上的cs3110
工具的一部分。我们的框架与一个开源库Pa_ounit
非常相似(实际上是基于它构建的)。
假设您正在编写一个确定数字是否为质数的函数:
let is_prime : int -> bool =
fun x -> x=3 || x=5 || x=7
(显然,你还没有写完函数……)假设您已经将此函数保存在名为prime.ml
的文件中。要编写单元测试,创建一个名为prime_test.ml
的新文件(文件名的选择是一种约定,而不是严格要求)。在prime_test.ml
中,您可以编写如下的单元测试:
open Prime
open Assertions
TEST_UNIT "test_prime_one" = assert_true (is_prime 3)
TEST_UNIT "test_prime_two" = assert_true (is_prime 7)
TEST_UNIT "test_not_prime_eight" = assert_false (is_prime 8)
let () = Pa_ounit_lib.Runtime.summarize()
前两行打开了被测试代码和断言模块的模块。请注意,无论模块名称的首字母是否大写,在实际文件名中都要大写。与每个单元测试相关联的字符串只是描述性名称。如果添加了任何新测试,最后一行(对summarize
的调用)应保持为文件的最终行。如果所有测试都通过,它将正常退出。
要运行单元测试,请执行以下操作:
cs3110 compile prime_test.ml
cs3110 test prime_test.ml
作为输出,您将看到:
3 tests ran, 0 test_modules ran
示例:单元测试
下面的文件包含一些有错误的代码:
Turn on Javascript to see the program.
这是一个单元测试文件:
Turn on Javascript to see the program.
上述测试案例通过了吗?如果没有,哪些测试失败了?如果通过了,这是否保证了上述函数的正确性?您能想到我们没有测试的其他一些情况吗?向测试文件添加一些自己的案例,并修复函数的实现,使您的测试通过。
测试驱动开发
测试不一定要在编写代码之后严格进行。在测试驱动开发中,测试是首要的!
假设我们正在处理一个用于日期的数据类型:
type day = Sunday | Monday | Tuesday | Wednesday
| Thursday | Friday | Saturday
我们想要编写一个函数next_weekday:day -> day
,它返回给定日期后的下一个工作日。我们首先编写最基本、有错误的版本:
let next_weekday (d:day) = Sunday
然后我们开始编写测试。例如,我们知道周一后的下一个工作日是周二。因此我们添加一个测试:
TEST_UNIT "next_weekday_test1" = assert_true (next_weekday(Monday) = Tuesday)
我们编译并运行测试;它失败了。这是好事!现在我们知道我们有一些功能要添加。让我们回去让这个测试通过:
let next_weekday (d:day) =
match d with
Monday -> Tuesday
| _ -> Sunday
我们编译并运行测试;它通过了。是时候添加更多测试了:
TEST_UNIT "next_weekday_test2" = assert_true (next_weekday(Tuesday) = Wednesday)
TEST_UNIT "next_weekday_test3" = assert_true (next_weekday(Wednesday) = Thursday)
TEST_UNIT "next_weekday_test4" = assert_true (next_weekday(Thursday) = Friday)
我们编译并运行测试;许多失败。这是好事!我们添加新功能:
let next_weekday (d:day) =
match d with
Monday -> Tuesday
| Tuesday -> Wednesday
| Wednesday -> Thursday
| Thursday -> Friday
| _ -> Sunday
我们编译并运行测试;它们通过了。也许过于得意于我们添加功能的成功,我们注意到了已经形成的模式,并决定完成编写函数:
let next_weekday (d:day) =
match d with
Monday -> Tuesday
| Tuesday -> Wednesday
| Wednesday -> Thursday
| Thursday -> Friday
| Friday -> Saturday
| Saturday -> Sunday
| Sunday -> Monday
(当然我们搞错了。)我们添加了一些新测试来完成测试所有可能的情况:
TEST_UNIT "next_weekday_test5" = assert_true (next_weekday(Friday) = Monday)
TEST_UNIT "next_weekday_test6" = assert_true (next_weekday(Saturday) = Monday)
TEST_UNIT "next_weekday_test7" = assert_true (next_weekday(Sunday) = Monday)
我们编译并运行测试;测试 5 和 6 失败!令人惊讶,我们看了一下测试 5,注意到 next_weekday(Friday)
的代码肯定是错的,然后修复了 bug。在为测试 6 修复相同的 bug 后,我们最终得到了以下完整的代码片段和单元测试:
let next_weekday (d:day) =
match d with
Monday -> Tuesday
| Tuesday -> Wednesday
| Wednesday -> Thursday
| Thursday -> Friday
| Friday -> Monday
| Saturday -> Monday
| Sunday -> Monday
TEST_UNIT "next_weekday_test1" = assert_true (next_weekday(Monday) = Tuesday)
TEST_UNIT "next_weekday_test2" = assert_true (next_weekday(Tuesday) = Wednesday)
TEST_UNIT "next_weekday_test3" = assert_true (next_weekday(Wednesday) = Thursday)
TEST_UNIT "next_weekday_test4" = assert_true (next_weekday(Thursday) = Friday)
TEST_UNIT "next_weekday_test5" = assert_true (next_weekday(Friday) = Monday)
TEST_UNIT "next_weekday_test6" = assert_true (next_weekday(Saturday) = Monday)
TEST_UNIT "next_weekday_test7" = assert_true (next_weekday(Sunday) = Monday)
测试驱动开发与极限编程(XP)相关,是关于如何构建团队编程项目的一系列思想。它强调代码的增量开发:总有一些可以进行测试的东西。测试不是在实施后发生的事情;相反,持续测试用于及早捕获错误。因此,在编写代码时立即开发单元测试非常重要。自动化测试套件至关重要,以便持续测试几乎不需要任何努力。
极限编程强调对可以开发单元测试的代码模块进行自下而上的开发。极限编程还强调编程团队的社交过程。程序员密切合作,更多地通过口头交流而不是正式设计文档进行沟通。规格说明很简短,程序员依赖于开发团队的共同知识。极限编程是快速开发软件的好方法,但也因未能产生高级设计文档而受到批评。从长远来看,以这种方式开发的项目可能难以管理,因为代码与工程师紧密相关。
编写单元测试的指南
在编写测试时,记住首字母缩略词 FIRST:
-
快速:测试应该快速运行并完成。
-
独立性:没有测试应该依赖于其他测试。
-
可重复:测试应该在任何环境中工作——即它们不应该依赖于任何外部因素,比如当前时间。
-
自验证:测试应该能够在人类的手动检查下报告成功/失败。
-
及时:测试应该在编写代码之前或与代码一起编写。
此外:
-
保持每个单元测试都很小。测试应设计为诊断一个特定的功能是否工作或不工作。
-
以能够暗示它们测试内容的方式命名测试。如果没有其他内容,
*funcname*_test*i*
的模式效果相当不错。 -
不断进行测试。每次添加新功能时,重新运行所有单元测试以确保没有破坏任何内容。
黑盒测试
在next_weekday
的示例中,相对容易确定要编写的单元测试。但对于更复杂的函数,决定编写什么样的测试以及多少个测试可能会很困难。开发单元测试的一个好策略是仅仅通过查看正在测试的函数的规范来编写它们。函数的规范实际上给了我们很多关于要测试什么的信息:
-
任何先决条件可能会给我们关于要测试的值的想法。
-
后置条件可能具有结构,建议测试值。
仅基于函数的规范进行测试,而不是基于其实现,称为黑盒测试。这个想法是函数是一个我们无法看进去的盒子。
作为黑盒测试的一个例子,考虑编写一个函数来计算美国的渐进所得税税率中所欠的税款金额。这个函数的后置条件可能规定税率由以下表格确定:
收入 | 税率 |
---|---|
$0–$8,925 | 10% |
$8,926–$35,250 | 15% |
$35,251–$87,850 | 25% |
… | … |
这个后置条件中有结构,建议要测试的值。与其测试从$0 到$87,850 的每个美元金额,我们可以测试后置条件建议的分区:一个测试用于分区$0–$8,925,另一个测试用于$8,926–$35,250,依此类推。这种测试策略称为等价类划分。每个分区极端末端的值通常是特别有成效的测试用例。例如,我们可以为$0 编写一个测试,为$8,925 编写一个测试,为$8,926 编写一个测试,为$35,250 编写一个测试,依此类推。这种测试策略称为边界值分析。
白盒测试
一个函数的规范并不一定给出我们撰写良好测试套件所需的所有信息。通过查看函数的实现,我们可能会受到启发,编写额外的测试。基于函数实现的测试称为白盒测试(或者更好地说玻璃盒测试)。这个想法是函数是一个我们可以看进去的盒子。
作为白盒测试的一个例子,考虑编写一个函数来确定二次方程ax² + bx + c = 0的实根(如果有的话)。该函数的规范可能如下所示:
(* requires: a <> 0.0
* returns: SOME (f1,f2) if there exists an f1 and f2 such that
* each fi is a root of ax² + bx + c. Or NONE if no real
* roots exist. *)
let qroots((a:int),(b:int),(c:int)) : (float*float) option = ...
在这种情况下,我们可能会受到先决条件和等价类划分的启发,为每个系数为负、零或正时生成 27 个测试用例。(这不是个坏主意!) 但如果我们继续阅读函数的实现,我们会得到新的启发:
let qroots((a:float),(b:float),(c:float)) : (float*float) option =
let d = (b *. b) -. (4.0 *. a *. c) in
if d < 0.0
then None
else Some ((~-. b +. (sqrt d)) /. (2.0 *. a),
(~-. b -. (sqrt d)) /. (2.0 *. a))
在函数体中,我们看到d
的值与if
表达式的then
分支或else
分支的评估有关。因此,编写探索两个分支的测试用例是有意义的:
TEST_UNIT "qroots_test1" = assert_true ((qroots(1.0,3.0,-4.0) = Some (1.0,-4.0)))
TEST_UNIT "qroots_test2" = assert_true (qroots(3.0,4.0,2.0) = None)
代码覆盖是测试套件测试的代码量的度量标准。有多种方法来衡量覆盖率:通过测试的函数数量,是否测试了if
或match
表达式的每个分支,是否存在足够的测试来导致每个布尔子表达式都取true
和false
等。通过编写以上两个测试,我们正在增加按分支衡量的代码覆盖率。
防御五:调试
所以你发现了一个 bug。接下来呢?
-
**将错误简化为小测试用例。**调试是一项艰苦的工作,但测试用例越小,您定位错误代码的可能性就越大。因此,花在这种简化上的时间可能是节省的时间,因为您不必重新阅读大量代码。在获得小测试用例之前不要继续调试!
-
**采用科学方法。**提出关于为什么出现错误的假设。您甚至可以将该假设写在笔记本中,就像您在化学实验室中一样,以便在自己的头脑中澄清它并跟踪您已经考虑过的假设。接下来,设计一个实验来证实或否定该假设。运行您的实验并记录结果。根据您所学到的内容重新制定您的假设。直到您已经合理、科学地确定了错误的原因。
-
**修复错误。**修复可能是简单纠正拼写错误。或者它可能揭示了导致您进行重大更改的设计缺陷。考虑是否需要根据其他位置的代码进行修复,例如,是否是复制粘贴错误?如果是这样,您是否需要重构代码?
-
**将小测试用例永久添加到您的测试套件中。**您不希望错误再次出现在您的代码库中。因此,通过将其保留为单元测试的一部分来跟踪该小测试用例。这样,每当您进行将来的更改时,您都将自动防范同样的错误。从以前的错误提炼出的重复运行测试称为回归测试。
OCaml 调试的机制
以下是在 OCaml 中强制调试时的一些提示。
-
**打印语句。**插入一个打印语句以确定变量的值。假设您想知道以下函数中
x
的值是多少:let inc x = x+1
只需添加下面的行以打印该值:
let inc x = let () = print_int(x) in (* added *) x+1
Pervasives模块包含许多其他可用的打印语句。
-
**函数跟踪。**假设您想要查看函数的递归调用和返回的跟踪。使用
#trace
指令:let rec fib x = if x<=1 then 1 else fib(x-1) + fib(x-2) #trace fib;;
如果你评估
fib 2
,你现在会看到以下输出:fib <-- 0="" 2="" fib="" <--="" --=""> 1 fib <-- 1="" fib="" --=""> 1 fib --> 2
要停止追踪,请使用
#untrace
指令。
致谢
这些笔记中的部分内容基于麻省理工学院 6.005 课程的 Rob Miller 的调试幻灯片。
附录:代码审查
在软件系统中发现故障的一种极其有效的方法是代码审查,程序员通过代码和文档来解释其工作原理和为什么是正确的。程序员的工作是说服一个专家审查团队(通常是 1-3 人),他们充当怀疑的魔鬼的辩护者。
有不同的方法可以有效地使用代码审查:
-
代码演示。 在演示方法中,程序员向审查团队展示文档和代码,团队提供评论。这是一个非正式的过程。重点是代码而不是编码者,因此更容易避免伤害感情。然而,团队可能无法获得关于代码正确性的充分保证。
-
代码检查。 在这里,审查团队主导代码审查过程。一些,尽管不一定很多,团队事先准备是有用的。他们为审查过程定义目标,并与编码者互动,了解可能存在质量问题的地方。再次强调尽可能使过程无责任是重要的。
-
配对编程。 通过配对编程是进行代码审查最不正式的方法,其中代码由一对工程师开发:驾驶员编写代码,观察员观察。观察员的角色是扮演批评者,思考潜在错误,并帮助解决更大的设计问题。通常最好让观察员是对手头编码任务有更多经验的工程师。观察员审查代码,充当驾驶员必须说服的魔鬼的辩护者。当一对人正在制定规范时,观察员会考虑如何使规范更清晰或更简短。配对编程还有其他好处。与合作伙伴一起工作通常更有趣和教育性,它有助于让双方都专注于任务。如果你刚开始与另一位程序员合作,配对编程是了解你的合作伙伴思维方式并建立共同词汇的好方法。合作伙伴也最好交换角色。配对编程是 CS 3110 的一个绝佳方法,我们建议尝试。
代码审查可能非常有效。在 IBM 进行的一项研究中(Jones, 1991),代码检查发现了 65%的已知编码错误和 25%的已知文档错误,而测试仅发现了 20%的编码错误和没有文档错误。更正式的检查过程可能比演示更有效。一项研究(Fagan, 1976)发现,代码检查导致的代码故障比演示少 38%。
通过代码审查可能会很昂贵。琼斯发现,为代码检查做准备每 150 行代码需要一个小时,实际检查每小时涵盖 75 行代码。检查团队最多有三人可以提高检查质量;超过这个数量,更多的检查员似乎并没有帮助。花费大量时间准备检查似乎也没有用。也许这是因为检查的价值很大程度上在于与编码人员的互动。
第四讲:作用域、柯里化和列表
-
作用域和绑定
-
柯里化函数
-
OCaml 列表
作用域
OCaml 中的变量声明绑定变量到作用域,程序的一部分,变量在其中代表绑定到的值。例如,当我们写let
x = e[1] in
e[2]时,标识符 x 的作用域是表达式 e[2]。在该作用域内,标识符 x 代表表达式 e[1] 计算得到的任何值 v。由于 x = v,OCaml 通过将 x 的出现替换为值 v 重写let
表达式来评估它。例如,表达式let x = 2 in x + 3
被评估为2 + 3
,然后使用算术运算得到结果值5
。
函数也绑定变量。当我们在 OCaml 中编写函数定义时,我们为函数名和函数参数引入新变量。例如,在此表达式中,绑定了两个变量:
let f x = e1 in e2
形式参数x
的作用域正好是表达式e[1]
。变量f
(它绑定到函数值)的作用域是let
的主体,即e[2]
。
let
表达式可以一次引入多个变量,如下例所示:
let x = 2
and y = 3
in x + y
这里x
和y
都将let
的主体作为它们的作用域。即使y
在x
之后声明,y
的定义也不能引用变量x
——它不在作用域内。
要声明递归函数,函数必须在其自身的主体中处于作用域内。在 OCaml 中,这需要使用let rec
而不是let
。使用let rec
,它声明的每个变量都在其自身的定义以及所有其他变量的定义中都是在作用域内的。为使其正常工作,使用这些变量的所有定义都必须是函数。例如,下面是如何定义相互递归函数even
和odd
的方法:
let rec even x = x = 0 || odd (x-1)
and odd x = not (x = 0 || not (even (x-1)))
in
odd 3110
此示例中有两个名为x
的变量,它们仅在绑定它们的各自函数内部作用域中。但是,变量even
和odd
在彼此的定义以及let
的主体内都是在作用域内的。
限定符标识符
可以使用open
表达式命名模块中定义的事物,而不使用限定符标识符:
# String.length "hi";;
*- : int = 2*
# open String;;
# length "bye";;
*- : int = 3*
OCaml 提供了许多预定义的库模块,非常有用。例如,String
模块提供了许多有用的字符串操作,而List
模块提供了列表操作。许多有用的操作位于Pervasives
模块中,默认情况下已经打开。要了解有关 OCaml 库及其提供的操作的更多信息,请参阅Objective Caml 参考手册,第 IV 部分。
例如,有一个用于计算整数绝对值的内置操作称为Pervasives.abs
,可以简单地称为abs
。
花些时间浏览库,并找出它们提供了什么。你不应该重新编写库中已有的东西(除非我们明确要求你这样做)。
柯里化函数
我们看到,具有多个参数的函数实际上只是传递元组作为参数的函数的语法糖。例如,
let plus (x, y) = x + y
是糖语法,表示
let plus (z : int * int) = match z with (x, y) -> x + y
这反过来又是糖语法表示
let plus = fun (z : int * int) -> match z with (x, y) -> x + y
当我们应用此函数时,比如对元组 (2, 3)
,评估过程如下:
plus (2, 3)
= (fun (z : int * int) -> match z with (x, y) -> x + y) (2, 3)
= match (2, 3) with (x, y) -> x + y
= 2 + 3
= 5
原来,OCaml 还有另一种声明具有多个形式参数的函数的方式,事实上,这是通常的方式。上述声明可以以柯里化形式给出如下:
let plus x y = x + y
或者将所有类型明确写出:
let plus (x : int) (y : int) : int = x + y
注意参数之间没有逗号。同样,当应用柯里化函数时,我们不写逗号:
plus 2 3 = 2 + 3 = 5
这里发生的事情比看起来的要复杂。回想一下我们说过函数实际上只有一个参数。当我们写 plus 2 3
时,函数 plus
只被传递了一个参数,即数字 2。我们可以将该术语括起来,如 (plus 2) (3)
,因为应用是左结合的。换句话说,plus 2
必须返回一个函数,该函数可以应用于 3 以获得结果 5。实际上,plus 2
返回一个将 2 添加到其参数的函数。
这是如何工作的?上述柯里化声明是为了创建一个高阶函数的语法糖。它代表着:
let plus = function (x : int) -> function (y : int) -> x + y
对 plus 2 3
的评估过程如下:
plus 2 3
= ((function (x : int) -> function (y : int) -> x + y) 2) 3
= (function (y : int) -> 2 + y) 3
= 2 + 3
= 5
因此,plus
实际上是一个以 int
作为参数的函数,并返回一个类型为 int -> int
的新函数。因此,plus
的类型是 int -> (int -> int)
。我们可以简单地写为 int -> int -> int
,因为类型运算符 ->
是右结合的。
原来,我们可以将诸如 +
这样的二元运算符视为函数,并且它们像 plus
一样进行柯里化:
# (+);;
- : int -> int -> int = <fun>
# (+) 2 3;;
- : int = 5
# let next = (+) 1;;
val next : int -> int = <fun>
# next 7;;
- : int = 8;
列表
到目前为止,我们唯一能构建的真正数据结构是由元组构成的。但元组不允许我们构建在编译时大小未知的数据结构。为此,我们需要一种新的语言特性。
我们熟悉的一个简单数据结构是单链表。原来 OCaml 中已经内置了列表。例如,在 OCaml 中,表达式 []
是一个空列表。表达式 [1;2;3]
是一个包含三个整数的列表。
在 OCaml 中,列表的所有元素都必须具有相同的类型。例如,整数列表的类型为 int list
。类似地,列表 ["hi"; "there"; "3110"]
的类型将为 string list
。但 [1; "hi"]
不是合法列表。在 OCaml 中,列表是同质列表,而不是异质列表,其中每个元素可以具有不同的类型。
列表是不可变的:你不能改变列表的元素,不像 Java 中的数组。一旦列表被构造,它就不会改变。
构造列表
我们经常想要用较小的列表构造一个列表。我们可以使用 @
运算符连接两个列表。例如,[1;2;3] @ [4;5]
= [1;2;3;4;5]
。然而,这个运算符并不是很快,因为它需要构建整个第一个列表的副本。(它不会复制第二个列表,因为第二个列表的存储与连接列表的存储是共享的。)
在构建列表时,我们更经常使用 ::
运算符,它将一个元素添加到现有列表的前面(“前置”意味着“添加到前面”)。表达式 1::[2;3]
是 1
添加到列表 [2;3]
的前面。这就是列表 [1;2;3]
。如果我们对空列表使用 ::
,它会生成一个单元素列表:1::[]
= [1]
。
出于历史原因,回到 Lisp 语言,我们通常将 ::
运算符称为“cons”。
列表是不可变的事实符合 OCaml 是函数式语言的特性。这实际上也有助于提高 OCaml 的效率,因为这意味着不同的列表数据结构可以在计算机内存中共享部分表示。例如,评估 h::t
只需要在计算机内存中为一个额外的列表节点分配空间。它与现有列表 t
共享其余部分。
列表的模式匹配
从列表中提取元素的最佳方法是使用模式匹配。运算符 ::
和括号构造函数可以在 match
表达式中用作模式。例如,如果我们有一个列表 lst
,并且希望在 lst
为空时得到值 0,在 lst
有一个元素时得到值 1,在 lst
有 2 个或更多元素时得到值 2,我们可以编写:
match lst with
[] -> 0
| [x] -> 1
| _ -> 2
在这里,如果评估第二个匹配分支,则 x
将绑定到列表的单个元素。
经常,操作列表的函数是递归的,因为它们需要对每个元素执行一些操作。例如,假设我们想要计算字符串列表的长度。我们可以编写一个递归函数来实现这个目标(实际上,库函数 List.length
就是这样做的):
(* Returns the length of lst *)
let rec length (lst : string list) : int =
match lst with
[] -> 0
| h :: t -> 1 + length t
这里的逻辑是,如果列表为空([]
),它的长度显然是零。否则,如果它是将元素 h 添加到另一个列表 t 中,其长度必须比 t 的长度大一。
可以使用括号语法编写模式。这与使用 ::
运算符编写类似模式完全相同。例如,以下模式都是等价的:[x;2]
、x::2::[]
、x::[2]
。当用作项时,这些表达式也都是等价的。
库函数
OCaml 结构体List
包含许多用于操作列表的实用函数。在使用列表之前,值得一看。我们稍后会更详细地讨论其中一些。两个应该谨慎使用的函数是 hd
和 tl
。这些函数分别获取列表的头部和尾部。然而,如果应用于空列表,则会引发异常。它们很容易让人忘记列表可能为空的可能性,从而创建出现预期异常的情况,导致程序崩溃。因此,通常最好避免使用它们。
列表示例
我们可以使用模式匹配来实现列表上的其他有用函数。假设我们想要一个函数,该函数通过列表中的索引提取列表元素,其中第一个元素的索引为零。我们可以通过同时对列表和整数 n 进行模式匹配来整洁地实现这一点:
(* nth lst n returns the nth element of lst. *)
let rec nth (lst : string list) (n : int) : string =
match lst with
h :: t -> if n = 0 then h else nth t (n - 1)
| [] -> raise Not_found
如果 n
小于 0 或大于或等于 lst
的长度,则会引发 Not_found
异常。
朗诵 3:高阶函数,匿名函数,柯里化,副作用,打印和异常
这个朗诵涵盖:
-
高阶函数
-
匿名函数
-
柯里化
-
副作用和打印
-
异常
高阶函数
函数在 OCaml 中就像任何其他值一样。这究竟意味着什么?这意味着我们可以将函数作为参数传递给其他函数,我们可以将函数存储在数据结构中,我们可以从其他函数返回函数作为结果。这个完整的含义直到后来才会让你明白,但相信我们,它会。
让我们看看为什么拥有高阶函数很有用。第一个原因是它允许您编写更通用的代码,因此更可重用的代码。作为一个运行示例,考虑整数上的函数double和square:
let double (x : int) : int = 2 * x
let square (x : int) : int = x * x
现在让我们想出一个函数来将一个数字四倍。我们本可以直接做,但出于完全扭曲的动机,决定使用上面的double函数:
let quad (x : int) : int = double (double x)
��够直接了。那么一个将整数提升到四次幂的函数呢?
let fourth (x : int) : int = square (square x)
这两个函数之间有一个明显的相似性:它们的作用是将一个给定的函数两次应用于一个值。通过将函数传递给另一个函数twice
作为参数,我们可以抽象出这个功能,从而重用代码:
let twice ((f : int -> int), (x : int)) : int = f (f x)
使用这个,我们可以写:
let quad (x : int) : int = twice (double, x)
let fourth (x : int) : int = twice (square, x)
我们利用了这两个函数之间的相似性来节省工作。这可能非常有帮助。如果有人提出了一个改进(或更正)版本的twice
,那么使用它的每个函数都会从改进中受益。
函数twice
是所谓的高阶函数:它是一个从函数到其他值的函数。注意twice
的类型是((int -> int) * int) -> int
。
为了避免污染顶层命名空间,将函数定义为局部函数以传递为参数可能很有用。例如:
let fourth (x : int) : int =
let square (y : int) : int = y * y in
twice (square, x)
为了在接下来要做的评估示例中更清晰,让我们使用上周看到的函数的另一种语法来重新编写:
let fourth = fun x ->
let square = fun y -> y * y in
twice (square, x)
当我们评估使用高阶函数的表达式时会发生什么?我们使用与之前相同的规则:当一个函数被应用(调用)时,我们用函数体替换调用,用参数变量(实际上是出现在参数模式中的变量)绑定到相应的实际值。例如,fourth 3
的评估如下:
fourth 3
--> (fun x -> let square = fun y -> y * y in twice (square, x)) 3
--> let square = fun y -> y * y in twice (square, 3)
--> twice (fun y -> y * y, 3)
--> (fun y -> y * y) ((fun y -> y * y) 3)
--> (fun y -> y * y) (3 * 3)
--> (fun y -> y * y) 9
--> 9 * 9
--> 81
匿名函数
我们刚刚使用的“替代语法”对于函数来说是更加广泛有用的。您可能会注意到,定义和命名一个函数只是为了将其作为参数传递给另一个函数似乎有点愚蠢。毕竟,我们真正关心的是twice
得到一个将其参数加倍的函数。幸运的是,OCaml 提供了一个更好的解决方案——匿名函数:
let fourth (x : int) : int = twice (fun (y : int) -> y * y, x)
我们引入一个新表达式,表示“一个期望某种类型的参数并返回某个表达式值的函数”:
e ::= … | fun
(
x : t) ->
e
fun
表达式创建了一个匿名函数:一个没有名称的函数。参数类型可以省略;OCaml 将自动推断它。匿名函数的返回类型未声明,会自动推断。fun (y : int) -> y = 3
的类型是什么?
答案: int -> bool
请注意声明
let square : int -> int = fun (y : int) -> y * y
具有相同效果的是
let square (y : int) : int = y * y
实际上,没有fun
的声明只是对更繁琐的长定义的语法糖。(但对于递归函数来说不是这样。)
柯里化
匿名函数对于创建传递给其他函数的函数很有用,但也对编写返回其他函数的函数很有用。让我们将twice
函数重写为接受一个函数作为参数并返回一个将原始函数应用两次的新函数:
let twice (f : int -> int) =
fun (x : int) -> f (f x)
此函数接受一个类型为int -> int
的函数f
作为参数,并返回值fun (x : int) -> f (f x)
,这是一个函数,当应用于参数时,将f
应用两次于该参数。因此,我们可以写成
let fourth = twice (fun (x : int) -> x * x)
let quad = twice (fun (x : int) -> 2 * x)
并尝试评估fourth 3
确实会产生81
。
函数返回其他函数在函数式编程中非常常见,因此 OCaml 为它们提供了特殊的语法。例如,我们可以将上面的 twice 函数写成
let twice (f : int -> int) (x : int) : int = f (f x)
这里的“第二个参数”x
不是twice
的参数,而是twice f
的参数。函数twice
只接受一个参数,即函数f
,并返回另一个函数,该函数接受一个参数x
并返回一个int
。这里的区别至关重要。
此设备称为柯里化,取自逻辑学家 H·B·柯里的名字。此时,您可能担心返回中间函数的效率,因为您最终会一次性传递所有参数。如果您想测试一下(您应该找出如何做到这一点),但请放心,柯里化函数在函数式语言中是完全正常的,因此没有值得担心的速度惩罚。
twice
的类型是(int -> int) -> int -> int
。->
运算符是右结合的,因此这等价于(int -> int) -> (int -> int)
。请注意,如果我们省略了对f
类型的括号,我们将不再有一个接受另一个函数作为参数的函数,因为int -> int -> int -> int
等价于int -> (int -> (int -> int))
。
以下是更多有用的高阶函数示例,我们留给您思考(并在家里尝试):
let compose ((f, g) : (int -> int) * (int -> int)) (x : int) : int =
f (g x)
let rec ntimes ((f, n) : (int -> int) * int) =
if n = 0
then (fun (x : int) -> x)
else compose (f, ntimes (f, n - 1))
副作用
到目前为止,我们只向您展示了纯函数式编程。但在某些情况下,命令式编程是不可避免的。打印值到屏幕就是其中之一。到目前为止,您可能发现在没有任何方法在屏幕上显示中间值的情况下调试您的 OCaml 代码很困难。OCaml 提供了函数print_string : string -> unit
来将字符串打印到屏幕上。
打印到屏幕被称为副作用,因为它改变了计算机的状态。到目前为止,我们编写的函数没有改变任何状态,只是计算某个值。稍后我们将向您展示更多副作用的示例,但现在打印就足够了。
由于 OCaml 的类型系统,print_string
不像 Java 的System.out.println
那样重载。要打印int
、float
、bool
等,必须先将其转换为字符串。幸运的是,有内置函数可以进行这种转换。例如,string_of_int
将int
转换为string
。因此,要打印 3,我们可以写成print_string (string_of_int 3)
。这里需要括号,因为 OCaml 从左到右评估表达式。
那么如何在代码中放置打印语句呢?有两种方法。第一种是使用let
表达式。这些可以放在其他let
表达式中,允许您打印中间值。
let x = 3 in
let () = print_string ("Value of x is " ^ (string_of_int x)) in
x + 1
还有第二种方式。为此,我们引入新的语法。
e ::= … | (
e[1];
… ;
e[n] )
这个表达式告诉 OCaml 按顺序评估表达式e[1],…,*e[n]并返回评估e[n]*的结果。因此,我们可以将我们的例子写成
let x = 3 in
(print_string ("Value of x is " ^ (string_of_int x));
x + 1)
异常
为了处理错误,OCaml 提供了内置异常,就像 Java 一样。要声明一个名为Error
的异常,您可以这样写
exception Error
然后,要抛出异常,我们使用raise
关键字。使用平方根函数的示例是
let sqrt1 (x : float) : float =
if x < 0 then raise Error
else sqrt x
异常的类型与抛出异常的代码匹配。例如,在sqrt1
函数中,Error
的类型将是float
,因为表达式必须求值为实数。
异常也可以携带值。一个例子是内置异常Failure
,定义如下
exception Failure of string
要引发此异常,我们写
raise (Failure "Some error message")
我们还可以通过使用try-with
关键字捕获异常。滥用这种能力是不明智的。过多使用异常会导致难以阅读的意大利面代码。在这门课程中,可能永远不需要处理异常。异常应该只在真正异常的情况下引发,也就是说,在造成无法恢复的损害时。如果可以通过检查边界或使用选项来避免异常,那是更可取的。请参考 OCaml 风格指南,了解更多关于如何正确使用异常的示例和信息。
第五讲:多态性和模式匹配
变体类型
列表非常有用,但事实证明它们并不像看起来那么特殊。我们可以实现我们自己的列表,以及其他更有趣的数据结构,例如二叉树。
我们已经看到了一些简单的变体类型的示例,有时被称为代数数据类型或简称为数据类型。变体类型提供了一些必要的功能:能够有一个变量包含多种类型的值。
与元组类型和函数类型不同,但与记录类型相同,变体类型不能是匿名的;它们必须用它们的名称声明。假设我们想要一个变量,它可以包含三个值之一:Yes、No 或 Maybe,非常类似于 Java 中的 enum
。其类型可以声明为一个变体类型:
# type answer = Yes | No | Maybe;;
*type answer = Yes | No | Maybe*
# let x : answer = Yes;;
*val x : answer = Yes*
type
关键字声明了新类型的名称。变体类型是用一组构造函数声明的,描述了创建该类型值的可能方法。在这种情况下,我们有三个构造函数:Yes
、No
和 Maybe
。构造函数名称必须以大写字母开头,而 OCaml 中的所有其他名称必须以小写字母开头。
不同的构造函数也可以携带其他值。例如,假设我们想要一个类型,它可以是 2D 点或 3D 点。它可以声明如下:
type eitherPoint = TwoD of float * float
| ThreeD of float * float * float
类型 eitherPoint
的一些值的示例包括:TwoD (2.1, 3.0)
和 ThreeD (1.0, 0.0, -1.0)
。
假设我们有一个类型为 eitherPoint
的值,它可以是某个值的 TwoD
或 ThreeD
。我们需要一种方法来提取“某个值”。这可以通过模式匹配来实现。例如:
let lastTwoComponents (p : eitherPoint) : float * float =
match p with
TwoD (x, y) -> (x, y)
| ThreeD (x, y, z) -> (y, z)
变体类型语法
我们使用 X 作为元变量来表示构造函数的名称,T 来表示类型的名称。方括号 [ ] 表示可选的语法元素。然后,变体类型声明的一般形式如下所示:
type
T = X[1][of
t[1]] | … | X[n] [of
t*[n]*]
变体类型引入了术语 e、模式 p 和值 v 的新语法:
e ::= … | X e |
match
* e *with
p[1]->
e[1]| … | p[n]->
*e[n]*p ::= *X *| X(
x[1] : t[1], …, x[n] : t[n])
v ::= c | (v[1], …, v[n]) |
fun
p->
e | X v
注意表达式 “match
* e *with
p[1] ->
e[1 ]| … | p[n] ->
e[n]” 中的竖线是该结构的语法的一部分;其他竖线 (|) 是 BNF 符号的一部分。
我们可以使用变体类型来定义许多有用的数据结构。事实上,bool
实际上只是一个具有名称为 true
和 false
的构造函数的变体类型。
实现整数列表
我们可以使用变体类型编写我们自己的列表版本。假设我们想要定义表现为整数链表的值。链表要么为空,要么有一个整数,后面跟着包含其余列表元素的另一个列表。这导致一个非常自然的变体类型声明:
type intlist = Nil | Cons of (int * intlist)
此类型有两个构造函数,Nil
和Cons
。它是一个递归类型,因为它在自己的定义中(在Cons
构造函数中)提到了自己,就像递归函数是在自己的定义中提到了自己一样。
任何整数列表都可以用这种类型来表示。例如,空列表只是构造函数Nil
,而Cons
对应于操作符::
。以下是一些列表的例子:
let list1 = Nil (* the empty list: [] *)
let list2 = Cons (1, Nil) (* the list containing just 1: [1] *)
let list3 = Cons (2, Cons (1, Nil)) (* the list [2; 1] *)
let list4 = Cons (2, list2) (* also the list [2; 1] *)
(* the list [1; 2; 3; 4; 5] *)
let list5 = Cons (1, Cons (2, Cons (3, Cons (4, Cons (5, Nil)))))
(* the list [6; 7; 8; 9; 10] *)
let list6 = Cons (6, Cons (7, Cons (8, Cons (9, Cons (10, Nil)))))
因此,我们可以构造任意我们想要的列表。我们也可以使用模式匹配来拆解它们。例如,我们上面的length
函数可以通过将列表模式转换为相应的使用构造函数的模式来为intlist
编写。同样,我们可以实现许多其他对列表的函数,如下面的例子所示。
(* An intlist is either Nil or Cons of an int and a (shorter) intlist *)
type intlist = Nil | Cons of int * intlist
(* Returns the length of lst *)
let rec length (lst : intlist) : int =
match lst with
| Nil -> 0
| Cons (h, t) -> length t + 1
(* is the list empty? *)
let is_empty (lst : intlist) : bool =
match lst with
| Nil -> true
| Cons _ -> false
(* Notice that the match expressions for lists all have the same
* form -- a case for the empty list (Nil) and a case for a Cons.
* Also notice that for most functions, the Cons case involves a
* recursive function call. *)
(* Return the sum of the elements in the list *)
let rec sum (lst : intlist) : int =
match lst with
| Nil -> 0
| Cons (i, t) -> i + sum t
(* Create a string representation of a list *)
let rec to_string (lst : intlist) : string =
match lst with
| Nil -> ""
| Cons (i, Nil) -> string_of_int i
| Cons (i, Cons (j, t)) ->
string_of_int i ^ "," ^ to_string (Cons (j, t))
(* Return the head (first element) of the list *)
let head (lst : intlist) : int =
match lst with
| Nil -> failwith "empty list"
| Cons (i, t) -> i
(* Return the tail (rest of the list after the head) *)
let tail (lst : intlist) : intlist =
match lst with
| Nil -> failwith "empty list"
| Cons (i, t) -> t
(* Return the last element of the list (if any) *)
let rec last (lst : intlist) : int =
match lst with
| Nil -> failwith "empty list"
| Cons (i, Nil) -> i
| Cons (i, t) -> last t
(* Return the nth element of the list (starting from 0) *)
let rec nth (lst : intlist) (n : int) : int =
match lst with
| Nil -> failwith "index out of bounds"
| Cons (i, t) ->
if n = 0 then i
else nth t (n - 1)
(* Append two lists: append [1; 2; 3] [4; 5; 6] = [1; 2; 3; 4; 5; 6] *)
let rec append (l1 : intlist) (l2 : intlist) : intlist =
match l1 with
| Nil -> l2
| Cons (i, t) -> Cons (i, append t l2)
(* Reverse a list: reverse [1; 2; 3] = [3; 2; 1].
* First reverse the tail of the list
* (e.g., compute reverse [2; 3] = [3; 2]), then
* append the singleton list [1] to the end to yield [3; 2; 1].
* This is not the most efficient method. *)
let rec reverse (lst : intlist) : intlist =
match lst with
| Nil -> Nil
| Cons (h, t) -> append (reverse t) (Cons (h , Nil))
(******************************
* Examples
******************************)
(* Here is a way to perform a function on each element
* of a list. We apply the function recursively.
*)
let inc (x : int) : int = x + 1
let square (x : int) : int = x * x
(* Given [i1; i2; ...; in], return [i1+1; i2+1; ...; in+n] *)
let rec addone_to_all (lst : intlist) : intlist =
match lst with
| Nil -> Nil
| Cons (h, t) -> Cons (inc h, addone_to_all t)
(* Given [i1; i2; ...; in], return [i1*i1; i2*i2; ...; in*in] *)
let rec square_all (lst : intlist) : intlist =
match lst with
| Nil -> Nil
| Cons (h, t) -> Cons (square h, square_all t)
(* Here is a more general method. *)
(* Given a function f and [i1; ...; in], return [f i1; ...; f in].
* Notice how we factored out the common parts of addone_to_all
* and square_all. *)
let rec do_function_to_all (f : int -> int) (lst : intlist) : intlist =
match lst with
| Nil -> Nil
| Cons (h, t) -> Cons (f h, do_function_to_all f t)
let addone_to_all (lst : intlist) : intlist =
do_function_to_all inc lst
let square_all (lst : intlist) : intlist =
do_function_to_all square lst
(* Even better: use anonymous functions. *)
let addone_to_all (lst : intlist) : intlist =
do_function_to_all (fun x -> x + 1) lst
let square_all (lst : intlist) : intlist =
do_function_to_all (fun x -> x * x) lst
(* Equivalently, we can partially evaluate by applying
* do_function_to_all just to the first argument. *)
let addone_to_all : intlist -> intlist =
do_function_to_all (fun x -> x + 1)
let square_all : intlist -> intlist =
do_function_to_all (fun x -> x * x)
(* Say we want to compute the sum and product of integers
* in a list. *)
(* Explicit versions *)
let rec sum (lst : intlist) : int =
match lst with
| Nil -> 0
| Cons (i, t) -> i + sum t
let rec product (lst : intlist) : int =
match lst with
| Nil -> 1
| Cons (h, t) -> h * product t
(* Better: use a general function collapse that takes an
* operation and an identity element for that operation.
*)
(* Given f, b, and [i1; i2; ...; in], return f(i1, f(i2, ..., f (in, b))).
* Again, we factored out the common parts of sum and product. *)
let rec collapse (f : int -> int -> int) (b : int) (lst : intlist) : int =
match lst with
| Nil -> b
| Cons (h, t) -> f h (collapse f b t)
(* Now we can define sum and product in terms of collapse *)
let sum (lst : intlist) : int =
let add (i1 : int) (i2 : int) : int = i1 + i2 in
collapse add 0 lst
let product (lst : intlist) : int =
let mul (i1 : int) (i2 : int) : int = i1 * i2 in
collapse mul 1 lst
(* Here, we use anonymous functions instead of defining add and mul.
* After all, what's the point of giving those functions names if all
* we're going to do is pass them to collapse? *)
let sum (lst : intlist) : int =
collapse (fun i1 i2 -> i1 + i2) 0 lst
let product (lst : intlist) : int =
collapse (fun i1 i2 -> i1 * i2) 1 lst
(* Trees of integers *)
type inttree = Empty | Node of node
and node = { value : int; left : inttree; right : inttree }
(* Return true if the tree contains x. *)
let rec search (t : inttree) (x : int) : bool =
match t with
| Empty -> false
| Node {value=v; left=l; right=r} ->
v = x || search l x || search r x
let tree1 =
Node {value=2; left=Node {value=1; left=Empty; right=Empty};
right=Node {value=3; left=Empty; right=Empty}}
let z = search tree1 3
使用递归类型表示树
树是另一种非常有用的数据结构。与列表不同,它们不是内置到 OCaml 中的。二叉树要么是
-
空树(没有子节点),或者
-
包含一个值和两个子树(二叉树)的节点。
为了变化一下,让我们使用记录类型来表示树节点。在 OCaml 中,我们必须定义两种相互递归的类型,一个用于表示树节点,另一个用于表示(可能为空的)树:
type inttree = Empty | Node of node
and node = { value : int; left : inttree; right : inttree }
相互递归类型声明何时合法的规则有点棘手。基本上,任何递归类型的循环必须包含至少一个记录类型或变体类型。由于inttree
和node
之间的循环包括两种类型,因此此声明是合法的。
2
/ \ Node {value=2; left=Node {value=1; left=Empty; right=Empty};
1 3 right=Node {value=3; left=Empty; right=Empty}}
因为树节点中存储了几个东西,使用记录而不是元组来保持它们的清晰是有帮助的。但元组也可以工作。
我们可以使用模式匹配来编写通常用于递归遍历树的算法。例如,这是一个对树的递归搜索:
(* Return true if the tree contains x. *)
let rec search ((t: inttree), (x:int)): bool =
match t with
Empty -> false
| Node {value=v; left=l; right=r} ->
v = x || search (l, x) || search (r, x)
当然,如果我们知道树遵循二叉搜索树的不变性,我们可以编写一个更有效的算法。
使用递归类型表示自然数
我们甚至可以定义像数字一样的数据结构,证明我们也不一定需要将数字内置到 OCaml 中!自然数要么是值为零,要么是另一个自然数的后继。这个定义自然地导致了对行为像自然数的值nat
的以下定义:
type nat = Zero | Next of nat
这是你可能在数理逻辑课程中定义自然数的方式。我们定义了一个新类型nat
,Zero
和Next
是此类型值的构造函数。类型nat
是一个递归类型,它允许我们构建具有任意数量嵌套Next
构造函数的表达式。这样的值表现得像自然数:
let zero = Zero
and one = Next Zero
and two = Next (Next Zero)
let three = Next two
let four = Next three
当我们询问解释器 four 代表什么时,我们得到
four;;
*- : nat = Next (Next (Next (Next Zero)))*
相应的 Java 定义将是
public interface nat { }
public class Zero implements nat {}
public class Next implements nat {
nat v;
Next(nat v) { v = this.v; }
}
nat zero = new Zero();
nat one = new Next(new Zero());
nat two = new Next(new Next(new Zero()));
nat three = new Next(two);
nat four = new Next(three);
实际上,实现是类似的。
现在我们可以编写操作此类型值的函数了。
let isZero (n : nat) : bool =
match n with
Zero -> true
| Next m -> false
在这里,我们正在模式匹配具有类型 nat
的值。如果值是 Zero
,我们会计算为 true
;否则,我们会计算为 false
。
let pred (n : nat) : nat =
match n with
Zero -> failwith "Zero has no predecessor"
| Next m -> m
在这里,我们确定一个数字的前驱。如果 n
的值匹配 Zero
,那么我们会引发一个异常,因为自然数中没有零的前驱。如果值匹配 Next m
,其中 m
的值也必须是 nat
类型,那么我们返回 m
。
类似地,我们可以定义一个函数来相加两个数字:
let rec add (n1 : nat) (n2 : nat) : nat =
match n1 with
Zero -> n2
| Next m -> add m (Next n2)
如果你尝试评估 add four four
,解释器会回答:
add four four;;
*- : nat = Next (Next (Next (Next (Next (Next (Next (Next Zero)))))))*
这是 8 的 nat
表示。
为了更好地理解我们计算的结果,我们希望将这些值转换为 int
类型:
let rec toInt (n : nat) : int =
match n with
Zero -> 0
| Next n -> 1 + toInt n
这相当容易。现在我们可以写 toInt (add four four)
并得到 8。逆操作呢?
let rec toNat (i : int) : nat =
if i < 0 then failwith "toNat on negative number"
else if i = 0 then Zero
else Next (toNat (i - 1))
要确定一个自然数是偶数还是奇数,我们可以编写一对互相递归的函数:
let rec even (n : nat) : bool =
match n with
Zero -> true
| Next n -> odd n
and odd (n : nat) : bool =
match n with
Zero -> false
| Next n -> even n
你必须使用关键字 and
来组合这样的互相递归函数。否则,在引用尚未定义的 odd
时,编译器会报错。
最后,我们可以根据加法来定义乘法。
let rec mul (n1 : nat) (n2 : nat) : nat =
match n1 with
Zero -> Zero
| Next m -> add n2 (mul m n2)
这给出了
toInt (mul (toNat 5) (toNat 20));;
*- : int = 100*
模式匹配
结果显示 OCaml 模式的语法比我们在上一堂课上看到的更丰富。除了用于创建和投影元组和记录值,创建和检查变体类型值的新术语外,我们还有能力将模式与值匹配,将其拆分为其部分。
使用正确的方式,模式匹配会导致简洁清晰的代码。这是因为 OCaml 模式匹配允许一个模式出现为另一个模式的子表达式。例如,我们在上面看到 Next n
是一个模式,但 Next (Next n)
也是一个模式。这第二个模式仅匹配形式为 Next (Next
v)
的值,其中 v 是某个值(也就是某个东西的后继的后继),并将变量 n
绑定到该值。
同样,在我们之前的 nth
函数的实现中,一个巧妙的技巧是使用模式匹配来同时执行 if n = 0
和 match
。我们对元组 (lst, n)
进行模式匹配:
(* Returns the nth element of lst *)
let rec nth lst n =
match (lst, n) with
(h :: t, 0) -> h
| (h :: t, _) -> nth (t, n - 1)
| ([], _) -> failwith "nth applied to empty list"
在这里,我们还添加了一个子句来捕获空列表并引发异常。我们还使用通配符模式 _
来匹配元组的 n
组件,因为我们不需要将 n
的值绑定到另一个变量上——我们已经有了 n
。我们可以让这段代码变得更短;你能看出来怎么做吗?
示例:对记录进行模式匹配
所有的自然数都是非负数,但我们可以通过使用由符号和幅度组成的表示来模拟整数的自然数:
type sign = Pos | Neg
type integer = { sign : sign; mag : nat }
我们将整数定义为具有两个字段:sign 和 mag 的记录类型。请记住,记录是无序的,因此没有“第一个”字段的概念。
sign
和 integer
的声明都创建了新类型。然而,可以编写类型声明,只是为现有类型引入新名称。例如,如果我们写了 type number = int
,那么类型 number
和 int
可以互换使用。
我们可以使用integer
的定义来写一些整数:
let zero = {sign=Pos; mag=Zero}
let zero' = {sign=Neg; mag=Zero}
let one = {sign=Pos; mag=Next Zero}
let negOne = {sign=Neg; mag=Next Zero}
现在我们可以编写一个函数来确定任意整数的后继:
let inc (i : integer) : integer =
match i with
{sign = _; mag = Zero} -> {sign = Pos; mag = Next Zero}
| {sign = Pos; mag = n} -> {sign = Pos; mag = Next n}
| {sign = Neg; mag = Next n} -> {sign = Neg; mag = n}
在这里,我们正在模式匹配记录类型。请注意,在第三个模式中,我们进行模式匹配,因为mag
字段与一个模式本身匹配,Next n
。请记住,模式是按顺序测试的。如果前两个模式被交换,这个函数的含义会发生什么变化?
前驱函数非常相似,很明显我们可以编写函数来在这种表示中添加、减去和乘以整数。
OCaml 语法
考虑到编写复杂模式的能力,我们现在可以为 OCaml 编写一个更全面的语法了。
语法类 | 语法变量和语法规则 | 示例 |
---|---|---|
标识符 | x, y | a , x , y , x_y , foo1000 , … |
数据类型,数据类型构造函数 | X, Y | Nil , Cons , list |
| 常量 | c | …~2
, ~1
, 0
, 1
, 2
(整数) 1.0
, ~0.001
, 3.141
(浮点数)
true
, false
(布尔值)
"hello"
, ""
, "!"
(字符串)
#"A"
, #" "
(字符) |
一元运算符 | u | ~ , not , size , … |
---|---|---|
二进制运算符 | b | + , * , - , > , < , >= , <= , ^ , … |
表达式(项) | e ::- c * | x | u e * | e[1] b e[2] |if * e*[1]then * e*[2] else * e*[3]* | let d[1]…**d[n]*in eend | e ( e[1], …, e[n]) | ( e[1], …, e[n]) | # n e | { x[1]= e[1], …, x[n]= e[n]} | # *x e | X( e) | match e *with p[1]-> e[1]| … | p[n]-> e[n] | ~0.001 , foo , not b , 2 + 2 , Cons(2, Nil) |
模式 | p ::= c * | x | ( p[1], …, p[n]) | *{ x[1]= p[1], …, x[n]= p[n]} | X | X ( p ) | a:int , (x:int,y:int), I(x:int) |
| 声明 | d ::= val
p = e |fun
y p :
t - e | da``tatype
Y- X[1][of
t[1]] |
… |
X[n ][of
t[n]] | `val one = 1 fun square(x: int): int
datatype d - N | I of int` |
类型 | t ::= int | float | bool | string | char | t[1]-> t[2]* | *t[1]* …* t[n] | { x[1]:t[1], x[2]:t[2],…, x[n]:t[n]} | Y | int , string , int->int , bool*int->bool |
---|---|---|
values | v ::= c | ( v[1], …, v[n]) | { x[1]= v[1], …, x[n]= v[n]} | X( v) | 2 , (2,"hello") , Cons(2,Nil) |
*注意:*对浮点常量进行模式匹配是不明智的。浮点数的相等性是一件危险的事情。最好的做法是测试一个浮点数是否在另一个浮点数的某个小距离 epsilon 内。
多态性
有一个很好的功能,可以让我们避免一遍又一遍地重写相同的代码,以便它适用于不同的类型。假设我们想要编写一个函数,用于交换有序对中值的位置:
let swapInt ((x : int), (y : int)) : int * int = (y, x)
and swapReal ((x : float), (y : float)) : float * float = (y, x)
and swapString ((x : string), (y : string)) : string * string = (y, x)
这很烦人,因为我们每次都要写完全相同的算法。更糟糕的是!如果两个对元素具有不同的类型怎么办?
let swapIntReal ((x : int), (y : float)) : float * int = (y, x)
and swapRealInt ((x : float), (y : int)) : int * float = (y, x)
等等。有一种更好的方法:
# let swap ((x : 'a), (y : 'b)) : 'b * 'a = (y, x);;
*val swap : 'a * 'b -> 'b * 'a = <fun>*
我们不是为 x
和 y
写明确的类型,而是写下类型变量'a
和 'b
。swap
的类型是 'a * 'b
->
'b * 'a
. 这意味着我们可以将 swap 视为具有任何我们能够通过一致替换其类型中的'a
和'b
而获得的类型。我们可以将新的 swap 替换为所有旧定义:
swap (1, 2) (* (int * int) -> (int * int) *)
swap (3.14, 2.17) (* (float * float) -> (float * float) *)
swap ("foo", "bar") (* (string * string) -> (string * string) *)
swap ("foo", 3.14) (* (string * float) -> (float * string) *)
实际上,我们可以在 swap
的定义中省略类型声明,OCaml 将自动找出它可以给出的最一般的多态类型:
# let swap (x, y) = (y, x);;
*val swap : 'a * 'b -> 'b * 'a = <fun>*
将 swap
用作具有许多不同类型的能力被称为多态性,源自希腊语的 “多种形式”。
请注意,在任何多态表达式的使用中,类型变量必须一致地进行替换。例如,swap
的类型为(int * float) -> (string * int)
是不可能的,因为该类型会一致替换类型变量'a
,但不会替换'b
。
OCaml 程序员通常将类型'a
和'b
读作 “alpha” 和 “beta”。这比说 “单引号 a” 或 “撇号 a” 要容易。他们还希望能够使用希腊字母。类型变量可以是由单引号引导的任何标识符;例如,'key
和 'value
也是合法的类型变量。OCaml 编译器需要在这些标识符之前加上单引号,以便知道它正在看到一个类型变量。
重要的是要注意,要使参数 x
在多态上成立,函数不能以任何方式使用 x
以确定其类型。它必须将 x
视为黑盒子。请注意,swap
并不以任何有趣的方式使用其参数 x
或 y
,而是将它们视为黑盒子。当 OCaml 类型检查器检查 swap
的定义时,它只知道 x
是某种任意类型 'a
。它不允许对 x
执行任何不能在任意类型上执行的操作。这意味着该代码保证适用于任何 x
和 y
。然而,我们可以应用其他多态函数。例如,
# let appendToString ((x : 'a), (s : string), (convert : 'a -> string)) : string =
(convert x) ^ " " ^ s;;
*val appendToString : 'a * string * ('a -> string) -> string = <fun>*
# appendToString (3110, "class", string_of_int);;
*- : string = "3110 class"*
# appendToString ("ten", "twelve", fun (s : string) -> s ^ " past");;
*- : string = "ten past twelve"*
参数化类型
我们还可以定义多态数据类型。例如,我们将整数列表定义为
type intList = Nil | Cons of (int * intList)
但是,我们可以通过使用参数化变体类型来使其更通用:
type 'a list_ = Nil | Cons of ('a * 'a list_)
参数化数据类型是创建一系列相关数据类型的方法。名称'a
是类型参数,可以提供任何其他类型。例如,int list_
是整数列表,float list_
是浮点数列表,依此类推。但是,list_
本身并不是一种类型。还要注意,我们不能使用list_
来创建每个元素可以是任何类型的列表。T list_
的所有元素必须是T
。
let il : int list_ = Cons (1, Cons (2, Cons (3, Nil))) (* [1; 2; 3] *)
let fl : float list_ = Cons (3.14, Cons (2.17, Nil)) (* [3.14; 2.17] *)
let sl : string list_ = Cons ("foo", Cons ("bar", Nil)) (* ["foo"; "bar"] *)
let sil : (string * int) list_ =
Cons (("foo", 1), Cons (("bar", 2), Nil)) (* [("foo", 1); ("bar", 2)] *)
注意list_
本身并不是一种类型。我们可以将list_
视为一个函数,当应用于类似int
的类型时,会产生另一种类型(int
list_
)。它是一个参数化类型构造器:一个接受参数并返回类型的函数。其他语言也有参数化类型构造器。例如,在 Java 中,您可以声明一个参数化类:
class List<T> {
T head;
List <T> tail;
...
}
在 OCaml 中,我们可以定义多态函数,它们知道如何操作任何类型的列表:
(* polymorphic lists *)
type 'a list_ = Nil | Cons of 'a * 'a list_
(* is the list empty? *)
let is_empty (lst : 'a list_) : bool =
match lst with
| Nil-> true
| _ -> false
(* length of the list *)
let rec length (lst : 'a list_) : int =
match lst with
| Nil-> 0
| Cons (_, rest) -> 1 + length rest
(* append [a; b; c] [d; e; f] = [a; b; c; d; e; f] *)
let rec append (x : 'a list_) (y : 'a list_) : 'a list_ =
match x with
| Nil-> y
| Cons (h, t) -> Cons (h, append t y)
(* [1; 2; 3] *)
let il = Cons (1, Cons (2, Cons (3, Nil)))
let il2 = append il il
let il4 = append il2 il2
let il8 = append il4 il4
(* ["a"; "b"; "c"] *)
let sl = Cons ("a", Cons ("b", Cons ("c", Nil)))
let sl2 = append sl sl
let sl4 = append sl2 sl2
(* reverse the list: reverse [1; 2; 3; 4] = [4; 3; 2; 1] *)
let rec reverse (x : 'a list_) : 'a list_ =
match x with
| Nil-> Nil
| Cons (h, t) -> append (reverse t) (Cons (h, Nil))
let il4r = reverse il4
let sl4r = reverse sl4
(* apply the function f to each element of x
* map f [a; b; c] = [f a; f b; f c] *)
let rec map (f : 'a -> 'b) (x : 'a list_) : 'b list_ =
match x with
| Nil-> Nil
| Cons (h, t) -> Cons (f h, map f t)
let mil4 = map string_of_int il4
(* insert sep between each element of x:
* separate s [a; b; c; d] = [a; s; b; s; c; s; d] *)
let rec separate (sep : 'a) (x : 'a list_) : 'a list_ =
match x with
| Nil-> Nil
| Cons (h, Nil) -> x
| Cons (h, t) -> Cons (h, Cons (sep, separate sep t))
let s0il4 = separate 0 il4
对于树,
type 'a tree = Leaf | Node of ('a tree) * 'a * ('a tree)
如果我们使用记录类型作为节点,那么记录类型也必须是参数化的,并且在树类型的相同元素类型上进行实例化:
type 'a tree = Leaf | Node of 'a node
and 'a node = {left: 'a tree; value: 'a; right: 'a tree}
还可以在参数化类型上具有多个类型参数,这种情况下需要使用括号:
type ('a, 'b) pair = {first: 'a; second: 'b};;
let x = {first=2; second="hello"};;
*val x: (int, string) pair = {first = 2; second = "hello"}*
抽象语法和变体类型
我们早些时候注意到,BNF 声明与变体类型声明之间存在相似性。实际上,我们可以定义行为类似于对应的 BNF 声明的变体类型。然后,这些变体类型的值代表了语言中可以出现的合法表达式。例如,考虑一个 OCaml 类型表达式的合法 BNF 定义:
(基本类型) | b ::= int | float | string | bool | char |
---|---|
(类型) | t ::= b | t -> t | t[1] * t[2] * …* t[n] | { x[1] : t[1]; …; x[n] : t[n] } | X |
这个语法与以下类型声明具有完全相同的结构:
type id = string
type baseType = Int | Real | String | Bool | Char
type mlType = Base of baseType | Arrow of mlType * mlType
| Product of mlType list | Record of (id * mlType) list
| DatatypeName of id
任何合法的 OCaml 类型表达式都可以由包含相应类型表达式所有信息的mlType
类型的值表示。这个值被称为该表达式的抽象语法。它是抽象的,因为它不包含关于在程序中表示表达式的实际符号的任何信息。例如,表达式int * bool -> {name : string}
的抽象语法将是:
Arrow (Product (Cons (Base Int, Cons (Base Bool, Nil))),
Record (Cons (("name", Base String), Nil)))
即使对于相同类型表达式的更详细的版本,抽象语法也会完全相同:((int * bool) -> {name : string})
。编译器通常在内部使用抽象语法来表示它们正在编译的程序。当我们了解 OCaml 的工作原理时,我们将在课程后期看到更多的抽象语法。
第四次课:数据类型复习
数据类型构造函数,绑定和使用出现
按照惯例,数据构造函数(在下面的示例中为Penny
、Nickel
等)以大写字母开头。有几个例外,比如true
和false
。
type coin = Penny | Nickel | Dime | Quarter
按照惯例,变量(在示例中为value
和c
)以小写字母开头。
let value (c : coin) : float =
match c with
Penny -> 0.01
| Nickel -> 0.05
| Dime -> 0.10
| Quarter -> 0.25
上面的例子是一个典型的match
表达式。但考虑以下变体。看起来应该是相同的,但当我们编译此函数时,OCaml 会抱怨有多余的模式。
let bad_value (c : coin) : float =
let penny = Penny in
match c with
penny -> 0.01
| Nickel -> 0.05
| Dime -> 0.10
| Quarter -> 0.25
为什么?毕竟,这不就相当于以下内容吗?
let bad_value2 (c : coin) : float =
let penny = Penny in
if c = penny then 0.01
else if c = Nickel then 0.05
else if c = Dime then 0.10
else if c = Quarter then 0.25
else raise (Failure "impossible!")
不!实际上,更像是
let bad_value2 (c : coin) : float =
let penny = Penny in
match c with
random_variable_name -> 0.01
| Nickel -> 0.05
| Dime -> 0.10
| Quarter -> 0.25
或者甚至
let bad_value3 (c : coin) : float =
let penny = Penny in
match c with
_ -> 0.01
| Nickel -> 0.05
| Dime -> 0.10
| Quarter -> 0.25
在匹配表达式中,如果在一个模式的->
的左侧有一个数据构造函数C,那么我们正在比较要匹配的表达式e的值是否为C。但如果它是一个变量名,那么我们正在声明一个新的、新鲜的变量实例,并将其绑定到e的值。因此,下面的任何模式都是多余的,因为这个匹配永远不会失败。
表达式中标识符的出现可以是绑定出现或使用出现。例如,以下是标识符id
的所有绑定出现的示例:
let id = e (* a value declaration *)
let id = e in ... (* a value declaration *)
let id (arg : s) : t = e (* a function declaration *)
let id (arg1 : s1) (arg2 : s2) : t = e (* a function declaration *)
match e with
id -> ... (* a pattern match *)
相反,几乎任何其他东西,例如
if id = e then ... else ...
id + 3
是对id
的使用发生。在这些出现中,id
被评估,并且其值是在最近的id
绑定出现中绑定给它的值,无论是作为函数参数、函数声明、值声明还是模式匹配。
在上面的例子中
let bad_value (c : coin) : float =
let penny = Penny in
match c with
penny -> 0.01
| Nickel -> 0.05
| Dime -> 0.10
| Quarter -> 0.25
在匹配的第一个模式中,OCaml 不会查看penny
之前是否绑定到Penny
。在匹配表达式中,penny
的出现也是一个绑定出现。匹配将成功,并且将penny
重新绑定到c
的值。
为什么这样做?最重要的原因是在模式中绑定标识符是一种非常有用的设备,可以使代码简洁、优雅。可以同时在一个模式中绑定几个标识符。例如,
match e with
Add (x, y) :: t -> x + y
| ...
同时绑定三个标识符x
、y
和t
,然后可以在右侧的表达式中使用它们。如果不需要值,请在模式中使用通配符_
,它匹配任何内容。例如,
match e with
Add (x, y) :: _ -> x + y
| ...
如果我们想要在模式中允许标识符的使用出现,我们将需要一种方法来区分它们与绑定出现。目前在 OCaml 中没有办法做到这一点。
这里有一个谜题来说明绑定和使用出现之间的区别。以下表达式的值是多少?
(*1*) let f ((x : int), (y : int)) : int =
(*2*) let x = y in
(*3*) let y = x in
(*4*) let (y, x) = (x, y * y) in
(*5*) match (y, x) with
(*6*) (x, 1) -> 0
(*7*) | (x, y) -> x
(*8*) in f (2, 3)
搞清楚这并不容易,但是下面是如何思考的。让我们按照它们在不同绑定出现的行中绑定的值来引用变量。因此,x[1]表示x
在第 1 行绑定的值。
-
第 1 行绑定了三个标识符
f
,x
和y
。标识符x
和y
绑定到第 8 行提供的f
的参数。所以 x[1] = 2,y[1] = 3。标识符f
绑定到其主体在第 2-7 行给出的函数。 -
第 2 行包含了
x
的绑定出现和y
的使用出现。所以 x[2] = y[1] = 3。 -
第 3 行包含了
y
的绑定出现和x
的使用出现。x
最接近的绑定在第 2 行。所以 y[3] = x[2] = 3。 -
第 4 行包含了
x
和y
的绑定出现在=
的左边,并且在=
的右边使用了这两个标识符。使用出现从最近的前一个绑定中获取它们的值。所以 y[4] = x[2] = 3,而 x[4] = y[3] * y[3] = 9。 -
第 5 行只包含
x
和y
的使用出现。值分别为 x[4] = 9 和 y[4] = 3。它初始化了元组(y[4],x[4])= (3, 9) 的模式匹配。 -
第 6 行将 x[6] 绑定到 y[4] = 3,并尝试将 (3, 9) 与 (3, 1) 进行匹配。匹配失败,所以我们继续下一个模式。
-
第 7 行将 x[7] 绑定到 y[4] = 3,并将 y[7] 绑定到 x[4] = 9,并尝试将 (3, 9) 与 (3, 9) 进行匹配。匹配成功,
->
右侧的值是 x[7] = 3,这也是整个表达式的值。
使用多态性
列表类型
由于列表非常有用,OCaml 提供了一个内置的参数化列表类型称为list
。它的行为就像我们在讲座中定义的List
类型一样,只是构造函数的名称已更改。构造函数[]
生成一个空列表(与Nil
相比较),构造函数::
通过在另一个列表之前添加第一个元素来构建一个新列表(与Cons
相比较)。就好像list
被声明为:
type 'a list = [] | :: of 'a * 'a list
(尽管由于 OCaml 的命名惯例,我们实际上不能这样做)。构造函数::
是一个中缀操作符,这在表示上非常方便。OCaml 解释器也知道如何很好地打印列表。空列表打印为[]
,非空列表使用分号分隔的项目在括号内打印。这些形式也可用于编写列表。注意,[]
是类型为'a list
的多态值;它对于所有类型T list
都充当空列表。以下是一些展示列表工作原理的示例:
[];;
*- : 'a list = []*
let it = 2 :: [];;
*val it : int list = [2]*
let both = 1 :: it;;
*val both : int list = [1; 2]*
let both2 =
match both with
x :: lst -> lst
| [] -> [];;
*val both2 : int list = [2]*
let both3 =
match both2 with
x :: lst -> lst
| [] -> [];;
(* we don't "recover polymorphism" here; it would be unsafe in general *)
*val both3 : int list = []*
both = 1 :: 2 :: [];;
(* we can test lists for equality if we can test their elements *)
*- : bool = true*
match both with
[x; y] -> x + y (* we can use bracket notation in patterns *)
| _ -> 0;;
*- : int = 3*
[[]];;
*- : 'a list list = [[]]*
就像类型一样,我们必须确保在match
表达式中编写全面的模式:
match ["hello"; "goodbye"] with
s :: _ -> s ^ " hello";;
*Warning P: this pattern-matching is not exhaustive. Here is an example of a value that is not matched: []*
内置列表带有许多有用的预定义 OCaml 库函数,例如以下等等:
val List.length : 'a list -> int
val @ : ('a list * 'a list) -> 'a list (* append two lists *)
val List.hd : 'a list -> 'a
val List.tl : 'a list -> 'a list
val List.nth : ('a list * int) -> 'a
当然,所有这些函数也可以轻松地为我们自己定义的List
类型实现。
多个类型参数
我们在课堂上看到了 OCaml 的两个相关特性:
-
能够生成类型提及类型变量的多态值,以及
-
能够相对于任意类型变量对类型进行参数化。
多态值通常是函数值,但也存在其他多态值,比如 []
(以及我们定义的 Nil
)。实际上,数据类型可以根据多个类型参数进行参数化。例如,以下类型 ortype
是一个类型级函数,接受一对类型并产生一个新类型:
# type ('a, 'b) ortype = Left of 'a | Right of 'b | Both of 'a * 'b;;
*type ('a, 'b) ortype = Left of 'a | Right of 'b | Both of 'a * 'b*
# Left 2;;
*- : (int, 'a) ortype = Left 2*
# Right "hello";;
*- : ('a, string) ortype = Right "hello"*
# Both (true, 'a');;
*- : (bool, char) ortype = Both (true, 'a')*
注意值 Left 2
和 Right "hello"
在一种类型上仍然是多态的。OCaml 总是从 'a
开始计算类型变量,因此上面 Left 2
匹配中的 *- : (int, 'a) ortype*
而不是 *- : (int, 'b) ortype*
。
参数化类型选项
另一个重要的标准参数化类型是option
,它表示值的可能存在性。其定义如下:
type 'a option = Some of 'a | None
选项在某种类型的有用值没有意义时通常被使用。这对应于 Java 中 null
的一些用法(None
的行为类似于null
)。不同之处在于,使用 option
是类型安全的。只要你使用模式匹配与 option
,就不会出现运行时空指针异常的危险,因为类型系统强制你考虑到None
的可能性。应该使用模式匹配来检查并提取值。更详细的选项描述在 OCaml 库文档中可用。