描述编程语言自句法始. 正如每个程序员所知, 语言的句法总以BNF (Backus-Naur Form) 语法的某个变种形式出现, 其枚举了符合语法的词汇和句子. 困难之处实际上在于刻画程序的意义, 即程序是如何进行计算的.
在这本书的第一部分, 我们建立了一个基于句法的语义描述方法. 我们从这样的观察开始, 即计算 (computation) 是对于算术 (calculation) 的一般化, 一个孩子所接受的算术训练从"加等于"这样的材料开始. 诀窍在于看出这种形式的算术也可应用到程序上来.
程序的算术意味着观察一个表达式或者语句的句法, 然后将其与另一个表达式或者语句联系起来, 通常假定是更简单的. 对于表达式而言, 前面的声明是容易理解的. 它等于, 即和关联起来. 即便是函数应用于参数值也可以这种方式表达 [译注: 虽然本身就是一个二元函数]:
用数学的语言来说, 我们是将编程语言的语义描述为其句法上的关系. 对于学过函数式编程语言的人而言, 这种声明并不令人意外. 我们知道函数式编程并不超出将七年级代数转换为编程语言太多, 而代数定律是将代数表达式互相联系起来的等式. [译注: 在某种意义上来说, 这句话是一种虚张声势.] 但令人惊讶的是, 这种方法具有描述(几乎)所有编程语言的语义的可能性, 即便是包含命令式副作用的语言.
现在我们来介绍这种想法, 从将句法定义为集合开始, 这是必要的数学元知识.
BNF语法可有多种用途. 一种含义是字符串的集合. 另一种解释是"树"的集合, 其常被称为抽象句法(树). 本书我们总是指后者.
对于本章和下一章而言, 我们使用下面的BNF语法作为例子:我们将其当作下列施加于抽象句法树集合上的约束的缩写:从技术上说, 是满足以上约束的最小集合. 为了构造这个集合, 我们先容纳基本元素和, 然后归纳地将其中的东西组合成复合元素.
记号: 我们有时用""表示"集合", 但有时""也代表"的任意一个元素". 从上下文来看, 含义总是明确的. 有时, 我们将下标或一撇附加在集合的名字上来集合的任意元素, 例如""或者"". 因此, 以上约束也可以写成
在有限的空间之中枚举出的所有元素显然是不可能的:然而, 给定某个树, 我们可以通过论证它满足约束来证明其属于. 例如, 就在中:通常, 这样的论证也可以被安排成所谓的证明树的形式:绝大多数时候, 证明树以没有标签的形式出现, 因为每一步通常都是显然的:
关系是序对的集合. 例如, 我们可以将关系定义成将的每个元素与自身匹配的关系:对于像这样的二元关系, 与其记, 不如写成:甚至可以更简单只要其被理解为的定义. 实际上, 关系是自反的, 对称的, 传递的, 即其满足以下三个约束条件:如果一个关系是自然的, 对称的, 传递的, 那么其就被称为一个等价关系 (equivalence). 关系的特定名字, 例如, 暗示了该关系是一个等价关系.
以下定义了一个关系, 它既不自反, 对称, 也不传递.在规约语义的上下文中, 这样的关系被称为规约的概念. 对于该定义的一个小小的修饰可以产生一个自反关系:另一种定义的方法在于扩展并显式约束新的关系为自反的:因此关系是的自反闭包. 我们还可以通过添加对称和传递约束来定义另一个关系:关系是的对称传递闭包, 并且它也是的自反对称传递闭包或者说等价闭包.
和的实际例子暗示了一个编程语言是如何通过句法以及句法上的关系定义的, 或者更确切地说, 作为一个抽象句法树上的集合以及该集合上的一个关系. 实际上, 或许机敏的读者可能开始怀疑是布尔表达式的语法, 其中代表, 代表, 代表"或"运算符. 关系将具有相同(布尔)值的表达式等同起来.
的确, 使用上面的约束, 我们可以表明, 正如:然而, 这并不能直接得出和布尔"或"表现得完全一致. 如果我们希望建立这种联系, 那么我们不得不证明关于的一般声明, 例如对于任意的表达式有.
换言之, 编程语言的语义和我们想要知道的这个语义的性质之间存在一般性的gap. 出于许多目的, 语义的性质和其将表达式或程序联系至的值一样重要. 例如, 如果的确满足"或"的法则, 那么一个编译器或许可以安全地将优化为. 类似地, 如果编程语言的语义可以保证数字不会被加到任何非数字的东西上去, 那么语义的实现就无需检查加法运算的参数以保证它们都是数字.
关系让人想到小学里从算术和代数中学到的. 正如在这种情况下我们教授学生出于各种目的都使用这样的等式推理, 我们可以使用关系来证明特定表达式的等价性. 尽管如此, 在一般情况下, 这个关系并没有指明该如何从一个任意的得到或——这是我们在构建一个语义的解释器时所真正需要的东西.
在这种意义下, 关系实际上比更有用. 在的定义的两种情况下, 一个表达式都与另一个更小的表达式联系起来. 而且, 对于任何表达式, 要么是或, 要么将与至多一个另外的表达式联系起来. 因此, 我们可以将想成是一个单步规约, 与解释器采取单一的求值步骤迈向最终的值对应起来.
使用, 我们可以定义其自反传递闭包:这产生了一个多步规约关系. 特别地, 多步关系将一个表达式映射至许多其他的表达式, 但是和之中最多只能有一个.
关系和被有意设计为非对称的, 这强调了求值具有特定的方向性. 例如, 给定表达式, 我们可以表明存在一个从它到的规约序列:左列的空白隐式地被前一行的右列的表达式所填充. 于是, 每一行都是对于的论证中的一步.
表达式该怎样规约呢? 根据或者, 它压根就不能规约. 从直觉上来说, 应该被规约为, 即根据来简化第一个子表达式. 然而, 的定义中没有能够匹配作为源表达式的规则. 也就是说, 我们只能规约具有形式或者的表达式. 尽管最外层的的右侧表达式可以是任意的, 但左侧的表达式必须是或者才行 [译注: 即才能规约].
如果我们希望将这样的表达式规约为答案, 那么我们必须扩展关系为另一个能够支持子表达式规约的关系.关系是的兼容闭包. 类似于, 是一个单步规约关系, 但是允许对于表达式的任意子表达式进行规约. 可规约表达式 (reducible expression) 被称为redex, 而一个redex周围的文本被称为其上下文.
特别地, 关系包含. 我们可以用以下证明树来说明这个包含:继续使用, 我们可以将规约至:最后, 如果我们将定义为的自反传递闭包, 那么我们就得到了. 因此, 是由生成的自然规约关系.
一般来说, 仅仅关系的自反闭包, 等价闭包, 以及自反传递闭包不是很有趣. 我们往往最感兴趣的是兼容闭包及其自反传递闭包. 这两种关系与表达式求值和解释的典型概念相对应. 而且, 的等价闭包也是有趣的, 因为其将产生相同结果的表达式联系起来.
使我们接近了求值的有用概念, 但是我们还没有抵达那里. 尽管, 但是我们也有和. 然而, 对于一个求值器而言, 我们仅关心一个是否能求值到一个结果以及这个结果是还是. 其他一切都是无关紧要的.
我们使用两个定义来形式化地陈述这个想法. 首先, 我们需要刻画我们所认为的"程序"的结果是什么. 显然, 是的一个子集, 因为结果也是我们的"编程语言"的表达式.
我们的第二个定义将求值刻画为关系, 其将每个表达式映射至一个结果.这里我们使用了另外的记号来定义关系, 这暗示了其为函数, 即将每个元素映射至最多一个元素的关系. [译注: 这是所谓的partial function.] 我们使用函数记号的原因在于, 若作为求值器要make sense的话, 那么它就必然是一个函数 (至少对于确定性的编程语言而言).
这个关系的名字既有下标也有上标. 自然地, 前者只是在说这个函数基于关系, 而后者是在告诉我们求值的定义基于关系. 我们用这两者来装饰函数的名字, 因为存在许多其他变种定义. 例如, 以下定义使用基于的等价关系而不是自然规约关系.等价关系当然是的兼容, 自反, 传递, 对称闭包. [译注: 更确切地说, 是兼容闭包的等价闭包, 因为这两个运算并不可以交换.] 通过来定义一个求值函数表明了程序的计算真的就只是一般化了来源于代数的运算概念.
以下表格总结了我们到目前为止引入的概念和记号.[译注: 如果仅是对称闭包, 一般情况下并不保持传递性, 所以需要对称传递闭包.] 最终, 我们希望得到, 即一般的求值关系.
一旦我们有了一个编程语言的句法和语义, 我们可以提出问题, 进行实验, 以及考虑变体. 在本部分中, 我们将看看编程语言理论家会提出的最基本的问题, 并研究如何回答它们. 在本书的第二部分, 我们将引入对于句法和语义进行实验的工具, 通常其将有助于提出猜想和问题.
现在我们使用第一章引入的句法和语义以刻画我们可以提出什么样的问题以及如何严格地回答这些问题. 第一节展现了如何用数学术语提出关于语言的问题. 第二节用数学定理和证明刻画了回答, 引入了对于本书的剩余部分而言关键的证明技术.
第一章定义了数个求值器, 包括. 从编程语言实现者的角度来看, 这个函数使用了某种类似于机器的东西, 这个机器的初状态, 中间状态和终状态是表达式, 而指令是关系. 它启动程序, 等待直至机器到达一个终状态 (或), 然后报告这个最终结果.
一个显然的问题是, 这个求值器是否对于某个固定的程序总是产生恰好一个结果. [译注: 从上下文看, 这里更确切的说法是产生至多一个结果, 因为产生恰好一个结果是更强的条件, 是说这个关系是一个total function.] 用数学术语来说, 我们是在问这个求值器是否是一个函数. 如果的确如此, 那么若我们观察到对于相同的程序产生了两个不同的结果, 那么就知道这个求值器的实现是错的.
现在回忆一下, 关系和函数都是序对的集合. 每个序对将一个输入和一个输出结合起来. 关系和函数的不同之处在于, 后者对于任何输入最多只包含一个相关的序对. 因此, 我们的第一个问题是在问以下声明是否成立:
对于所有的, 且可以推出.用函数记号的话, 那就变成了
对于所有的, 且可以推出.[译注: 读者应该将这种函数记号当成关系记号的等价物, 而不是函数真的一定产生了一个值, 因为我们尚不知道这个关系对于每个输入是否都存在至少一个输出. 或者, 读者可以将解释为对于有定义且是一个相应的输出. 另外, 之前的到这里变成了, 译者感到行文有点不太连贯, 虽然这并不影响后面的呈现的正确性.]
第1章不止定义了一个求值器, 实际上定义了两个. 从理想来说, 这两个定义应该引入的是相同的函数. 也就是说, 这两个求值器对于相同的程序应该产生同样的结果. 证明这样一个声明将允许我们根据需要交换地使用这两个定义. 例如, 当与一个数学老师争辩时, 我们可以使用来刻画程序执行泛化了七年级代数. 而当与负责编程语言实现的软件工程师讨论的语义时, 或许我们应该使用.
因此, 我们的第二个问题关心的是等式求值器和有向求值器之间的关系. 更确切地说, 我们想要知道它们是否是相同的函数:若将函数视为序对的集合, 那么问题就变成了这两个集合是否包含相同的元素:
对于所有的和, 当且仅当这个陈述一个大致上的翻译为
对于所有的和, 当且仅当[译注: 同样地, 我们应该将这种记号当作关系记号的等价形式.] 这个翻译的意思是说如果对于有定义 [译注: 有定义即存在以为输入的相应序对] 而输出就是 [译注: 第一个问题解决了的话, 那么就是唯一的], 那么对于也有定义, 并且输出也是, 反方向也该这么解释. [译注: 如果这个问题解决了的话, 那么是(部分)函数就可以推出也是函数.]
自然地, "有定义"这个表达暗示了另外一个问题, 即是否求值器对于所有可能的输入都产生一个输出. 这个陈述有一个直接的数学表达, 即
对于所有的, 存在一个满足注意到, 这里我们丢掉了上标, 因为使用哪一个是无关紧要的.
既然我们已经理解了关于编程语言的句法和语义我们所能提问的最简单问题, 那么其回答显然应该是关于相应数学模型的定理. 我们的第一个定理是在陈述是一个(部分)函数.
从证明的定理开始, 我们遵循历史路线, 首先建立了等式演算的一致性. 一旦我们证明了这两个定义描述了相同的关系, 那么我们就知道也是一个函数. 同样根据传统, 在显然的时候我们去掉了定理中的量词前缀 (对于所有, 存在), 并且我们使用函数式记号来表达结果存在且是一个特定的值.
前述证明的最后论证要求我们研究对于而言的的证明 (或者说证明树) 的形状. 既然是单步规约关系的自反, 对称, 传递闭包, 证明的计算包含有一系列两个方向上的这样的单步规约:[译注: 原谅我将本来二维的图片用这样的单行的数学符号表达, 但是图片的意图应该清晰地传达了.] 在这张图片里, 每个表达式而每个箭头都表示序列中的相邻项之间的一个关系. 形式地, 与相对应.
来源于这张图片的关键性的洞察在于或许可以重塑这个计算, 使得所有的规约步骤都是从到某个以及从到相同的. 换言之, 如果, 那么存在一个表达式满足且:如果我们能够证明对于两个等价的项总是存在这样一个, 那么一致性的证明就结束了.
根据前面这样的推理, 我们将的一致性证明转换为了关于证明树的形状的声明, 即建立之后我们能够重塑它的证明树. 这个关键性的洞察应该归功于Church和Rosser, 它们运用这个想法分析了被称为演算的语言的一致性 (这是下一章的主题). 据此, 这样的引理以他们的名字命名.
又一次, 我们基于 (modulo) 另外一个关于规约系统的声明的证明完成了证明. 更确切地说, 我们假定如果遇到了左边的情况, 那么可以找到一个项使得我们能够构造右边的情况:这个性质被称为菱形性质, 因为这张图片需要"规约分支"能够被补全为菱形的形状. 当规约概念 (例如) 的兼容闭包的自反传递闭包满足这样的性质时, 其被称为Church-Rosser的.
既然由一系列证明步骤构成, 那么检查后者是否也满足菱形性质是很自然的事情. 如果的确如此, 那么我们可以将的小菱形组合起来获得的大菱形. 尽管菱形性质对于并不那么成立, 一个足够强的性质的确可以满足.
演算的句法提供了一种简单而规整的方法来待应用的函数, 抑或是作为其他函数的输入和输出的函数. 在演算之中, 对于这种函数的刻画专注于从参数到结果的规则, 而忽略了对于函数的命名, 以及其定义域和陪域. 例如, 一位数学家会将某个集合上的恒等函数写成是而若用演算的句法的话, 则为
在1950年代晚期和1960年代早期, 人们发现编程语言和演算的诸多方面之间存在联系. 这里的动机是多样的, 有的只是想要刻画Algol 60的语义, 而有的是试图通过已经得到良好理解的数学系统来理解整个编程语言领域的面貌. 实际上, 人们希望学会如何系统设计编程语言.
Landin是这群研究者之中的新星. 他设计了一种被称为ISWIM的基于演算的语言, 并探索了其通过抽象或者说虚拟机器的应用和实现. 不严格地说, ISWIM几乎是一个函数式编程语言, Scheme编程语言是其最近的亲戚. [译注: 当然, 有的人可能认为Standard ML之类的语言比Scheme更加接近于ISWIM. 另外, ISWIM其实是一类语言, 而不是一个语言.]
虽然人们欣赏ISWIM的表达力并将其当作灵感的源泉, 人们也意识到ISWIM和演算之间的关系并不那么直接. Plotkin最终设计了演算的一个变种, 其与ISWIM有着直接而自然的对应关系. 他也表明了, 这种对应是一般性的原则. 他将这原则具体化为了ISWIM的两个变种: call-by-name和call-by-value.
这一章和接下来的一章里我们呈现了ISWIM, 其演算, 以及ISWIM和演算之间的关系. 具体来说, 本章呈现了ISWIM的句法和ISWIM的"算术"系统. 这包含一个求值器的定义以及对于这个求值器所定义的最广泛等式系统的探索. 接下来的一章展现了演算的一个元定理是如何自然地定义了ISWIM的一个高层次的抽象机器的.
ISWIM是一族编程语言, 根据字面常量和原始运算符而有所不同. ISWIM族的每一语言都扩展了演算的句法以基本常量的集合和一族多元函数.
这里的里的不是确定的, 其代表了原始运算符的参数数目 (元数), 并且我们额外限制这个应用的实际参数的数目必须等于相应的原始运算符的元数.
为了明确起见, 我们使用其中是表示数字的numeral.
和可以自然地扩展至新的句法上来.
受到Algol 60的call-by-value参数传递机制的启发, Landin以类似的方式设计了ISWIM的函数. 也就是说, 在ISWIM之中, 一个函数应用在函数取得控制之前对于其实际参数求值. 这么做可以消除在函数体中对于一个参数多次求值或者追踪一个参数是否已经被求值过了的开销. 这也排除了纯粹
因为ISWIM的函数只接受被求值了的参数, 那么我们必须首先规定何谓值. 从直觉上说, 值是对其求值可以立即知道结果的项. [译注: 一般我们在操作语义之中采用这种观点, 在其他场合, 值还有另外的含义.] 或者说, 值是任意的表达式在规约过程之中的目的地. 我们定义值的集合如下:
应用不可能是一个值, 因为它就像刻画计算的发动机一样. 变量也是值, 不过这值得额外的解释.
基于对于值的刻画, 现在我们可以定义ISWIM的基本规约概念.
限制参数必须是一个值迫使参数表达式的规约在对于函数应用求值之前进行. 例如,
除了函数应用, 计算ISWIM的系统还必须考虑原始运算. 既然ISWIM是随着常量集和原始运算符集的改变而变化的一族语言, 那么对于这些集合我们需要一个足够一般的规约概念, 因而我们引入了一个部分
对于我们之前约定好了的常量和运算符, 我们选择了以下具体的
ISWIM求值器的定义是优雅的, 但是难于实现. 为了确定一个程序的结果, 程序员可以自由地应用等式系统的规则以任意的顺序, 以任意的方向, 在程序的任意位置. 然而, 这种灵活性并非将求值器实现为(元)程序的良好基础. 根据一致性定理,
依照所处的上下文, 求值器的效率可能并不重要. 例如, 如果
前一章的句法机器的每个求值循环中都需要执行三个步骤. 首先, 其需要判断程序是否是一个值; 如果是的话, 那么机器就停止了. 其次, 或者说程序并非一个值, 那么机器会将程序分成一个求值上下文
这个机器显见的低效之处在于反复将程序分成一个求值上下文和一个可规约表达式.