《计算的本质》阅读笔记

2,080 阅读13分钟

文章里使用ruby来写,为了加深理解我用js写了一遍。代码地址在github

程序的含义

为了完整地定义编程语言,我们需要:语法,描述程序看起来是什么样子的;语义(semantics), 描述程序的含义。

语法

通过语言的语法规则,我们能把像y=x+1这样可能有效的程序与像ysdf234@$这样毫无 意义的字符串区分开。

而读程序需要语法解析器:这个分析器程序能够读取代表程序的字符串,根据语法规则检查它是否有效,然后把它转换成一个适合被进一步处理的结构化表示。

语法解析器读入像y=x+1这样的字符串,然后把它转换成抽象语法树(AST)。抽象语法树是源代码的一种表示,去掉了空格之类无关的细节,而只关心程序的分层结构。

语法关心的只是程序的表面是什么,而不是它的含义。程序有可能语法正确但没有任何实际意义。

操作语义

一个计算机语言的操作语义(operational semantic )描述一段合理的程序是怎样被理解为一系列计算机步骤的。这些步骤就是这个程序的意义。

小步语义

假象一台机器, 用这台机器直接按照这种语言的语法进行操作一小步一小步地对其进行反复规约,从而对一个程序求值。不管最后得到的结果含义是什么,我们每一步都能让程序更接近最终结果。

这种小步规约类似于对代数求值的方式。例如,为了对(1 x 2) + (3 x 4)求值,我们知道应该:

  1. 执行左侧的乘法(1 x 2),这样表达式就规约成了2 + (3 x 4);
  2. 执行右侧的乘法(3 x 4),这样表达式就规约成了2 + 12;
  3. 执行加法,最终得到14;

我们认为14就是结果,因为已经不能再进行规约了。

我们将探索一个玩具级编程语言的语义,姑且将这种语言叫做Simple。

规则将作用于表达式的抽象语法树,本章需要手动创建抽象语法树(当然,最终我们会通过一个语法解析器自动构建这些树,在2.6节中会介绍)

查看具体代码

文件说明

  1. 表达式(expression 文件夹下)

    包括 Add,Multiply,Number, Boolean, LessThan

  2. 语句(statement 文件夹下)

    它是一个表达式,用来求值生成另一个表达式;换句话说,一个语句能够通过求值改变抽象机器的状态。机器唯一的状态就是环境,因此我们允许 Simple 的语句生成一个新的环境以替换当前的环境。

包括 DoNothing,Assign,If,Sequence,While

小步规约操作语义的特征

总是把一个表达式转换成另一个表达式,这是小步规约操作语义应该遵守的规则

  1. 加法(乘法)表达式的规则:(一个常用的策略是从左到右的顺序对参数进行规约)

    • 如果加法左边的参数能够规约,就规约左边的参数
    • 如果加法左边的参数不能改规约了,但是可以规约右边的参数,那么就规约右边的参数
    • 如果两边都不能规约,他们应该就是数字了,就把他们加到一起
  2. 赋值的规约规则:

    • 如果赋值表达式能够规约,那么就对其规约,得到的结果是一个规约了的赋值语句和一个没有改变的环境
    • 如果赋值表达式不能规约,那么就更新环境把这个表达式与赋值的变量关联起来,得到的结果是一个 doNothing 语句和一个新的环境。
  3. 条件的规约规则:

    • 如果条件能够规约,那么就对其规约,得到的结果是一个规约了的语句和一个没有改变的环境
    • 如果条件是表达式 true 了,就规约成结果语句和一个没有变化的环境
    • 如果条件是表达式 false,就规约成替换语句和一个没有变化的环境
  4. 序列的规约规则:

    • 如果第一条语句是 DoNothing,就规约成第二条语句和原始的环境
    • 如果第一条语句不是 DoThing,就对其进行规约,得到的结果是一个新的序列(规约之后的第一条语句,后面跟着第二条语句)和一个规约了的环境
  5. 循环语句的规约规则: 是使用序列语句把 while 的一个级别展开,把它规约成一个只执行一次循环的 if 语句,然后再重复原始的 while。这意味这我们只需要一个规约规则:

    把:
    while( 条件 ) {
        语句主体
    }
    规约成:
    if( 条件 ) {
        语句主体;
        while( 条件 ) {
            语句主体
        }
    } else {
        DoNothing
    }
    

正确性

语句 «x = true; x = x + 1» 是一段语法有效的 Simple 代码,我们确实可以构建一个抽象语法树去表述它,但在试图对其规约的时候,它会奔溃,因为尝试在true上加1的时候抽象机器会停止。处理这个问题的一个方法就是在表达式能被规约的时候增加更多的约束,加入对求值失败可能性的考虑,这是求值过程又可能会终止,而不是总是要试图规约成一个值。(可以在reduce里加入类型判断,只有都为number时才可以相加)

最终我们需要一个比语法更强大的东西,它能看到未来并让我们避免执行过程中可能出现的崩溃。这一章是关于动态语义(dynamic semantic)的--程序执行时具体在做什么,但这并不是程序所拥有的唯一一种含义;在第9章将研究静态语义(static semantic),看看如何根据语言的动态语义来判断一个语法上有效的程序是否具有有用的含义

大步语义

大步语义的思想是,定义如何从一个表达式或者语句直接得到它的结果。这必然需要把程序的执行当成一个递归的而不是迭代的过程:大步语义说的是,为了一个更大的表达式求值,我们要对所有比它小的表达式求值,然后把结果结合起来得到最终答案。

小步语义提供了一种轻松的方式以监听计算的中间阶段,而大步语义只是返回一个结果,不会产生任何关于计算的证据。

查看具体代码

文件说明

  1. 表达式(expression 文件夹下)

    在小步语义中,我们不得不区分 1+2 和 3 这种不可规约的表达式。但在大步语义中,每个表达式都能求值。只是有些表达式求值会得到它们自身。

  2. 语句(statement 文件夹下)

    我们可以把大步语义的语句求值看成一个过程,这个过程总是把一个语句和一个初始环境转成一个最终的环境,这避免了小步语义不得不对reduce产生的中间语句进行处理的复杂性。

大步规约操作语义的特征

  1. 循环语句的规约规则:

    • 对条件求值,得到true或者false
    • 如果条件求值结果是true,就对语句主体求值得到一个新的环境,然后在那个新的环境下重复循环(也就是说对整个while语句再次求值),最后返回作为结果的环境
    • 如果条件求值结果是false,就返回未修改的环境

指称语义

指称语义(denotational semantic)转而关心从程序本来的语言到其它表示的转换。只是用一种语言替换另一种语言,而不是把语言转换成真实的行为。

形式化语义实践

最简单的计算机

确定性有限自动机

确定性有限自动机(Deterministic Finite Automaton, DFA)

结构图

处于状态1并且读入字符a时,切换到状态2,再读入字符a时,切换到状态1。处于状态2是一个接受状态。

也就是说一个DFA机器,它能读取一个字符序列,并且提供一个‘是/否’的输出,以证明这个序列是否已经被接受。

查看具体代码

代码说明

  1. FARule:

    每个规则都有一个 applies_to 的方法(这个方法会返回 true 或者 false,指示这个规则是否可以在某个特定情况下应用),还有一个 follow 方法(在决定采用某条规则之后返回关于机器应该如何改变的信息)

  2. DFARulebook:

    next_state 使用 FARule 里面的方法,定位到正确的规则,并找到 DFA 接下来的状态。

  3. DFA:

    追踪它的当前状态,并且报告它当前是否处于接受状态。读取字符并改变状态

  4. DFADesign

    一旦DFA获得了一些输入,它就可能不再处于其实状态了,因此我们不能再次使用它检查输入的一个新的完整序列。这意味这要从头创建它--像以前那样使用同样的起始状态、接受状态和规则手册--每当想要检查它是否接受一个新的字符串时。我们可以再一个对象里封装它的构造参数来避免手工执行这一操作。

非确定性有限自动机

非确定性有限自动机(Nondeterministic Finite Automaton, NFA)

结构图

对每一个输入序列不再只有一条执行路径。处于状态1并且读入b的时候,它可能会按照一条规则仍然保持在状态1,但也可能会按照另一条规则进入状态2。

一台DFA的下一状态总是完全由它的当前状态和输入决定,但是一台NFA在向下一个状态转移时会有多种可能性,而且有时候根本无法转移。

在确定性计算机上模拟一台NFA,关键时找到一种方法探索出这台机器所有可能的执行。这种暴力方法把所有可能全都摆出来,以此避免了只模拟一种可能执行时所需要的‘幽灵般’的预见性。

查看具体代码

代码说明

  1. NFARulebook:

    next_states 接受当前状态的一个数组和当前的字符,通过查找获取所有可能的规则,返回接下来所有可能状态的组合。

  2. DFA:

    追踪它的当前状态,并且报告它当前是否处于接受状态。读取字符并改变状态。与 DFA 不同的是,它有一个当前可能的状态集合 current-states 而不是只有一个当前的确定状态 current-state,因此如果 current-states 里有一个是接受状态,就说它是处于接受状态

  3. DFADesign

    自动创建新的 NFA 对象

自由移动(free move)

我们设计一台接受长度是2的倍数和字符'a'组成的字符串('aa', 'aaaa', ...)和3的倍数和字符'a'组成的字符串('aaa', 'aaaaaa', ...)的DFA。但是问题是这台机器还接受像'aaaaa'这样的字符串,因为它可以在回到状态1之后再转移到另一条选择上面。

所以我们需要引入一个自由移动的机器特性来解决这种问题。

自由移动

自由移动表示选择了一条路之后,就没法退回来了。

而代码如何模拟NFA中的自由移动呢?在FARule中定义null规则,然后在取得current-states时,通过读取null来获得自由移动之后的current-stats。

正则表达式

给定一个正则表达式和一个字符串,我们如何写程序决定这个字符串是否与那个表达式匹配呢?

有限自动机完全适合这个工作。就像我们即将看到的,把任何正则表达式转成一个等价的NFA是可能的--每一个与正则表达式匹配的字符串都能被这台NFA接受,反过来一样--把字符串输入给一台模拟的NFA看它是否能被接受,从而判断字符串能否与正则表达式匹配。

我们可以把这个看成是为正则表达式提供了一种指称语义: 我们不一定知道如何直接执行一个正则表达式,但是可以展示如何把它表示成一台NFA,并且因为有了NFA的操作语义,所以可以执行这个指称实现同样的结果。

查看具体代码

代码说明

  1. Empty

    这个 NFA 只接受空字符串

  2. Literal

    只接受包含那个字符的,单字符串的 NFA

  3. Concatenate(串联)

    对于‘ab’字符串,我们可以把两个NFA按顺序连接到一起,用自由移动把它们连结在一起,并且保留第二个NFA的接受状态

    因此,组合机器的原材料是:

    • 第一个NFA的起始状态;
    • 第二个NFA的接受状态;
    • 两台NFA的所有规则;
    • 一些额外的自由移动,可以把第一条NFA旧的接受状态与第二个NFA旧的起始状态连接起来;
  4. Choose

    增加一个新的起始状态并使用自由移动把它与两台原始机器之前的起始状态连接起来

    在这种情况下,组合机器的原材料是:

    • 一个新的起始状态;
    • 两台NFA的所有接受状态;
    • 两台NFA的所有规则;
    • 两个额外的自由移动,可以把新的起始状态与NFA旧的起始状态连接起来
  5. Repeat

    我们为a*构造一个NFA,其开头是一个a对应的NFA,然后做两个补充:

    • 从它的接受状态到开始状态增加一个自由移动,这样它就可以与多于一个'a'匹配了;
    • 增加一个可自由移动到旧的开始状态的新状态,并且使其作为接受状态,这样它就可以匹配空字符串了

    这次我们需要:

    • 一个新的起始状态,他也是一个接受状态;
    • 旧的NFA中所有的接受状态;
    • 旧的NFA中所有的规则;
    • 一些额外的自由移动,把旧NFA的每一个接受状态与旧的起始状态连接起来;
    • 另一些自由移动,把新的起始状态与旧的起始状态连接起来

等价性

DFA的模拟是从一个当前状态转移到另一个,而NFA的模拟是从一个当前可能状态的集合移动到另一个可能状态的集合。尽管一个NFA的规则手册可以是非确定性的,但是对于一个给定的输入从当前状态出发移动到哪些状态,这个决定总是完全确定性的。

这种确定性意味着我们总可以构造一台DFA来模拟一台特定的NFA。

查看具体代码

代码说明

  1. NFADesign

    修改to_nfa方法,增加一个可选的参数‘当前状态’,这样就可以用任意集合的当前状态构建一台NFA,而不是只能使用NFADesign的起始状态。

  2. NFARulebook

    增加alphabet方法,获取可以读入哪些字符的数组

  3. NFASimulation

    枚举各种输入字符下NFA的下一个状态的可能。