指称语义与小步语义
一段程序在形式上只是一个符号串,程序的语义是人对程序意义的理解。现在我们希望严格化地定义这种理解。
指称语义
下面要讨论的这种方式称为“指称语义”。它是定义程序语义的非常直接方式。指称语义基于抽象语法树,通过归纳定义出表达式的语义和程序语句的语义,描述程序运行的整体效果。
表达式的指称语义
首先定义表达式的指称语义,这里我们只考虑由常数、变量、四则运算构成的表达式。我们不考虑变量的范围,并认为变量只能取整数,这样任何时候一个具体表达式的语义应当都是一个整数。
表达式的语义只取决于所有变量的取值。我们可以定义一个称为“程序状态”的概念。程序状态\(s\)就是某一时刻所有变量的取值,它是一个从变量名到\(\Z\)的映射。综上,一个表达式的指称语义是一个从程序状态到整数的函数:\([\![ n]\!](s)=n\)(常量的指称语义恒为常量);\([\![ x]\!](s)=s(x)\)(变量的指称语义是当前程序状态上变量的值);\([\![ e_1+e_2]\!](s)=[\![ e_1]\!](s)+[\![ e_2]\!](s)\)(四则运算递归定义)。
表达式的化简其实就是表达式的等价变换,指称语义下的表达式等价就是两个表达式的指称语义在任何程序状态下都相等。
布尔表达式是一类特殊的表达式,它不同于上面定义的整数表达式,它只有真假两种取值。布尔表达式可以看作整数类型表达式和“与或非及其它二元运算符”以后的新表达式,定义方式并没有本质区别(可以为“与”、“或”的指称语义加上短路求值的情况)。
程序语句的指称语义
表达式是在某一程序状态下一个符号串映射的值,而程序语句在执行前后将可能改变程序状态。于是,在指称语义中程序语句的语义就被定义为执行前后程序状态\(s_1,s_2\)的所有可能的二元组。枚举所有可能的\(s_1\),在执行语句后得到\(s_2\),程序语句的语义就是所有这些二元组\((s_1,s_2)\)。空语句就是所有相同程序状态构成的二元组,赋值语句是所有把赋值变量修改成了特定值以后的二元组,顺序执行语句就是两个二元关系的复合,if语句是条件表达式的真假(相当于取子集,而子集也是一种特殊的二元关系)复合上对应的语句。while语句可以枚举循环的执行次数\(k\),对于每个确定的\(k\)它相当于一系列if语句与顺序执行语句,因此容易找到它对应的二元关系集合,之后再把\(k\)从\(1\)到正无穷并起来即可。
然而上面这种定义while语句语义的方式并不是最简洁的。我们观察到,我们一定可以写出以下关系:\([\![\text{while}(e)\text{ do } \{c\}]\!]\)\(=\text{test\_true}([\![e]\!])\circ [\![c]\!]\circ [\![\text{while}(e)\text{ do } \{c\}]\!]\)\(\cup \text{test\_false}([\![e]\!])\),于是while语句的语义一定是方程\(X=\text{test\_true}([\![e]\!])\circ [\![c]\!]\circ X\cup \text{test\_false}([\![e]\!])\)的解,也即while语句的语义是函数\(F(X)=\text{test\_true}([\![e]\!])\circ [\![c]\!]\circ X\cup \text{test\_false}([\![e]\!])\)的一个不动点(注意\(X\)是二元关系的集合)。但\(F\)的不动点不总是唯一的,对于while(true){skip}
这样极端的例子,任何二元关系都是不动点。而我们不能认为while(true){skip}
的指称语义包含了任何二元关系,因为执行这个语句前后程序状态一定是不会发生改变的(因为没有变量被修改了值)。也就是说,我们不能直接把while语句的指称语义定义为\(F(X)\)的所有不动点,而是应当定义为\(F(X)\)的最小不动点(这里序关系就是集合的包含关系)。现在我们要说明这样的最小不动点总是存在的。为此,我们要引入偏序集与Bourbaki-Witt不动点定理。
满足自反、传递、反对称性的集合\((A,\leq_A)\)称为偏序集。自反性是指\(\forall a\in A,a \leq_A a\);传递性是指\(\forall a,b,c\in A,a\leq_A b \and b \leq_A c \implies a \leq_A c\);反对称性是指\(\forall a,b\in A,a\leq_A b \and b\leq_A a \implies a=b\)。对于\(S \subseteq A\),如果对于任意的\(a,b\in S\),要么有\(a \leq_A b\)要么有\(b\leq_A a\),那么就称\(S\)是一条链(换言之链是\(A\)中两两可比较大小的一个子集)。空集是一条链。如果存在\(t\in A\),\(\forall a \in S\)成立\(a \leq_A t\),对每个使得\(\forall a \in S\)成立\(a \leq_A b\)的\(b\)成立\(b \leq_A t\),就称\(t\)是\(S\)的上确界,记为\(\text{lub}(S)\)。如果\(A\)中任意的链都有上确界,就称\((A,\leq_A)\)是完备偏序集。对于函数\(F:A \to A\),如果\(\forall a,b \in A,a \leq_A b \implies F(a)\leq_A F(b)\),就称\(F\)是单调函数。可见对于单调函数,\(F(S)\)也是一条链。对于完备偏序集,如果单调函数\(F\)满足对于任意非空的链\(F(\text{lub}(S))=\text{lub}(F(S))\),就称\(F\)是单调连续函数。完备偏序集有最小元\(\text{lub}(\varnothing)\),因为空集是一条链并且有上确界,而任何元素都是空集的上界,因此空集的上确界小于任何元素。记\(\bot=\text{lub}(\varnothing)\)。现在,对于完备偏序集上的单调连续函数\(F\),\(\{\bot,F(\bot),F(F(\bot)),\cdots\}\)是一条链,因此有上确界\(\text{lub}(\bot,F(\bot),F(F(\bot)),\cdots)\)。\(\{F(\bot),F(F(\bot)),F(F(F(\bot))),\cdots\}\)也是一条链,它和原来的链有相同的上确界。根据单调连续,马上得到\(F(\text{lub}(\bot,F(\bot),F(F(\bot)),\cdots))=\text{lub}(\bot,F(\bot),F(F(\bot)),\cdots)\)。我们找到了一个不动点!Bourbaki-Witt不动点定理指出,这一定是\(F\)的最小不动点。Pf:假如\(F(a)=a\),那么\(\bot \leq_A a\)成立,\(F(\bot)\leq_A F(a)=a\)成立……因此\(\text{lub}(\bot,F(\bot),F(F(\bot)),\cdots) \leq_A a\)。
我们可以证明\(F(X)=\text{test\_true}([\![e]\!])\circ [\![c]\!]\circ X\cup \text{test\_false}([\![e]\!])\)是一个单调连续函数,因此我们将while语句的指称语义定义为上述函数的集合论意义下的最小不动点。
语法拓展
在现实中,整数类型表达式是有范围限制的,例如\(2^{64}\)已经是一个很大的范围限制了。因此当表达式的值越界时,应当返回求值出错。一种直接的做法是,将表达式的值域定义为\(\Z_{2^{64}} \cup \{\epsilon\}\),其中\(\epsilon\)表示越界。在做表达式的四则运算时,也要先判断是否越界再做运算。另一种更实用的做法是把表达式的指称语义定义为程序状态与表达式的值的二元关系\((s,n)\),如果越界则为空集。如果二元运算中包括除法,那么也要在除数为0时返回求值出错。为此,表达式的指称语义要分normal和error两种情况,它们分别对应不同的二元关系集合。
由于表达式求值可能出错,因此程序语句的语义中也应当包含出错。同时,程序语句也可能出现while死循环的情况。因此语句的语义可以分为normal、error和inf三种情况,它们对应不同的程序状态二元关系集合。在递归定义程序语句的语义时,要考虑到多种情况。以顺序执行为例,\(c_1;c_2\)的normal情况应当是\(c_1,c_2\)都normal;\(c_1;c_2\)的error情况应当是\(c_1\)的error并上\(c_1\) normal且\(c_2\)出错……对于while语句而言,normal和error的情况依然定义为最小不动点,但inf的情况必须定义为最大不动点(为什么?)。为此,必须引入完备格和Knaster-Tarski不动点定理。
对于偏序集\((A,\leq_A)\),如果\(A\)的任意子集都有上确界,就称\((A,\leq_A)\)为一个完备格。完备格的任意子集一定有下确界,只需取出所有恒小于这个子集的元素构成的集合,取这个集合的上确界,由于完备格一定存在上确界,因此这个上确界就是原来子集的下确界。由此可见完备格上下是对称的。完备格上的单调函数\(F\)满足\(S=\{x\mid F(x)\leq_A x\}\)的下确界\(x_0\)满足\(F(x_0)=x_0\)(先证\(F(x_0)\leq_A x_0\),再证\(x_) \leq_A F(x_0)\),然后由反对称性)。显然,\(x_0\)是\(F\)的最小不动点。而由于完备格是上下对称的,因此也一定存在最大不动点。这就是Knaster-Tarski不动点定理。
由此,我们把while语句的inf情况定义为\(F(X)=\text{test\_true}([\![e]\!])\circ [\![c]\!].(\text{nrm})\circ X\cup [\![c]\!].(\text{inf})\)的最大不动点。
如果要在程序语言中加入取地址操作,那么我们要把变量的定义修改为地址和地址上的值的二元组,此时表达式要分为左值和右值两种情况。
如果要在程序语言中加入break、continue的控制流语句,那么需要在程序语句的指称语义中增加brk和cnt的情况,表示当前程序段以brk、cnt的方式终止。
如果要加上函数调用,那么表达式也有可能改变程序状态了,因此表达式的指称语义要修改为值与两个程序状态的三元组,并相应地增加函数调用的指称语义……
小步语义
小步语义是另一种定义程序语义的方式。 它类似于用解释器运行代码的机制,每次运行一个单步,整个程序就是多个单步的总和。在运行时,当前正在运行的语句或正在计算的表达式称为focused program,稍后运行的部分称为evaluation context。evaluation context分为三种,一种是当前focus的表达式是整个正在计算的表达式的一部分;一种当前focus的表达式是程序语句的一部分,例如if语句和while语句中的条件判断;还有一种是focus的程序语句是整体程序语句的一部分,例如程序语句的顺序执行。
于是一个单步就可以被定义为从一个三元组\((c,k,s)\)可以到达另一个三元组\((c',k',s')\),其中\(c\)是focused program,\(k\)是evaluation context,\(s\)是程序状态。因此,定义小步语义就是要定义对于所有可能的三元组\((c,k,s)\),它将以什么规则到达什么样的\((c',k',s')\)。
首先,对于变量\(x\)显然应当有\((x,\epsilon,s)\to (s(x),\epsilon,s)\)(暂时不考虑evaluation context)。对于表达式的二元运算(以加法为例),我们应当先计算左边再计算右边,为此我们在evaluation context里增加\(\text{KBinopL,KBinopR}\)。这样就能定义以下小步规则:\((e_1+e_2,\epsilon,s)\)\(\to (e_1,\text{KBinopL}(+,e_2),s)\),\((n_1,\text{KBinopL}(+,e_2),s)\)$\to $$(e_2,\text{KBinopR}(n_1,+),s)\(,\)(e_2,\text{KBinopR}(n_1,+),s)$$\to(n_1+n_2,\epsilon,s)$。在evaluation context不为空时,我们规定允许后续执行语句附加到所有成立的小步各自的evaluation context上。这样我们就完整定义了二元运算的小步语义。其它语句的定义是类似的,这里就不赘述了。
小步的复合形成多步,多步关系定义为小步的自反传递闭包,记为\((c,k,s)\to^* (c',k',s')\)。
指称语义与小步语义的等价关系
我们证明指称语义与小步语义是等价的,也即从表达式和程序语句的指称语义能够推出它们小步运行形成的多步关系上的定义,也能从小步语义推出表达式和程序语句在指称语义上的定义。
为了从表达式的指称语义推出小步语义, 我们对表达式的语法树做归纳。程序语句的情形也是类似的。
为了从小步语义推出指称语义,我们只需要证明,在某个程序语句按照小步运行的一个多步过程上,任何一个单步的前后状态按照指称语义执行完结果都相同。为此,我们需要定义evaluation context的指称语义,这样只需要依次复合focus和evaluation context就能够得到按照指称语义执行当前程序状态的结果。这里唯一需要新定义的是后续执行语句的指称语义,以\(\text{KBinopL}\)为例,\([\![\text{KBinopL}(+,e_2)]\!].(\text{nrm})=\{(s,n_1,n)\mid (s,n) \in [\![n_1+e_2]\!]\}\),其它的是类似的。