Coursera 《 Programming Languages》 by University of Washington
说明
这是华盛顿大学的一门幕课《编程语言》(Programming Language)第一部分的讲义翻译。该课程可能是我接触过的所有幕课中最优质的。老师 Dan Grossman 用很简单的代码示范和很短的时间就能把一门语言的核心教给学生,并且让学生明白背后的原理。简单的问题复杂化的老师比比皆是,但是反过来,能够把复杂的问题简单化的绝对是大师。国内的幕课我加过不少,这样的老师凤毛鳞角。
这门课第一部分用ML语言讲授,教完基本的核心语法后立刻就用这门语言实现了一个简单的只支持基本的算术运算的解释器。随着课程进展,这个解释器会越来越完备。最后,学生需要在作业中完成核心的代码,将它变成一个支持语法作用域和递归的解释器。
在后面的部分中,这个基本的解释器会被不同的语言重写。在这个过程中,学生会体验到不同的编程范式之间的差异和共同点。当然,我只翻译了第一部分,后面的部分没有翻译。
对于没有看视频,没有做作业的人来讲,这个讲义似乎没有多少意义,看了等于没看。所以,这只相当于我自己记的笔记。
我强烈建议有条件有兴趣的人亲自去参加课程学习。
课程在Coursera上: https://www.coursera.org/learn/programming-languages
B站有课程视频:https://www.bilibili.com/video/BV1YW41177Ei
Part A
第一节
欢迎来到编程语言课程
一门被称为“编程语言”的课程可能意味着许多不同的东西。对我们来说,这意味着有机会学习一些基本概念,这些基本概念会以某种形式出现在几乎所有编程语言中。我们还将了解这些概念是如何“组合在一起”,为程序员提供了所需要的东西。我们将使用不同的语言来观察他们如何用互补的方法来表达这些概念。所有这些都是为了让你成为一个更好的软件开发人员,不论你使用什么语言。
很多人会说这门课程“教授”了3种语言ML(在A部分)、Racket(在B部分)和Ruby(在C部分),但这个描述并不准确。我们仅仅通过这些语言来学习各种范式和概念,因为它们非常适合于这个目的。如果我们的目标是让你尽可能高效率地使用这三种语言,那么课程材料将会和现在有很大的不同。也就是说,能够学习新的语言并认识到不同语言之间的异同是本课程一个重要的目标。
课程大部分使用函数式编程(ML和Racket都是函数式语言),它强调不可变数据(没有赋值语句)和函数,特别是能够接受和返回其他函数的函数。正如我们之后将要在C部分中所讨论的,函数式编程所做的某些事情与面向对象编程完全相反,但也有许多相似之处。函数式编程不仅是一种非常强大和优雅的方法,而且学习它可以帮助您更好地理解各种编程风格。
在课程开始的时候,通常要做的事情是激发课程的积极性,对于本课程来说,就是需要解释为什么你应该学习函数式编程,以及为什么学习不同的语言、范例和语言概念是值得的。我们将把这些讨论推迟到第三次家庭作业之后进行。当大多数学生更关心如何理解课程中的作业时,这一点就显得太重要了。更重要的是,在我们就术语和经验达成共识之后,讨论起来就容易多了。动机确实很重要,但是我们“改天再说”,我承诺等待是值得的。
ML表达式和变量绑定
让我们以一种教授核心编程语言概念的方式开始“学习ML”,而不仅仅是“记下一些有用的代码”。因此,请特别注意在开始的阶段,我们用来描述非常非常简单的代码的词汇。我们正在建立一个基础,在本周和下周我们会加快进度。不要试图把你所看到的和你在其他语言中已经知道的联系起来,因为这很可能会导致痛苦的挣扎。
ML程序是一系列绑定的组合。检查每个绑定的类型,假设它通过了类型检查,接下来会对其进行求值。绑定的类型(如果有的话)取决于静态环境,所谓”静态环境“大致上是文件中前面绑定的类型。如何对绑定进行求值则取决于动态环境,”动态环境“大致上相当于文件中前面绑定的值。当我们说环境时,我们通常指的是动态环境。有时候”上下文“被作为静态环境的同义词。
有几种不同类型的绑定,但现在我们暂时只考虑变量绑定,在ML中变量绑定的语法是:
val x = e;
这里,val
是一个关键字,x
可以是任意变量名,e
可以是任意表达式。我们将学习许多表达式的写法。分号在文件中是可选的,但在read-eval-print loop中是必需的,以便让解释器知道您已经完成了绑定的输入。
我们现在知道了变量绑定的语法(如何书写),但还需要知,道它的语义(如何进行类型检查和求值)。这主要取决于表达式e
。要对一个变量绑定进行类型检查,我们使用“当前静态环境”(之前绑定的类型)来对e
进行类型检查(这取决于e
是什么类型的表达式),并生成一个“新的静态环境”,在新的静态环境中,x
的类型为t
,除此之外,和之前的静态环境相同,其中t
是e
的类型。求值规则类似于:求值变量绑定,我们使用“当前动态环境”(之前绑定的值)来计算e
(这取决于它是什么类型的表达式),并生成一个“新的动态环境”,在新的动态环境中,x
的值为v
,除此以外和原先的动态环境相同,其中v
是计算e
的结果。
值是一个表达式,“没有更多的计算要做”,也就是说,没有办法简化它。如下文所要描述的,17
是值,但8+9
不是。所有值都是表达式。但是并非所有表达式都是值。
对于“ML程序意味着什么”的整个描述(绑定、表达式、类型、值、环境)可能看起来是非常理论化的或深奥的,但是它恰恰是我们需要为几种不同类型的表达式提供精确和简洁的定义的基础。下面有几个这样的定义:
- 整数常量
- 语法:整数常量用一个数字序列来表示,例如
32
,~34
。注意,ML使用波浪号表示负号,而不是通常的短横。 - 类型:在任何静态环境中整数常量都是
int
类型 - 求值:在任何动态环境中整数常量都求值为其自身(它本身就是一个值)
- 语法:整数常量用一个数字序列来表示,例如
- 加法
- 语法:
e1 + e2
,其中,e1
和e2
都是表达式 - 类型:如果
e1
和e2
在相同的静态环境中的类型都是int
,则整个表达式的类型为int
,否则将无法通过类型检查 - 求值:在同一个动态环境中,
e1
求值为v1
,e2
求值为v2
,然后将v1
和v2
相加,得到整个加法表达式的值
- 语法:
- 变量
- 语法:变量名用一个 字母和下划线的序列 来表示
- 类型:在当前静态环境中查找变量的类型并使用该类型
- 求值:在当前动态环境中查找变量的值并使用该值
- 条件
- 语法:
if e1 then e2 else e3
,其中,e1
,e2
,e3
是表达式 - 类型:在当前静态环境下,
e1
类型为bool
,e2
和e3
的类型相同,整个表达式的类型就是e2
和e3
的类型 - 求值:在当前动态环境下,先求值
e1
,如果结果为true
,那么,e2
的值就是整个if表达式的值;如果e1
求值为false
,那么e3
的求值结果就是整个表达式的值
- 语法:
- 布尔常量
- 语法:
true
或者false
- 类型:在任何静态环境中都是
bool
类型 - 求值:在任何动态环境中都求值为其自身
- 语法:
- 小于比较
- 语法:
e1 < e2
,其中e1
和e2
是表达式 - 类型:如果
e1
和e2
在同一个静态环境中都是int
类型,那么结果是bool
类型,否则不能通过类型检查 - 求值:先求值
e1
得到v1
,求值e2
得到v2
,如果v1
小于v2
则结果为true
, 否则结果为false
- 语法:
每当你在一种编程语言中学习的新结构时,你都应该问以下三个问题:语法是什么?什么是类型检查规则?求值规则是什么?
使用 use
使用read eval print循环时,可以方便地从文件中批量添加绑定序列
use "foo.sml";
上面的表达式类型是unit,求值结果是()
(它是unit类型唯一的值),但它的效果是将文件"foo.sml"中的所有绑定引入到当前环境中.
变量是不可变的
绑定是不可变的。给定val x = 8 + 9
,我们生成了一个动态环境,在这个动态环境中x
被映射到17
。在这个环境中,x
总是映射到17
;ML中并没有“赋值语句”来更改x
所映射到的内容。稍后您可以使用另一个同名的绑定,例如val x = 19;
,但这事实上创建了一个不同的环境,在新环境中x
的后一个绑定会遮蔽前一个绑定。然而在之前的环境中,x
并没有被改变,仍然绑定到17
。当我们在定义会使用到变量的函数时,这种区别非常重要。
函数绑定
回想一下,ML程序是一系列绑定。每个绑定都会添加到静态环境(用于对后续的绑定进行类型检查)和动态环境(用于对后续的绑定进行求值)。我们已经引入了变量绑定;现在引入了函数绑定,即如何定义和使用函数。然后,我们将学习如何使用对偶和列表从较小的数据块构建出更大的数据块。
函数在某种程度上类似于Java等语言中的方法—它是用参数进行调用的,并且有一个能够产生结果的函数体。与方法不一样的是,没有类、this
等概念。我们也没有类似return
语句的东西。一个简单的例子是,计算x^y的函数, 假设 y ≥ 0:
(* 只有在 y >= 0 的情况下,下面的函数才能正确地工作 *)
fun pow (x:int, y:int) =
if y = 0
then 1
else x * pow (x, y-1)
语法:
函数绑定的语法看起来象这样(我们将在本课程稍后的部分概括这个定义):
fun x0 (x1 : t1, ..., xn : tn) = e
这是一个名为x0
的函数的绑定。它需要n个参数 x1, ..., xn
,参数的类型分别是t1, ..., tn
, 有一个表达式e
作为函数体。一如既往,仅仅有语法还不够,我们还必须为函数绑定定义类型规则和求值规则。粗略地说,在e
中,在调用时传递进来的参数被依次绑定到x1...xn
, 对x0
调用的结果就是计算e
的结果。
类型检查:
为了对函数绑定进行类型检查,我们在一个静态环境中对函数体e
进行类型检查,这个静态环境(除了之前已经存在的所有绑定以外)将 x1
映射到 t1
, ...xn
映射到 tn
以及 x0
映射到 t1 * ... * tn -> t
. 因为x0
已经存在于环境中,我们还可以进行递归的函数调用,也就是说,函数可以使用它自己来定义自己。函数类型的语法是"argument types"->"result type"
,其中参数类型由*
分隔(正好是乘法表达式中使用的相同字符)。函数体e
必须具有类型t
,即x0
结果的类型。考虑到下面的求值规则,这是有意义的,因为函数调用的结果就是求值e
的结果。
但是,t
到底是什么——我们并没有把它写下来?它可以是任意类型,由类型检查器(语言实现的一部分)来决定t
应该是什么,使用它作为x0
的结果类型会使“万事俱备”。现在,我们暂时将把它视为魔术,但是类型推断(找出没有写下来的类型)是ML的一个非常酷的特性,这将在本课程后面讨论。事实证明,在ML中,几乎不必写下类型。稍后,等到我们学习模式匹配以后,我们会发现,参数类型t1, ... tn
其实也是可选的。
在函数绑定之后,x0
将与其类型一起添加到静态环境中。而函数的参数不会添加到顶级静态环境中—它们只能在函数体中使用。
求值:
函数绑定的求值规则很简单:函数就是一个值—我们只需要将x0
添加到环境中,作为以后可以调用的函数。正如递归所预期的那样,x0
位于函数体和后续绑定的动态环境中(但不像Java那样,可以用于之前的绑定,因此定义函数的顺序非常重要)。
函数调用:
函数绑定只对函数调用(一种新的表达式)有用。语法是e0 (e1, ..., en)
,如果只有一个参数,则括号是可选的。类型规则要求e0
的类型类似于t1 * ... * tn -> t
,并且,对于1 ≤ i ≤ n,ei
的类型为 ti
。那么整个调用的类型为t
。希望你不会感觉太奇怪。对于求值规则,我们使用调用点所处的环境来求值e0
得到v0
,e1
得到v1
, ..., en
得到vn
。那么v0
必须是一个函数(假设调用类型被选中),我们在扩展的环境中求值函数的主体,函数参数被映射到v1, ..., vn
。
我们到底是在什么样的环境中使用这些参数来展开的?正确答案是在函数被定义时的环境,而不是函数被调用时的环境。这种区别现在暂时不需要关注,但我们稍后将详细讨论。
综上所述,我们可以确定下面的代码将生成一个ans为64的环境:
fun pow (x:int, y:int) = (* 只有当 y >= 0 时结果才正确 *)
if y = 0
then 1
else x * pow (x, y-1)
fun cube (x:int) =
pow (x, 3)
val ans = cube (4)
对偶和元组
编程语言需要有将简单数据构建成复合数据的方法。我们将在ML中学习的第一种方法是对偶。建立一个对偶的语法是(e1,e2)
,其中e1
求值得到v1
,e2
求值为v2
,(v1, v2)
这一对值本身就成为一个值。 由于v1
和/或v2
本身也可以是对偶(嵌套),因此我们可以使用多个“基本”值来构建复合数据,而不仅仅是两个整数。对偶的的类型用t1 * t2
来表示,其中t1
是第一部分的类型,t2
是第二部分的类型。
如同函数只有在被调用时才有用一样,对偶的两个成员也需要能够被单独访问才有用。在介绍模式匹配之前,我们使用#1
和#2
分别访问一个对偶的第一部分和第二部分。#1 e
或#2 e
的类型规则很直观:假设e
是一个对偶,其类型为 ta * tb
。那么#1 e
的类型就是 ta
,而#2 e
的类型都是tb
。
下面是几个使用对偶的函数示例,div_mod
或许是其中最有趣的,因为它使用一个对偶作为返回值,其中同时包括了两个答案。在ML中,这是非常令人愉快的,而在Java中,如果想要从一个函数中返回两个值的话,你需要先定义一个类,为这个类编写一个构造函数,用这个类创建一个新的对象,将对象的字段初始化,然后再编写一个return
语句.
fun swap (pr : int * bool) =
(#2 pr, #1 pr)
fun sum_two_pairs (pr1 : int * int, pr2 : int * int) =
(#1 pr1) + (#2 pr1) + (#1 pr2) + (#2 pr2)
fun div_mod (x : int , y : int) =
(x div y, x mod y)
fun sort_pair (pr : int * int) =
if (#1 pr) < (#2 pr)
then pr
else ((#2 pr), (#1 pr))
事实上,ML的元组支持任意数量的元素,例如,一个三元组的整数示例是(7, 9, 11)
,它的类型是int * int * int
。你可以分别用#1 e
, #2 e
, #3 e
去访问元组e
中的每一个元素。
对偶和元组可以根据需要嵌套,例如(7, (true, 9))
是一个类型为 int * (bool * int)
的值;而((7, true), 9)
的类型是(int * bool) * int
;(7, true, 9)
的类型则是int * bool * int
。
列表
尽管我们可以根据需要嵌套对偶(或元组),但是对于任何对偶变量,或者是返回对偶的任何函数,都必须具有对偶类型,该类型决定了“真实数据”的数量。即使是元组,类型说明也必须明确地指定它到底有多少个元素。这通常过于严格了。我们可能需要一个数据列表(例如整数),并且在进行类型检查时还不知道列表的长度(它可能取决于函数的参数)。ML提供了列表,它比对偶更灵活,因为它们可以有任意长度,但是另一方面,同一个列表中所有的元素都必须是相同的类型。
[注] 元组相当于其它语言的数组或者是向量,而列表本质上则是链表,可以动态地变化长度。
空列表写作[]
,元素数量为0. 列表也是一个值,和其它值一样,它求值为自身。列表的类型写作t list
, 其中t
就是列表中所有元素的类型,空列表的类型可以是任意类型,在ML中写作'a list
(读作“引号a list”,或者“alpha list”)。
非空列表写作[v1, v2,..,vn]
, 你可以用直接书写一个列表字面量来构造一个列表[e1, ..., en]
, 其中每个表达式都求值为一个值。更常见的用法是通过 e1::e2
来构造一个列表,读作“e1 cons 到 e2”(就是LISP的cons
)。这里的e1
求值为一个类型为t
的值,而e2
则是一个类型为t
的列表。cons的结果是一个以e1
开头的新列表,后面跟着e2
当中所有原来的元素。
和函数和对偶一样,只有当我们可以处理列表中的元素时,创建列表才是有意义的。和对偶一样,在学习了模式匹配后,我们将改变使用列表的方式,但是现在,我们只使用ML提供的三个操作列表的函数。
null
如果参数是空列表返回true
, 否则返回false
hd
返回列表开头的第一个元素,如果列表为空则引发异常tl
返回列表中除了第一个元素以外剩下的所有元素,如果列表为空则引发异常
下面是一些简单的函数示例,接受或返回列表:
fun sum_list (xs : int list) =
if null xs
then 0
else hd (xs) + sum_list (tl xs)
fun countdown (x : int) =
if x = 0
then []
else x :: countdown (x - 1)
fun append (xs : int list, ys : int list) =
if null xs
then ys
else (hd xs) :: append (tl xs, ys)
生成和使用列表的函数几乎总是递归的,因为列表的长度未知。要编写递归函数,思考过程涉及到递归的基本情况——例如,空列表的答案应该是什么——以及递归步进——如何用列表的剩余部分来表示答案。
当你这样想的时候,许多问题就变得简单了,这让习惯于思考while循环和赋值语句的人感到惊讶。上面的append(附加)函数就是一个很好的例子,它接受两个列表,它把一个列表附加到另一个列表上而形成一个新的列表。这段代码实现了一个优雅的递归算法:如果第一个列表是空的,那么我们只需要简单地返回第二个列表就完成了附加。否则,我们就把第一个列表的尾部append到第二个列表上。这几乎是正确的答案,但我们还需要将第一个列表的第一个元素再添加回来(使用::
运算,来源于LISP的cons, 几十年来一直被称为“consing”)。这里没有什么神奇的-我们不断地用越来越短的第一个列表进行递归调用,然后随着递归调用的完成,我们又将递归调用中删除掉的列表元素一个一个添加回来。
最后,我们可以任意组合成对和列表,而不必为我们的语言添加任何新特性。例如,这里有几个函数使用了整数对偶的列表。请注意,最后一个函数如何重用前面的函数以获得非常简短的解决方案。这在函数式编程中非常常见。事实上,我们应该注意到的是,firsts
和seconds
函数很相似,但我们并没有在它们之间共享任何代码。我们稍后将学习到如何解决这个问题。
fun sum_pair_list (xs : (int * int) list) =
if null xs
then 0
else #1 (hd xs) + #2 (hd xs) + sum_pair_list (tl xs)
fun firsts (xs : (int * int) list) =
if null xs
then []
else (#1 (hd xs)) :: (firsts (tl xs))
fun seconds (xs : (int * int) list) =
if null xs
then []
else (#2 (hd xs)) :: (seconds (tl xs))
fun sum_pair_list2 (xs : (int * int) list) =
(sum_list (first xs)) + (sum_list (seconds xs))
let表达式
Let表达式是一个绝对重要的特性,它允许我们以非常简单、通用和灵活的方式使用局部变量。Let表达式对于风格和效率至关重要。let表达式让我们拥有局部变量。实际上,它允许我们拥有任何类型的本地绑定,包括函数绑定。因为它是一种表达式,所以它可以出现在表达式可以出现的任何地方。
在语法上,let表达式是:
let b1 b2 ... bn in e end
其中,每一个 b1
, b2
... 都是一个绑定,而e
是一个表达式。
let表达式的类型检查和语义与ML程序中顶级绑定的语义非常相似。我们依次求值每个绑定,为后续绑定创建一个更大的环境。因此,我们可以将所有早期的绑定用于后面的绑定,也可以将它们全部用于e
。我们将绑定的范围称为“它可以用在哪里?”,因此let表达式中绑定的范围是该let表达式中的后期绑定和let表达式(e)的“body”。值e的计算结果是整个let表达式的值,并且,不出所料,类型e是整个let表达式的类型。
例如,该表达式的计算结果为7;请注意,x的一个内部绑定是如何屏蔽外部绑定的。
let val x = 1
in
(let val x = 2 in x + 1 end) + (let val y = x + 2 in y + 1 end)
end
还需要注意let表达式本身是一个表达式,它可以作为一个加法表达式当中的子表达式而存在(尽管这是一个很愚蠢的示例,代码风格也不好,因为很难阅读)。
Let表达式也可以绑定函数,因为函数只不过是另一种绑定。如果一个辅助函数只被一个函数所需要,并且在其他地方不太可能有用,那么最好将它绑定到局部。例如,这里我们使用一个局部的辅助函数count
来帮助生成列表[1,2,...,x]
:
fun countup_from1 (x : int) =
let fun count (from : int, to : int) =
if from = to
then to :: []
else from :: count (from + 1, to)
in
count (1, x)
end
但是,我们还可以做得更好。当我们对count的调用求值时,我们会在动态环境中求值count的主体,该环境是定义count的环境,并使用count参数的绑定进行扩展。
上面的代码并没有真正利用这一点:count的主体只使用from
、to
和count
(用于递归)。它也可以使用x
,因为当count
被定义时,x
已经存在于环境中。那么我们就根本不需要变量to
,因为在上面的代码中,它总是与x具有相同的值。所以这是更好的样式:
fun countup_from1_better (x : int) =
let fun count (from : int) =
if from = x
then x :: []
else from :: count (from + 1)
in
count 1
end
这种技术——定义一个在作用域中使用其他变量的局部函数——是函数式编程中非常常见和方便的事情。遗憾的是,许多非函数式语言很少或根本不支持这样做。
局部变量通常是保持代码可读性的好方法。当它们绑定到可能比较费时的计算结果时,它们可以避免耗时的重复计算。例如,考虑以下不使用let表达式的代码:
fun bad_max (xs : int list) =
if null xs
then 0 (* note: bad style; see below *)
else if null (tl xs)
then hd xs
else if hd xs > bad_max (tl xs)
then hd xs
else bad_max (tl xs)
[注] 在xs
是一个升序排序的列表的时候,上面的 bad_max
的复杂度实际上是 O(n^2)。随着xs长度的增长,很快就算不动了。
我们可以使用let表达式来避免重复计算。此版本只计算一次列表尾部的最大值,并将结果值存储在tl_ans
中。
fun good_max (xs : int list) =
if null xs
then 0 (* note: bad style; see below *)
else if null (tl xs)
then hd xs
else
(* for style, could also use a let-binding for hd xs *)
let val tl_ans = good_max (tl xs)
in
if hd xs > tl_ans
then hd xs
else tl_ans
end
可选值
上一个示例并没有正确地处理列表为空的情况,它只是简单地返回0.这并不是好的风格,以0作为空列表的最大值并不合理,因为列表本身不包含任何数字,也就没有所谓的最大值。我们应该合理地处理这种情况。一种可能性是抛出一个异常;如果你感兴趣,可以自己学习ML的异常,我们在后面的课程中会讨论异常。反过来,我们还可以更改返回的类型,要么返回最大值,要么指示传入列表为空,即,没有所谓最大值。以我们现在所掌握的语法结构,我们可以通过返回一个int list
来实现上述想法,如果输入的是空列表,则返回[]
,如果输入的列表不是空的,则返回只一个元素的列表,其中包含了最大值。
虽然这样做是有效的,但是这种做法有些过火。函数总是返回一个包含0个或1个元素的列表,并不能准确地描述我们要返回的东西。ML标准库提供了option
,这是一个精确的描述:一个option
(可选的)值有 0 个或 1 个东西:NONE
是一个没有携带任何东西的 option
值,而SOME e
则将对e
求值,得到v
,SOME e
作为一个值,它携带了真正的值v
。NONE
的类型为'a option
,如果e
的类型是t
,那么SOME e
的类型就是t option
。
给定一个值,要怎么使用它?就像我们用null
来判断一个列表是否为空一样,可以用isSome
来判断一个option
值是否是有真正的值。isSome NONE
返回false
。就像我们用hd
和tl
来获取列表的一部分一样(空列表引发异常),我们可以用valOf
来获取SOME
所携带的值(valOf NONE
将会引发异常)
下面是一个使用options的更好的版本,它的返回值类型是int option
:
fun better_max (xs : int list) =
if null xs
then NONE
else
let val tl_ans = better_max (tl xs)
in
if isSome tl_ans andalso valOf tl_ans > hd xs
then tl_ans
else SOME (hd xs)
end
上面的版本工作得很好,是一个合理的递归函数,因为它不会重复任何可能昂贵的计算。但是,让每个递归调用(最后一个调用除外)都用SOME
创建一个option,只是为了让调用方访问它所携带的值,这既尴尬又有点低效。这里有一种替代方法,我们对非空列表使用了局部辅助函数,然后让外部函数返回一个option。请注意,如果使用[]调用辅助函数,其实会引发异常,但由于它是在局部定义的,因此我们可以确保这永远不会发生。
fun better_max2 (xs : int list) =
if null xs
then NONE
else let (* 可以合理地假设参数不会是空列表,因为这个局部函数只会在内部调用 *)
fun max_nonempty (xs : int list) =
if null (tl xs)
then hd xs
else let val tl_ans = max_nonempty (tl xs)
in
if hd xs > tl_ans
then hd xs
else tl_ans
end
in
SOME (max_nonempty xs)
end
其它表达式和操作符
ML拥有您需要的所有算术和逻辑运算符,但语法有时与大多数语言不同。以下是一些有用的其他表达形式的简要列表:
-
逻辑与:
e1 andalso e2
,只有当e1
和e2
都是true
的时候整个表达式的值才是true
。如果e1
的值为false
,就不会再对e2
求值(短路),整个表达式的值为false
。e1
和e2
都必须是布尔类型,并且整个表达式的类型也是布尔类型。在其它语言中,逻辑与通常写为e1 && e2
,但是这不是ML的写法,e1 and e2
也不是正确的ML写法(and
确实是ML的关键字,后面会遇到)。和e1 andalso e2
等效的写法是if e1 then e2 else false
,但是前者显然再好。 -
逻辑或:
e1 orelse e2
, 只要e1
和e2
中任何一个值为true
,整个表达式的值就是true
。逻辑或同样是短路的,只要e1
为真,就不再对e2
求值,只有在e1
为false
的情况下才会对e2
求值。e1
和e2
都必须是布尔类型,整个表达式也是布尔类型。其它语言中的逻辑或通常写为e1 || e2
,或者e1 or e2
,这都不是合法的ML表达式。等效的逻辑或表达式可以写为if e1 then true else e2
,但是用orelse
要更好。 -
逻辑非:
not e
,not
其实只是一个普通函数,以一个布尔类型的值作为参数,返回一个取反后的布尔类型的值。我们可以自己定义这个not
函数:fun not x = if x then false else true
。在其它语言中,逻辑非通常写为!e
,但是在ML中,感叹号具有另外的意义(和可变变量有关,但是我们的课程不会涉及这部分内容)。 -
相等测试:
e1 = e2
,等号可以比较很多类型的值是否相等,包括整数、布尔值、字符串等。但是浮点数不行,浮点数的相等测试需要用到标准库里的Real.==
函数。 -
不相等测试:
e1 <> e2
,ML提供了专门的不相等测试运算,和等价的not (e1 = e2)
相比,显然要更简洁。 -
其它的算术比较和其它语言基本相同:
>, <, >=, <=
-
减法写作
e1 - e2
,减号必须有两个操作数,减号不能作为单目运算符对一个数取负。在ML中,取负有专门的运算符,就是波浪号~
,负7写作~7
而不是-7
。对于整数,你确是可以把负7写成0 - 7
,但是~7
是更好的写法。
无突变的好处
在ML中,没有办法更改绑定、元组或列表的内容。如果x映射到某个环境中的某个值,如对偶列表[(3, 4), (7,9)]
,那么x将永远映射到该环境中的该列表。没有将x更改为映射到其他列表的赋值语句。(您可以引入一个新的绑定来遮蔽x,但这不会影响任何在原来的环境中查找x的代码。)不存在允许您更改列表的头或尾的赋值语句。并且也不存在允许您更改元组内容的赋值语句。因此,我们有用于构建复合数据和访问片段的语法构造,但没有用于对所构建的数据进行修改的语法构造。
这确实是一个非常强大的特性!这可能会让你感到惊讶:一种语言如果缺失某些东西,怎么可能成为一种优点呢?因为如果没有这样的特性,那么当您编写代码时,您就不能依赖其他代码来执行会使代码出错、不完整或难以使用的操作。拥有不可变的数据可能是一种语言最重要的“非特性”,也是函数式编程的主要贡献之一。
尽管不可变数据有很多优点,但这里我们将重点讨论一个重要的优点:它使共享和别名变得无关紧要。在采用Java(或是其他以可变数据为标准、赋值语句泛滥的语言)之前,让我们重新考虑上面的两个例子。
fun sort_pair (pr : int * int) =
if (#1 pr) < (#2 pr)
then pr
else ((#2 pr), (#1 pr))
在sort_pair
中,很显然我们在else
分支中构建并返回了一个新的pair,但是在then
分支中,我们到底是返回了pair的引用还是它的副本呢?假设调用如下:
val x = (3, 4)
val y = sort_pair x
现在,y
到底是x
的拷贝还是它的别名呢?答案是不知道,在ML中,没有任何构造可以判断x
和y
是否是同一个对象的别名, 也没不需要去分清楚它们是不是别名。如果副作用是允许的,那么生活就会变得不同。假设我们说,改变x
的第二部分,使它的值变成 5 而不是 4, 那么我们就必须很小心地留意,y
的第二部分是否也会被不经意地修改掉。
如果你感到好奇,我们希望上面的代码可能会创建一个别名,上面的sort_pair
函数在then
分支中返回了传递进去的pr
本身。而下面的版本,则清晰地创建并且返回了一个副本:
fun sort_pair (pr : int * int) =
if (#1 pr) < (#2 pr)
then (#1 pr, #2 pr)
else ((#2 pr), (#1 pr))
用(#1 pr, #2 pr)
这样的做法去生成pr
的拷贝并非好的风格,直接返回pr
本身显然要更好。然而,在允许有副作用的语言中,程序员就不得不这样做了。[译注: Python程序员应该有体会,当你试图把一个列表的拷贝赋值给另外一个变量时,必须很小心地使用列表拷贝语法y = x[:]
,如果直接直接这样写y = x
,那么y
和x
指向的是同一个对象,对其中一个变量的修改会影响到另外一个对象]。在 ML 中,程序员永远不需要分辨x
和y
是否是同一个对象。
我们的第二个例子是优雅的列表附加函数append
:
fun append (xs : int list, ys : int list) =
if null xs
then ys
else (hd xs) :: append (tl xs, ys)
我们可以问一个类似的问题:返回的列表和参数传递进来的列表之间是否共享元素?答案还是:不重要,因为调用者也不知道。答案也是肯定的:我们建立了一个新的列表,它“重用”了ys
的所有元素。这节省了空间。但是,如果以后有人可以修改列表的内容,这将会非常混乱。节省空间是不可变数据结构一个很好的优点,在编写优雅的算法时,不需要担心是否还存在别的别名。
事实上,tl
函数已经隐式地引入了别名(尽管你没有注意到):它返回了列表的尾部的别名,而不是返回了列表尾部的拷贝。这种做法是“便宜”的,对于长列表来说,生成它的拷贝代价太“昂贵”了。
append
示例与sort_pair
示例非常相似,但更引人注目的是,如果你有许多潜在长度较大的列表,则很难跟踪潜在的别名。如果我在[3, 4, 5]
附加在[1, 2]
后面, 我会得到一个新列表[1, 2, 3, 4, 5]
,但是如果以后有人能把[3, 4, 5]
修改成[3, 7, 5]
,那么由append
生成的新列表到底是保持原样的[1, 2, 3, 4, 5]
,还是会变成[1, 2, 3, 7, 5]
?
在类似于Java的语言中,这是一个至关重要的问题,这就是为什么Java程序员必须纠结于何时使用对旧对象的引用以及何时创建新对象。有时,过分关注别名是正确的做法,有时,避免变异是正确的做法——函数式编程将帮助您更好地处理后者。
最后一个例子,下面的Java代码是实际存在于Java库中的代码,它导致了一个重要的安全漏洞(现在已经修复了)。假设我们维护着谁能访问磁盘上某些文件的权限。让每个人都看到谁有权限是可以的,但是显然只应当允许那些有权限的人真正使用资源。思考下面的错误代码(不相关的部分已省略):
class ProtectedResource {
private Resource theResource = ...;
private String[] allowedUsers = ...;
public String[] getAllowedUsers() {
return allowedUsers;
}
public String currentUser() { ... }
public void userTheResource() {
for (int i = 0; i < allowdUsers.length; i++ ) {
if (currentUser().equals(allowedUsers[i])) {
... // access allowed: use it
return ;
}
}
throw new IllegalAccessException();
}
}
你发现问题了吗?问题出在:getAllowedUsers
是一个public
的方法,它返回了数组allowedUsers
的别名,因此,任何用户(包括那些原本不应该拥有权限的用户)都可以通过执行getAllowedUsers()[0] = currentUser()
来获取访问权限。哎哟我去!如果Java拥有某种数组,不允许修改其内容就好了,但是没有这种东西。所以,在Java中,我们要经常记得制作一个拷贝。下面的修复代码使用了一个循环来拷贝数组,更好的做法是使用类似于System.arraycopy
这样的库函数,或者是Arrays
类里面类似的库函数。这些函数之所以存在,就是因为数组复制是很常见的操作,部分原因是因为副作用的存在。
public String[] getAllowedUsers() {
String[] copy = new String[allowedUsers.length];
for (int i = 0; i < allowedUsers.length; i++)
copy[i] = allowedUsers[i];
return copy;
}
编程语言的组成部分
现在,我们已经学习了足够的ML语言来编写一些简单的函数和程序,我们可以列出定义和学习任何编程语言所必需的基本“部分”:
- 语法:你如何用具体的语言来书写各个部分?
- 语义:不同的语言特征意味着什么?例如,表达式是如何计算的?
- 习惯用法:使用语言特征来表达计算的常用方法是什么?
- 库:语言提供了什么?如果语言没有提供所需要的库(如访问文件),你要如何实现?
- 工具:可用于操作语言程序的工具(编译器、Read-Eval-Print Loop、调试器等)
虽然库和工具对于成为一个有效的程序员是必不可少的(避免重新发明轮子),但本课程并不太关注它们。这可能会给人留下错误的印象,即,我们使用的是“愚蠢的”或“不切实际的”语言,但在一门关于编程语言概念上的相似性和差异的课程中,库和工具就没有那么重要了。
第二节
建立新类型的概念和方法
编程语言有基本类型,如int、bool、unit和复合类型,这些类型在其定义中包含其他类型。我们已经看到了在ML中生成复合类型的方法,即使用元组、列表和选项。我们将很快学习新的方法来生成更灵活的复合类型,并为我们的新类型命名。要创建复合类型,实际上只有三个基本构建块。任何像样的编程语言都以某种方式提供了这些构建块:
- 每一个(Each of): 在一个复合类型当中包含了 t1, t2 ... 到 tn 之间每一个类型的值
- 其中之一(One of): 在一个复合类型当中包含了 t1, t2 ... 到 tn 等类型的值之一
- 自引用: 一个复合类型,其中可能包含对自身的引用以构造递归的数据结构
Each-of 类型是大多数程序员最熟悉的。元组就是一个例子:int * bool
描述了包含 int 和 bool 的值。带有字段的Java类也是一种类似的东西。
One-of 类型也很常见,但不幸的是,在许多编程入门课程中没有得到太多的强调。int option
是一个简单的示例:此类型的值包含 int 或不包含 int。对于在 ML 中包含 int 或 bool 的类型,我们需要数据类型绑定,这是本节课程的重点。在具有类(如Java)的面向对象语言中,其中一种类型是通过子类化实现的,但这是本课程后面要讨论的主题。
自引用允许类型描述递归的数据结构。对于 Each-of 和 One-of 类型的组合很有用。例如,int list
描述的值要么不包含任何内容,要么包含一个 int
和另一个 int list
。任何编语言中的整数列表都可以用 or、and 和自引用来描述,因为这就是整数列表的含义。
当然,由于复合类型可以嵌套,所以我们可以对 Each-of One-of 和自引用进行任意的嵌套。例如,考虑类型(int * bool) list * (int option) list * bool
。
Records: “Each-of”类型
记录(Record)属于“Each-of”类型,其中的每个组件都是一个命名字段。例如,{foo : int, bar : int * bool, baz : bool * int}
使用名为foo
, bar
, baz
的三个字段描述了记录。这只是一种新类型,就像元组在我们刚学习时是新类型一样。
记录表达式生成记录值,例如,表达式
{bar = (1+2, true andalso true), foo = 3+4, baz = (false, 9)}
将会求值得到一个记录值:
{bar = (3, true), foo = 7, baz = (false, 9)}
它的类型是:
{foo : int, bar : int * bool, baz : bool * int}
因为字段的顺序并不重要(我们使用字段名代替)。一般来说,记录表达式的语法是{f1 = e1, ... fn = en}
, 其中,像往常一样,每个ei
可以是任意表达式。这里每个f
可以是任意字段名(字段名不能重复)。字段名基本上可以是任意字母或数字序列。
在ML中,我们不必声明我们想要一个具有特定字段名和字段类型的记录类型—我们只需直接写下一个记录表达式,类型检查器就会为它提供正确的类型。记录表达式的类型检查规则并不奇怪:对每个表达式进行类型检查以获得一些类型ti,然后构建具有所有正确字段名和正确类型的记录类型。因为字段名的顺序无关紧要,所以REPL在打印时总是按字母顺序排列,以保持一致性。
记录表达式的求值规则类似:将每个表达式求值为一个值并创建相应的记录值。
现在我们知道了如何创建记录值,我们还需要一种方法来访问记录中的字段。访问记录字段的方法是#foo e
,其中foo
是一个字段名。类型检查要求e
是一个拥有名为foo
的字段的记录类型,假设此字段的类型为t
,则#foo e
的类型也是t
。求值器将e
求值为一个记录值,然后生成foo
字段的内容。
名称寻址 vs 位置寻址,语法糖和元组的真相
记录和元组非常相似。它们都是允许由任意数量的组件所构成的"Each-of"构造。唯一真正的区别是记录是“按名称”寻址的,而元组是“按位置”寻址的,这意味着,对于记录,我们使用字段名访问它们的片段,因此在记录表达式中写入字段的顺序无关紧要。但是元组没有字段名,所以我们使用位置(索引)来区分组件。
在设计一种语言结构或选择使用哪种语言结构时,按名称还是按位置是一个经典的决定,在某些情况下,使用某种寻址方法更为方便。作为一个粗略的指南,对于少量的组件来说,按位置比较简单,但是对于较大的复合类型来说,要记住哪一个位置是什么东西变得困难了。
Java 的方法参数(以及到目前为止我们所描述的ML函数参数)实际上采用了一种混合方法:方法体使用变量名来引用不同的参数,但是调用者按位置传递参数。在其他一些语言中,调用者也可以按名称传递参数(关键字参数)。
尽管有“按名称 vs. 按位置”的差异,记录和元组仍然非常相似,我们可以完全按照定义记录的方式来定义元组。以下是方法:
-
当你写下
(e1, e2, ..., en)
这样的元组,其实就相当于你写下了{1=e1, 2=e2, ... n=en}
这样的一个记录。即,元组其实是用 1, 2, 3 ... 等作为字段名的记录 -
类型
t1 * t2 ... * tn
不过是{1:t1, 2:t2, ..., n:tn}
的另一种写法 -
注意,
#1 e
,#2 e
等现在已经表达了正确的事情:获取字段名为 1 或 2 的记录值
事实上,ML实际上就是这样定义元组的:元组就是一个记录。也就是说,元组的所有语法都只是书写和使用记录的简便方法。REPL总是尽可能地使用元组语法,因此如果您计算{2 = 1 + 2, 1 = 3 + 4}
,REPL会将结果打印为(7,3)
。使用元组语法是更好的风格,但是我们不需要给元组定义单独的语义:我们可以使用上面的“另一种编写方式”规则,然后重用记录的语义。
这是我们将要看到的众多 语法糖 例子中的第一个。我们说,“元组只是字段名为1,2,…,n的记录的语法糖。”它是语法的,因为我们可以用等价的记录语法来描述元组的一切。它是糖,因为它使语言更甜美。语法糖这个词被广泛使用。语法糖是一种很好的方法,可以使编程语言中的关键思想保持小型化(使其更易于实现),同时为程序员提供方便的编写方法。实际上,在家庭作业1中,我们使用元组时并不知道记录的存在,即使元组本质上就是记录。
数据类型绑定:我们自己的“One-of”类型
现在,我们介绍数据类型绑定,这是继变量绑定和函数绑定之后的第三种绑定。 我们从一个愚蠢但简单的示例开始,因为它可以帮助我们了解数据类型绑定的许多不同方面。 我们可以这样写:
datatype mytype = TwoInts of int * int
| Str of string
| Pizza
大致而言,这定义了一种新类型,它的值可以是int * int
或 字符串 或 不包含任何内容。任何值都将被信息“标记”,使我们知道它属于哪个变体:这些“标记”是TwoInts
,Str
和Pizza
。 其中TwoInts
和Str
可以用来标记相同类型但是值不同的基础数据,我们称之为构造函数。实际上,即使我们的示例对每个变体使用不同的类型,也很常见。
更准确地说,上面的代码向环境中添加了四个东西:
-
一个新的类型
mytype
,之后我们可以像使用其它类型一样使用它 -
三个构造函数
TwoInts
,Str
,Pizza
构造函数意味着两件事:首先,它是一个用于创建新类型的值的函数, 或者它实际上就是新类型的值。在我们的示例中,TwoInts
是 int * int -> mytype
类型的函数,Str
是string->mytype
类型的函数,而Pizza
是一个mytype
类型的值; 第二,我们在case
表达式中使用构造函数,如下所示:
因此,我们知道了如何构建mytype
类型的值:使用正确类型的表达式来调用构造函数(或者仅使用Pizza
值)。这些函数调用的结果是得到一些带有标签的值,让我们“知道它属于哪个变体”。REPL使用类似于TwoInts(3,4)
, 或者是Str "he"
这样的形式来表示这些值。
剩下的事,就是找回碎片的方法...
为什么ML不提供对数据类型值的访问
给定一个类型为mytype
的值,我们要如何才能访问存储在其中的数据呢?首先,我们需要找出它属于哪个变体,因为类型mytype
的值可能是由TwoInts
,Str
或Pizza
生成的,这会影响可用的数据。一旦知道了我们拥有什么变体,就可以访问该变体携带的基础数据(如果有的话)。
回想一下我们是如何对列表和option
(也是一种类型)执行此操作的:我们具有用于测试我们拥有哪个变体的函数(null
或isSome
)和用于获取片段的函数(hd
,tl
或valOf
),其中 如果给出错误变体的参数,则会引发异常。
ML对于数据类型绑定完全可以采用相同的方法。 例如,它可能已经采用了我们上面的数据类型定义,并将isTwoInts
,isStr
和isPizza
等函数添加到环境中,所有类型均为mytype -> bool
。 并且可能添加了诸如mytype -> int * int
类型的getTwoInts
和mytype -> string
类型的getStr
之类的函数,并且可能会引发异常。
但是ML并没有采用这种方法。相反,它做得更好。您可以使用更好的方法自己编写这些函数,尽管这样做通常很糟糕。实际上,在学习了更好的东西之后,我们将不再像以前那样使用列表和option
的函数-我们只是从这些函数开始,因此可以一次只学习一件事。
ML如何访问数据类型的值:case表达式
更好的方式是使用case
表达式。下面是关于前面的mytype
类型的一个基本示例:
fun f x = (* f 的类型为 mytype -> int *)
case x of
Pizza => 3
| TwoInts(i1, i2) => i1 + i2
| Str s => String.size s
从某种意义上说,case
表达式就像一个更强大的if-then-else
表达式:像条件表达式一样,它求值其两个子表达式:第一个是关键字case
和of
之间的表达式,第二个是首先和x
匹配成功的的子表达式。 但是除了有两个分支(一个代表真,一个代表假),我们可以为数据类型的每个变体有一个分支(我们将在下面进一步概括)。 与条件表达式一样,每个分支的表达式必须具有相同的类型(在上面的示例中为int
),因为类型检查器无法知道将使用哪个分支。
每个分支的形式为p => e
,其中p
是模式,e
是表达式,我们用|
分隔不同的分支。 模式看起来像表达式,但是并不认为它们是表达式。相反,它们用于与case
表达式的的第一个子表达式(case
之后的部分)求值后的结果相匹配。 这就是为什么求值case
表达式称为模式匹配的原因。
在本讲座中,我们使模式匹配保持简单:每个模式使用不同的构造函数,并且模式匹配根据单词case
后的表达式选择“正确的”分支。求值该分支的结果就是整体答案;其他的分支不会被求值。例如,如果将TwoInts(7,9)
传递给f
,则将选择第二个分支。
这涉及到使用 One-of 类型来检查变体的部分,但是模式匹配还涉及到“获取基础数据”的部分。由于TwoInts
携带了两个值,因些它的模式可以(就现在而言是“必须”)使用两个变量(i1
, i2
)。作为匹配的一部分,在用于求值箭头=>
右侧的表达式的环境中,相对应的值(7和9)被绑定到i1
和i2
。从这个意义上讲,模式匹配就像一个let
表达式:它将变量绑定在局部作用域内。类型检查器知道这些变量具有什么类型,因为在模式中使用的构造函数是由数据类型绑定所创建的,在数据类型绑定中已经指明了。例如,TwoInts of int * int
指明了TwoInts(i1, i2)
中的i1
和i2
分别是int
和int
。
为什么case表达式比测试变体和提取片段的函数更好?
-
我们永远无法“弄乱”并试图从错误的变体中提取某些东西。也就是说,我们不会遇到与
hd []
类似的异常。 -
如果case表达式遗漏了一个变体的分支,则类型检查器将给出警告消息。这表明求值case表达式时可能会找不到匹配的分支,在这种情况下它将引发异常。如果没有此类警告,则说明不会发生这种情况。
-
如果case表达式重复使用了相同的变体,则类型检查器将给出错误消息,因为其中一个分支永远不可能会用到。
-
如果你仍然需要类型于
null
和hd
之类的函数,你可以轻松地自己实现它们(在做作业的时候不要这么干) -
模式匹配比到目前为止所介绍的更加通用和强大。我们在下面将会给出有关模式匹配的“全部事实”。
“One-of”类型的示例
现在,让我们考虑几个使用“One-of”类型的示例,因为到目前为止,我们仅考虑了一个愚蠢的示例。
首先,它们非常适合枚举一组固定的选项,并且比使用小整数好得多。 例如:
datatype suit = Club | Diamond | Heart | Spade
许多语言都支持这种枚举,包括Java和C,但是ML更进一步让变体可以携带数据,因此我们可以执行以下操作:
datatype rank = Jack | Queen | King | Ace | Num of int
然后,我们可以将两种类型的每种类型结合在一起: suit * rank
当您在不同情况下拥有不同的数据时,One-of 类型的数据也很有用。例如,假设您想通过学生的学号来识别学生,但是如果有的学生没有学号(可能是他们刚上大学),那么您将使用他们的全名(名字,可选 中间名和姓氏)。 此数据类型绑定直接捕获了该想法:
datatype id = StudentNum of int
| Name of string * (string option) * string
不幸的是,在这种情况下,程序员常常表现出对“One-of”类型的深刻理解,并坚持使用"Each-of"类型,这就像把锯子当作锤子用一样(它起作用,但你做的是错误的事情)。考虑以下糟糕的代码:
(* 如果学号是-1,则使用其它字段,否则忽略其它字段 *)
{student_num : int , first : string, middle : string option, last : string}
这种方法要求所有代码都遵循注释中的规则,而不需要类型检查器的帮助。它还浪费空间,在每个记录中都有不应该使用的字段。
另一方面,如果我们想同时存储每个学生的学号(如果他们有)和全名,那么使用"Each-of"类型就是正确的方法:
{ student_num : int option,
first : string,
middle : string option,
last : string }
我们的最后一个示例是包含了常量、取反、加法和乘法的算术表达式的数据定义。
datatype exp = Constant of int
| Negate of exp
| Add of exp * exp
| Multply of exp * exp
由于有自引用,这个数据定义真正描述的是树,其中叶子是整数,内部节点要么是一个子节点的取反,要么是两个子节点的相加,要么是两个了节点相乘。我们可以编写一个函数,接受 exp 并对其求值:
fun eval e =
case e of
Constant i => i
| Negate e2 => ~ (eval e2)
| Add(e1, e2) => (eval e1) + (eval e2)
| Multiply(e1, e2) => (eval e1) * (eval e2)
所以,下面的函数调用结果是 15:
eval (Add (Constant 19, Negate (Constant 4)))
请注意,构造函数只是我们用其他表达式调用的函数(通常是由构造函数生成的其他值)。
我们可以在exp类型的值上编写许多函数,其中大多数函数将以类似的方式使用模式匹配和递归。下面是您可以编写的处理exp参数的其他函数:
-
表达式中最大的常数
-
表达式中所有常量的列表(使用列表 append)
-
表达式中是否至少有一次乘法的布尔值
-
表达式中加法表达式的数目
下面最是最后一个:
fun number_of_adds e =
case e of
Constant i => 0
| Negate e2 => number_of_adds e2
| Add(e1, e2) => 1 + number_of_adds e1 + number_of_adds e2
| Multiply(e1, e2) => number_of_adds e1 + number_of_adds e2
到目前为止,关于 datatype 绑定和 Case 表达式的精确描述
我们可以将我们所知道的关于数据类型和模式匹配的知识总结如下:
下面的绑定
datatype t = C1 of t1 | C2 of t2 | ... | Cn of tn
引入了一个新的类型t
,每个构造函数Ci
都是类型为ti -> t
的函数。对于“不携带任何内容”的变量,省略了“of ti”,这样的构造函数只有类型t
。要“获取t的片段”,我们可以使用case表达式:
case e of p1 => e1 | p2 => e2 | ... | pn => en
case表达式将e
求值为值v
,找到与v
匹配的第一个模式pi
,并求值ei
以生成整个case表达式的结果。到目前为止,模式看起来像Ci(x1, …, xn)
,其中Ci
是t1 * ... * tn -> t
类型的构造函数(如果Ci
不携带任何内容,则只携带Ci
)。这样的模式匹配形式为Ci(v1, ..., vn)
的值,并将每个xi
绑定到vi
以求值对应的ei
。
类型同义词
在继续讨论数据类型之前,让我们将它们与另一种同样引入了新类型名的有用绑定形式进行比较。类型同义词只是为现有类型创建另一个名称,该名称可以与现有类型完全互换。
例如,我们这样写:
type foo = int
然后我们可以在任何写int
的地方都写成foo
,反之亦然。因此,给定一个foo->foo
类型的函数,我们可以用3调用该函数,并将结果添加到4。REPL有时会根据情况打印foo
,有时打印int
;细节并不重要,由语言实现自己决定。对于像int
这样的类型,这样的同义词并不是很有用(不过稍后当我们研究ML的模块系统时,我们将基于这个特性进行构建)。
但是对于更复杂的类型,可以方便地创建类型同义词。下面是我们在上面创建的类型的基础上的一些示例:
type card = suit * rank
type name_record = { student_num : int option,
first : string,
middle : string option,
last : string }
记住这些同义词是完全可以互换的。例如,如果一个家庭作业问题需要一个类型为card -> int
的函数,并且REPL报告您的解决方案的类型为suit * rank -> int
,这是可以的,因为这些类型是“相同的”
相反,datatype 绑定引入的类型与任何现有的类型都不同。它创建构造函数用来生成这种新类型的值。因此,例如,唯一与suit
相同的类型是suit
,除非我们稍后为它引入一个同义词。
列表和Option也是数据类型
因为数据类型定义可以是递归的,所以,我们可以使用它来定义我们自己的列表类型。例如,下面的绑定适用于整数链表:
datatype my_int_list = Empty
| Cons of int * my_int_list
我们可以使用构造函数Empty
和Cons
来生成my_int_list
的值,并且可以使用case表达式来使用这些值:
val ont_two_three = Cons(1, Cons(2, Cons(3, Empty)))
fun append_mylist (xs, ys) =
case xs of
Empty => ys
| Cons(x, xs') => Cons(x, append_mylist(xs', ys))
事实证明,“内置”的列表和option只是预定义的数据类型。就风格而言,使用内置的广为人知的特性比发明自己的特性要好。
更重要的是,更好的方式是使用模式匹配来访问列表和选项值,而不是我们前面看到的函数null
、hd
、tl
、isSome
和valOf
。(我们使用它们是因为我们还没有学会模式匹配,我们不想延迟练习函数式编程的技能。)
对于option,您需要知道的是SOME
和NONE
仅仅只是构造函数,我们使用它来创建值(就像以前一样),并在模式中访问值。下面是后者的一个简短示例:
fun inc_or_zero intoption =
case intoption of
NONE => 0
| SOME i => i + 1
列表的情况类似于一些方便的语法特性:[]
实际上是一个不携带任何参数的构造函数,而::
实际上是一个携带两个东西的构造函数,但是::
有些不同寻常,因为它是一个中缀运算符(它放在两个操作数之间),无论是在创建东西还是在模式中:
fun sum_list xs =
case xs of
[] => 0
| x::xs' => x + sum_list xs'
fun append (xs, ys) =
case xs of
[] => ys
| x::xs' => x :: append(xs', ys)
注意这里的x和xs'只不过是通过模式匹配引入的局部变量。我们可以用任何名字来表示我们想要的变量。我们甚至可以使用hd
和tl
— 这样做只会将外部环境中预定义的函数隐藏起来。
通常,访问列表和选项(option)时,您应该选择模式匹配,而不是null
和hd
之类的函数,这与数据类型绑定的原因是一样的:您不能遗漏case分支,不能应用错误的函数,等等。那么,既然null hd tl
这些函数不建议使用,为什么ML环境会预先定义这些函数呢?在某种程度上,因为它们可以作为参数传递给其他函数,这是本课程下一节的主要主题。
多态数据类型
除了内置列表使用[]
和::
等奇怪的语法之外,我们定义的my_int_list
和内置的列表以及option唯一的区别在于,内置的列表和option是多态的 - 它们可以用于承载任意类型的值,正如我们见过的int list
, int list list
, (bool * int) list
等。你也可以定义自己的多态数据类型,实际上它对于构建“通用”数据结构非常有用。虽然我们不会在这里集中讨论如何使用这个特性(也就是说,你不需要搞懂如何使用它),但是它并没有什么非常复杂的地方。例如,系统中预定义的option其实是像这样定义的:
datatype 'a option = NONE | SOME of 'a
上面的绑定不会引入option类型。相反,如果t
是一个类型,那么t option
就是类型。您还可以定义采用多种类型的多态数据类型。例如,这里有一个二叉树,其中内部节点保存类型为'a
的值,而叶子保存类型为'b
的值
datatype ('a, 'b) tree = Node of 'a * ('a, 'b) tree * ('a, 'b) tree
| Leaf of 'b
接下来,我们就能拥有(int, int) tree
(每个节点和叶子都有一个int),以及(string, bool) tree
(每个节点都有一个字符串,每个叶子都有一个bool)这样的类型。对于常规数据类型和多态数据类型,使用构造函数和模式匹配的方式是相同的。
“Each-of”类型的模式匹配:关于变量绑定的真相
到目前为止,我们已经对"One-of"类型的数据使用了模式匹配,我们也可以对"Each-of"类型使用模式匹配。给定一个记录值{f1=v1, ..., fn=vn}
, 模式{f1=x1, ..., fn=xn}
匹配并绑定到vi
,正如你所预期的,模式中字段的顺序并不重要。和前面一样,元组是记录的语法糖:模式(x1, ..., xn)
与{1=x1, ..., n=xn}
相同,并匹配元组值(v1, ..., vn)
,后者与{1=v1, ..., n=vn}
相同。我们可以用下面的函数来求三元素元组int * int * int
的和:
fun sum_triple (triple : int * int * int) =
case triple of
(x,y,z) => z + y + x
与记录有关的类似的示例如下:
fun full_name (r : {first:string, middle:string, last:string}) =
case r of
{first=x, middle=y, last=z} => x ^ " " ^ y ^ " " ^ z
然而,只有一个分支的case表达式的风格很糟糕 - 它看起来很奇怪,因为case表达式的目的正是区分各种不同的情况。那么,当我们知道单个模式肯定会匹配时,我们应该如何对“Each-of”类型使用模式匹配呢?所以我们使用模式匹配只是为了方便地提取值?事实证明,您也可以在val
绑定中使用模式!所以这种方法是更好的方式:
fun full_name (r : {first:string, middle:string, last:string}) =
let val {first=x, middle=y, last=z} = r
in
x ^ " " ^ y ^ " " ^ z
end
fun sum_triple (triple : int * int * int) =
let val (x, y, z) = triple
in
x + y + z
end
实际上,我们可以做得更好:就像在val
绑定中模式可以用于将变量(例如x、y和z)绑定到表达式的各个部分(例如,三元组)一样,我们可以在定义函数的时候就使用模式,在函数被调用时模式将用于匹配传递进来的值。下面是我们示例函数的第三种也是最好的方法:
fun full_name {first=x, middle=y, last=z} =
x ^ " " ^ y ^ " " ^ z
fun sum_triple (x,y,z) =
x + y + z
这个版本的sum_triple
应该会引起你的兴趣:它将一个triple作为参数,并使用模式匹配将三个变量绑定到函数体中使用的三个部分。但是它看起来很像一个接受三个int
类型参数的函数。那么int * int * int -> int
到底是接受三个参数的函数?还是接受一个三元组的函数?
事实真相是,你上当了:ML中不存在多参数函数:ML中的所有函数都只能接受一个参数!每次我们编写一个多参数函数时,实际上是在编写一个单参数函数,它将元组作为参数,并使用模式匹配来提取片段。这是一个很常见的习惯用法,也很容易被忽视,在和朋友讨论你的ML代码时,谈论“多参数函数”是可以的。但就实际的语言定义而言,它实际上是一个单参数函数:扩展到第一个版本的sum_triple
的语法糖(带有单分支case表达式那个版本)。
这种灵活性有时是有用的。In languages like C and Java, you cannot have one function/method compute the results that are immediately passed to another multi-argument function/method. 但是对于一个参数是元组的函数来说,这么做很容易。下面是一个愚蠢的例子,我们通过“向左旋转两次”来“向右旋转一个三元组”:
fun rotate_left (x, y, z) = (y, z, x)
fun rotate_right triple = rotate_left(rotate_left triple)
更一般地说,您可以计算元组,然后将它们传递给函数,即使该函数的作者考虑的是多个参数。
那么零参数的函数呢?它们也不存在。绑定fun f () = e
使用unit模式()
来匹配传递unit值()
的调用,()
是unit类型唯一的值。unit是一个datatype,它只有一个构造函数,不带参数,并且使用了不寻常的语法()
。基本上预定义的unit是这样定义的:datatype unit = ()
。
题外话:类型推导
通过使用模式来访问元组和记录的值,而不是类型于#foo
的语法,您会发现不再需要指定函数参数的类型。事实上,在ML中传统的做法是不使用它们 — 您可以始终使用REPL来找出函数的类型。我们之前需要它们的原因是#foo
没有提供足够的信息来检查函数的类型,因为类型检查器不知道记录应该有哪些其他字段,但是上面介绍的记录/元组模式提供了这些信息。在ML中,每个变量和函数都有一个类型(除非您的程序无法通过类型检查)-类型推断仅仅意味着您不需要写下类型。
因此,我们上面使用模式匹配而不是#middle
或#2
的示例都不需要指定参数类型
fun sum_triple triple =
case triple of
(x, y, z) => z + y + x
fun sum_triple triple =
let val (x, y, z) = triple
in
x + y + z
end
fun sum_triple (x, y, z) =
x + y + z
而下面的版本需要明确的指定参数类型:
fun sum_triple (triple : int * int * int) =
#1 triple + #2 triple + #3 triple
因为类型检查器无法接受下面的函数
fun sum_triple triple =
#1 triple + #2 triple + #2 triple
并推断出它的参数类型是int * int * int
,从上面的函数,仅能得出triple
是一个三元素的元组,它可以是int * int * int
, 或者是int * int * string
,或者是int * bool * string
,几乎是无限多种可能。如果不使用#
语法,由于类型推断的便利性,ML几乎从不需要添加显式的类型说明。
事实上,类型推断有时会揭示函数比你想象的更为通用。思考以下代码,它只使用了元组/记录中的一部分数据:
fun partial_sum (x, y, z) = x + z
fun partial_name {first = x, middle = y, last = z} = x ^ " " ^ z
在这两种情况下,推导出的函数类型表明y
可以是任意类型,因引我们可以像这样调用:partial_sum (3,4,5)
或者partial_sum (3,false,5)
我们将在以后的章节中讨论这些多态函数以及类型推断是如何工作的,因为它们本身就是主要的课程主题。现在,只要停止使用#
,停止编写参数类型,如果你偶尔看到由类型推断得出的类似于'a
或'b
这样的类型,请不要迷惑,后面将会详细讨论...
离题:多态类型和相等类型
我们现在鼓励您在程序中保留显式类型注释,但如上所述,这可能会意外导致更通用的类型。假设您被要求编写一个int * int * int -> int
类型的函数,其行为类似于上面的partial_sum
,但是REPL正确地指出partial_sum
的类型是int * 'a * int -> int
。这是没问题的,因为多态性表明partial_sum
的类型更为通用。如果您可以采用一个包含'a
、'b
、'c
等的类型,并一致地替换这些类型变量中的每一个,以得到您“想要”的类型,那么您就有了一个比您想要的更通用的类型。
另一个例子,我们编写的append
函数的类型是'a list * 'a list -> 'a list
,因此,通过将'a
替换为string
,我们可以像使用 string list * string list -> string list
一样使用append
。我们可以使用任意类型,而不仅仅是字符串。实际上,我们什么都不做:这只是一个心理练习,检查一种类型是否比我们需要的更为通用。注意,像'a
这样的类型变量必须一致地被替换,这意味着append
的类型并不比string list * int list -> string list
为通用。
你也可以看到带有两个前导单引号的类型变量,比如''a
。这些被称为相等类型,它们是ML的一个相当奇怪的特征,与我们当前的研究无关。基本上,ML中的=
运算符(用于比较事物)适用于多种类型,不仅仅是int,它的两个操作数必须具有相同的类型。例如,它既适用于字符串类型,也适用于元组类型,元组中的所有类型都支持相等(例如int * (string * bool)
)。但并不是每种类型都适用。像''a
这样的类型只能用“相等类型”代替。
(* ’’a * ’’a -> string *)
fun same_thing(x,y) = if x=y then "yes" else "no"
(* int -> string *)
fun is_three x = if x=3 then "yes" else "no"
同样,我们将在后面更详细地讨论多态类型和类型推断,但是这个离题有助于避免家庭作业2中的混淆:如果您编写了一个函数,REPL给出了比您需要的更为通用的类型,那是OK的。另外,请记住,如上所述,如果REPL使用的类型同义词与您预期的不同,也可以。
嵌套模式
事实证明,模式的定义是递归的:我们在模式中任意位置放入的模式变量,都可以用另一个模式来代替。粗略地说,模式匹配的语义是,被匹配的值必须具有和模式相同的“形状”,并且变量被绑定到“正确的片段”。(这是一个非常复杂的解释,这就是为什么下面描述一个精确的定义。)例如,模式a::(b::(c::d))
将匹配任何至少有3个元素的列表元素,它将a绑定到第一个元素,b绑定到第二个元素,c绑定到第三个元素,d绑定到所有剩下的其他元素(如果有的话)的列表。另一方面,模式a::(b::(c::[]))
将只匹配正好有三个元素的列表。另一个嵌套模式是(a, b, c)::d
,它匹配任何非空的三元组列表,将a绑定到head的第一个组件,b绑定到head的第二个组件,c绑定到head的第三个组件,d绑定到列表的tail。
一般来说,模式匹配就是获取一个值和一个模式,并(1)确定模式是否匹配该值,以及(2)如果匹配,则将变量绑定到值的正确部分。下面是模式匹配的优雅递归定义的一些关键部分:
-
变量模式
x
匹配任意一个值v
,并引入一个绑定(从x
到v
) -
如果
C
是不携带任何数据的构造函数,则模式C
与值C
匹配 -
模式
C p
,其中C
是一个构造函数,p
是一个模式,如果p
匹配v
(即嵌套模式匹配携带的值),则匹配形式为C v
的值(注意构造函数是相同的)。它引入了p
匹配v
引入的绑定。 -
如果
p1
与v1
匹配,并且p2
与v2
匹配,并且pn
与vn
匹配,那么模式(p1,p2,...pn)
匹配元组(v1,v2,...,vn)
。它引入了递归匹配所引入的所有绑定。 -
类似于
{f1=p1,...,fn=pn}
形式的记录模式
这个递归定义以两种有趣的方式扩展了我们之前的理解。首先,对于带有多个参数的构造函数C
,我们不必编写C (x1,...,xn)
这样的模式,尽管我们经常这样做。我们也可以写C x
;这将x
绑定到值C (v1, ..., vn)
所携带的元组上。实际上,所有构造函数都采用0个或1个参数,但1个参数本身可以是元组。所以C (x1, ..., xn)
实际上是一个嵌套模式,其中(x1, ..., xn)
部分只是一个匹配所有元组的模式.
还有其他类型的模式。有时我们不需要将变量绑定到值的一部分。例如,考虑以下用于计算列表长度的函数:
fun len xs =
case xs of
[] => 0
| x::xs' => 1 + len xs'
我们并没有用到变量x
,在这种情况下,最好不要引入变量。相反,我们可以用通配符模式来匹配所有内容(就像变量模式匹配所有内容一样),但是不引入绑定。所以我们应该写成:
fun len xs =
case xs of
[] => 0
| _::xs' => 1 + len xs'
根据我们的一般定义,通配符模式非常简单:
- 通符符模式
_
匹配任意值v, 并且不引入绑定。
最后,您可以在模式中使用整数常量。例如,模式37
与值37
匹配并且不引入绑定。
几个有用的嵌套模式的例子
“zipping”或“unzipping”列表(本例中有三个)是使用嵌套模式而不是一堆难看的嵌套case表达式的一个优雅示例:
exception BadTriple
fun zip3 list_triple =
case list_triple of
([], [], []) => []
| (hd1::tl1, hd2::tl2, hd3::tl3) => (hd1, hd2, hd3) :: zip3(tl1, tl2, tl3)
| _ => raise BadTriple
fun unzip3 lst =
case lst of
[] => ([], [], [])
| (a, b, c) :: tl => let val (l1, l2, l3) = unzip3 tl
in
(a::l1, b::l2, c::l3)
end
下面的例子检查整数列表是否是经过排序的:
fun nondecreasing intlist =
case intlist of
[] => true
| _::[] => true
| head::(neck::rest) => (head <= neck andalso nondecreasing (neck::rest))
有时,通过匹配两个值来比较两个值也是很优雅的。下面的例子,用于确定乘法的结果到底是正数还是负数,有点傻,但说明了这个想法:
datatype sgn = P | N | Z
fun multsign (x1, x2) =
let fun sign x = if x = 0 then Z else if x > 0 then P else N
in
case (sign x1, sign x1) of
(Z, _) => Z
| (_, Z) => Z
| (P, P) => P
| (N, N) => P
| _ => N (* many say bad style; I am okay with it *)
end
最后一个案例的风格是值得讨论的:当您在底部包含这样一个“catch all”的case时,类型检查器不会再替你查检是否遗漏了其它case。因此,如果要使用这种技巧,您需要格外小心,并且枚举剩余的case(在本例中是(N,P)
和(P,N)
)可能不太容易出错。That the type-checker will then still determine that no cases are missing is useful and non-trivial
since it has to reason about the use (Z,_)
and (_,Z)
to figure out that there are no missing possibilities of type sgn * sgn.
可选:函数绑定中的多重 case
到目前为止,我们已经在case表达式中的"One-of"类型上看到了模式匹配。我们还看到了模式匹配val或函数绑定中"Each-of"类型的良好风格,这就是“多参数函数”的真正含义。但是有没有一种方法可以与val/函数绑定中的"One-of"类型匹配呢?这似乎是个坏主意,因为我们需要多种可能性。但事实证明,ML在函数定义中有特殊的语法。下面是两个示例,一个用于我们自己定义的数据类型"exp",另一个用于列表:
datatype exp = Constant of int
| Negate of exp
| Add of exp * exp
| Multiply of exp * exp
fun eval (Constant i) = i
| eval (Negate e2) = ~ (eval e2)
| eval (Add(e1,e2)) = (eval e1) + (eval e2)
| eval (Multiply(e1,e2)) = (eval e1) * (eval e2)
fun append ([],ys) = ys
| append (x::xs’,ys) = x :: append(xs’,ys)
这仅仅是一个个人喜好的问题,我并不是很喜欢这种风格。但是它在ML程序员中确实很常见,所以也欢迎您使用。就语义而言,它只是单个函数体(case表达式)的语法糖:
fun eval e =
case e of
Constant i => i
| Negate e2 => ~ (eval e2)
| Add(e1,e2) => (eval e1) + (eval e2)
| Multiply(e1,e2) => (eval e1) * (eval e2)
fun append e =
case e of
([],ys) => ys
| (x::xs’,ys) => x :: append(xs’,ys)
一般来讲,下面的语法:
fun f p1 = e1
| f p2 = e2
...
| f pn = en
仅仅是下面这种写法的语法糖:
fun f x =
case x of
p1 => e1
| p2 => e2
...
| pn => en
注意,append示例使用嵌套模式:通过将模式(例如[]
或x::xs'
)放在其他模式中,每个分支匹配一对列表。
异常
ML有一个内置的异常概念。可以使用raise
原语引发(或称为抛出)异常。例如,标准库中的hd
函数在使用空列表调用时,会抛出List.Empty
异常:
fun hd xs =
case xs of
[] => raise List.Empty
| x::_ => x
您可以使用异常绑定创建自己的异常类型。异常可以选择性地携带值,这使得引发异常的代码可以提供更多信息:
exception MyUndesirableCondition
exception MyOtherException of int * int
各种异常非常类似于数据类型绑定的构造函数。实际上,它们是函数(如果它们携带值)或值(如果不携带值),它们创建了exn
类型的值,而不是数据类型的值。因此,Empty
、MyUndesirableCondition
和MyOtherException (3,9)
都是exn
类型的值,而MyOtherException
的类型是int * int -> exn
。
通常我们只是使用异常构造函数作为参数来抛出,比如raise MyOtherException (3,9)
,但是我们可以更普遍地使用它们来创建exn
类型的值。例如,下面是一个函数的版本,它返回整数列表中的最大元素。如果用[]
调用,它将接受类型为exn
的参数并抛出它,而不是返回一个option
或引发一个特殊的异常,比如List.Empty
。因此调用方可以传入其选择的异常。(类型检查器可以推断ex
必须具有exn
类型,因为这是raise
所期望的参数类型。)
fun maxlist (xs, ex) =
case xs of
[] => raise ex
| x::[] => x
| x::xs' => Int.max (x, maxlist (xs', ex))
注意调用maxlist ([3,4,0], List.Empty)
并不会引发异常;此调用将异常值传递给函数,然而函数并不会抛出该值。
与异常相关的另一个特性是处理(也称为捕获)异常。为此,ML有handle
表达式,看起来像e1 handle p => e2
,其中e1
和e2
是表达式,p
是匹配异常的模式。语义是对e1
求值,并将结果作为答案。但是如果e1
引发了一个匹配p
的异常,那么将计算e2
,这就是整个表达式的结果。如果e1
引发与p
不匹配的异常,那么整个handle
表达式也会引发该异常。类似地,如果e2
引发异常,那么整个表达式也会引发异常。
和case表达式一样,handle
表达式也可以有多个分支,每个分支都有一个模式和表达式,语法上用|
分隔。
尾递归和累加器
本主题涉及新的编程习惯用法,但没有介绍新的语言结构。它定义了尾递归,描述了尾递归与用函数式语言(如ML)编写高效递归函数的关系,并介绍了使用累加器使某些函数成为尾递归函数的技巧。
要了解尾部递归和累加器,请考虑以下用于对列表元素进行求和的函数:
fun sum1 xs =
case xs of
[] => 0
| i::xs' => i + sum1 xs'
fun sum2 xs =
let fun f (xs, acc) =
case xs of
[] => acc
| i::xs' => f (xs', i + acc)
in
f (xs, 0)
end
两个函数的计算结果完全相同,但是sum2
更复杂一些,它使用了一个局部辅助函数,该函数接受一个额外的参数,称为acc
,表示“accumulator”。在函数f
的基本情况下,我们返回acc
,为最外层调用传递的值是0
,与sum1
的基本情况下使用的值相同。这种模式很常见:非累加器样式中的基本情况成为初始累加器,而累加器样式中的基本情况只是返回累加器。
当sum2显然更复杂时,为什么会首选它呢?要回答这个问题,我们需要了解一点函数调用是如何实现的。从概念上讲,内存中存在一个调用栈,它是一个栈(包含push和pop操作的数据结构),每个已经开始执行但尚未完成的函数调用都是这个栈当中的一个元素。每个元素都存储了一些东西,比如局部变量的值以及函数的哪个部分还没有被计算。当一个函数体的求值调用到另一个函数时,一个新的元素被push
到调用栈上,当被调用的函数完成时,刚刚被压栈的元素再被pop
出来继续执行。
因此,对于sum1,对sum1
的每一次递归调用都会产生一个调用栈元素(有时称为“栈帧”),即栈的高度与列表的长度一样大。这是必要的,因为在弹出每个栈帧之后,调用者必须“执行函数体的剩余部分” —— 将i
添加到递归结果并返回。
给出到目前为止的描述,sum2
并没有更好的地方:sum2
调用f
,然后f
对每个列表元素进行一次递归调用。但是,当f
对f
进行递归调用时,在被调用方返回后,调用方除了返回被调用方的结果之外,没有其他事情要做。这种情况称为尾部调用(我们不要试图弄清楚为什么这样称呼它),像ML这样的函数式语言通常承诺一个基本的优化:当调用是尾部调用时,调用方的栈帧在调用之前弹出-被调用方的栈帧只是替换调用方的栈帧。这很有意义:调用方只是无论如何都要返回被调函数的结果。因此,对sum2
的调用永远不会使用超过1个栈帧。
为什么函数式语言的实现包含这种优化?通过这样做,递归有时可以像while
循环一样高效,不会使调用栈变大。所谓“有时”就是当调用是尾部调用时,程序员可以对此进行推理,因为您可以查看代码并确定哪些调用是尾部调用。
尾部调用不需要是同一个函数(f
可以调用g
),因此它们比while
循环更灵活,while
循环总是要“调用”同一个循环。使用累加器是将递归函数转换为“尾部递归函数”(所有递归调用都是尾部调用的函数)的常用方法,但并不总是这样。例如,处理树(而不是列表)的函数通常具有与树的深度一样大的调用堆栈,但在任何语言中都是如此:而循环对于处理树不是很有用。
[注] 尾递归优化可以消除调用栈增长,改善空间复杂度,但是时间复杂度则取决于算法本身的效率。并不是消除调用栈增长就能获得直接的好处。
尾递归的更多例子
尾递归对于处理列表的函数来说很常见,但是这个概念更一般。例如,这里有两个factorial
函数的实现,其中第二个使用了尾递归辅助函数,因此它只需要少量的常量调用栈空间:
fun fact1 n = if n = 0 then 1 else n * fact (n - 1)
fun fact2 n =
let fun aux (n, acc) = if n = 0 then acc else aux (n - 1, acc * n)
in
aux (n, 1)
end
值得注意的是,fact1 4
和fact2 4
给出了相同的答案,尽管前者执行了4 * (3 * (2 * (1 * 1)))
, 而后者执行了(((1 * 4) * 3) * 2) * 1
。我们依赖的事实是乘法的交换率(a * (b * c)) = (a * b * c)
,以及乘以1就是恒等函数(1 * x = x * 1 = x)
。前面的sum
例子对加法作了类似的假设。一般来说,将非尾递归函数转换为尾递归函数通常需要关联性,但许多函数是关联的。
一个更有趣的例子是这个用于反转列表的低效函数:
fun rev1 lst =
case lst of
[] => []
| x::xs => (rev1 xs) @ [x]
我们马上就可以意识到它不是尾递归的,因为在递归调用之后,它仍然需要将结果附加到一个元素列表上,该元素列表包含列表的头部。虽然这是递归反转列表的最自然的方法,但效率低下的原因不仅仅是创建一个深度等于参数长度的调用栈,我们称之为n。更糟糕的问题是,执行的总工作量与n2成正比,即这是一个二次方算法。原因是附加两个列表所需的时间与第一个列表的长度成比例:它必须遍历第一个列表—请参阅前面讨论的我们自己的append
实现。在对rev1
的所有递归调用中,我们使用长度为n−1,n−2,…,1的第一个参数调用@
,从1到n−1的整数之和是n ∗ (n − 1) / 2
。
正如您在数据结构和算法课程中所学到的,对于足够大的n,像这样的二次方算法要比线性算法慢得多。也就是说,除非n总是很小,那么为了节省程序员的时间而使用简单的递归算法是值得的,否则就不应该使用如此低效的算法。另外,幸运的是,使用累加器习惯用法可以得到几乎是同样简单的线性算法。
fun rev2 lst =
let fun aux (lst, acc) =
case lst of
[] => acc
| x::xs => aux (xs, x::acc)
in
aux (lst, [])
end
关键的区别在于:(1)尾递归(2)我们对每个递归调用只做常量的工作,因为::
不必遍历它的每一个参数。
尾递归的精确定义
虽然大多数人依靠直觉来判断“哪些调用是尾部调用”,但我们可以更精确地定义尾递归。The definition has one part for each kind of expression; here are several parts:
-
对于
fun f(x) = e
,e
就是尾部位置 -
如果一个表达式不处在尾部位置,则其子表达式都不在尾部位置
-
如果
if e1 then e2 else e3
是在尾部位置,那么e2
和e3
都是尾部位置,而e1
不是。case表达式也类似 -
如果
let b1 ... bn in e end
处于尾部,那e
处于尾部(但是局部绑定中的表达式不是尾部) -
函数调用的参数不能算是尾部位置
第三节
介绍一些术语
本节重点介绍头等函数和函数闭包。 “头等”是指函数可以计算,可以传递,可以存储等,可以出现在任何其他值可以计算,传递,存储的地方。例如,可以将函数作为参数传递给另外的函数,或者作为函数的返回值,然后将它们存放在对偶中,使它们成为数据类型构造函数所携带的数据的一部分,等等。“函数闭包”是指引用了在函数体外定义的变量的函数,这使头等函数更加强大,稍后我们将会看到。我们从简单的头等函数开始,暂时先不使用函数闭包。术语“高阶函数”仅指代接受或返回其他函数的函数。
诸如头等函数,函数闭包和高阶函数之类的术语经常被混淆或被视为同义词。因为世界上有很多地方对这些术语的使用并不严谨,所以我们也不会太担心它们。但是,头等函数的思想和函数闭包的思想确实是两个截然不同的概念,我们经常将它们结合在一起来编写精美的,可重用的代码。因此,我们将推迟介绍闭包的概念,因此我们可以将其作为一个单独的概念进行介绍。
[注]在译者看来,闭包确实是不同的概念,但是头等函数和高阶函数并没有什么不同。
函数式编程是一个更通用的术语。该术语也经常不精确地用于代指几个不同的概念。最重要和最常见的两个是:
-
在大多数或所有情况下都不使用可变数据:到目前为止,我们在整个过程中都避免了突变,并且将继续这样做。
-
将函数作为值对待,这就是本节的全部内容
除此之外,与函数式编程相关的还包括:
-
鼓励递归和递归数据结构的编程风格
-
使用更接近于数学传统中函数定义的语法或风格进行编程
-
任何不是面向对象编程的东西(这其实是不正确的)
-
使用某些与惰性有关的编程习语,这是某种编程构造/习语的技术术语,我们将在本课程稍后的部分进行简短的研究。
一个明显的问题是“到底是什么使得一个编程语言之所以成为函数式语言?”您的老师得出的结论是,这不是一个有确切答案的问题,几乎没有什么道理。但是可以说,一种函数式语言是一种比其他风格的编程语言更方便,更自然,更通用的语言。至少,它需要对不可变数据,头等函数,函数闭包有良好的支持。我们将越来越多地看到提供了这些支持,同时也为其他的编程风格(例如面向对象)提供了良好支持的新语言,我们将在课程结束时学到一些。
以函数作为参数
头等函数最常见的用法是将它们作为参数传递给其他函数,因此我们首先来看看这种用法
fun n_times (f, n, x) =
if n = 0
then x
else f (n_times (f, n-1, x))
我们可以说,参数f
是一个函数,因为最后一行用参数调用了f
。n_times
所做的计算是计算f (f ( ... (f (x))))
,其中对f
的调用次数为n
。这是一个真正有用的辅助函数。例如,这是它的3种不同的用法:
fun double x = x + x
val x1 = n_times (double, 4, 7) (* 答案是 112 *)
fun increment x = x + 1
val x2 = n_times (increment, 4, 7) (* 答案是 11 *)
val x3 = n_times (tl, 2, [4, 8, 12, 16]) (* [12, 16] *)
像任何辅助函数一样,n_times
使我们可以抽象出多个计算的公共部分,因此我们可以通过传入不同的参数以不同的方式重用某些代码。主要的新颖之处在于使其中一个参数成为函数,这是一种功能强大且灵活的编程习惯用法。这也很合理-我们在这里没有引入任何新的语言构造,而只是用了您可能没有想到的方式使用我们已经知道的语言构造.
一旦定义了此类抽象,就可以找到它们的其他用途。例如,即使我们的程序今天不需要将任何值翻三番,也许明天也会需要,在这种情况下,我们可以使用n_times
定义函数Triple_n_times
:
fun triple x = 3 * x
fun triple_n_times (n, x) = n_times (triple, n, x)
多态类型和函数作为参数
现在让我们思考n_times
的类型,即(’a -> ’a) * int * 'a -> 'a
。首先考虑类型(int -> int) * int * int -> int
可能会更简单,这就是上面x1
和x2
使用n_times
的方式:它需要3个参数,第一个参数本身就是一个函数接受并返回一个int
值。类似地,对于x3
,我们使用n_times
,就好像它具有类型(int list -> int list) * int * int list -> int list
。但是,为n_times
选择这些类型中的任何一种都会使它的用处不大,因为只有某些示例能通过类型检查。类型('a -> 'a) * int * 'a -> 'a
表示第三个参数,结果可以是任何类型,但它们必须与第一个参数的参数和返回类型相同。当类型可以是任何类型并且不必与其他类型相同时,我们使用不同的字母(’b
,’c
等)
这就是所谓参数多态性,或者,更常见的叫法是泛型。它使函数可以接受任何类型的参数。它是与头等函数分开的另一个问题:
-
有些函数接受函数作为参数,但是并非多态类型
-
有些具有多态类型的函数不接受函数作为参数
但是,我们的许多具有头等函数的示例将具有多态类型。这是一件好事,因为它使我们的代码更具有可重用性。
如果没有参数多态性,我们将不得不为列表可能具有的每种元素重新定义列表。取而代之的是,我们可以使用适用于任何类型的列表的函数,例如length
,其类型为 'a list -> int
,即使它不使用任何函数参数。相反,下面就是一个非多态类型的高阶函数:它的类型为(int -> int) * int -> int
:
fun times_until_zero (f, x) =
if x = 0 then 0 else 1 + times_until_zero (f, f x)
匿名函数
并非每一个传递给其它函数的函数(比如triple
)都需要在顶层环境中定义,如果只是在局部使用这些函数,则最好将它定义在局部。所以,我们可以这样写:
fun triple_n_times (n, x) =
let fun triple x = 3 * x in n_times (triple, n, x) end
实际上,我们可以给triple
函数一个更小的范围:我们仅仅需要把它作为n_times
的第一个参数,因此我们可以在那里有一个let表达式来求值triple
函数:
fun triple_n_times (n, x) = n_times ((let fun triple y = 3 * y in triple end), n, x)
注意,这个示例实际上是糟糕的样式,我们需要让let表达式“返回”triple
,因为和往常一样,let表达式会产生in
和end
之间的表达式结果。在这种情况下,我们只需在环境中查找triple
,结果函数就是我们将其作为第一个参数传递给n_times
的值。
ML提供了一种更简洁的方式来定义函数,就在您使用它们的地方,就像在最终的最佳版本中一样:
fun triple_n_times (n,x) = n_times((fn y => 3 * y), n, x)
这段代码定义了一个匿名函数fn y => 3 * y
。它是一个接受参数y
并具有函数体3 * y
的函数。 fn
是关键字,而=>
(不是=
)也是语法的一部分。我们并没有给函数命名(它是匿名的,看到了吗?),这很方便,因为我们不需要给它命名。我们只是想将一个函数传递给n_times
,在n_times
的函数体中,此函数绑定到f
。
通常将匿名函数用作其他函数的参数。此外,您可以在可以放置表达式的任何位置放置匿名函数-它只是一个值,即函数本身。匿名函数唯一不能做的就是递归,正是因为您没有用于递归调用的名称。在这种情况下,您需要像以前一样使用fun
绑定,并且fun
绑定必须位于let表达式中或位于顶层环境中。
对于非递归函数,您可以将匿名函数与val绑定一起使用,而不是使用fun绑定。例如,这两个绑定是完全相同的东西:
fun increment x = x + 1
val increment = fn x => x + 1
它们都将increment
绑定到一个值,该值是返回其参数加1的函数。因此,函数绑定几乎是语法糖,但是它们支持递归,这是必不可少的。
过度的函数包装
尽管匿名函数非常方便,但是有一个糟糕的习惯用法,使它们无缘无故地被使用。思考下面的代码:
fun nth_tail_poor (n, x) = n_times ((fn y => tl y), n, x)
fn y => tl y
是什么?它是一个返回其参数的列表尾的函数。但是,系统中已经有一个现成的函数:tl
!通常,当我们只使用f
时,没有理由写成fn x => f x
。这类似于初学者的习惯,即if x then true else false
而不是x
。只需改成这样:
fun nth_tail (n, x) = n_times (tl, n, x)
Map和Filter
现在,我们来思考一个非常有用的高阶函数:
fun map (f, xs) =
case xs of
[] => []
| x::xs' => (f x)::(map (f, xs'))
map
函数接受一个列表和一个函数f
,并通过将f应用于该元素的每个元素来生成一个新列表。这是两个示例用法:
val x1 = map (increment, [4, 8, 12, 16]) (* answer : [5, 9, 13, 17] *)
val x2 = map (hd, [[1, 2], [3, 4], [5, 6, 7]]) (* answer: [1, 3, 5] *)
map
函数的类型会显示为:('a -> 'b) * 'a list -> 'b list
,您可以将任何类型的列表传递给map
,但是f
的参数类型必须是列表的元素类型(它们是'a
)。但是f
的返回值类型可以是不同的类型'b
。结果列表是一个'b list
。对于x1
而言,'a
和'b
都是int
;而对于x2
,'a
是int list
, 'b
是int
。
ML标准库提供了非常相似的功能List.map,但它是以柯里化的形式定义的,“柯里化”是我们将在本节中稍后将要讨论的主题。
尽管我们的示例很简单,但是map的定义和使用是一个非常重要的习惯用法。我们可以很容易地在递增所有元素的整数列表上编写递归函数,但相反,我们将工作分为两部分:map
的实现者知道如何遍历递归数据结构,在当前情况下是列表。map
的客户端知道如何处理那里的数据,以increment
为例,即给每个数字加上一。您可以想象这些任务中的任何一个 - 遍历一个复杂的数据片段或为每个片段进行一些计算 - 都要复杂得多,并且最好由不同的开发人员来完成,而无需对其他任务做任何假设。这正是将map
用作具有函数的帮助函数所允许的。
这是第二个与列表有关的非常有用的高阶函数。它需要一个类型为'a -> bool
的函数和一个'a list
作为参数,并返回一个'a list
,该列表仅包含该在输入的列表元素中,传递给输入的函数返回true
的元素:
fun filter (f, xs) =
case xs of
[] => []
| x::xs' => if f x
then x::(filter (f, xs'))
else filter (f, xs')
这是一个使用示例,它假定列表元素是对偶,并且第二个元素是int
类型;它返回第二个元素是偶数的列表元素:
fun get_all_even_snd xs = filter((fn (_, v) => v mod 2 = 0), xs)
(请注意,我们是如何对匿名函数的参数使用模式匹配的。)
返回函数
函数也可以返回函数,这里有一个例子:
fun double_or_triple f =
if f 7
then fn x => 2 * x
else fn x => 3 * x
double_or_triple
的类型是(int -> bool) -> (int -> int)
:The if-test makes the type of f clear,并且,if的两个分支必须具有相同的类型,此处的情况为int -> int
。但是,ML会将类型打印为(int -> bool) -> int -> int
,这是相同的。括号不是必要的,因为->
关联到左边,即t1 -> t2 -> t3 -> t4
等价于t1 -> (t2 -> (t3 -> t4))
。
不仅仅是数字和列表
因为ML程序倾向于使用列表,所以您可能会忘记高阶函数对列表的作用更大。我们的第一个示例只是使用整数。但是高阶函数对于我们自己的数据结构也非常有用。在这里,我们使用is_even
函数来查看算术表达式中的所有常量是否均为偶数。我们可以轻松地将true_of_all_constants
重用于我们要检查的任何其他属性。
datatype exp = Constant of int
| Negate of exp
| Add of exp * exp
| Multiply of exp * exp
fun is_even v =
(v mod 2 = 0)
fun true_of_all_constants (f, e) =
case e of
Constant i => f i
| Negate e1 => true_of_all_constants (f, e1)
| Add (e1, e2) => true_of_all_constants (f, e1) andalso
true_of_all_constants (f, e2)
| Multiply (e1, e2) => true_of_all_constants (f, e1) andalso
true_of_all_constants (f, e2)
fun all_even e = true_of_all_constants (if_even, e)
词法作用域
到目前为止,我们传递给其他函数或从其他函数返回的函数都是闭合的:函数体仅使用函数的参数和局部定义的变量。但是我们知道函数可以做的还不止这些:它们可以使用范围内的任何绑定。这样做与高阶函数结合在一起非常有力,因此使用此技术学习有效的习语至关重要。但是首先,正确理解语义更为关键。这可能是整个课程中最微妙和最重要的概念,因此请慢慢阅读并仔细阅读。
函数体是在定义函数的环境中求值的,而不是在调用函数的环境中求值的。这里一个非常简单的示例来说明差异:
val x = 1
fun f y = x + y
val x = 2
val y = 3
val z = f (x + y)
在上面的示例中,f
绑定到一个函数,它有一个参数y
。它的函数体会在定义函数的环境中查找变量x
的值。在定义函数的环境中,x
绑定以值1
,因此此函数始终将它的参数加上一。之后,我们有了一个不同的环境,其中f
映射到刚刚的函数,x
映射到2
,y
映射到3
,然后调用f (x+y)
,求值是这样进行的:
-
查找
f
以获得之前描述的函数 -
在当前环境中查找
x
和y
,求值x+y
得到5
-
使用
5
作为参数去调用函数,这意味着在“旧的”环境中求值函数体x+y
, 其中x
映射为1,y
映射为5,因此结果为6
需要注意的是,参数是在当前环境中求值产生的(结果为5),但是函数体是在“旧的”环境中进行求值的。下面我们讨论为什么要采用这种语义,但是首先我们先精确地定义这种语义,并通过使用高阶函数的其他示例来理解这种语义。
这种语义称为词法作用域。此外,还有另外一种使用当前环境的糟糕的语义,被称为“动态作用域”。在动态作用域的语义中,上面的例子求值会得到7,而不是5.
环境和闭包
我们已经说过了,函数本身也是值,但是我们并不确定所谓的值到底是什么。现在,我们可以解释,一个函数值包含了两个部分:函数的代码(很显然),以及创建函数时的“当前环境”。这两个部分确实形成了一个“对”,但是我们将“对”放在引号中,因为它并不是ML的对偶,而是由两个部分组成的东西。您不能单独访问“对偶”的各个部分;您所能做的就是调用该函数。调用的过程中同时使用了这两个部分,因为它使用环境部分来求值代码部分。
这样一个“对”就称为函数闭包或仅称为闭包。原因是,虽然代码本身可以具有自由变量(变量没有在代码内部绑定,所以它们需要由某些外部环境绑定),但闭包附带了提供所有这些绑定的环境。因此,闭包总体上是“封闭的”-只要给定函数的参数,它就具备了产生函数的结果所需的一切。
在上面的示例中,fun f x = x + y
将f
绑定到一个闭包,闭包的代码部分是一个函数fn y => x + y
,而环境部分将x
映射为1.因此,用任何参数y
调用此闭包的结果都是y+1
。
高阶函数示例(傻)
当我们有了高阶函数,词法作用域和闭包变得更有趣了,已经描述过的语言将会引导我们找到正确的答案。
例一:
val x = 1
fun f y =
let
val x = y + 1
in
fn z => x + y + z
end
val x = 3
val g = f 4
val y = 5
val z = g 6
这里,f
绑定到一个闭包,在闭包的环境部分中,x
映射到1
。之后,当我们求值f 4
的时候,我们相当于在一个扩展的环境中(x
映射为1,y
映射为4)求值的是let val x = y + 1 in fn z => x + y + z end
。但是由于let
绑定的存在,x
被遮蔽了。我们在x
映射为5,y
映射为4的环境中求值fn z => x + y + z
。像fn z => x + y + z
这样的函数如何求值?其实我们用当前环境创建了一个闭包。因此,f 4
返回了一个闭包,当调用这个闭包时,它总是将9与它的参数z
相加,无论在什么地方调用。因此,在示例的最后一行中,z
将绑定到15.
例二:
fun f g =
let
val x = 3
in
g 2
end
val x = 4
fun h y = x + y
val z = f h
在这个例子中,f
绑定到一个闭包,该闭包将另一个函数g
作为参数并且返回g 2
的结果。绑定到h
的闭包总是将其参数与4相加,因为参数是y
,主体是x + y
,并且函数是在x
映射到4的环境中定义的。所以在最后一行,z
将被绑定到6.绑定val x = 3
完全不相关:调用g 2
的计算方法是:查找g
以获得传入的闭包,然后将该闭包与其环境(其中映射x
到4)一起使用2作为参数。
为什么采用词法作用域
虽然词法作用域和高阶函数需要一些时间来适应,但几十年的经验表明,这种语义正是我们需要的。本节剩下的大部分内容将描述各种广泛使用的、功能强大的、依赖于语法作用域的习惯用法。
但首先,我们也可以通过展示动态作用域(您只有一个当前环境并使用它来求值函数体)如何导致一些基本问题来激发你对词法作用域的思考。
首先,假设在上面的示例1中,f
的函数体被更改为让val q = y + 1 in fn z => q + y + z
。在词法作用域下,这工作得很好:我们总是可以更改局部变量的名称及其用法,而不会影响任何内容。在动态范围下,现在对g 6
的调用将毫无意义:我们将尝试查找q
,但在调用g 6
的环境中并没有q
。
第二,再次考虑示例1的原始版本,但现在将行val x = 3
更改为val x = "hi"
。在词法范围下,这也工作得很好:绑定从未实际使用过。在动态作用域下,对g 6
的调用将查找x
,得到一个字符串,并尝试对它执行加法,这在类型检查的程序中是不应该发生的。
例2也出现了类似的问题:在这个例子中f
的函数体非常糟糕:我们有一个从未使用过的局部绑定。在词法作用域下,我们可以删除它,将函数体更改为g 2
,并且知道这对程序的其余部分没有影响。在动态作用域下,它就会产生影响。此外,在词法作用域下,我们知道任何使用绑定到h
的闭包都会将其参数与4相加,而不管其他函数(如g)是如何实现的,以及它们使用的变量名是什么。这是一个关键的关注点分离,只有词法作用域能提供。
对于程序中的“常规”变量,词法作用域是一种可行的方法。对于某些习语,动态作用域有一些引人注目的用途,但是很少有语言对这些习惯用法有特别的支持(Racket支持),而且很少有现代语言将动态作用域作为默认值。但是您已经看到了一个更像动态作用域而不像词法作用域的特性:异常处理。当引发异常时,求值必须“查找”应该求值的句柄表达式。这种“查找”是使用动态调用堆栈完成的,不考虑程序的词法结构。
将闭包传递给像filter这样的迭代器
上面的例子有些蠢,所以我们需要展示依赖词法作用域的有用程序。我们将展示的第一个习惯用法是将函数传递给迭代器,如map
和filter
。我们之前传递的函数没有使用它们的环境(只使用它们的参数,可能还有局部变量),但是能够在闭包中传递使得高阶函数更加有用。考虑:
fun filter (f, xs) =
case xs of
[] => []
| x::xs' => if f x then x::(filter (f, xs')) else filter (f, xs')
fun allGreaterThanSeven xs = filter (fn x => x > 7, xs)
fun allGreaterThan (xs, n) = filter (fn x => x > n, xs)
下面是另外两个示例:
fun allShorterThan1 (xs, s) = filter (fn x => String.size x < String.size s, xs)
fun allShorterThan2 (xs, s) =
let
val i = String.size s
in
filter (fn x => String.size x < i, xs)
end
这两个函数都接受字符串列表xs
以及一个单独的字符串s
,并返回一个仅包含xs
中比s
短的字符串的列表。它们都使用闭包,在匿名函数被调用时查找s
或i
。第二个更为复杂,但效率更高:第一个函数,在xs
上每迭代一次,都要重复计算一次String.size s
,而第二个函数只计算一次String.size s
,将字符串s
的长度保存在变量i
当中,filter
函数每迭代一次都是直接与i
做比较,而不需要再次计算String.size s
。
Fold和闭包的更多示例
除了map
和filter
以外,第三个非常常用的高阶函数是fold
,它可以有几个稍有不同的定义,也可能会有不同的名字(比如reduce
和inject
)。下面是一个常见定义:
fun fold (f, acc, xs) =
case xs of
[] => acc
x::xs' => fold (f, f (acc, x), xs')
fold
接受一个“初始答案”acc
,并使用f
来"组合"acc
和列表的第一个元素,使用它使用新的”初始答案“来”折叠“列表的其余部分。我们可以通过指定一些”如何组合元素“的函数来小心地使用fold
处理对列表的迭代。例如,对列表foo
中的元素求和:
fold ((fn (x, y) => x + y), 0, foo)
与map
和filter
一样,fold
的大部分功能来自于客户端传递闭包,闭包可以有”私有字段“(以变量绑定的形式)来保存他们想要查询的数据。这里有两个例子。第一个函数计算整数列表xs
当中,处于某个整数范围内的元素的数量。第二个函数检查列表xs
当中的所有字符是否都比s
的长度要短。
fun numberInRange (xs, lo, hi) =
fold ((fn (x, y) =>
x + (if y >= lo andalso y <= hi then 1 else 0)),
0, xs)
fun areAllShorter (xs, s) =
let
val i = String.size s
in
fold ((fn (x, y) => x andalso String.size y < i), true, xs)
end
这种将递归遍历(折叠或映射)与对元素(传入的闭包)执行的数据处理分离的模式是基本的。在我们的例子中,这两个部分都很简单,我们只需要简单的几行就可以完成整个过程。更一般地说,我们可能需要遍历一组非常复杂的数据结构,或者需要进行非常复杂的数据处理。最好将这些关注点分开,这样就可以分别解决不同层面的编程问题。
闭包的惯用法:函数组合
函数组合
在编程时,创建新函数是很有用的,有些函数只是其他函数的组合。你可能在数学上也做过类似的事情,比如当你组合两个函数时。例如,这里有一个函数,它精确地完成了函数组合:
fun compose (f, g) = fn x => f (g x)
它接受两个函数f
和g
,然后返回另外一函数,该函数将它的参数传递给g
,再将g x
的结果传递给f
。关键在于,代码fn x => f (g x)
在定义f
和g
的环境中调用它们。注意,compose
的类型被推导为('a -> 'b) * ('c -> 'a) -> 'c -> 'b
,这相当于可以写成:('b -> 'c) * ('a -> 'b) -> ('a -> 'c)
,这两种类型仅仅是使用了不同的类型变量名。
作为一个可爱而方便的库函数,ML库将中缀运算符o
定义为函数组合,就像在数学中一样。所以与其写:
fun sqrt_of_abs i = Math.sqrt (Real.fromInt (abs i))
你可以写成:
fun sqrt_of_abs i = (Math.sqrt o Real.fromInt o abs) i
但第二个版本更清楚地表明,我们可以使用函数组合来创建一个函数,就像我们用val绑定到一个变量,就像第三个版本:
val sqrt_of_abs = Math.sqrt o Real.fromInt o abs
虽然这三个版本的可读都很不错,但第一个版本并没有清晰地向读者表明,sqrt_of_abs
只是其他函数的组合。
管道操作符
在函数式编程中,编写其他函数来创建更大的函数是很常见的,因此为它定义方便的语法是很有意义的。尽管上面的第三个版本很简洁,但它和数学中的函数组合一样,具有许多程序员都很奇怪的特性,即计算从右向左进行:“取绝对值,将其转换为实数,然后计算平方根”可能比“取转换为实数的平方根”更容易理解绝对值。”
我们也可以为从左到右定义方便的语法。让我们首先定义我们自己的中缀运算符,让我们把函数放在调用它的参数的右边:
infix |> (* 告诉解释器,|> 是一个出现在两个参数之间的函数 *)
fun x |> f = f x
接下来,我们就可以写:
fun sqrt_of_abs i = i |> abs |> Real.fromInt |> Math.sqrt
这个操作符,通常称为管道操作符,在F#编程中非常流行。(F#是ML的一种方言,运行在.Net上,与其他.Net语言编写的库交互良好。)正如我们所看到的,管道操作符的语义并不复杂。
闭包的惯用法:柯里化和局部应用
我们考虑的下一个习惯用法通常非常方便,在定义和使用高阶函数(如map、filter和fold)时经常使用。我们已经看到,在ML中,每个函数只接受一个参数,因此必须使用一个习惯用法来获得多个参数的效果。我们以前的方法将一个元组作为一个参数传递,因此元组的每个部分在概念上都是多个参数中的一个。另一个更聪明、更方便的方法是让一个函数接受第一个参数,然后返回另一个接受第二个参数的函数,依此类推。词法作用域对于这项技术的正确使用至关重要。
这种技术被称为“柯里化”,这是因为一位名叫 Haskel Curry 的逻辑学家研究了相关的思想。如果你不知道这些,那么“柯里化”这个词就没有什么意义了。
定义和使用柯里化的函数
下面是使用柯里化的“三参数”函数示例:
val sorted3 = fn x => fn y => fn z => z >= y andalso y >= x
如果我们调用sorted 4
,我们就得到了一个闭包,这个闭包需要在它的环境里面有一个参数x
,如果接下来我们用5
去调用这个闭包,我们又得到了另外一个闭包,这个新的闭包需要环境中存在x
和y
。我们再次用6
去调用这个新的闭包,结果就得到了true
,因为 6 > 5 > 4
。这就是闭包的工作方式。
所以,((sorted3 4) 5) 6
计算得到了我们想要的东西,感觉很接近用3个参数去调用sorted3
。更好的是,括号是可选的,我们可以写成sorted3 4 5 6
,这种写法与前面带括号的写法是完全相同的,这实际上比之前我们用元组来传递多个参数更节省打字:
fun sorted3_tupled (x, y, z) = z >= y andalso y >= x
val someClient = sorted3_tupled (4, 5, 6)
一般来说,语法e1 e2 e3 e4
隐式地表示嵌套的函数调用(((e1 e2) e3) e4)
,之所以这么设定,是因为它可以让柯里化的函数使用非常愉快。
局部应用
尽管我们可能希望我们当前的sorted3
的大多数客户端代码都提供所有的3个参数,但是它们可能会提供较少的参数,稍后再使用另外的参数去掉用中间结果所产生的闭包。这被称为“局部应用”,因为我们提供了参数的子集(更确切地说,是前缀)。作为一个愚蠢的例子,sorted3 0 0
返回一个函数,如果其参数为非负,则返回true
。
局部应用与高阶函数
使用柯里化来创建类似于迭代器的函数非常方便。例如,下面是fold
函数的柯里化版本:
fun fold f = fn acc => fn xs =>
case xs of
[] => acc
| x::xs' => fold f (f (acc, x)) xs'
现在,我们可以用这个fold
函数去定义一个对列表做求和的函数:
fun sum1 xs = fold (fn (x,y) => x+y) 0 xs
但是与局部应用相比,上面这种写法不必要地复杂,我们其实可以写成:
val sum2 = fold (fn (x, y) => x + y) 0
局部应用的便利性就是为什么ML标准库中的许多迭代器使用它们作为第一个参数的函数。例如,所有这些函数的类型都是柯里化的:
val List.map = fn : ('a -> 'b) -> 'a list -> 'b list
val List.filter = fn : ('a -> bool) -> 'a list -> 'a list
val List.foldl = fn : ('a * 'b -> 'b) -> 'b -> 'a list -> 'b
举个例子,List.foldl ((fn (x, y) => x + y), 0, [3, 4, 5])
并不能通过类型检查,因为List.foldl
预期一个类型为'a * 'b -> 'b
的函数作为参数,而不是一个元组。正确的调用是List.foldl (fn (x, y) => x + y) 0 [3, 4, 5]
,用一个函数作为参数去调用 List.foldl
,返回的是一个闭包,依此类推。
定义柯里化的函数是有语法糖的,您可以用空格来分隔多个形参,而不是使用匿名函数。所以,其实fold
函数最好的写法是:
fun fold f acc xs =
case xs of
[] => acc
| x::xs' => fold f (f (acc, x)) xs'
另一个有用的柯里化函数是List.exists
,我们在下面的示例中会用到。这些库函数很容易自己实现,因此我们应该很容易就能理解它们:
fun exists predicate xs =
case xs of
[] => false
| x::xs' => predicate x orelse exists predicate xs'
柯里化概述
虽然柯里化和局部应用对高阶函数很有用,但它们在一般情况下也很有用。它们适用于任何多参数的函数,局部应用也非常方便。在本例中,zip
和range
都用柯里化的形式来定义,countup
局部应用了range
。add_numbers
函数将列表[v1, v2, ..., vn]
转换为[(1, v1), (2, v2), ..., (n, vn)]
。
fun zip xs ys =
case (xs, ys) of
([], []) => []
| (x::xs', y::ys') => (x, y) :: (zip xs' ys')
| _ => raise Empty
fun range i j = if i > j then [] else i :: range (i+1) j
val countup = range 1
fun add_numbers xs = zip (countup (length xs)) xs
将函数组合为柯里化或非柯里化的其它函数
有时候,函数已经是柯里化的了,但是它的参数的顺序并不是你想要的局部应用的顺序。或者,有时当你想让一个函数使用元组或者相反。幸运的是,我们前面学过的组合函数的方法来生成新的函数:
fun other_curry1 f = fn x => fn y => f y x
fun other_curry2 f x y = f y x
fun curry f x y = f (x, y)
fun uncurry f (x, y) = f x y
查看这些函数的类型可以帮助您了解它们的作用。另外,这些类型也是很吸引人的。如果你将->
读作“含有”,将*
读作“和”,那么所有这些函数的类型都是赘述的。
效率
最后,你可能想知道到底哪一种写法更快?到底是柯里化的函数,还是元组化的函数更快?其实这基本上不重要,它们都形参的数量成正比,然而形参的数量通常很少。对于软件性能中最关键的函数,选择更快的方法可能很重要。在我们正在使用的ML编译器版本(SML/NJ)中,元组化的函数要更快一些。而在广泛使用的 OCaml,Haskell 和 F# 等实现中,柯里化的函数更快一些。因此,柯里化是这些语言中定义多参数函数的标准方法。
值约束
一旦你学会了柯里化和局部应用,你就可尝试用它来创建一个多态函数。不幸的是,某些用法在ML中不起作用,比如下面这两种写法:
(*turn [v1,v2,...,vn] into [SOME v1, SOME v2, ..., SOME vn]*)
val mapSome = List.map SOME
(*turn [v1,v2,...,vn] into [(v1,v1),(v2,v2),...,(vn,vn)]*)
val pairIt = List.map (fn x => (x,x))
以我们目前所学过的知识,没有什么理由不能这么写,特别是,所有下面的这些函数都可以正常工作:
fun mapSome xs = List.map SOME xs
val mapSome = fn xs => List.map SOME xs
val pairIt : int list -> (int * int) list = List.map (fn x => (x, x))
val incrementIt = List.map (fn x => x + 1)
原因被称为“值约束”,这种约束有时令人讨厌。在ML语言中之所以有这种限制,其实有很好的理由:如果没有这种限制,类型检查器可能会允许某些代码破坏类型系统。这只能发生在使用副作用的代码中,而上面的代码不是,但是类型检查器并不知道这一点。
最简单的方法是忽略它,一直到你收到有关值约束的警告/错误。当你得到警告的时候,把val
绑定变成一个fun
绑定就行了,就像上面第一个例子中的一样。
在下一节研究类型推断时,我们将更详细地讨论值约束。
通过引用进行突变
我们现在终于介绍了ML对突变的支持。在某些情况下变异是可以的。函数式编程中的一个关键方法是,只有在“更新某个对象的状态,以便该状态的所有用户都能看到已发生的更改”是模拟计算的自然方式时才使用它。此外,我们希望将突变的特征分开,以便知道何时不使用突变。
在ML中,大多数东西确实不能被修改。相反,您必须创建一个引用,它是一个内容可以修改的容器。使用表达式ref e
创建一个新引用(初始内容是对e
求值的结果)。你可以通过!r
来得到一个引用r
的当前内容(不要与Java或C中的取反运算!
混淆),您可以用r := e
来更改r
的内容。包含t
类型值的引用的类型写为t ref
.
理解引用的一个好办法是将它作为一个具有一个字段的记录,该字段可以用 :=
运算符进行更新。
下面是一些简短的例子:
val x = ref 0
val x2 = x (* x and x2 both refer to the same reference *)
val x3 = ref 0
(* val y = x + 1*) (* wrong: x is not an int *)
val y = (!x) + 1 (* y is 1 *)
val _ = x := (!x) + 7 (* the contents of the reference x refers to is now 7 *)
val z1 = !x (* z1 is 7 *)
val z2 = !x2 (* z2 is also 7 -- with mutation, aliasing matters*)
val z3 = !x3 (* z3 is 0 *)
闭包的惯用法:回调函数
我们考虑的下一个常见习惯用法是实现一个库,该库检测感兴趣的“事件”发生时,通知之前“注册”过的客户端。客户端可以通过提供一个“回调函数”来注册他们感兴趣的事件,回调函数是一个在事件发生时被调用的函数。可能需要此类库的事件示例包括用户移动鼠标或按键等。另一个例子是来自网络接口的数据。电脑玩家在游戏中的“轮到你了”事件是另一个例子。
这些库的目的是允许多个客户端注册回调。库的实现者不知道发生事件时客户端需要计算什么,客户端可能需要“额外的数据”来进行计算。因此,库实现者不应该限制每个客户机使用的“额外数据”。闭包非常适合这种情况,因为函数的类型t1->t2
没有指定闭包使用的任何其他变量的类型,所以我们可以将“额外数据”放在闭包的环境中。
如果您使用过Java的Swing库中的“事件侦听器”,那么您就在面向对象的设置中使用了这个习惯用法。在Java中,您可以通过定义一个带有附加字段的子类来获得“额外数据”。对于一个简单的监听器来说,这可能需要大量的按键,这是Java语言添加匿名内部类的主要原因(在本课程中,您不需要知道这些,但我们稍后将展示一个示例),这些类更接近闭包的便利性。
在ML中,我们将使用变异来展示回调函数的习惯用法。这是合理的,因为我们确实希望注册一个回调函数来“改变世界的状态”——当一个事件发生时,现在可以调用更多的回调函数。
我们的示例使用这样一种思想:当按下键盘上的键时,回调函数被调用。我们将给回调函数传递一个int
来编码被按下的键。我们的接口只需要一种注册回调的方法(在真正的库中,您可能还需要一种注销它们的方法。)
val onKeyEvent : (int -> unit) -> unit
客户端将传递int->unit类型的函数,当稍后使用int调用时,该函数将执行他们想要的任何操作。为了实现这个函数,我们使用一个包含回调函数列表的引用。然后,当事件实际发生时,我们假设调用了函数onEvent
,它调用列表中的每个回调:
val cbs : (int -> unit) list ref = ref []
fun onKeyEvent f = cbs := f :: (!cbs) (* The only "public" binding *)
fun onEvent i =
let fun loop fs =
case fs of
[] => ()
| f::fs' => (f i; loop fs')
in loop (!cbs) end
最重要的是,onKeyEvent
的类型对于回调函数被调用时可以访问的额外数据没有限制。这里有不同的客户端(对onKeyEvent
的调用),它们在其环境中使用不同类型的绑定(惯用法val _ = e
通常用于执行一个表达式,仅仅是为了利用利用表达式的副作用而不是它的返回值,在本例中是注册回调函数)
val timesPressed = ref 0
val _ = onKeyEvent (fn _ => timesPressed := (!timesPressed) + 1)
fun printIfPressed i =
onKeyEvent (fn j => if i = j
then print ("you pressed " ^ Int.toString i ^ "\n")
else ())
val _ = printIfPressed 4
val _ = printIfPressed 11
val _ = printIfPressed 23
可选:闭包的惯用法:抽象数据类型
我们将要思考的最后一个习惯用法是最花哨和最微妙的。这不是程序员通常会做的事情——在现代的编程语言中通常有一种更为简单的方法来做到。它是一个高级示例,用于演示具有相同环境的闭包记录非常类似于面向对象编程中的对象:函数是方法,环境中的绑定是私有字段和方法。这里没有新的语言特征,只有词法作用域。它(正确地)表明,函数式编程和面向对象编程比直觉所认为的要更为相似(我们将在本课程的第三部分重新讨论这个主题;它们之间还有一些重要区别)
抽象数据类型(ADT)的关键是要求客户端通过函数集合去使用它,而不是直接访问它的私有实现。由于这种抽象,我们可以在以后更改数据类型的实现方式,而不必更改它对客户端的行为方式。在面向对象语言中,您可以通过定义一个包含所有私有字段(客户端无法访问)和一些public methods(客户端接口)的类来实现ADT。我们可以在ML中用闭包记录做同样的事情;闭包从环境中使用的变量对应于私有字段。
作为一个例子,考虑一个整数集合的实现,它支持创建一个新的更大的集合,并查看一个整数是否在一个集合中。我们的集合是无副作用的,因为向集合中添加一个整数会产生一个新的、不同的集合(用ML的引用实现可变版本的集合也很容易)。在ML中,我们可以定义一个类型来描述我们的接口。
datatype set = S of { insert : int -> set, member : int -> bool, size : unit -> int }
粗略地说,集合是一个包含三个字段的记录,每个字段都有一个函数。下面的写法写起来更简单:
type set = { insert : int -> set, member : int -> bool, size : unit -> int }
但是这种做法在ML中不起作用,因为type定义是不能递归的。因此,我们必须处理在定义集合的函数记录周围有一个构造函数S
的轻微不便,即使集合是each-of类型的集合,而不是one-of类型的集合。注意,我们没有使用任何新的类型或功能;我们只需要一个类型来描述一个记录,其中包含名为insert、member和size的字段,每个字段都有一个函数。
一旦有了一个空集,我们就可以使用它的insert
字段来创建一个单元素集合,然后使用该集的insert
字段来创建一个双元素集合,依此类推。所以我们的接口需要的另一件事就是这样的绑定:
val empty_set = ... : set
在实现此接口之前,让我们看看客户机如何使用它(有些括号是可选的,但可能有助于理解代码):
fun use_sets () =
let val S s1 = empty_set
val S s2 = (#insert s1) 34
val S s3 = (#insert s2) 34
val S s4 = #insert s3 19
in
if (#member s4) 42
then 99
else if (#member s4) 19
then 17 + (#size s3) ()
else 0
end
同样,我们并没有使用到新的特性。#insert s1
读取一个记录字段,在本例中,它生成一个函数,然后我们可以用34来调用它。如果我们使用Java,我们可能会编写s1.insert(34)
来执行类似的操作。val
绑定使用模式匹配来剥离set类型值上的S
构造函数。
有很多方法可以定义空集合;他们都会使用闭包技术来“记住”集合中的元素。有一种方法:
val empty_set =
let
fun make_set xs =
let
fun contains i = List.exists (fn j => i = j) xs
in
S { insert = fn i => if contains i
then make_set xs
else make_set (i::xs),
member = contains,
size = fn () => length xs
}
end
in
make_set []
end
所有的幻想都存在于make_set
中,empty_set
只是make_set
返回的记录。make_set
返回的是set
类型的值。它本质上是一个有三个闭包的记录。闭包可以使用xs
、辅助函数contains
和make_set
。和所有函数体一样,在被调用之前它们不会被执行。
可选:其他语言的闭包
为了结束对函数闭包的研究,我们从ML转向了在Java(使用泛型和接口)和C(使用带有显式环境参数的函数指针)中展示相似的编程模式。本节不纳入考核,可以跳过。但是它可帮助您理解闭包,并且应该也有助于你理解一种语言中的中心思想如何影响到你在其它语言中解决问题的方式。也就是说,它可以使你成为更好的Java或C程序员。
我们ML把代码写出来,然后再“移植”到Java和C,该代码定义了我们自己的多态的链表构造函数和与该类型有关的三个多态函数(其中两个是高阶函数)。我们将研究几种不同的用Java或C编写类似代码的方式,这将有助于我们更好地理解闭包和对象之间的相似性(Java)以及如何使用环境显式地实现闭包(C语言中的做法)。在ML中,本没有必要定义我们自己的类型构造函数,因为'a list
就已经足够了,但是这样做有助于我们与Java和C版本进行比较。
datatype 'a mylist = Cons of 'a * ('a mylist) | Empty
fun map f xs =
case xs of
Empty => Empty
| Cons(x,xs) => Cons(f x, map f xs)
fun filter f xs =
case xs of
Empty => Empty
| Cons(x,xs) => if f x then Cons(x,filter f xs) else filter f xs
fun length xs =
case xs of
Empty => 0
| Cons(_,xs) => 1 + length xs
这里有两个函数端函数使用到了上面的库(后者并不是特别有效,但是展示了length
和filter
的简单用法)
val doubleAll = map (fn x => x * 2)
fun countNs (xs, n : int) = length (filter (fn x => x = n) xs)
可选:Java中使用对象和接口实现闭包
Java 8 引入了对闭包的支持,就像现在大多数主流的面向对象语言一样(C#, Scala, Ruby, ...),但是值得思考的是,在没有引入闭包之前,我们如何用Java实现类似的代码,在近20年以来,闭包几乎是必需的。虽然我们没有一等公民的函数,没有柯里化或类型推断,但是我们有泛型,我们可以用一个方法定义接口,我们可以像函数类型一样使用它。不用多说,先看下面用Java实现闭包模拟的代码,然后简要讨论您以前可能没有见过的特性以及其它的实现方法:
interface Func<B, A> {
B m(A x);
}
interface Pred<A> {
boolean m(A x);
}
class List<T> {
T head;
List<T> Tail;
List(T x, List<T> xs) {
head = x;
tail = xs;
}
static <A, B> List<B> map(Func<B, A> f, List<A> xs) {
if (xs == null)
return null;
return new List<B>(f.m(xs.head), map(f, xs.tail));
}
static <A> List<A> filter(Pred<A> f, List<A> xs) {
if (xs == null)
return null;
if (f.m(xs.head))
return new List<A>(xs.head, filter(f, xs.tail));
return filter(f, xs.tail);
}
static <A> int length(List<A> xs) {
int ans = 0;
while (xs != null) {
++ans;
xs = xs.tail;
}
return ans;
}
}
class ExampleClients {
static List<Integer> doubleAll(List<Integer> xs) {
return List.map((new Func<Integer, Integer() {
public Integer m(Integer x) { return x * 2; }
}),
xs);
}
static int countNs(List<Integer> xs, final int n) {
return List.length(List.filter((new Pred<Integer>() {
public boolean m(Integer x) { return x == n; }
}),
xs));
}
}
上面的代码使用了几种有趣的技巧和特性:
-
为了代替
map
函数的类型a -> 'b
以及filter
函数的类型'a -> bool
,我们使用了有一个方法的通用接口。实现了其中一个接口的类可以具有它所需要的任何类型的字段,这些字段将充当闭包所携带的环境的角色。 -
泛型类
List
充当了数据类型绑定的角色。构造函数按照预期初始化head
和tail
字段,使用Java标准约定的null
来表示空列表。 -
Java 中的表态方法可以是泛型的,只要在返回类型的左侧显式地提到类型变量。除此之外,
map
和filter
的实现与它们的ML实现相似,在Func
或Pred
接口中使用一个参数的方法来将函数作为参数。对于length
,我们可以使用递归来实现,但是Java传统上更喜欢循环。 -
如果你从未见过匿名内部类,那么
doubleAll
和countNs
看起来会有点奇怪。有点像匿名函数,这种语言我允许我们包装一个实现了接口的对象,而不需要给该对象的类命名。相反,我们在实现接口时使用new
适当地实例化类型变量,然后提供方法的定义。作为一个内部类,这个定义可以使用封闭对象的字段或封闭方法的最终局部变量和参数,以更麻烦的语法来获取闭包环境的诸多便利性(匿名内部类已经被添加到Java中,以支持回调用类似的习惯用法。)
我们可以用许多不同的方法来编写Java代码,特别有趣的是:
-
在Java中,尾递归的效率并不如循环,因此,
map
和filter
的实现更偏爱循环是合理的。在不反转中间列表的情况下,这样做比你想象的要复杂得多(您需要保留一个指向前一个元素的指针,并且第一个元素需要特殊的代码),这就是为什么在编程面试中经常会问这种程序的原因之一。递归版本很容易理解,但是对于很长的列表是很不明智的。 -
一个更加面向对象的做法是让
map
,filter
和length
成为实例方法,而不是表态方法。方法的签名需要改成:
<B> List<B> map(Func<B,T> f) { ... }
List<T> filter(Pred<T> f) { ... }
int length () { ... }
这种做法的缺点是,如果客户端可能有一个空列表,我们必须在使用这些方法时添加特殊情况。原因是空列表用null
表示,使用null
作为调用的接收方会引发NullPointerException
异常。因此,doubleAll
和countNs
方法必须检查其参数是否为null
,以避免类型异常。
-
另一个更面向对象的方法是不要使用
null
来表示空列表。相反,我们可以有一个抽象的List
类,它有两个子类,一个用于空列表,另一个用于非空列表。这种方法对于具有多个构造函数的数据类型是一种更加可靠的面向对象方法,并且使用它可以使前面的实例方法建议在没有特殊情况的情况下得到解决。对于习惯于使用null的程序员来说,它确实显得更复杂、更长。 -
匿名内部类只是一种便利。相反,我们可以定义“普通”类来实现
Func<Integer,Integer>
和Pred<Integer>
,并创建实例来传递给map
和filter
。对于countNs
示例,我们的类将有一个int
字段来保存n
,我们将把这个字段的值传递给类的构造函数,该构造函数将初始化这个字段。
可选:使用显式的环境在C语言中实现闭包
C语言确实有函数,但它们不是闭包。如果将指针传递给函数,则它只是一个指向代码的指针。正如我们已经学习过的,如果一个函数参数只能使用它自己的参数,那么高阶函数就没有那么有用了。那么在C语言中我们能做什么呢?我们可以改变高阶函数如下:
-
明确地将环境作为另一个参数
-
让函数也能够接收一个环境
-
调用函数参数时,将环境传递给它
所以不是像这样的高阶函数:
int f(int (*g)(int), list_t xs) { ... g(xs->head) ...}
我们会让它看起来像这样:
int f(int (*g)(void*,int), void* env, list_t xs) { ... g(env,xs->head) ... }
我们使用void*
是因为我们希望f
处理使用不同类型环境的函数,所以没有好的选择。客户端必须从其他兼容类型转换到void*
或从void*
转换。我们这里不讨论这些细节。
虽然C代码还有很多其他细节,但在定义中使用显式环境以及map
及filter
的用法是与其他语言版本的关键区别:
#include <stdlib.h>
#include <stdint.h>
#include <stdbool.h>
typedef struct List list_t;
struct List {
void * head;
list_t * tail;
};
list_t * makelist (void * x, list_t * xs) {
list_t * ans = (list_t *)malloc(sizeof(list_t));
ans->head = x;
ans->tail = xs;
return ans;
}
list_t * map(void* (*f)(void*, void*), void* env, list_t * xs) {
if (xs == NULL)
return NULL;
return makelist(f(env, xs->head), map(f, env, xs->tail));
}
list_t * filter(bool (*f)(void*, void*), void* env, list_t * xs) {
if (xs == NULL)
return NULL;
if (f(env, xs->head))
return makelist(xs->head, filter(f, env, xs->tail));
return filter(f, env, xs->tail);
}
int length(list_t* xs) {
int ans = 0;
while (xs != NULL) {
++ans;
xs = xs->tail;
}
return ans;
}
void* doubleInt(void* ignore, void* i) { // type casts to match what map expects
return (void*)(((intptr_t)i) * 2);
}
list_t * doubleAll(list_t * xs) { //assumes list holds intptr_t fields
return map(doubleInt, NULL, xs);
}
bool isN(void* n, void* i) { // type casts to match what filter expects
return ((intptr_t)n) == ((intptr_t)i);
}
int countNs(list_t * xs, intptr_t n) { //assumes list hold intptr_t fields
return length(filter(isN, (void*)n, xs));
}
与Java一样,使用递归代替循环要简单得多,但效率可能较低。另一种选择是定义一个结构体,将代码和环境放在一个结构体中,但是我们使用的方法是为每个高阶函数增加一个void*
参数,这种做法在C代码中更为常见。
对于那些对C规范细节感兴趣的人:要注意,上面的客户端代码,特别是doubleInt
, isN
, 和countNs
函数的代码是不可移植的,从技术上讲,我们不能假设一个intptr_t
值可以被转换为void*
,然后再转换回来,除非那个值以指针开始,而不是一个保存为intptr_t
的数字。尽管上面编写的代码是一种相当普遍的做法,但是可移植的版本将要用到指向数字的指针,或者将库中void*
的使用替换为intptr_t
。后一种方法仍然是可重用的库,因为任何指针都可以和intptr_t
相互转换。
标准库文档
本主题与本节的其他部分关系不大,但是对于完成家庭作业3是需要的,它对任何编程语言都非常有用,并且它显示了ML中预定义的一些有用的函数(无论是否为高阶函数)。
和其它许多语言一样,ML也有一个标准库。可以假定标准库中的代码始终可用。将代码包含在标准库中有两个常见且不同的原因:
-
我们需要一个标准库来与“外部世界”交互,以提供原本无法实现的功能。例如打开文件或设置计时器;
-
标准库可以提供一些通用的、常用的函数,在标准库只定义它们一次是值得的,以便所有程序都可以使用相同的函数名,参数顺序等。类似的函数包括连接两个字符串,印射到列表的map函数等。
标准库通常会比较大,不应该指望老师在课程上对其进行教学。你应该习惯自己查文档,并对“可能提供的内容”和“可能提供的位置”做出大致的直觉判断。因此,在“作业3”中,我们将留给你一些信息,方便你在ML标准库中找到有关的一些简单函数。
与大多数现代语言相比,在线文档非常原始,但是完全可以满足我们的需求。 只需转到:
[http://www.standardml.org/Basis/manpages.html]
这些函数是用ML的模块系统进行组织的,我们将在下一部分学习模块系统。例如,关于字符串的有用函数在结构Char
中。要在Bar
中使用函数foo
,请写成Bar.foo
,这正是我们一直使用List.map
之类的函数的方式。一个麻烦是字符串结构的函数记录在签名STRING
下。签名基本上是结构的类型,我们将在后面进行研究。某些库函数被认为非常有用,它们不在诸如hd
之类的结构中,这些绑定在 [http://www.standardml.org/Basis/top-level-chapter.html] 中有描述。
有时候,当你在编程时,你只需要快速的提醒,而不是完整的文档。例如,你可能会忘记参数的顺序,或者是忘记了函数到底是柯里化的还是需要元组作为参数。通常,你可以使用REPL来快速获取所需要的信息。如果你输入List.map
函数,它将返回其类型。如果你不记得函数的名称,甚至可以猜测一个函数的名称。如果输入错误,只会收到unbound variable
的消息。最后,你可以使用REPL打印出结构提供的所有绑定。只需要这样做:
structure X = List; (* 我们想搞清楚List结构中有些什么东西 *)
> structure X : LIST (* 这是REPL返回的东西 *)
signature X = LIST; (* REPL会把List结构中的所有绑定列出来 *)
由于在REPL中查找内容非常方便,因此某些其他语言的REPL进行了更进一步,并提供了一些特殊命令来打印与功能或库相关的文档。
第四节
什么是类型推导
虽然我们使用ML类型推导已有一段时间了,但是我们并没有仔细研究它。我们将首先仔细定义什么是类型推导,然后通过几个例子来了解ML类型推导是如何工作的。
Java、C和ML都是静态类型语言的例子,这意味着每个绑定都有一个“在编译时”(即在运行程序的任何部分之前)确定的类型。类型检查器是一个编译时过程,它在编译时就判定程序是否符合语法。相比之下,Racket、Ruby和Python是动态类型化的语言,这意味着绑定的类型不是提前确定的,像将42绑定到x,然后将x作为字符串处理这样的计算会导致运行时错误。在我们用Racket做了一些编程之后,我们将比较静态类型和动态类型的优缺点作为一个重要的课程主题。
与Java和C不同,ML是隐式类型的,这意味着程序员很少需要写下绑定的类型。这通常是方便的(尽管有争议它到底是使代码更容易阅读还是更难阅读),但这并不能改变ML是静态类型的事实。相反,类型检查器必须更加复杂,因为它必须推导出(即,找出)程序员没有编写的所有缺失的类型声明。原则上,类型推导和类型检查可以是分开的步骤(推导器可以完成自己的部分,检查器可以查看结果是否应该通过类型检查),但是在实践中,它们经常被合并到“类型检查器”中。注意,一个正确的类型推导器必须找到一个解决方案,来解决所有类型应该是什么样的问题,否则它就必须拒绝这个程序。
特定编程语言的类型推导到底是容易的、困难的还是根本就不可能的,通常并不明显。它与类型系统的宽容程度不成正比。例如,“接受一切”和“不接受任何东西”的“极端”类型系统都很容易进行推导。当我们说类型推导可能是不可能的,我们的意思是在技术意义上的不可判定性,就像著名的图灵停机问题。我们的意思是,对于某些类型系统,没有任何计算机程序能够实现类型推导:(1)推导过程总是终止,(2)如果推导可能,推导过程总是成功,(3)如果推导不可能,推导过程总是失败。
幸运的是,ML的设计相当巧妙,因此类型推导可以由一个相当直接和优雅的算法来执行。虽然有些程序的推导非常缓慢,但人们在实践中编写的程序从来不会导致这种行为。我们将用几个例子来说明ML类型推导算法的关键方面。这将给你一种类型推导不是“魔法”的感觉。为了继续讨论其他课程主题,我们不会描述完整的算法或编写代码来实现它。
ML类型推导最终与参数多态性交织在一起-当推导器确定函数的参数或结果“可以是任何东西”时,结果类型使用 'a
、'b
等。但是类型推导和多态性是两个完全独立的概念:一种语言可以有一个或另一个。例如,Java有泛型,但没有方法参数/结果类型的推导。
ML类型推导概述
下面是ML类型推导工作原理的概述:
- 它按顺序确定绑定的类型,使用早期绑定的类型推导后期绑定的类型。这就是为什么不能在文件中使用以后的绑定。(需要时,可以使用相互递归,类型推导将所有相互递归绑定的类型一起确定。本节后面将介绍相互递归。)
- 对于每个val或fun绑定,它分析绑定以确定有关其类型的必要事实。例如,如果我们看到表达式 x+1,我们得出结论x必须同样是int类型。
- 之后,对函数参数或结果中的任何无约束类型使用类型变量(例如,'a)。
- (强制值限制-只有变量和值可以具有多态类型,稍后讨论。)
关于ML类型系统的一个惊人的事实是,这种“按顺序”的方式不会导致我们拒绝一个可以进行类型检查的程序,也不会导致我们接受一个不应该接受的程序。所以显式类型注释实际上是可选的,除非您使用像#1这样的特性。(#1的问题是,它没有为类型推导提供足够的信息来知道元组/记录应该有哪些其他字段,而ML类型系统需要知道字段的确切数目和所有字段的名称。)
下面是一个非常简单的初始示例:
val x = 42
fun f(y,z,w) = if y then z+x else 0
类型推导首先给出x类型int,因为42有类型int。然后它继续推导f的类型。接下来我们将通过其他示例研究一个更逐步的过程,但这里让我们只列出关键事实:
- y必须是bool类型,因为我们在if表达式中用它做测试
- z必须是int类型,因为它将和x做加法运算,而x的类型是int型
- w可以是任意的类型,因为它自始至终都没有被使用过
- f必须返回int,因为它的函数体是一个if条件表达式,它的两个分支都返回int(如果两个分支的类型不一致,类型检查将失败)
因此,函数f的类型必须是 bool * int * 'a -> int
ML类型推导更详细的例子
现在我们将一步一步地通过几个示例,生成类型推理算法所需的所有事实。注意,人们在“头脑中”进行类型推导时通常会走捷径,就像在头脑中做算术一样,但关键是有一个通用的算法,它会有条不紊地通过代码收集约束,并将它们放在一起得到答案。
第一个示例,请考虑推导此函数的类型:
fun f x =
let val (y, z) = x in
(abs y) + z
end
下面是我们如何推导类型:
- 看第一行,假设函数f的类型是
T1 -> T2
,x
的类型为T1
,f
返回值的类型为T2
- 在
let
表达式的val
绑定中,x
必须是一个对偶(两个元素的元组),所以,实际上T1 = T3 * T4
,y
的类型是T3
,而z
的类型是T4
- 接下来看加法表达式,从上下文中我们可以知道,
abs
函数的类型是int -> int
,所以,y
的类型为T3
,则意味着T3 = int
。同样地,abs y
的类型为int
,那么,加法函数+
的另一个参数z
也必须是int
,所以,T4 = int
。 - 因为加法表达式的类型是
int
,所以let
表达式的类型是int
.因为let
表达式的类型是int
,所以f
的返回值类型是int
,即T2 = int
把所有这些约束放在一起,T1 = int * int
(因为T1 = T3 * T4
)和T2 = int
,所以f
的类型是int * int -> int
。
下一个例子:
fun sum xs =
case xs of
[] => 0
| x::xs' => x + (sum xs')
-
从第一行开始,假设
sum
的类型是T1 -> T2
,xs
的类型是T1
-
看
case
表达式,xs
必须与所有的模式兼容,观察该模式,它们都匹配任何列表,因为它们是由列表构造函数构建的(在x::xs'
的情况下,子模式匹配任何类型的任何东西)。 因此,由于xs
具有类型T1
,因此实际上T1 = T3 list
。 -
接下来看
case
分支的右侧,我们知道,同一个case
表达式的所有分支都必须返回相同类型的值。第一个分支返回值是0
,而0
是int
型,所以,可以确定T2 = int
。 -
在第二个
case
分支中,我们在x
和xs'
可用的上下文中对它进行类型检查。 由于我们将模式x :: xs'
与T3 list
进行匹配,因此必须确保x的类型为T3
,而xs'
的类型为T3 list
。 -
现在看右边,我们用
x
与sum
函数的递归调用结果相加,sum
的类型是T1 -> T2
,由于T2 = int
,(sum xs')
的返回值也只可能是int
。因此实际上T3 = int
。
把它代换在一起的过程就是:
T1 -> T2
T3 list -> T2
T3 list -> int
int list -> int
最终,我们得到了函数 sum的类型:int list -> int
请注意,在对xs'
求和之前,我们已经推断出了所有内容,但是我们仍然必须检查类型的使用是否一致,否则就拒绝。例如,如果我们写了sum x
,那就不能进行类型检查-这与前面的事实不一致。让我们更深入地了解这一点,看看会发生什么:
fun broken_sum xs =
case xs of
[] => 0
| x::xs' => x + (broken_sum x)
broken_sum
的类型推导与sum
基本相同。上一个示例中的前四个项目符号都适用,得出broken_sum
的类型为T3 list -> int
,x
的类型为T3
,xs'
的类型为T3 list
,此外,T3 = int
.- 我们通过调用
broken_sum x
偏离了正确的sum
实现。对于类型检查器来说,x
的类型必须与broken_sum
的参数类型相同,即T1 = T3
。然而,我们知道,T1 = T3 list
,所以,这个新的约束T1 = T3
实际上产生了一个矛盾:T3 = T3 list
。如果我们想更具体一些,我们可以使用T3 = int
将它重写为int = int list
。显然,这就是问题所在。
当您的ML程序不进行类型检查时,类型检查器会报告发现矛盾的表达式以及该表达式中涉及的类型。虽然有时这些信息是有用的,但是有时候真正出问题的表达式在别的地方,类型检查器一直到最后才能发现矛盾所在。
多态类型的例子
我们剩下的例子将推断多态类型。我们所要做的就是遵循与上面所做的相同的过程,但是当我们完成时,函数类型的某些部分仍然是不受约束的。对于每个“可以是任何东西”的Ti,我们使用一个类型变量来表示('a,'b等)。
fun length xs =
case xs of
[] => 0
| x::xs' => 1 + (length xs')
类型推导的过程与sum很相似,我们最终确定:
- length的类型为 T1 -> T2
- xs的类型为T1
- T1 = T3 list
- T2 = int,因为0可以作为length的返回值
- x的类型为T3,xs'的类型为T3 list
- 对 (length xs')的递归调用符合类型检查,因为 xs 的类型是T3 list,即T1,也就是length 的参数所要求的类型。而且,我们可以将递归调用的结果与整数1相加,因此,T2 = int。
几乎所有约束都与sum相同,除了 T3 = int。实际上,T3可以是任意类型,length都可以通过类型检查。类型推导识别出,完成所有操作后,函数length的类型是 T3 list -> int,而T3可以是任何类型。因此,我们最终得到 'a list -> int。规则很简单:对于最终结果中不受约束的每个类型Ti,请使用类型变量('a, 'b, 'c...)。
第二个例子:
fun compose (f, g) = fn x => f (g x)
- compose的参数必须是一个对偶,compose的类型是:T1 * T2 -> T3,f的类型为T1, g的类型为T2
- compose返回一个函数,T3等于T4 -> T5,x的类型为T4
- g的类型必须是T4 -> T6,即,T2 = T4 -> T6
- f的类型必须是T6 -> T7,即,T1 = T6 -> T7
- f的返回值就是compose的返回值,所以,T7 = T5, T1 = T6 -> T5
把它们放在一起,compose的类型就是:
(T6->T5) * (T4->T6) -> (T4->T5)
T4, T5, T6并没有限制类型,所以,我们把它们替换成类型变量
('a -> 'b) * ('c -> 'a) -> ('c -> 'b)
下面是一个简单的示例,其中也包含多个类型变量:
fun f (x,y,z) =
if true
then (x,y,z)
else (y,x,z)
- 第一行,函数f的类型为 T1 * T2 * T3 -> T4,x为T1,y为T2,z为T3
- 两个分支的返回值类型必须相同,两个分支的返回值类型分别为:T4 = T1 * T2 * T3和T4 = T2 * T1 * T3.如此则表明,T1 = T2
将限制条件放在一起,f的类型为:T1 * T1 * T3 -> T1 * T1 * T3。将每一个不受约束的类型都用类型变量替换掉,最终得到: 'a * 'a * 'b -> 'a * 'a * 'b。x和y必须具备相同的类型,但z的类型则不一定要与它们相同。请注意,类型检查器会对两个分支都进行检查,即便是永远也不会执行的 else 分支。
可选:值限制
正如到目前为止所描述的,ML类型系统是不完善的,这意味着在某些情况下,即便程序中具有错误类型的值,程序也会被接受,例如将int放在原本应该是的字符串的地方。该问题是由多态类型和可变引用的组合引起的,并且修复此类错误,需要对类型系统作一些特殊的限制,称为值限制。
这是一个演示程序:
val r = ref NONE (* 'a option ref *)
val _ = r := SOME "hi" (* instantiate 'a with string *)
val i = 1 + valOf(!r) (* instantiate 'a with int *)
对于给定的函数/运算符
ref ('a -> 'a ref)
:= ('a ref * 'a -> unit)
! ('a ref -> 'a)
似乎一切都符合类型检查。即使我们不应该直接使用用于类型检查/推断的规则,也可以接受该程序,但是最终我们会尝试把“ hi”和1相加。
为了恢复可靠性,我们需要一个更严格的类型系统,不允许此程序通过类型检查。具体的选择是防止第一行具有多态类型。因此,第二行和第三行就无法通过类型检查,因为它们将无法用string或int实例化 'a 。通常,只有当val绑定中的表达式是值或变量时,ML才会为val绑定中的变量提供多态类型。这称为值限制。在我们的示例中,ref NONE是对函数ref的调用。函数调用不是变量或值。所以我们得到一个警告,r的类型是 ?X1 option ref,X1是一个“虚拟类型”,不是类型变量。这使得r没有用处,后面的行无法顺利通过类型检查。这个限制是否足以使类型系统变得健壮,这一点并不明显,但事实上它已经足够了。
对于上面的r,我们可以使用表达式ref NONE,但是我们必须使用类型注释来为r提供一个非多态类型,比如int option ref。
正如我们之前在学习部分应用时所看到的,值限制有时会很麻烦,即便因为我们没有使用突变(副作用),它并不是问题。我们可以看到下面的绑定成为值限制的受害者,pairWithOne 并不是多态的:
val pairWithOne = List.map (fn x => (x,1))
我们看到了多种解决方法。 一个办法是使用函数绑定,虽然没有了值限制,但是又会造成不必要的函数包裹。此函数具有所需的类型 'a list -> ('a * int) list:
fun pairWithOne xs = List.map (fn x => (x,1)) xs
有人可能想知道为什么我们不能仅对引用(在需要的地方)而不对诸如列表之类的不可变类型实施值限制。 答案是ML类型检查器无法始终知道哪些类型是真正的引用,哪些不是。在下面的代码中,我们需要在最后一行强制执行值限制,因为 'a foo和 'a ref是同一类型。
type 'a foo = 'a ref
val f : 'a -> 'a foo = ref
val r = f NONE
在本节稍后部分研究模块系统时,我们将看到类型检查器并不总是知道类型同义词的定义。 因此,为了安全起见,它将对所有类型强制执行值限制。
可选:使类型推导更加困难的某些东西
现在我们已经了解了ML类型推断的工作原理,我们可以做两个有趣的观察:
- 如果ML具有子类型化(例如,如果每个三元组也可以是一个对偶),则推理将更加困难,因为我们将无法得出类似 “T3 = T1 * T2” 的信息,因为等式过于严格。相反,我们需要表示T3是具有至少两个字段的元组的约束。根据各种细节,可以做到这一点,但是类型推断更加困难,结果也更难以理解。
- 如果ML没有参数多态性,则推理将更加困难,因为我们将不得不为诸如length和compose之类的函数选择某种类型,而这可能取决于它们的使用方式。
相互递归
我们已经看到了很多递归函数的示例,以及使用其他函数作为辅助函数的许多函数的示例,但是如果我们需要函数f调用g和g调用f呢? 那当然是有用的,但是ML的规则是绑定只能使用较早的绑定,这使它变得更加困难-应该首先使用f还是g?
事实证明,ML支持使用特别关键字的相互递归并将相互递归函数彼此相邻。 同样,我们可以具有相互递归的数据类型绑定。 在展示了这些新的构造之后,我们将展示您实际上可以通过使用高阶函数来解决对相互递归函数缺乏支持的问题,这通常是一个有用的技巧,特别是在ML中,如果您不希望相互递归 功能彼此相邻。
我们的第一个示例使用相互递归来处理int列表并返回bool。如果列表严格地在1和2之间交替并以2结束,则返回true。当然有很多方法可以实现这样的函数,但是我们的方法很好地为每个“状态”(例如“1必须在下一个”或“2必须在下一个”)提供了一个函数。一般来说,计算机科学中的许多问题都可以用这样的有限状态机来建模,而相互递归函数(每个状态对应一个)是实现有限状态机的一种优雅方法。
fun match xs =
let fun s_need_one xs =
case xs of
[] => true
| 1::xs' => s_need_two xs'
| _ => false
and s_need_two xs =
case xs of
[] => false
| 2::xs' => s_need_one xs'
| _ => false
in
s_need_one xs
end
(代码在模式中使用整数常量,这是一个偶尔很方便的ML特性,但对示例来说不是必需的。)
在语法方面,我们通过简单地将除第一个函数外的所有函数的关键字fun替换为and来定义相互递归函数。类型检查器将同时对所有函数(上例中的两个)进行类型检查,允许它们之间的调用,而不考虑顺序。
下面是第二个(愚蠢的)示例,它还使用两个相互递归的数据类型绑定。类型t1和t2的定义相互引用,这可以通过使用和代替第二个类型的数据类型来实现。t1和t2定义了两种新的数据类型。
datatype t1 = Foo of int | Bar of t2
and t2 = Baz of string | Quux of t1
fun no_zeros_or_empty_strings_t1 x =
case x of
Foo i => i <> 0
| Bar y => no_zeros_or_empty_strings_t2 y
and no_zeros_or_empty_strings_t2 x =
case x of
Baz s => size s > 0
| Quux y => no_zeros_or_empty_strings_t1 y
现在假设我们想实现上面代码的“无零或空字符串”功能,但是出于某种原因,我们不想将函数放在彼此相邻的位置,或者我们所使用的语言不支持相互递归的函数。我们可以编写几乎相同的代码,方法是将“later”函数自身传递给以函数为参数的“previous”函数的版本:
fun no_zeros_or_empty_strings_t1(f,x) =
case x of
Foo i => i <> 0
| Bar y => f y
fun no_zeros_or_empty_string_t2 x =
case x of
Baz s => size s > 0
| Quux y => no_zeros_or_empty_strings_t1(no_zeros_or_empty_string_t2,y)
这是允许接收函数作为参数的另一种强大的习惯性用法。
模块:命名空间管理
我们首先说明如何使用ML模块将绑定分为不同的命名空间。以后的部分将以此材料为基础,以涵盖使用模块隐藏绑定和类型的更为有趣和重要的主题。
为了学习ML,模式匹配和函数式编程的基础,我们编写了一些小程序,这些程序只是一系列绑定。对于较大的程序,我们希望使用更多的结构来组织代码。在ML中,我们可以使用structure来定义包含绑定集合的模块。最简单的说,您可以编写structure Name struct bindings end
,其中Name
是结构的名称(您可以选择任何东西;首字母大写是一种约定),而bindings
是任何绑定列表,包含val,fun,exception,datatype和type同义词。在结构内部,您可以使用较早的绑定,就像我们一直在“顶层”进行的操作一样(即,在任何模块外部)。在结构外部,通过编写Name.b
来引用Name
中的绑定b
。我们已经在使用这种符号来使用List.foldl
之类的函数;现在您知道了如何定义自己的结构。
尽管在示例中我们不会这样做,但是您可以将结构嵌套在其他结构中以创建树形层次结构。但是在ML中,模块不是表达式:您不能在函数内部定义它们,将它们存储在元组中,将它们作为参数传递等等。
如果在某种范围内您正在使用来自另一个结构的许多绑定,那么多次写入SomeLongStructureName.foo
可能会很不方便。当然,您可以使用val绑定来避免这种情况,例如val foo = SomeLongStructureName.foo
,但是如果我们使用了结构中的很多绑定(对于每个绑定我们都需要一个与之对应的新变量),或者需要用到外部模块中的构造函数名称就很不方便了。因此,ML允许您编写open SomeLongStructureName
,这种写法提供对模块签名中提到的模块中任何绑定的“直接”访问(您可以只写foo
)。通过open引入的绑定,其作用域从引入的地方开始生效一直到模块(或顶级程序)结束。
open的常见用法是在模块本身之外为模块编写简洁的测试代码。通常不赞成使用open的其他用途,因为它可能会引入意外的遮蔽,因为不同的模块可能会使用相同的绑定名称。例如,列表模块和树模块都可以具有名为map的函数。
签名
到目前为止,结构仅提供名称空间管理,这是一种避免程序中的不同部分名字冲突的方法。命名空间管理非常有用,但不是很有趣。更有趣的是给结构签名,这是模块的类型。它们使得我们可以定义模块外部代码必须遵守的严格接口。ML有几种方法可以用完全不同的语法和语义来完成此任务。我们只是展示了一种写下模块的显式签名的方法。这里是一个签名定义和结构定义的示例,它定义了结构MyMathLib必须具有签名MATHLIB:
signature MATHLIB =
sig
val fact : int -> int
val half_pi : real
val doubler : int -> int
end
structure MyMathLib :> MATHLIB =
struct
fun fact x =
if x = 0
then 1
else x * fact (x - 1)
val half_pi = Math.pi / 2.0
fun doubler y = y + y
end
因为:> MATHLIB
的缘故,结构MyMathLib必须提供MATHLIB中所声明的所有内容,并且类型正确才能通过类型检测。签名也可以包括datatype, exception, type. Because we check the signature when we compile MyMathLib, we can use this information when we check any code that uses MyMathLib. In other words, we can just check clients assuming that the signature is correct.
隐藏某些东西
在学习如何使用ML模块向客户端隐藏实现细节之前,请记住,将接口与实现分开可能是构建正确,健壮,可重用程序的最重要的策略。而且,我们已经可以使用函数以各种方式隐藏实现。例如,所有这三个函数都将参数加倍,并且客户端代码(即调用方)无法判断我们是否将其中一个函数替换为另一个函数:
fun double1 x = x + x
fun double2 x = x * 2
val y = 2
fun double3 x = x * y
我们用来隐藏实现的另一个做法是在其他函数内部局部定义函数。我们可以稍后更改,删除或添加局部定义的函数,因为知道其他任何代码都不依赖旧版本。从工程角度看,这是一个关键的关注点分离。我可以努力改善函数的实现,只要遵循相同的接口,就不会导致调用到库的客户端代码不能运行。相反,客户做的任何事情也不会破坏上游的库函数。
But what if you wanted to have two top-level functions that code in other modules could use and have both of them use the same hidden functions? 有很多方法可以做到这一点(例如,创建函数的记录),但是拥有一些对模块“私有”的顶级函数会很方便。在ML中,没有像其他语言那样的“private”关键字。相反,您可以将需要隐藏的内容从签名中删除:签名中没有明确说明的任何内容都不能从外部使用。例如,如果我们将上面的签名更改为:
signature MATHLIB =
sig
val fact : int -> int
val half_pi : real
end
客户端代码就无法调用MyMathLib.doubler
了,绑定根本不在作用域内,因此,调用到它的代码将无法通过类型检查。通常,我们可以随心所欲地实现模块,但是只有在签名中显式列出的绑定才能被客户机直接调用。
介绍我们的扩展示例
模块系统学习剩下的部分将实现一个有理数的小模块作为示例。虽然真正的库将提供更多的特性,但我们的库只支持创建分数、两个分数相加以及将分数转换为字符串。我们的库打算(1)防止分母为零,(2)保持分数的简化形式(3/2而不是9/6,4而不是4/1)。虽然负分数很好,但在库内部从来没有负分母(−3/2而不是3/−2,3/2而不是−3/−2)。下面的结构实现了所有这些思想,使用辅助函数reduce(它本身使用gcd)来简化一个分数。
我们的模块维护了一些不变性,如代码顶部附近的注释所示。这些都是分数的性质,所有函数都遵守这些不变性。如果一个函数违反了不变性,其他函数可能会出错。例如,gcd函数的实现对于负参数是不正确的,但是因为分母从不为负,所以gcd永远不会用负参数去调用。
structure Rational1 =
struct
(* Invariant 1: all denominators > 0
Invariant 2: rationals kept in reduced form, including that
a Frac never has a denominator of 1 *)
datatype rational = Whole of int | Frac of int * int
exception BadFrac
(* gcd and reduce help keep fractions reduced,
but clients need not know about them *)
(* they _assume_ their inputs are not ngeative *)
fun gcd (x, y) =
if x = y
then x
else if x < y
then gcd (x, y - x)
else gcd (y, x)
fun reduce r =
case r of
Whole _ => r
| Frac (x, y) =>
if x = 0
then Whole 0
else let val d = gcd (abs x, y) in (* using invariant 1 *)
if d = y
then Whole (x div d)
else Frac (x div d, y div d)
end
(* when making a frac, we can zero denominators *)
fun make_frac (x, y) =
if y = 0
then raise BadFrac
else if y < 0
then reduce (Frac (~x, ~y))
else reduce (Frac (x, y))
(* using math properties, both invariants hold of the result
assuming they hold of the arguments *)
fun add (r1, r2) =
case (r1, r2) of
(Whole i, Whole j) => Whole (i + j)
| (Whole i, Frac (j, k)) => Frac (j + k * i, k)
| (Frac (j, k), Whole i) => Frac (j + k * i, k)
| (Frac (a, b), Frac (c, d)) => reduce (Frac (a * d + b * c, b * d))
(* given invariant, prints in reduced form *)
fun toString r =
case r of
Whole i => Int.toString i
| Frac (a, b) => (Int.toString a) ^ "/" ^ (Int.toString b)
end
签名示例
我们尝试给上面的模块一个签名,让客户端代码可以使用它,但是不会违反它的约定。
由于reduce和gcd是我们不希望客户依赖或滥用的辅助函数,因此一个自然签名如下:
signature RATIONAL_A =
sig
datatype rational = Frac of int * int | Whole of int
exception BadFrac
val make_frac : int * int -> rational
val add : rational * rational -> rational
val toString : rational -> string
end
要使用这个签名来隐藏gcd和reduce, 我们只需要将上面的结构定义的第一行改成structure Rational1 :> RATIONAL_A
。
虽然这种方法确保客户机不能直接调用gcd或reduce(因为它们在模块外“不存在”),但这不足以确保正确地使用模块中的绑定。模块的“正确”含义取决于模块的规范(而不是ML语言的定义),因此让我们更具体地了解有理数库的一些所需属性:
- 属性:toString总是以简化形式返回分数的字符串表示
- 属性:没有导致无限循环的代码
- 属性:没有代码除以0
- 属性:没有分母为0的分数
这些属性是外部可见的;它们是我们向客户承诺的。相反,不变性是内部的;它们是与实现有关的事实,有助于确保属性。上面的代码维护不变性,并在某些地方依赖它们来确保属性,特别是:
- 如果用参数≤0调用gcd,它将违反这些属性,但是因为我们知道分母大于0,reduce可以将分母传递给gcd而不必担心。
- toString和add的大多数情况都不需要调用reduce,因为它们可以假设它们的参数已经是简化形式。
- add利用了数学中两个正数的乘积为正数的性质,所以我们知道没有引入非正数分母。
不幸的是,在signature RATIONAL_A
下,仍然必须信任客户机不破坏属性和不变性!由于签名暴露了数据类型绑定的定义,ML类型系统不会阻止客户机直接使用构造函数Frac和Whole,从而绕过我们建立和保留不变性的所有努力。客户可以制造“坏的”分数,比如Rational.Frac(1,0)
, Rational.Frac(3,~2)
,或Rational.Frac(9,6)
,根据我们的规范,任何一个都可能导致gcd或toString行为异常。虽然我们可能只打算让客户使用make_frac、add和toString,但我们的签名却允许更多。
一种自然的反应是通过删除行datatype rational = Frac of int * int | Whole of int
来隐藏数据类型绑定。虽然这是正确的直觉,但得到的签名毫无意义,将被拒绝:它反复提到存在一个未知的rational类型。相反,我们想实现的是,类型rational虽然存在,但是客户机除了知道它的存在之外,不能知道该类型是什么。在签名中,我们可以使用抽象类型来实现这一点,如此签名所示:
signature RATIONAL_B =
sig
type rational (* type now abstract *)
exception BadFrac
val make_frac : int * int -> rational
val add : rational * rational -> rational
val toString : rational -> string
end
(当然,我们还必须更改结构定义的第一行以使用此签名。这总是要做的,所以,接下来我们将不再提及。)
抽象类型的这个新特性,仅在签名中才有意义,这正是我们想要的。它允许我们的模块定义类型,而不暴露该类型的实现。语法只是给出一个没有定义的类型绑定。模块的实现是不变的;我们只是改变了客户端代码所拥有的信息量。
现在,客户端怎样才能生成有理数呢?好吧,首先必须用make_frac才能产生有理数,之后,既可以用make_frac生成有理数,也可以通过有理数运算(目前只有add)产生新的有理数。除此之外,别无它法。多亏我们写了make_frac和add的方法,所有的有理数总是以正数分母的简化形式存在的。
与RATIONAL_A
相比,RATIONAL_B
从客户那里拿走的是构造器Frac
和Whole
。所以客户端代码不能直接创建有理数,也不能在有理数上进行模式匹配。他们不知道有理数在内部是如何表示的。他们甚至不知道rational是作为datatype实现的。
抽象类型在编程中非常重要。
可爱的转折:暴露 Whole 函数
通过使rational类型抽象化,我们从客户端代码那里拿走了构造函数Frac
和Whole
。虽然这样做有助于防止客户端代码创建非简化的分数或者是分母为负数的分数,但其实只有构造函数Frac
是有潜在问题的,Whole
并没有问题。允许客户端通过Whole a-int
创建一个有理数并不违反我们的规范,我们可以在模块中添加如下函数:
fun make_whole x = Whole x
然后在签名中添加 val make_whole : int -> rational
,当然,这样会导致不必要的函数包裹,一个更短的实现是:
val make_whole = Whole
当然,客户端也不知道make_whole
的具体实现,我们何必要创建一个与 Whole
相同的绑定呢?其实,可以直接在签名中将构造函数Whole
作为一个普通函数导出,而不必对模块本身做丝毫的更改:
signature RATIONAL_C =
sig
type rational (* type still abstract *)
exception BadFrac
val Whole : int -> rational (* client knows only that Whole is a function *)
val make_frac : int * int -> rational
val add : rational * rational -> rational
val toString : rational -> string
end
签名匹配规则
到目前为止,我们对给定特定签名的结构是否“应该进行类型检查”的讨论还相当非正式。现在让我们列举更精确的规则,说明结构与签名匹配意味着什么。(此术语与模式匹配无关。)如果结构与指定给它的签名不匹配,则模块不能通过类型检查。假设结构Name匹配签名BLAH:
- BLAH中的每一个val绑定,在Name模块中必须有相同类型(或者更宽松)的实现。即便签名中没有将绑定定义为多态类型,模块中的具体实现也可以是多态类型,但是反过来不行。签名中的val绑定可以由模块中的val, fun, 或者datatype提供。
- 对于BLAH中的每一个非抽象的类型绑定,Name模块中必须有完全相同的类型绑定。
- 对于BLAH中的每个抽象类型绑定,Name模块中都必须有创建该类型的绑定(datatype绑定或type同义词)。
注意,Name模块中可以存在任何没有出现在签名中的绑定。
等效实现
鉴于我们的属性和不变保持签名RATIONAL_B和RATIONAL_C
,我们知道客户端不能依赖于任何辅助函数或模块中定义的有理数的实际表示。因此,我们可以用具有相同属性的任何等效实现替换该实现:只要模块中对toString
绑定的任何调用都产生相同的结果,客户端就永远无法判断。这是另一项重要的软件开发任务:以不破坏客户机的方式改进/更改库。知道客户机遵守由ML签名强制执行的抽象边界是非常宝贵的。
作为一个简单的例子,我们可以使gcd
成为reduce
内部定义的一个局部函数,并且知道没有一个客户端会导致失败,因为它们不能依赖gcd的存在。更有趣的是,让我们改变结构的一个不变量。让我们不要把有理数简化。相反,让我们在把有理数转换成字符串之前先把它简化。这简化了make_frac
和add
,同时使toString
复杂化,而toString
现在是唯一需要reduce
的函数。这是整个结构,它仍然可以匹配签名RATIONAL_A、RATIONAL_B或RATIONAL_C:
如果我们给Rational1和Rational2签名RATIONAL\u A,两者都将进行类型检查,但是客户机仍然可以区分它们。例如,Rational1.toString (Rational1.Frac (21,3))
生成“21/3”,而Rational2.toString (Rational2.Frac (21,3))
生成“7”。但是如果我们给Rational1和Rational2签名RATIONAL_B或RATIONAL_C,那么这些结构对于任何可能的客户机都是等价的。这就是为什么使用像RATIONAL_B这样的限制性签名很重要的原因:这样您就可以在以后更改结构,而不必检查所有客户机。
虽然到目前为止我们的两个结构保持不同的不变性,但它们对rational类型使用相同的定义。对于RATIONAL_B或RATIONAL_C签名,这是不必要的;具有这些签名的不同结构可以以不同的方式实现该类型。例如,假设我们意识到在内部使用特殊的大小写整数会带来更多的麻烦。我们可以使用int * int
来定义这个结构:
signature RATIONAL_A =
sig
datatype rational = Frac of int * int | Whole of int
exception BadFrac
val make_frac : int * int -> rational
val add : rational * rational -> rational
val toString : rational -> string
end
signature RATIONAL_B =
sig
type rational (* type now abstract *)
exception BadFrac
val make_frac : int * int -> rational
val add : rational * rational -> rational
val toString : rational -> string
end
signature RATIONAL_C =
sig
type rational (* type still abstract *)
exception BadFrac
val make_frac : int * int -> rational
val add : rational * rational -> rational
val toString : rational -> string
end
structure Rational3 :> RATIONAL_B (* or C *)=
struct
type rational = int * int
exception BadFrac
fun make_frac (x, y) =
if y = 0
then raise BadFrac
else if y < 0
then (~x, ~y)
else (x, y)
fun Whole i = (i, 1)
fun add ((a, b), (c, d)) = (a * d + c * b, b * d)
fun toString (x, y) =
if x = 0
then "0"
else let fun gcd (x, y) =
if x = y
then x
else if x < y
then gcd(x, y - x)
else gcd(y, x)
val d = gcd (abs x, y)
val num = x div d
val denom = y div d
in
Int.toString num ^ (if denom = 1
then ""
else "/" ^ (Int.toString denom))
end
end
(This structure takes the Rational2 approach of having toString reduce fractions, but that issue is largely orthogonal from the definition of rational.)
注意,这个结构提供了RATIONAL_B所需要的一切。make_frac
函数的有趣之处在于它接受int * int
并返回int * int
,但是客户端不知道实际的返回类型,只有抽象类型rational
。虽然在签名中给它一个rational
类型的参数将匹配,但这将使模块变得无用,因为客户端将无法创建rational
类型的值。尽管如此,客户端不能只传递任何int * int
来添加或转换字符串;他们必须传递一些他们知道是rational
类型的东西。与我们的其他结构一样,这意味着只有通过make_frac
和add
才能创建有理数,这将强制执行我们所有的不变性。
我们的结构与RATIONAL_A
不匹配,因为它不提供RATIONAL
作为带有构造函数Frac
和Whole
的数据类型。
我们的结构确实与签名RATIONAL_C匹配,因为我们显式地添加了一个正确类型的Whole函数。没有一个客户能够将我们的“真实函数”与以前的结构将整个构造函数用作函数区分开来。
事实上,fun Whole i = (i,1)
匹配val Whole : int -> rational
是有趣的。模块中的Whole实际上是多态的:'a -> 'a * int
。ML签名匹配允许'a->'a * int
匹配int -> rational
,因为'a->'a * int
比int -> int * int
更一般,而int -> rational
是int -> int * int
的正确抽象。不太正式的是,Whole在模块内部有一个多态类型并不意味着签名必须在外部给它一个多态类型模块。事实上,它不能在使用抽象类型时使用,因为Whole不能有'a -> int * int
或'a -> rational
类型。
不同的模块定义不同的类型
虽然我们已经用相同的签名(例如RATIONAL_B)定义了不同的结构(例如Rational1、Rational2和Rational3),但这并不意味着来自不同结构的绑定可以相互使用。例如,Rational1.toString(Rational2.make_frac(2,3))
就无法通过类型检查,这是一件好事,因为它会打印一个未缩减的分数。它无法通过类型检查的原因是Rational2.rational
和Rational1.rational
是不同的类型。它们不是由相同的数据类型绑定创建的,即使它们看起来恰好相同。此外,在模块外,我们不知道它们看起来是一样的。实际上,Rational3.toString(Rational2.make_frac(2,3))
确实不需要类型检查,因为Rational3.toString
需要int * int
,但Rational2.make_frac(2,3))
返回一个由Rational2.frac
构造函数生成的值。
激励和定义等价性
一段代码与另一段代码“等价”的思想是编程和计算机科学的基础。每当你简化一些代码或说“这是做同样事情的另一种方法”时,你都会非正式地考虑等价性。这种推理出现在几种常见的场景中:
- 代码维护:你能在不改变程序其他部分行为的情况下简化、清理或重组代码吗?
- 向后兼容性:您可以添加新功能而不更改任何现有功能的工作方式吗?
- 优化:你能用更快或更节省空间的实现来代替代码吗?
- 抽象:外部客户可以告诉我是否对代码进行了更改吗?
还要注意,在上一讲中,我们对限制性签名的使用主要是关于等效性:通过使用更严格的接口,我们使更多不同的实现等效,因为客户端无法区分差异。
我们需要一个精确的等价定义,这样我们就可以决定某种形式的代码维护或签名的不同实现是否真的可以。我们不希望定义过于严格,以至于无法对代码进行修改,但我们不希望定义过于宽松,以至于用“等价”函数替换一个函数会导致程序产生不同的答案。希望通过研究等价的概念和理论,可以改进您看待用任何语言编写的软件的方式。
有许多不同的可能定义可以稍微不同地解决这种严格/宽松的紧张关系。我们将重点介绍一个有用的,通常由设计和实现编程语言的人来承担。我们还将通过假设一个函数有两个实现来简化讨论,我们想知道它们是否等价。
我们的定义背后的直觉如下:
- 如果函数
f
和函数g
产生相同的答案并且具有相同的副作用,那么函数f
就等同于函数g
(或者类似地适用于其他代码段),无论在任何带有任何参数的程序中调用它们的位置如何。 - 等价不需要相同的运行时间、相同的内部数据结构使用、相同的辅助函数等。所有这些都被认为是“不可观察的”,即不影响等价的实现细节。
例如,考虑两种非常不同的列表排序方法。如果它们对所有输入都产生相同的最终答案,那么它们仍然可以是等价的,无论它们在内部如何工作,或者一个是否更快。但是,如果对于某些列表,它们的行为不同,可能对于具有重复元素的列表,那么它们就不是等价的。
然而,通过隐式假设函数总是返回,并且除了产生它们的答案之外没有其他影响,上述讨论被简化了。更准确地说,我们需要两个函数在相同的环境中给定相同的参数:
- 产生相同的结果(如果他们产生结果);
- 具有相同的(非)终止行为;即,如果一个永远运行,另一个必须永远运行;
- 以相同的方式改变相同的(对客户端可见的)内存;
- 执行相同的输入/输出;
- 抛出同样的异常.
这些要求都很重要,因为我们知道如果我们有两个等价的函数,我们可以用一个替换另一个,并且程序中任何地方的使用都不会有不同的行为。
无副作用编程的另一个好处
确保两个函数具有相同副作用(变异引用、执行输入/输出等)的一个简单方法是完全没有副作用。这正是像ML这样的函数式语言所鼓励的。是的,在ML中,你可以让一个函数体改变一些全局引用或者其他东西,但是这通常是不好的风格。其他函数式语言是纯函数式语言,这意味着在(大多数)函数中确实没有办法进行变异。
如果您通过不在函数体中执行变异、打印等策略来“保持功能”,那么调用者可以假定许多其他方法无法实现的等价性。例如,我们能用(f x) * 2
替换(f x) + (f x)
吗?对于非函数式语言来说,这样做可能是错误的,因为调用f
可能会更新某些计数器或打印某些内容。在ML中,确实存在这种可能性,但作为一种风格,可能性要小得多,所以我们倾向于让更多的东西是等价的。在纯函数语言中,我们保证替换不会改变任何东西。一般的观点是,当你试图决定两段代码是否相等时,变异确实会妨碍你的工作——这是避免变异的一个很好的理由。
除了能够在维护没有副作用的程序时消除重复计算(如上面的 (fx)
),我们还可以更自由地重新排序表达式。例如,在Java、C等语言中:
int a = f(x);
int b = g(y);
return b - a;
下面的代码可能产生不同的结果:
return g(y) - f(x);
因为f
和g
的调用顺序不同。同样,这在ML中也是可能发生的,但是如果我们避免副作用,这可能就不太重要了。(但是,我们可能仍然要注意可能会抛出不同的异常和其他细节。)
标准等效
等价是很微妙的,尤其是当你试图在不知道两个函数可能被调用的所有位置的情况下确定它们是否等价时。然而,这是很常见的,例如当您编写未知客户机可能使用的库时。我们现在考虑几种在任何情况下都保证等价性的情况,因此这些是很好的经验法则,是函数和闭包如何工作的好提示。
首先,回顾我们所学的各种形式的句法糖。我们可以在函数体中使用或不使用语法糖,得到一个等价函数。如果我们不能,那么我们所使用的结构实际上不是语法糖。例如,f的这些定义是等价的,无论g与什么相关联:
fun f x =
if x
then g x
else false
(***************************)
fun f x =
x andalso g x
但是请注意,我们不一定要用if g x then x else false
代替x andalso g x
,如果g
有副作用或者不终止。
其次,我们可以更改局部变量(或函数参数)的名称,只要我们一致地更改它的所有用法。例如,f
的这两个定义是等价的:
val y = 14
fun f x = x + y + x
(***************************)
val y = 14
fun f z = z + y + z
但有一条规则:在选择新变量名时,不能选择函数体已经用来引用其他变量的变量。例如,如果我们尝试用y
替换x
,我们得到fun y = y + y + y
,这与我们开始使用的函数是不同的。以前未使用的变量从来都不是问题。
第三,我们可以使用或不使用助手函数。例如,g
的这两个定义是等价的:
val y = 14
fun g z = (z + y + z) + z
(***************************)
val y = 14
fun f x = x + y + x
fun g z = (f z) + z
同样,我们必须注意不要因为f
和g
具有潜在的不同环境而改变变量的含义。例如,这里g的定义并不等价:
val y = 14
val y = 7
fun g z = (z + y + z) + z
(***************************)
val y = 14
fun fx = x + y + x
val y = 7
fun g z = (f z) + z
第四,正如我们之前对匿名函数所解释的那样,不必要的函数包装是一种糟糕的风格,因为有一种更简单的等效方法。例如,fun g y = f y
和val g = f
总是等价的。然而,这里又出现了一个微妙的复杂情况。当我们有一个像f
这样的变量绑定到我们正在调用的函数时,这种方法是有效的,但在更一般的情况下,我们可能有一个表达式,它的计算结果是我们随后调用的函数。对于任何表达式e
,fun g y = e y
和val g = e
总是相同的吗?不。
举个愚蠢的例子,考虑fun h() (print "hi" ; fn x => x + x)
,e
是h()
。那么fun g y = (h())``y
是一个函数,每次调用它时都会打印出来。但是val g = h()
是一个不打印的函数-在为g
创建绑定时,程序将打印一次"hi"
。这应该不神秘:我们知道val绑定“立即”计算其右侧,但函数体在被调用之前不会被计算。
一个不那么愚蠢的例子可能是h
可能引发异常而不是返回函数。
第五,let val p = e1 in e2 end
几乎可以是(fn p => e2) e1
的语法糖。毕竟,对于任何表达式e1
和e2
以及模式p
,这两段代码都是:
- 将
e1
计算为一个值 - 将值与模式
p
匹配 - 如果匹配,则将
e2
求值为模式匹配扩展的环境中的值 - 返回
e2
的计算结果
因为这两段代码“做”的是完全相同的事情,所以它们必须是等价的。在Racket中,就是这样(使用不同的语法)。在ML中,唯一的区别是类型检查器:p中的变量在let版本中允许有多态类型,但在匿名函数版本中不允许。
例如,考虑let val x = (fn y => y) in (x 0, x true) end
。这个愚蠢的代码可以通过类型检查并返回(0, true)
,因为x
具有类型'a -> 'a
。但是(fn x => (x 0, x true)) (fn y => y)
不能通过类型检查,因为没有可以给x
的非多态类型,函数参数不能具有多态类型。这就是类型推断在ML中的工作方式。
重新审视我们的等价定义
通过设计,我们对等价性的定义忽略了一个函数需要多少时间或空间来求值。因此,两个总是返回相同答案的函数可能是等价的,即使一个用了一纳秒,另一个用了一百万年。从某种意义上说,这是一件好事,因为这个定义将允许我们用纳秒版本取代百万年版本。
但显然其他定义也很重要。数据结构和算法课程精确地研究渐近复杂性,以便他们能够区分一些算法是“更好的”(这显然意味着一些“不同”),即使更好的算法产生相同的答案。此外,渐进复杂性,通过设计,忽略了“常数因子开销”,这在某些程序中可能很重要,因此,这种更严格的等价定义可能过于宽松:我们可能真的想知道两个实现需要“大约相同的时间”
这些定义都不优越。所有这些都是计算机科学家一直使用的有价值的观点。可观察行为(我们的定义)、渐进复杂性和实际性能都是几乎每天都被软件开发人员使用的智能工具。