【Coq学习】Formal Reasoning About Programs 阅读笔记第三章第四章

第三章 数据抽象 Data Abstraction

由于书中所有的形式化证明都在相关的Coq代码里面,而Coq代码本身就是一种数据抽象的语义思想。因此,在继续讨论程序的语义和证明那些内容之前,本章先讨论数据抽象和封装相关的内容,也就是一种仅向用户暴露接口而把具体的实现细节隐藏起来的机制

补充抽象和封装:

抽象,顾名思义,重点在“抽”。抽出本质(或自己关心的)成份或特征,这些成份和特征“象”(能表示)原来的事物。在程序设计里, 抽象的目的是为了能够以“数据结构”(特征)和“函数”(行为)来描述原来的事物。比如,一个“鸟”类就可以看成是众多实际鸟中所抽出的特征和行为。
封装,顾名思义,重点在“封”。把原来的事物“装”在这个“封“里。使你只能感受“封”之外的特征和行为。
两个概念看起来都是“简化”了原来事物的特征和行为,但本质上的意图不同。抽象的目的,是为了能有效“表示”原来的事物,强调“ 抽”了以后还能很好的表示出所关心的原事物的特性。封装的目的,是为了使用和理解的“简单”。
抽象前和抽象后,是两个事物,只是抽象后的能代表抽象前的。
封装后的事物,包含了封装前的事物,只是 使用的时候,不用再关心封装在里面的原来的很多细节了。

3.1 抽象数据类型的代数接口 Algebraic Interfaces for Abstract Data Types

我们考虑简单队列这个经典的数据结构,也就是先进先出的线性表。在高效的队列实现中是有一些复杂性的。但是使用队列的客户端代码不需要知道这种复杂性。因此我们应该隐藏实现细节,将“队列”表述为抽象数据类型,也就是一组类型以及操作(a set of types and operations)

补充:

抽象数据类型( ADT,Abstract Data Type)是指一个数学模型以及定义在此数学模型上的一组操作。它通常是对数据的某种抽象,定义了数据的取值范围及其结构形式,以及对数据操作的集合。
https://baike.baidu.com/item/ADT/8945833

类型t(α)代表装了类型α的数据值的队列,通过把本身就包含了所有正常的数据类型的Set赋值给它来定义它是一个type。任意的类型α都存在空的队列以及入队和出队操作。入队操作的输入是一个队列t(α)和一个需要入队的类型为α的数据值,输出是一个队列t(α)。出队操作的输入是一个队列t(α),输出是一个队列t(α)和一个出队了的类型为α的数据值。出队操作的半个箭头表示函数的偏射,因为如果是一个空的队列的话,我们没法进行出队操作。这里规定了一个记法:对于偏函数f: 它是从集合A到集合B的偏映射,我们用f(x)=.来表示一个缺少的从A到B的映射关系。

由于我们之后要进行形式化的正确性证明,因此在定义了以上的抽象数据类型之后,我们还需要用specifications规范来丰富数据类型。一个规范的风格algebraic代数的:它是写出一组定律和使用数据类型的操作的等式。也就是把对代码实现的要求写成量化的等式。对于刚刚定义的队列,有如下两条合理的定律:

一个是出队操作对于空的队列是没有映射关系的;另一个是对于任意的队列q,如果dequeue(q)=.,那么q是一个空队列。任何代码实现都必须满足这两条定律,但在内部细节实现上是自由的。这个规约也可以用更易读的推理规则的符号来表示:

我们还可以使用分段函数的记法来进行完整的描述:

补充:
分段函数, 就是对于自变量x的不同的取值范围有不同的解析式的函数。例如,考虑绝对值函数的分段定义:

对应的Coq代码:

接下来考虑功能的实现。首先是最常见的实现方法是从列表头部入队,从列表尾部出队

字面量( Literals): 由语法表达式定义的 常量;或通过由一定字词组成的语词表达式定义的 常量。

对应的Coq代码:

还有一种实现方法是从列表尾部入队,从列表头部出队

对应的Coq代码:

递归算法的时间复杂度本质上是要看: 递归的次数 * 每次递归中的操作次数

(如果假设拼接操作花费的时间和第一个参数的长度成线性关系,那么以上这两种实现方法的最坏情况都是平方的时间复杂度。)还有其他的实现方法能降低复杂度为平摊常量的时间,但是需要扩展一下代数的规范风格,也就是接下来3.2节的内容。这里的平摊常量时间是指运行整个操作序列是线性时间,平摊到每一次操作上就是常量时间。

线性时间或Ο(n)时间的算法,表示此算法解题所需时间与输入资料的大小成正比

平摊常量时间
比如说,如果你要做一百万次操作,你不会在乎操作的最坏情况或最好情况,你在乎的是当你重复一百万次操作时总共需要多少时间。
因此,只要“偶尔一次”的最坏情况足够罕见,它的缓慢程度就可以被淡化。因此,平摊常量时间是指“如果你做了很多操作,每次操作所花费的平均时间”。
https://blog.csdn.net/weixin_30732825/article/details/94792775

3.2 具有自定义等价关系的代数接口 Algebraic Interfaces with Custom Equivalence Relations

首先我们在队列的抽象定义里面扩展一个新的操作P,它用≈符号表示在相同类型α的队列上的二元关系

二元关系将一个集合的元素(称为域)与另一个集合(称为共域)的元素相关联。集合X和Y上的 二元关系是一组新的有序对(X,Y),由X中的元素X和Y中的元素Y组成

接下来通过添加下面的3条定律,让≈表示等价关系。这3条定理分别是自反性、对称性和传递性。

然后我们就可以用这个新定义的≈符号去改写最开始的描述规约的定律。

对应的Coq代码:

这样改写的好处是它通过了完整性检查,把≈符号实例化为简单的相等符号就能确保和上一节中的两个队列的实现是一致的。

对应的Coq代码:

接下来我们就考虑另一种实现,经典的用两个栈实现队列

基本思想是把其中一个栈s1作为存储空间,入队时就把元素压入这个栈;而另一个栈s2作为临时缓冲区,需要出队的时候先将s1的元素全部倒入s2,再弹出s2的顶元素就是需要出队的元素,最后再主动将s2的元素逐个“倒回”s1。但是这样需要反复的倒入倒出,尤其是遇到连续的出队操作的时候,效率很低。
效率更高的思路是s2不主动向s1倒回元素:
入队时,直接将元素压入s1。
出队时,先判断s2是否为空,如不为空,则直接弹出顶元素;如为空,则将s1的元素逐个“倒入”s2,把最后一个元素弹出。
如果2个栈都为空的时候,出队操作会引起异常。
这个思路就避免了反复“倒”栈,仅在需要时才“倒”一次。
https://www.cnblogs.com/wanghui9072229/archive/2011/11/22/2259391.html

书里面就是用的效率更高的版本:

这里我们用了两个列表l1和l2来编码一个队列,可以用拼接操作来顺序连接l1和reverse l2来表示整个队列,那么如果用先前定义的等价关系来表示,q1≈q2 也就表示rep(q1)=rep(q2).

这是用两个栈实现队列的逻辑,但用户使用的时候是不知道细节的,只把它当作一个简单的队列来使用。

对应的Coq代码:

这一节为什么要介绍自定义等价关系的内容,作者举了一个例子,判断下面的两个队列的第一个列表是否相等:

分析它的底层双栈实现逻辑可以知道它们是不相等的。很多时候像这样的情况,同一个逻辑值可能有多种不同的物理表示,而等价关系就是让我们明确的指出哪些物理表示是等价的。

3.3 表示函数 Representation Functions

它是另一种规范的风格。我们可以强制每个队列包含一个函数来转换为标准的规范表示。看下面队列的抽象定义,最后一个rep函数把一个队列转换成一个list类型,也就是它提供了一个数据类型的参考实现

同样的,相应的公理也可以用新的函数改写:

对应的Coq代码:

3.4 抽象数据类型的固定参数类型 Fixing Parameter Types for Abstract Data Types

上面讨论的都是队列,接下来我们看另一种经典的抽象数据类型:finite sets有限的集合。下面是一个通用的有限集合的接口定义,:

接下来是遵循代数的规范风格(等式)的几条定律,用于描述这个有限集合的行为:

对应的Coq代码:

一个通用的实现方法是用未排序的列表。

对应的Coq代码:

对于特定的元素类型或者使用模式,我们可以建立特定类型的有限集合。比如考虑自然数的集合,大部分都包含了连续的数字。在这种情况下,我们只需存储集合中最小和最大的元素就行,因为中间都是连续的数字。这里定义了一些回滚的操作,当遇到集合里面的数字不能组成一个连续的区间的时候,就需要用相应的回滚操作把集合的类型转换为上面那个通用的、没有限定类型的实现里面定义的那样。所以这里的t0, empty0, add0和member0都表示上面那个list版本的操作。

fromRange操作是把一个范围内的数字转换为上一个list版本的集合类型。

Adhoc函数接收list版本的集合类型的参数,返回当前版本的类型。

对于集合的元素都是连续的数字的情况,这种实现的速度就比前一个通用的list版本快很多,降低了时间复杂度。

对应的Coq代码:

以上就是第三章,关于数据抽象的全部内容。

第四章 通过解释器实现语义 Semantics via Interpreters

前面三章都是讲句法相关的内容,也就是规定程序长什么样子是合法的。接下来我们开始关注程序的含义,也就是语义相关的内容。

4.1 通过有限映射来解析算术表达式的语义 Semantics for Arithmetic Expressions via Finite Maps

要解释第二章提到的算术表达式的语义,我们需要一种方法来指示每个变量的值finite maps有限映射理论在这里很有帮助。下面是一些关于映射的符号记法:

  • 圆点表示一个空的映射,它的域是空集。

  • m(k)表示在映射m里面键k所对应的映射关系。

  • m[k|--->v] 表示在映射m里扩展一个从键k映射到值v的映射关系。

有限映射是 有着 有限域的函数,其中域可以通过每个扩展操作进行扩展。

下面两个公理解释了基本运算符的相互作用:

有了这些运算符,我们就可以为算术表达式编写语义。我们考虑算术表达式的自然含义就是它计算的数字,而变量估值本身就是从变量名到数字的一个有限映射。那么我们就用变量估值来表达语义。下面是一个将变量估值映射到数字的递归函数。我们把一个算术表达式e的含义写成[| e |];这种表示法通常被称为牛津括号。本书把这种符号作为任意函数的语法糖,即使在给出定义这些函数的方程式时也用这个记法。我们把估值这个有限映射写为 v

这里注意加号在牛津括号里面表示的是syntax,句法,在括号外面才表示数学里的加法含义。

对应的Coq代码:

为了测试我们的语义,接下来定义一个变量替换的递归函数。[e'/x]e这个记法代表遍历表达式e,把每一个变量x都替换为e'后的结果。

对应的Coq代码:

对于刚刚定义的这两个递归函数,我们有以下这个定理:对所有的算术表达式e,e',变量x以及估值映射v,先对e进行变量替换后再进行估值映射 等价于 先在估值映射里面新增一条从变量x到e'对应的映射值的映射关系后 再进行估值映射。(相当于改了x的映射值)

这个定理的证明思路是对表达式e进行结构归纳证明。

如果e为常量n,那么参考替换函数的定义进行化简,左边替换部分的结果就是n,对n进行解释,也就是参考估值映射函数的定义进行化简,左边的最终结果就是n。右边先扩展x到e'对应值的映射关系,再对e进行解释,由于e为常量n,对应估值映射的第一种情况,所以化简结果也是n。左右相等,定理得证。

e的其他情况同理。

对于定理描述的情况,下面是一个交换图的表示法,也就是从起始状态出发,可以经过不同的操作路径到达相同的结果状态。

不管是用定理陈述还是交换图,这两种方法都可以描述相应的性质。

4.2 A Stack Machine

A stack machine在某种程度上类似于Forth编程语言,它的一切操作都是围绕着栈进行的,或类似于各种后缀表达式的计算器。

在i的上方标一个短横表示i组成的序列。这里定义程序programs的语法就是单条的指令或者一连串的指令instructions。这个语言的每条指令都是对栈进行操作,也就是后进先出的数字列表。下面是对这个语言的解释器interpreter的定义:这里依然重载了和上面相同的牛津括号,根据上下文context可以区分我们这里是在处理和之前不同的语言。这里s表示栈,v同样表示估值的有限映射,n横三角s表示将数字n push到栈顶。

最后的两个指令要求栈s至少存在两个元素。对于栈太短不足以执行后两个指令的情况,代码默认返回原始栈s。

上面是单条指令的interpreter,对于一连串的指令,同样也是重载牛津括号来表示一连串不同指令的interpretation的组成。

接下来,我们给出第一个可以称为编译器的例子,或者从一种语言到另一种语言的翻译。通过原始语言到目标语言的这个转换,我们就更接近硬件的实现了。

编译器是一种电脑程序,它会把 用某种编程语言 写成的源代码(原始语言),转换成另一种编程语言(目标语言)。 它主要的目的是 将 便于人编写,阅读,维护的高级计算机语言所写的源代码程序,翻译为计算机能解读、运行的低阶机器语言的程序,也就是可执行文件。

我们把算术表达式编译成堆栈程序,这样后面就很容易映射到通用的汇编语言的指令上了。我们使用类似向下取整的符号来表示编译,表明我们向下移动到了较低的抽象级别。然后另一个符号表示两条指令序列的拼接:

后两个二元运算符的情况:每个操作数首先按顺序进行计算,然后把各自的最终计算结果留在栈里面。等两个操作数e1和e2都计算完毕之后,再运行命令弹出它们,进行相应的加法或乘法操作,最后将结果push回栈顶。

接下来我们需要证明编译的正确性。

从这里开始,我们默认定理陈述里面的所有未知变量都是由全称量词量化的,也就是定理对变量的所有取值都保持成立。

编译正确性的证明需要用到一个引理:对于任意的算术表达式e、估值映射v、一连串的运行指令i'以及栈s,先对e进行编译后再依次运行所有指令,等价于,先对e进行估值映射入栈后再运行其他一系列指令is。

有了上面这个引理,我们就能证明以下定理4.2:对于任意的算术表达式e和估值映射v,先对e进行编译得到一连串的指令后再运行这些指令得到结果存入栈,等价于,对e进行解释(估值映射)得到对应值存入栈。

定理4.2的交换图:

4.3 一个简单的高级命令式语言 A Simple Higher-Level Imperative Language

Coq要求所有程序都能终止。接下来,我们将介绍一种带有 有界循环的简单命令式语言。还是考虑算术表达式的例子,下面是它的定义:

这里命令读写的隐式状态也包含了变量估值的映射,就像之前在表达式interpreter解释器中使用的那样。这里skip命令什么也不做,而x<---e扩展估值映射,把x映射到表达式e的值。c1;c2是简单的命令序列,最后一条命令是有界循环repeat e do c done,它执行命令c的次数等于e的值。

为了描述语义,接下来定义了一些符号:

id表示恒等函数,也就是id(x)=x,输入等于输出。

恒等函数为函数f(x) = x,输入等于输出。

f。g表示函数的复合,也就是g(x)的值作为函数f的定义域。

还有迭代的自复合函数,写成函数的幂的样子,定义如下:

这里依然重载了牛津括号,它的功能是作为变量估值的transformer,作用在一个估值映射v上,返回类型也是估值映射。

接下来用循环展开对语义做一个简单的优化。当循环的迭代计数为常数n时,我们可以用n个循环体的副本序列来代替循环。

循环展开,英文(Loop unwinding或loop unrolling),是 一种牺牲程序的尺寸来加快程序的执行速度的优化方法。 可以由程序员完成,也可由编译器自动优化完成。 循环展开最常用来降低循环开销,为具有多个功能单元的处理器提供指令级并行。 也有利于指令流水线的调度。

为了定义这个优化,我们需要一个递归函数和一个命令c的n个副本序列,记作nc。

我们用|...|表示优化,它不改变程序的抽象级别。

当多个定义方程应用于某个函数输入时,一般应用最早匹配到的方程。

接下来我们证明这个优化是保留了程序的语义的,这个证明需要一个辅助的引理Lemma 4.5:先把命令c表示为n个重复的命令序列后再依次执行,等价于重复执行n次命令c。

下面是引理等价的转换图表示法:

还需要另一个引理:对于两个等价的函数,迭代函数返回相同的结果。

接下来我们证明这个优化是保留了语义的,也就是对命令c进行循环展开的优化后的语义和不做优化的语义是相等的。对c进行归纳证明:

上述定理的转换图表示如下:

第四章到此结束。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值