这里对Section 7进行翻译。

课程主页:

https://www.coursera.org/learn/programming-languages-part-b/home

B站搬运:

https://www.bilibili.com/video/BV1tZ4y1D7

Coursera编程语言课程 第7节总结

标准说明:本总结涵盖的材料与课堂视频以及随视频发布的材料(幻灯片、代码)大致相同。它有助于以叙述的方式阅读材料,并将整个课程部分的材料放在一份文件中,特别是在以后复习材料时。请在讨论板上报告这些笔记中的错误。

目录

  • ML vs Racket
  • 什么是静态检查?
  • 正确性:健全性、完整性、不确定性
  • 弱类型
  • 更灵活的原语是一个相关但不同的问题
  • 静态检查的优缺点
  • 静态类型还是动态类型更方便?
  • 静态类型会阻止有用的程序吗?
  • 静态类型的早期错误检测重要吗?
  • 静态或动态类型会带来更好的性能吗?
  • 静态或动态类型使代码重用更容易吗?
  • 静态或动态类型更适合原型设计?
  • 静态类型还是动态类型更适合代码改进?
  • 可选:evalquote

ML vs Racket

在研究静态类型的一般主题及其优点/缺点之前,在我们迄今为止所研究的两种语言ML和Racket之间做一个更具体的比较是很有意思的。这两种语言在很多方面都很相似,它们的结构都鼓励函数式风格(避免突变,使用头等闭包),同时允许适当的突变。也有许多不同之处,包括非常不同的语法方法,ML对模式匹配的支持与Racket对结构的访问函数比较,Racket对let表达式的多种变体,等等。

但是这两种语言之间最普遍的区别是ML有一个静态类型系统,而Racket没有。(在DrRacket系统中还有一种相关的语言,叫做Typed Racket,它可以与Racket和许多其他语言很好地互动,允许你混合使用不同语言编写的程序。在本课程中,我们不会研究这个,所以我们在这里只提到Racket语言。)

我们在下面精确地研究什么是静态类型系统,ML的类型系统保证什么,以及静态类型的优点和缺点是什么。任何用ML和Racket编程的人可能已经对这些话题有了一些想法。ML通过做类型检查和报告错误,在运行前拒绝了很多程序。为了做到这一点,ML强制执行某些限制(例如,一个列表中的所有元素必须具有相同的类型)。因此,ML确保在编译时不存在某些错误(例如,我们永远不会尝试将一个字符串传递给加法运算符)。

更有趣的是,我们能不能用更像Racket的想法来描述ML和它的类型系统,反之,我们能不能用ML来描述Racket风格的编程?事实证明,我们可以,而且这样做既能拓展思维,又是后续话题的良好先导。

首先考虑一个Racket程序员如何看待ML。忽略语法差异和其他问题,我们可以把ML描述为Racket的一个子集。运行的程序产生类似的答案,但是ML拒绝了更多的程序,因为它们是非法的,也就是说,不是语言的一部分。这有什么好处呢?ML的设计是为了拒绝那些很可能是bug的程序。Racket允许像(define (f y) (+ y (car y))这样的程序,但任何对f的调用都会导致错误,所以这几乎不是一个有用的程序。因此,ML拒绝这个程序而不是等到程序员测试f,是很有帮助的。同样地,类型系统可以捕捉到由于程序的不同部分的假设不一致而产生的错误。函数(define (g x) (+ x x))(define (h z) (g (cons z 2)))本身都是合理的,但是如果h中的gg的这个定义绑定,那么对h的任何调用都会失败,就像对f的调用一样。例如,在这段代码中,if表达式和与xs绑定的表达式都不会进行类型检查,而是根据情况代表合理的Racket习语:

(define (f x) (if (> x 0) #t (list 1 2))
(define xs (list 1 #t "hi")) 
(define y (f (car xs)) 

那么,现在一个ML程序员可能如何看待Racket?一种观点是与上面的讨论相反,Racket接受一个超集程序,其中有些是错误的,有些不是。一个更有趣的观点是,Racket就是ML,其中每个表达式都是一个大数据类型的一部分。在这种观点中,每个计算的结果都被构造函数隐含包装到一个大的数据类型中,而像+这样的基元的实现会检查其参数的标签(例如,看它们是否是数字)并在适当的时候引发错误。更详细地说,这就像Racket有这样一个数据类型的绑定:

datatype theType = Int of int | String of string
                 | Pair of theType * theType
                 | Fun of theType -> theType
                 | ... (* one constructor per built-in type *)

那么就像当程序员写下42这样的东西时,它就隐含了真正的Int 42,这样每个表达式的结果都有theType类型。那么像+这样的函数,如果两个参数都没有正确的构造函数,就会引发错误,如果有必要,它们的结果也会被正确的构造函数包装起来。例如,我们可以把car看成是:

fun car v = case v of Pair(a,b) => a | _ => raise ... (* give some error *)

由于这种”秘密的模式匹配”没有暴露给程序员,Racket还提供了which-构造函数来供程序员使用。例如,原始的pair?可以被看作是:

fun pair? v = case v of Pair _ => true | _ => false 

最后,Racket的结构描述做了一件你无法用ML数据类型绑定来做的事情。它们可以动态地给数据类型添加新的构造函数。(你可以在ML中用exn类型做这个,但不能用数据类型绑定。如果你能做到,就不可能静态检查缺失的模式匹配子句。)

我们可以从theType的角度来考虑Racket,意味着你在Racket中做的任何事情都可以在ML中完成:ML程序员可以明确地使用类似上述theType符号的东西来编程。

什么是静态检查?

通常所说的”静态检查”是指在程序(成功)解析后、运行前为拒绝程序而做的任何事情。如果一个程序没有被解析,我们仍然会得到一个错误,但我们把这样的错误称为“语法错误”或“解析错误”。相比之下,来自静态检查的错误,通常是”类型错误”,将包括诸如未定义的变量或使用数字而不是pair。我们做静态检查时不需要对程序进行任何输入识别,这就是”编译时检查”,尽管在静态检查成功后,语言实现是否会使用编译器或解释器并不重要。

静态检查的执行方式是编程语言的定义的一部分。不同的语言可以做不同的事情;有些语言根本就不做静态检查。考虑到一种具有特定定义的语言,你也可以使用其他工具进行更多的静态检查,以尝试发现错误或确保没有错误,尽管这些工具不是语言定义的一部分。

最常见的方法是通过类型系统来实现语言的静态检查。当我们学习ML的时候,我们为每个语言结构给出了类型规则。每个变量都有一个类型,一个条件的两个分支必须有相同的类型,等等。ML的静态检查是检查这些规则是否被遵循(在ML的情况下,推断出类型来做到这一点)。但这是语言的静态检查方法(如何做),与静态检查的目的(完成什么)是不同的。其目的是为了拒绝那些没有意义的程序或可能试图滥用语言特性的程序。有一些错误是类型系统通常不能防止的(比如数组边界错误),还有一些是类型系统不能防止的,除非给它更多关于程序应该做什么的信息。例如,如果一个程序把条件的分支放在错误的顺序中,或者调用+而不是*,这仍然是一个程序,只是不是预期的程序。

例如,ML的类型系统的一个目的是防止将字符串传递给除法运算符等算术原语。相比之下,Racket使用了动态检查(即运行时检查),对每个值进行标记,让除法运算符检查其参数是否为数字。ML的实现不需要为此目的对数值进行标记,因为它可以依靠静态检查。但正如我们将在下面讨论的那样,交换条件是,静态检查器必须拒绝一些实际上不会做错的程序。

正如ML和Racket所证明的那样,防止“坏事”的典型点是“编译时”和“运行时”。然而,值得注意的是,关于我们何时宣布某件事情为错误,其实是有一个连续的eagerness。为了举例,考虑一下大多数类型系统没有静态地防止的东西:除以零。如果我们有一些包含表达式(/ 3 0)的函数,什么时候会引起一个错误:

  • 输入时:调整编辑器,使人们甚至不能写下分母为0的除法。这是近似的,因为也许我们正要写0.33,但我们不允许写0。
  • 编译时:我们一看到这个表达式就知道了。这是近似的,因为也许上下文是(if #f (/ 3 0) 42)
  • 链接时:一旦我们看到包含(/ 3 0)的函数可能是由某个“main”函数调用的。这没有编译时那么近似,因为有些代码可能永远不会被使用,但我们仍然要近似估计哪些代码可能被调用。
  • 运行时:只要我们执行除法就可以了。
  • 甚至更晚:与其引发错误,不如返回某种表示除以零的值,并且不引发错误,直到该值被用于我们需要实际数字的地方,比如索引到一个数组。

虽然”甚至更晚”的选项一开始看起来过于宽松,但这恰恰是浮点运算的特点。(/ 3.0 0.0)产生+inf.0,它仍然可以被计算,但不能被转换成一个精确的数字。在科学计算中,这是非常有用的,可以避免很多额外的情况:也许我们会做一些事情,比如取$\pi/2$的正切值,但只在最终答案中不使用的情况下。

正确性:健全性、完整性、不确定性

直观地说,如果一个静态检查器能防止它声称要防止的东西,那么它就是正确的;否则,无论是语言的定义还是静态检查的实现,都需要进行修改。但是,我们可以通过对健全性和完整性这两个术语的限定,对正确性进行更精确的描述。对于这两个术语,其定义是关于我们希望防止的一些事情X。例如,X可以是”一个程序查找一个不在环境中的变量”。

如果一个类型系统从不接受一个在某些输入下会做X的程序,那么它就是健全的。

如果一个类型系统从来没有拒绝过一个在任意输入下都不会做X的程序,那么这个类型系统就是完整的。

理解这些说法的一个好方法是,健全性可以防止假阴性(false negative),完整性可以防止假阳性(false positive)。术语假阴性和假阳性来自统计学和医学。假设有一个针对某种疾病的医学测试,但它不是一个完美的测试。如果测试没有检测出疾病,但病人实际上患有该疾病,那么这就是假阴性(测试是阴性的,但这是假的)。如果测试检测到了疾病,但病人实际上没有得病,那么这就是假阳性(测试是阳性的,但这是假的)。在静态检查中,疾病是”在某些输入下运行时执行X”,测试是”程序是否进行了类型检查?”健全性和完整性这两个术语来自于逻辑学,在编程语言的研究中经常使用。一个健全的逻辑只证明真实的东西。一个完整的逻辑可以证明所有真实的东西。在这里,我们的类型系统就是逻辑,我们试图证明的事情是”X不会发生”。

在现代语言中,类型系统是健全的(它们能防止它们所声称的),但不是完整的(它们拒绝它们不需要拒绝的程序)。健全性很重要,因为它让语言用户和语言实现者相信X永远不会发生。完整是好的,但希望在实践中很少有程序被不必要地拒绝,而且在这些情况下,希望程序员很容易修改程序,使其进行类型检查。

类型系统是不完整的,因为对于几乎任何你想静态检查的东西,都不可能实现一个满足如下条件的静态检查器,在你的语言中给定任何程序

  • (a)总是终止
  • (b)健全
  • (c)完整

既然我们必须放弃一个,(c)似乎是最好的选择(程序员不喜欢可能不终止的编译器)。

不可能性的结果正是计算理论研究核心的不可判定性思想。了解程序的重要属性不可判定意味着什么是成为受过教育的计算机科学家的基础。不可判定性直接暗示静态检查的内在近似(即不完整性),这一事实可能是不可判定性最重要的分支。

我们根本不可能写一个程序,把另一个ML/Racket/Java/等的程序作为输入,而这个程序总是正确地回答诸如”这个程序会不会除以0?”这样的问题。”这个程序会不会把一个字符串当成一个函数?” “这个程序会终止吗?”等等。

弱类型

那么为了安全起见,至少在某些情况下,语言的实现还是应该进行动态检查以防止X的发生,而且语言的定义应该允许这些检查在运行时失败。

但另一种说法是,如果X发生了,那就是程序员的错,而语言的定义不需要进行检查。事实上,如果X发生了,那么运行中的程序可以做任何事情:崩溃、破坏数据、产生错误的答案、删除文件、启动病毒或者让计算机着火。如果一种语言的程序允许合法的实现计算机着火(即使它可能不会),我们称这种语言为弱类型。那些对有缺陷的程序的行为有更多限制的语言被称为强类型语言。这些术语不太准确,因为类型系统的正确性只是问题的一部分。毕竟,Racket是动态类型的,但仍然是强类型的。此外,在弱类型语言中,实际不稳定和不可预测的行为的一大来源是数组边界错误(他们不需要检查边界,他们可以错误地访问一些其他数据),但很少有类型系统检查数组边界。

C和C++是著名的弱类型语言。为什么它们会被这样定义呢?简而言之,因为设计者不希望语言的定义迫使实现者做所有必要的动态检查。虽然执行检查有一定的时间成本,但更大的问题是,实现必须保留额外的数据(如值上的标记)来进行检查,而C/C++被设计成低级别的语言,程序员可以期望不添加额外的”隐藏字段”。

支持弱类型的一个较早的、现在已经很少见的观点体现在”strong types for weak minds”这一说法上。这种观点认为,任何强类型语言要么是静态地拒绝程序,要么是动态地进行不必要的测试(见上文的不确定性),所以人们应该能够在他/她知道不必要的地方”推翻”检查。在现实中,人类是非常容易出错的,我们应该欢迎自动检查,即使它不得不为我们在谨慎的一面犯错。此外,随着时间的推移,类型系统已经变得更有表现力了(例如,多态性),语言实现在优化不必要的检查方面也变得更好了(他们只是永远不会得到所有的检查)。与此同时,软件已经变得非常大,非常复杂,并被社会各界所依赖。在一个用C语言编写的3000万行的操作系统中,有一个错误可以使整个计算机陷入安全危机,这是一个很严重的问题。虽然这仍然是一个真正的问题,而且C语言提供的支持很少,但使用其他工具对C代码进行静态和/或动态检查,以试图防止这种错误,已经越来越普遍。

更灵活的原语是一个相关但不同的问题

假设我们改变了ML,使类型系统接受任何表达式e1+e2,只要e1e2有某种类型,并且我们改变了加法的评估规则,如果其中一个参数没有产生数字,则返回0。这是否会使ML成为一种动态类型的语言?它是”更加动态”的,因为语言更加宽松,一些”可能的”错误不会被急切地检测出来,但是仍然有一个类型系统在拒绝程序。我们同样可以改变Racket,使其在”+”被赋予不良参数时不产生错误。Racket的设计者选择不这样做,因为这很可能会掩盖错误,而不会有很大的作用。

其他语言做出了不同的选择,通过扩展原始运算的表示,在这样的情况下不出错,从而报告更少的错误。除了对任何种类的数据进行加密运算外,还有一些例子:

  • 允许超边界的数组访问。例如,如果arr少于10个元素,我们仍然可以允许arr[10],只需返回一个默认值或arr[10]=e,使数组更大。
  • 允许用错误的参数数调用函数。额外的参数可以被默默地忽略。太少的参数可以用语言选择的默认值来代替。

这些选择是语言设计的问题。赋予可能出现的错误以意义往往是不明智的,因为它掩盖了错误,并使它们更难调试,因为程序在一些无意义的应用计算发生后很久才运行。另一方面,这种”更动态”的功能在提供给程序员时被使用,所以显然有人认为它们是有用的。

就我们的目的而言,我们只是把这个问题与静态与动态类型分开考虑。我们不是在程序运行前或运行时阻止一些X(例如,调用一个参数过多的函数),而是改变语言语义,这样我们就根本不会阻止X——我们允许它,并扩展我们的评估规则,给它一个语义。

静态检查的优势和劣势

现在我们知道了什么是静态类型和动态类型,让我们来讨论一下几十年来关于哪个更好的争论。我们知道静态类型为你早期捕获许多错误,健全性确保某些类型的错误不会留下,而不完整性意味着一些完美的程序被拒绝。我们不会否认静态类型化是否可取(如果没有其他问题的话,这取决于你在检查什么),但我们将考虑七个具体的观点,并考虑每个观点的有效论据,包括支持和反对静态类型。

1. 静态类型还是动态类型更方便?

动态类型更方便的论点源于能够混合和匹配不同种类的数据,如数字、字符串和数据对,而不需要声明新的类型名称或用模式匹配使代码混乱。例如,如果我们想要一个返回数字或字符串的函数,我们可以直接返回数字或字符串,而调用者可以根据需要使用动态类型谓词。在Racket中,我们可以这样写:

(define (f y) (if (> y 0) (+ y y) "hi")) 
(let ([ans (f x)]) (if (number? ans) (number->string ans) ans) ) 

相比之下,类似的ML代码需要使用一个数据类型,在f中使用构造函数,并通过模式匹配来使用结果:

datatype t = Int of int | String of string
fun f y = if y > 0 then Int(y+y) else String "hi"
val _ = case f x of Int i => Int.toString i | String s => s

另一方面,静态类型使得假设数据具有某种类型更加方便,知道这种假设不能被违反,这将导致以后的错误。对于一个Racket函数来说,要确保某些数据是,例如,一个数字,它必须在代码中插入一个显式的动态检查,这是更多的工作,更难读。而相应的ML代码则没有这种尴尬。

(define (cube x) 
  (if (not (number? x)) 
      (error "bad arguments") 
      (* x x x))) 
(cube 7)

fun cube x = x * x * x
val _ = cube 7

请注意,如果没有Racket代码中的检查,实际的错误会出现在乘法的主体中,这可能会使不知道cube是用乘法实现的调用者感到困惑。

2. 静态类型会阻止有用的程序吗?

动态类型化并不排斥那些完全有意义的程序。例如,下面的Racket代码将'((7 . 7) . (#t . #t))绑定到pair_of_pairs上是没有问题的,但是相应的ML代码并没有通过类型检查,因为ML类型系统无法给f提供类型(这是ML的一个限制。有一些语言具有更具表现力的多态性形式,可以对这种代码进行类型检查。但是由于不可知性,总是有局限性。):

(define (f g) (cons (g 7) (g #t)))
(define pair_of_pairs (f (lambda (x) (cons x x))))

fun f g = (g 7, g true) (* does not type-check *)
val pair_of_pairs = f (fn x => (x,x))

当然,我们可以写一个ML程序来产生((7,7),(true,true)),但我们可能不得不”绕过类型系统”而不是按照我们想要的方式来做。

另一方面,动态类型的灵活性来自于在每个值上加一个标签。在ML和其他静态类型语言中,我们可以通过使用数据类型和显式标签在我们想做的时候做同样的事情。在极端情况下,如果你想在ML中像Racket那样编程,你可以使用一个数据类型来代表”The One Racket Type”,并在所有地方插入显式标签和模式匹配。虽然这种编程风格在任何地方使用都是很痛苦的,但它证明了一个观点:我们在Racket中可以做的事情,在ML中是做不到的。(我们在上面已经讨论过这个问题)

datatype tort = Int of int
              | String of string
              | Pair of tort * tort
              | Fun of tort -> tort
              | Bool of bool
              | ...
fun f g = (case g of Fun g' => Pair(g' (Int 7), g' (Bool true)))
val pair_of_pairs = f (Fun (fn x => Pair(x,x)))

也许支持静态类型的一个更简单的论据是,现代类型系统有足够的表现力,它们很少妨碍你的工作。你有多少次试图写一个像f这样无法在ML中通过类型检查的函数?

3. 静态类型的早期错误检测是否重要?

支持静态类型的一个明显的论点是,它能更早地捕捉到错误,只要你静态地检查(非正式地,”编译”)代码。软件开发的一个众所周知的常识是,如果在开发者还在思考代码的时候,就能更早地发现错误。考虑一下这个Racket程序:

(define (pow x)
   (lambda (y)
      (if (= y 0)
          1
          (* x (pow x (- y 1))))))

虽然算法看起来是正确的,但这个程序有一个bug:pow期望的是curried参数,但递归调用传递pow两个参数,而不是通过currying。这个bug直到用不等于0的y测试pow时才被发现。等效的ML程序根本没有通过类型检查:

fun pow x y = (* does not type-check *)
  if y = 0
  then 1
  else x * pow (x,y-1)

由于静态检查器可以捕捉到已知的错误类型,专家级的程序员可以利用这些知识将注意力集中在其他地方。一个程序员在写下大部分代码时,可能会对tupling与currying相当马虎,因为他知道类型检查器随后会给出一个可以快速纠正的错误列表。这可以腾出精力来关注其他任务,如数组边界推理或更高层次的算法问题。

一个动态类型的支持者会说,静态检查通常只抓到你无论如何都会通过测试抓到的错误。因为你仍然需要测试你的程序,在你运行测试之前抓到一些bug的额外价值就减少了。毕竟,下面的程序不能作为指数函数使用(它们使用了错误的算术),ML的类型系统不会检测到这一点,而测试可以捕捉到这个bug,也会捕捉到上面的currying bug:

(define (pow x) ; wrong algorithm
  (lambda (y)
     (if (= y 0)
     1
     (+ x ((pow x) (- y 1))))))
     
fun pow x y = (* wrong algorithm *)
   if y = 0
   then 1
   else x + pow x (y - 1)

4. 静态类型或动态类型会带来更好的性能吗?

静态类型可以使代码更快,因为它不需要在运行时进行类型测试。事实上,大部分的性能优势可能来自于不在第一时间存储类型标记,这需要更多的空间并减慢构造函数的速度。在ML中,只有在程序员使用数据类型构造器的地方才有运行时标签,而不是到处都有。

动态类型有三个合理的反驳理由。首先,这种低层次的性能在大多数软件中并不重要。其次,动态类型语言的实现可以而且确实试图优化它所认为的不必要的类型测试。例如,在(let ([x (+ y y)]) (* x 4))中,乘法不需要检查x和4是否是数字,而加法可以只检查一次y。虽然没有一个优化器可以从每个程序中删除所有不必要的测试(不确定性再次出现),但对于程序中性能重要的部分来说,这在实践中可能足够容易。第三,如果静态类型语言的程序员不得不在类型系统的限制下工作,那么这些变通方法就会削弱所谓的性能优势。毕竟,使用数据类型的ML程序也有标签。

5. 静态类型或动态类型使代码更容易被重用?

动态类型可以说使重用库函数更加容易。毕竟,如果你从cons单元中建立了很多不同种类的数据,你可以继续使用car, cdr, cadr等来获取这些数据,而不是为每个数据结构设计很多不同的getter函数。另一方面,这也会掩盖一些错误。 例如,假设你不小心把一个列表传给了一个接收树的函数。如果cdr对它们都有效,你可能只是得到了错误的答案,或者稍后导致一个神秘的错误,而对列表和树使用不同的类型可以更快发现错误。

这确实是一个有趣的设计问题,比静态类型和动态类型更普遍。通常情况下,重用一个你已经拥有的库或数据结构是很好的,尤其是你可以重用所有可用的函数。其他时候,这使得分离概念上不同的东西太困难了,所以最好是建立一个新的类型。这样,当你把错误的东西放在错误的地方时,静态类型检查器或动态类型测试可以发现。

6. 静态类型或动态类型对原型开发更好?

在软件项目的早期,你正在开发一个原型,往往同时你也在改变你对软件将做什么以及实现方式的看法。

动态类型通常被认为更适合于原型开发,因为当这些决定处于变化之中时,你不需要花费精力来确定变量、函数和数据结构的类型。此外,你可能知道你的程序的一部分还没有意义(在静态类型语言中不会进行类型检查),但你还是想运行你的程序的其余部分(例如,测试你刚写的部分)。

静态类型的支持者可能会反驳说,在你的软件设计中记录类型永远不会太早,即使(也许特别是)它们不清楚而且在变化。此外,注释代码或添加存根,如形式为_ => raise Unimplemented的模式匹配分支,通常是很容易的,并且记录了程序中哪些部分是已知不工作的。

7. 静态类型还是动态类型更有利于代码改进?

在软件工程中,大量的精力都花在了维护工作程序上,通过消除错误,增加新的功能,以及在一般情况下进化代码来进行一些改变。

动态类型有时对代码的改进更方便,因为我们可以改变代码,使其更具有允许性(接受更多类型的参数),而不必改变代码中任何预先存在的客户端。例如,考虑改变这个简单的函数:

(define (f x) (* 2 x))

到如下版本,可以处理数字或字符串:

(define (f x)
  (if (number? x)
      (* 2 x)
      (string-append x x)))

现有的调用者,大概是用数字来调用f的,都看不出这个变化,但新的调用者可以传入字符串,甚至是不知道值是数字还是字符串的值。如果我们在ML中做了类似的改变,没有任何现有的调用者会通过类型检查,因为他们都必须用Int构造函数来包裹他们的参数,并对函数结果使用模式匹配。

fun f x = 2 * x

datatype t = Int of int | String of string
fun f x =
  case f x of
    Int i => Int (2 * i)
  | String s => String (s ^ s)

另一方面,静态类型检查在进化代码时非常有用,可以捕捉进化过程中引入的错误。当我们改变一个函数的类型时,所有的调用者都不再通过类型检查,这意味着类型检查器给我们提供了一个宝贵的”待办事项清单”,即所有需要改变的调用点。根据这个论点,改进代码的最安全的方法是改变任何函数的类型,这些函数的规范正在发生变化,这是一个关于在类型中尽可能多地捕捉规范的论点。

ML中一个特别好的例子是当你需要为一个数据类型添加一个新的构造函数时。如果你没有使用通配符模式,那么你将为所有使用该数据类型的案例表达式得到一个警告。

尽管”来自类型检查器的待办事项清单”很有价值,但在清单上的所有项目被解决之前,程序将不会运行,这可能是令人沮丧的,或者,正如在前面的要求下所讨论的,你使用注释或存根来删除尚未发展的部分。

可选:evalquote

(这个简短的部分几乎没有触及用eval编程的表面。它实际上只是介绍了这个概念。我们鼓励有兴趣的学生自己去学习更多的知识)。

在某种意义上,说Racket是一种解释型语言是相当公平的:它有一个原始的eval,可以在运行时获取程序的表示并对其进行评估。例如,这个程序的风格很差,因为有很多更简单的方法来实现它的目的,它可能会也可能不会根据x来打印一些东西:

(define (make-some-code y)
   (if y
       (list 'begin (list 'print "hi") (list '+ 4 2))
       (list '+ 5 3)))

(define (f x)
  (eval (make-some-code x)))

Racket函数make-some-code很奇怪。它不曾打印或执行加法。它所做的只是返回一些包含符号、字符串和数字的列表。例如,如果用#t调用,它会返回:

'(begin (print "hi") (+ 4 2)) 

这不过是一个三元素列表,其中第一个元素是符号begin。 它只是Racket的数据。但如果我们看这个数据,它看起来就像一个我们可以运行的Racket程序。这些嵌套的列表是一个Racket表达式的完美表示,如果被评估,会打印"hi",结果是6。

eval原语接受这样的表示,并在运行时对其进行评估。我们可以执行任何我们想要的计算来生成我们传递给eval的数据。作为一个简单的例子,我们可以把两个列表附加在一起,比如(list '+ 2)(list 3 4)。如果我们用'(+ 2 3 4)的结果调用eval,即一个4元素的列表,那么eval返回9。

许多语言都有eval,许多语言没有,使用它的适当的习语是一个有很大争议的问题。大多数人都同意它往往被过度使用,但也是一个非常强大的结构,有时是你想要的。

一个基于编译器的语言实现(注意我们没有说”编译语言”)可以处理eval吗?嗯,它需要在运行时有编译器或解释器,因为它不能事先知道什么可能被传递给eval。基于解释器的语言实现也需要在运行时有一个解释器或编译器,但是,当然,它已经需要评估”常规程序”。

在Javascript和Ruby这样的语言中,我们没有Racket语法的便利性,在Racket语法中,程序和列表看起来如此相似,以至于eval可以接受一个与Racket语法完全相同的列表表示法。相反,在这些语言中,eval需要一个字符串,并通过首先解析它,然后运行它,将其解释为具体的语法。不管是哪种语言,如果给出一个不符合格式的程序或一个引发错误的程序,eval将引发一个错误。

在Racket中,像我们这样写make-some-code是很痛苦和不必要的。相反,有一种特殊形式的quote,它把它下面的所有东西都当作符号、数字、列表等,而不是当作要调用的函数。所以我们可以这样写

(define (make-some-code y)
  (if y
      (quote (begin (print "hi") (+ 4 2)))
      (quote (+ 5 3))))

有趣的是,evalquote是反义词。对于任何表达式e,我们应该有(eval (quote e))作为写e的一种糟糕的方式,但也是等价的。

通常情况下,quote是”太强了”——我们想引用大多数东西,但在我们正在构建的主要是语法中评估一些代码是很方便的。Racket有quasiquote和unquote可以做到这一点(如果感兴趣的话,请看手册),Racket的语言学前辈们几十年来都有这种功能。在现代脚本语言中,我们经常可以看到类似的功能:将表达式的评估嵌入到字符串中(我们可能会也可能不会调用eval,就像我们可能会也可能不会使用Racket的quote表达式来构建eval的东西)。这种功能有时在脚本语言中被称为插值,但它只是quasiquote。