lotuc
首发于lotuc
Programming Languages: Application and Interpretation【译1-4】

Programming Languages: Application and Interpretation【译1-4】

审校:@MrMathematica

原文:PLAI 第二版

GitHub:PLAI-cn

GitBook:PLAI-cn

翻译声明见 Github 仓库


1 引言

1.1 我们的哲学

请参见Youtube视频

1.2 本书的结构

与某些教科书不同,本书并没有采取自上而下的叙述方式,而是采用了对话发展的方式,有时也会回头描述讲过的话题。如同现实中的程序员,我们通常一步一步来构造程序。有时候我们的程序也会包括错误,这并不是因为我不知道该怎么写出正确的程序,而是因为这是帮助你学习的最好方式。错误会迫使你没法被动的学习,而是必须钻研:你永远也没法确信读到的材料就是真实的。

最终,你会得到正确的答案。短期来说,这种方式使人挫折,而且读者也没法将本书当做参考书来使用(你没法打开书,翻到随便一页,就认为其中的内容是正确的)。但是,挫败感是学习的一个部分。我不觉得有好方法绕开它。

在书中你会遇到

练习

这是练习。请做题。

这和传统教材中的练习题一样,需要你独立完成。如果你确实在某个课程中使用本教材,有可能这就是课后作业。但是本书也包含这种:

思考题

这是思考题,你看到了吗?

当你看到思考题的时候,请停下来。阅读、思考,形成答案之后再继续。这是因为思考题本质上就是练习题,唯一的区别是后文会给出其答案,或者你可以通过运行程序自行得到答案。如果你不加思考的继续阅读,那么你就会读到答案(或者,如果答案是可以通过运行程序获得的情况下,完全忽略答案)。这样做既没有测试你的知识水平,也无法锻炼你的思维能力。换一种说法,思考题是鼓励你积极学习的一部分。

1.3 本书使用的语言

本书使用的主要语言是Racket。然而,跟很多操作系统一样,Racket支持很多编程语言,所以你必须显式的告诉Racket你在使用什么语言进行编程。在Unix系统的shell脚本中你需要在脚本开头添加如下一行来指明语言:

#!/bin/sh

在脚本的头部,你可能会类似的指定:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" ...>

类似的,Racket需要你声明所使用的语言。Racket语言可能使用和Racket一样的括号语法,但是有不同的语义;或语义相同语法不同;或者有不同的语法和语义。因此每个Racket 程序以#lang <语言名字> 开头。默认的语言为Racket(名字为racket)。【注释】这本书中我们几乎总是使用语言:

plai-typed
在DrRacket版本5.3中,打开“语言/Language”菜单,“选择语言/Choose Language”菜单项,然后选择“使用代码中指定的语言/Use the language declared in the source”。

使用该语言时,除非特别指明,请在程序的第一行添加(本书后面例子代码中请假定我们添加了该行):

#lang plai-typed

Typed PLAI语言和传统Racket最主要的不同是它是静态类型的。它还给你提供了些有用的的东西(construct):define-typetype-casetest。【注释】下面是它们的使用实例。创建新的数据类型:

它还提供了其他一些有用的命令,比如控制测试输出的命令等。请参考该语言的文档了解。在DrRacket版本5.3中,打开“帮助/Help”菜单,选择“帮助台/Help Desk”菜单项,然后在帮助台的搜索栏中输入“plai-typed”。
(define-type MisspelledAnimal
  [caml (humps : number)]
  [yacc (height : number)])

它做的事情类似于在Java中:创建抽象类MisspelledAnimal,它有两个实体子类:camlyacc,它们的构造参数分别为humpsheight

该语言中,我们通过下面方式创建实例:

(caml 2)
(yacc 1.9)

如同其名字暗示的,define-type会创建给定名字的数据类型。当我们把该数据类型的值绑定到变量时就需要用到其类型:

(define ma1 : MisspelledAnimal (caml 2))
(define ma2 : MisspelledAnimal (yacc 1.9))

事实上这里你并不需要显式的声明类型,因为Typed PLAI在很多情况下(包括这里)都能够推断出正确的数据类型。因此上面的代码可以写成:

(define ma1 (caml 2))
(define ma2 (yacc 1.9))

不过我们倾向于对类型进行显式的声明。这么做一方面是尊崇规则,另一方面当我们日后阅读代码时有助于理解。

类型的名字可以递归的使用,本书会经常使用这种方式(例如2.4节中)。

该语言为我们提供了模式匹配功能,例如这个函数体:

(define (good? [ma : MisspelledAnimal]) : boolean
  (type-case MisspelledAnimal ma
    [caml (humps) (>= humps 2)]
    [yacc (height) (> height 2.1)]))

在表达式(>= humps 2)中,humps被绑定为caml实例的构造时所用到的参数。

最后,你应该编写测试案例,理想情况下,应该在开始定义函数之前写。当然在定义函数之后也需要写,以防代码被意外修改。

(test (good? ma1) #t)
(test (good? ma2) #f)

当你运行上面的代码时,语言会告诉你两个测试都通过了。要了解更多请参阅文档。

这里有一点可能比较费解。在模式匹配中,匹配数据字段时我们使用了和数据定义时相同的名字,humps(和 height)。这是完全没有必要的,模式匹配是基于位置的而不是名字。因此我们完全可以使用其它名字:

(define (good? [ma : MisspelledAnimal]) : boolean
  (type-case MisspelledAnimal ma
             [caml (h) (>= h 2)]
             [yacc (h) (> h 2.1)]))

因为每个h仅在其被引入的匹配分支中可见,所以上面的代码没有重名的问题。命名是请尊崇传统和可读性。通常来说,定义数据类型时可以使用长而描述性的名字;而定义类型子句时请使用简短的名字,因为日后这些名字会不断被用到。

我觉得很少有需要你会用到类型判断函数(如caml?),不过你可以用。数据类型定义时还会生成字段提取函数,例如caml-humps。有时候,直接使用字段提取函数会比使用模式匹配更简单。当然一般来说还是模式匹配更好用,就如刚才的good?所示。不过为了完整,我们实现如下:

(define (good? [ma : MisspelledAnimal]) : boolean
  (cond
    [(caml? ma) (>= caml-humps ma) 2]
    [(yacc? ma) (> (yacc-height ma) 2.1)]))

思考题

如果给函数传入了错误的数据类型会发生什么?比如传给caml构造器一个字符串?或者传给前述两个版本的good?函数一个数?

2 本书有关语法解析的一切

语法解析(parsing)是将输入字符流转换成结构化内部表示的过程。常见的内部表示是树 ,程序可以递归地处理树这种数据结构。例如,给定输入流:

23 + 5 - 6

我们可以将其转换成根节点为加法,左边节点表示数23,右边节点是用树表示5-6的树。语法解析器(parser)是用于实现这种转换的程序。

语法解析本身是个比较复杂,且由于歧义的存在,还远没有被解决的问题。例如上面的例子,你还可以将其转换成根节点为减法,子树为加法的树。我们还需要考虑加法操作符的是否符合交换性(左右参数是否能互换)等问题。要解析功能完整的语言(暂且不提自然语言),要考虑的问题只会更多更复杂。

2.1 轻量级的,内建的语法解析器的前半部分

这些问题使得语法解析本身适合当作单独的主题来讲,也确实有很多书本、课程和工具专注于该方面。从我们的角度来说,语法解析是种令人分心的东西,因为我们想学习的是编程语言的除去语法解析的各个部分。因此,我们使用Racket一个有用的功能来将输入流转换成树:readread和该语言的括号语法形式紧密关联,它将括号形式的字符流转换成内部树形式。例如,运行(read)然后输入——

(+ 23 (- 5 6))

——会产出一列表,其第一个元素是符号'+,第二个元素是数23,第三个元素是列表;该列表其第一个元素是符号'-,第二个元素是数5,第三个元素是数6

2.2 快捷方式

你应该知道,程序都会需要详尽的测试,而每次测试都需要手工输入会很麻烦。幸运的是,你可能猜得到,括号表达式可以在Racket中用引号来表达,也就是你刚才看到的'<expr>形式——其效果和运行(read)然后输入<expr>一样。

2.3 语法解析得到的类型

事实上,我刚才的描述并不准确。之前说(read)会返回列表等类型。在Racket中确实如此,但在Typed PLAI中,事情稍有不同,(read)返回值类型为s-expression(符号表达式的简写)。

> (read)
- s-expression
[type in (+ 23 (- 5 6))]
'(+ 23 (- 5 6))

Racket包含了强大的s-expression系统,其语法还甚至可以表达带循环的结构。不过我们只会用到其中的一部分。

在静态类型的语言中,s-expression被认为是和其他类型(例如数、列表)都不同的数据。在计算机内部,s-expression是一种递归数据类型,其基本构造是原子值——例如数、字符串、符号,组合形式可以是表、向量等。因此,原子值(数、字符串、符号等)即是其自由类型,也是一种s-expression。这就造成了输入的歧义,我们后文讨论。

Typed PLAI采取一种简单的方式来处理这种歧义:当直接输入时,原子结构就是它本身的类型;当输入为大结构的一部分时——包括read或者引用——它们就是s-expression类型。你可以通过类型转换将其转换为基本类型。例如:

> '+
- symbol
'+
> (define l '(+ 1 2))
> l
- s-expression
'(+ 1 2)
> (first l)
. typecheck failed: (listof '_a) vs s-expression in:
  first
  (quote (+ 1 2))
  l
  first
> (define f (first (s-exp->list l)))
> f
- s-expression
'+

这方面和Java程序的类型转换类似。我们后文再学习类型转换。

请注意,表结构的第一个元素的类型并不是符号:表形式的s-expression是由s-expressions组成的表。因此,

> (symbol->string f)
. typecheck failed: symbol vs s-expression in:
  symbol->string
  f
  symbol->string
  f
  first
  (first (s-exp->list l))
  s-exp->list

类型转换:

> (symbol->string (s-exp->symbol f))
- string
"+"

必须对s-expressions进行类型转换确实是个麻烦事,但是某种程度的麻烦是不可避免的:因为我们的目的是把没有类型的输入,通过严谨的类型分析,转化为有类型的。所以有些关于输入的假设必须明文给出。

好在我们只在语法解析中使用s-expressions,而我们的目的是尽快处理完语法解析!所以,这一点只会帮助我们尽快摆脱语法解析。

2.4 完整的语法解析器

原则上read就是完整的语法解析器。不过其输出过于一般化:结构体中并不包含其意向的注释信息。所以我们倾向于使用更具体的表达方式,类似于前文中“表达加法”和“表达数”的那种。

首先,我们必须引入一种数据类型来表示这类关系。后文(第三章)会详细讨论为啥采用这种数据类型,还有我们如何得出该数据类型。现在请先假设它是给定的:

(define-type ArithC
  [numC (n : number)]
  [plusC (l : ArithC) (r : ArithC)]
  [multC (l : ArithC) (r : ArithC)])

现在我们需要能将s-expression解析成该数据类型的函数。这就是语法解析器的另一半:

(define (parse [s : s-expression])
  (cond
    [(s-exp-number? s) (numC (s-exp->number s))]
    [(s-exp-list? s)
     (let ([sl (s-exp->list s)])
       (case (s-exp->symbol (first sl))
         [(+) (plusC (parse (second sl)) (parse (third sl)))]
         [(*) (multC (parse (second sl)) (parse (third sl)))]
         [else (error 'parse "invalid list input")]))]  ;无效的表输入
    [else (error 'parse "invalid input")]))  ;无效的输入

简单运行如下:

> (parse '(+ (* 1 2) (+ 2 3)))
- ArithC
(plusC
 (multC (numC 1) (numC 2))
 (plusC (numC 2) (numC 3)))

恭喜!你完成了首个程序的表示。从今往后我们就只需要处理用递归的树结构表示的程序了,再也不用担心各种不同的语法,还有如何把语法转换为树形结构了。我们终于可以开始学习编程语言了!

练习

如果传给语法解析器的参数忘了加引号,后果是啥?为什么?

2.5 尾声

Racket的语法继承自Scheme和Lisp,不乏争议。不过请观察它给我们带来的深层次好处:对传统语法进行解析会很复杂,而解析这种语法则简单明了,不管是从字符流到s-expressions的解析,还是从s-expressions进一步到语法树的解析。

这种语法的好处就是其多用途性。需要的代码少,而且可以方便的插入各种应用场景。所以很多基于Lisp的语言其语义各不相同,但都保留了历史继承而来的这种语法。

当然,我们也可以采用XML,它更好用;或者JSON,它和s-expression有着本质的不同!


3 解释器初窥

现在有了程序的表示方法,我们有很多方式可以用来操纵它们。我们可能想把程序打印的漂亮点(pretty-print),将其转换成其它格式的代码(编译),查看其是否符合特定属性(校验),等等。现在,我们专注于考虑得到其对应的值——计算(evaluation)——将程序规约成值。

让我们来为我们的算术语言写个解释器形式的求值器。选择算术运算是出于下面三个主要原因:(a)你已经知道怎么计算加减乘除了,我们可以专注于其实现;(b)基本上每门语言都会包含算术运算,所以我们可以从它开始进行语言的扩展;(c)该问题大小合适,足以展示我们要学习的很多要点。

3.1 算术表达式的表示

我们首先需要统一算术表达式的表示法。我们只打算支持两个运算符——加法和乘法——以及基本的数。需要一种东西来表达算术表达式。算是表达式的嵌套规则是啥呢?表达式可以任意地嵌套。

思考题

为什么我们不把除法也包括进来呢?这么做对前文总结会产生什么影响?

这里不包括除法的原因是,我们暂时不打算讨论什么表达式是合法的。显然1除以2是合法的,但是1除以0就有争议了。1除以(1减去1)就更有争议了。目前我们无需陷入这种矛盾,以后再讨论。

于是我们可以使用如下的表达式:

(define-type ArithC  ;具体算术
  [numC (n : number)]
  [plusC (l : ArithC) (r : ArithC)]
  [multC (l : ArithC) (r : ArithC)])

3.2 写个解释器

下面开始写该算术语言的解释器。首先我们考虑一下该解释器的类型:它的输入显然是ArithC值,返回值的类型呢?当然是数啦。即我们的解释器是输入为ArithC输出为数的函数。

练习

为该解释器写一些测试案例。

由于输入类型是递归定义的数据类型,很自然的解释器也应该递归地处理输入。程序模板如下:【注释】

(define (interp [a : ArithC]) : number
  (type-case ArithC a
             [numC (n) n]
             [plusC (l r) ...]
             [multC (l r) ...]))
《程序设计方法》一书(又译《如何设计程序》)详细介绍了模板这一概念。

你很可能想当然的直接写出如下的代码:

(define (interp [a : ArithC]) : number
  (type-case ArithC a
             [numC (n) n]
             [plusC (l r) (+ l r)]
             [multC (l r) (* l r)]))

思考题

你能找到其中的错误吗?

首先,我们先补充模板代码:

(define (interp [a : ArithC]) : number
  (type-case ArithC a
             [numC (n) n]
             [plusC (l r) ... (interp l) ... (interp r) ...]
             [multC (l r) ... (interp l) ... (interp r) ...]))

填充必要部分得到解释器:

(define (interp [a : ArithC]) : number
  (type-case ArithC a
             [numC (n) n]
             [plusC (l r) (+ (interp l) (interp r))]
             [multC (l r) (* (interp l) (interp r))]))

这样,我们就完成了第一个解释器!我知道有点虎头蛇尾,但是我保证,它会变得越来越复杂。

3.3 你注意到了吗?

有件事情我没和你讲清楚:

思考题

在这个语言中,加法和乘法的“意义”是啥?

太抽象了,不是吗?让我们把它变得更具体一些。计算机中有很多种不同的加法:

  • 首先,有很多种不同的数:固定长度(例如,32位)整数,带符号固定长度(例如,31位外加1个符号位)整数,任意精度整数;在有些语言中,有理数;各种不同格式的固定位数浮点数;在有些语言中,复数;如此等等。在确定数类型之后,加法可能只支持其中的一部分组合。
  • 其次,某些语言支持某些(其他)数据类型的加法,比如矩阵加法。
  • 再次,某些语言支持字符串“相加”。这里引号表示我们并没有进行数学上相加的操作,而是用语法上用+符号表示操作。有的语言用这表示字符串拼接;也有语言在这种情况下返回数(比如把字符串所表示的数相加)。

这些都是加法所代表的不同含义。语义是把语法(例如+)映射到含义(例如,以上列举的部分或者所有)。

于是游戏来了:以下哪些是相同的?

  • 1 + 2
  • 1 + 2
  • ’1’ + ’2’
  • ’1’ + ’2’

回到之前的问题,我们用的语义是啥?我们直接使用了Racket所提供的语义,因为程序直接把+映射到了Racket的+上。其实这也不一定是对的:比如说,如果Racket的+也支持字符串,那么我们这里提供的操作就限制+只能用在数上(事实上Racket的+并不支持字符串)。

如果我们想要不同的语义,需要显式的实现出来。

练习

需要哪些修改,这里的加法能支持带符号32位数的算术?

一般来说,我们需要避免简单的借用宿主语言的语义。后面我们还会讨论这个话题。

3.4 扩展此语言

我们选择的第一个语言功能非常有限,于是有很多种方式可以将其扩展。有的扩展,比如添加数据结构和函数,就必须要增加解释器所支持的数据类型(假设我们并不打算采用哥德尔计数法)。其他的扩展,比如增加更多算术操作,就不必修改核心语言及其解释器。我们下一章就讨论此问题。


4 初试去语法糖

我们从非常斯巴达式的算术语言开始。下一步我们来看看,在现有语言框架下怎么支持更多算术操作。我们只加2种,以做示范。

4.1 扩展:添加双目减法操作

首先,我们来添加减法。由于我们的语言已经包含了数、加法和乘法,用这些操作足以定义减法了:

a - b = a + -1 × b

好的,这很简单!但是我们要怎样将它变成可运行的代码呢。首先,我们面临一个决定,将减法操作符放在哪?将其像其它两个操作符一样处理,在现有的ArithC数据类型中添加一条规则?这种想法看上去很自然,也很诱人。

思考题

修改ArithC这种做法有什么不好的地方呢?

这会导致几个问题。首先,显然地,我们将需要修改所有处理ArithC的代码。就目前而言,还很简单,只涉及到了我们的解释器。但是如果在更为复杂的语言实现中,这会是个问题。其次,要添加的结构是可以用已实现的语法结构定义的,去修改已有数据结构的方式让人觉得代码不够模块化。最后一点,也是最微妙的一点,修改ArithC这种行为有概念上的错误。因为ArithC描述的是我们语言的核心部分。而减法(和其他类似添加特性)是用户交互的部分,属于表层语言。明智的做法是,将不同类型的概念放到不同的数据类型中,而不是把它们硬塞到一起。有时候这么做看上去有点笨拙,不过长远来看,它会让我们的程序更易于阅读易于维护。此外,你可能会将不同的功能扩展放在不同的层次上,这么做(将核心语法和表层语法区分开)正有利于这么做。

因此,我们尝试定义新的数据类型来反应我们的表层语言语法结构:

(define-type ArithS  ;表层算术
  [numS (n : number)]
  [plusS (l : ArithS) (r : ArithS)]
  [bminusS (l : ArithS) (r : ArithS)]
  [multS (l : ArithS) (r : ArithS)])

它看起来和ArithC基本相同,遵从了相似的递归结构,唯一的区别就是加了一个子句。

数据类型定了,接下来需要做两件事。第一是要修改语法解析器,让其返回ArithS类型数据(而不是ArithC类型)。第二是要实现去语法糖(desugar)函数,它需要能把ArithS值转换成ArithC值。

先来实现去语法糖函数简单的部分:

<desugar> ::=  ;去语法糖

    (define (desugar [as : ArithS]) : ArithC
      (type-case ArithS as
        [numS (n) (numC n)]
        [plusS (l r) (plusC (desugar l)
                            (desugar r))]
        [multS (l r) (multC (desugar l)
                            (desugar r))]
        <bminusS-case>))  ;二元减法子句

把数学描述转化为代码:

<bminusS-case> ::=  ;二元减法子句

    [bminusS (l r) (plusC (desugar l)
                          (multC (numC -1) (desugar r)))]

思考题

️常见错误是忘了递归地对lr进行desugar操作。忘了会发生什么?请自行尝试。

4.2 扩展:取负数操作

让我们来考虑另一种更有意思的扩展,取负数操作(unary negation)。这使得你需要对语法解析器进行一定修整,当读到-符号时,需要往前读以判断它是减法还是取负操作。但这不是有意思的部分!

取负数操作可以有几种去语法糖的方法。很自然的我们会想到:

-b = 0 - b

继续完成减法的去语法糖操作,我们得到:

-b = 0 + -1 × b

思考题

你觉得这两种中哪个更好呢?为什么?

大家可能希望使用第一种方式,因为它看起来更为简单。假设我们扩展了ArithS数据类型,添加取负数的表示法:

[uminusS (e : ArithS)]  ;一元减法表达式

对应去语法糖的实现也很直接:

[(uminusS (e) (desugar (bminusS (numS 0) e)))]

检查看看有没有类型错误。eArithS类型,所以它可以被当作参数传递给bminusS来进行去语法糖操作。所以这里要做的不是对e去语法糖,而是将其直接嵌入到生成的表达式中。在去语法糖的工具中,这种直接将某个输入项嵌入到另一个项中,然后递归调用去语法糖函数的做法很常见,被称之为宏(macro)。(在我们这个例子中,“宏”是umiunsS的定义。)

然而该定义存在两个问题:

1. 第一个问题是,该递归是生成的(generative),这需要我们得对其进行特别关注。【注释】我们可能会希望使用下面这种方式来重写它:

[uminusS (e) (bminusS (numS 0) (desugar e))]

它确实消除了生成性(generativity)。

如果你没听过生成递归,可以阅读《程序设计方法》(又译《如何设计程序》)一书第五部分。简单来说在生成递归中,子问题是输入的计算结果,而不是输入的子成分。我们这个例子还是很简单的,这里的“计算”就是bminusS构造函数。

思考题

很不幸的是,上面的转换有问题,试着找出问题吧。找不出的话,运行一下试试。

第二个问题是,它依赖于bminusS的意义;如果bminusS的意义发生变化,uminusS的意义也就发生了变化,即使我们并没打算改变uminusS的意义。作为对比,另一种更鲁棒的做法是,定义函数,其输入是两个项,输出是第一个项加上-1乘以第二个项的表示法,然后用该函数来定义uminusSbminusS

你可能会说,减法的意义不可能发生改变,这么做有啥意义呢?事情并不总是这样的。确实减法的意义不太可能改变;但是另一方面,它的实现可能会改变。例如,开发者决定为减法操作打印日志。采用前一种做法(宏展开),所有取负数操作就也会打出日志;而采用后一种做法就不会。

很幸运,这个例子我们还有更简单的选择:

-b = -1 × b

这种展开方式完全可行,而且还是结构递归。我们花这些篇幅讨论各种不同展开方式的原因是,告诉你各种选择和其带来的问题,毕竟现实中你不会总是那么幸运。

编辑于 2017-11-30

文章被以下专栏收录