SICP读书笔记
SICP读书笔记
https://sicp.liujiacai.net/index.html
https://github.com/jiacai2050/sicp
https://learnxinyminutes.com/
https://github.com/lfkdsk/SICP-Magical-Book
https://lfkdsk.github.io/learn-sicp-0/
https://lfkdsk.github.io/learn-sicp-1/
https://lfkdsk.github.io/learn-sicp-2/
https://lfkdsk.github.io/learn-sicp-3/
https://lfkdsk.github.io/learn-sicp-4/
https://lfkdsk.github.io/learn-sicp-5/
https://lfkdsk.github.io/learn-sicp-6/
https://lfkdsk.github.io/learn-sicp-7/
第一章 构造过程抽象
http://liujiacai.net/blog/2015/07/18/sicp-chapter1-summary/
最近两个月除了工作之外,业余时间一直在研习sicp这本经典书。关于这本书的讨论有很多,像老赵写过SICP的书托,我觉得与其讨论这本书有没有读的价值,不如花上些,随手翻翻,如果感兴趣,就读下去;否则直接忽略即可。计算机理论发展到了现在,有太多太多经典需要我们去读了,恐怕我们这一辈子都无法读完,为什么不找你感兴趣的来读呢?
我这次建了个Github库来记录课后每一道习题与平时的所感所悟,希望对后面阅读sicp的同胞们有所帮助。
废话不多说了,趁着上周刚看完第一章,现在进行一下总结。
本章主旨–构造过程抽象
我们在进行程序设计时,接触到的无非就是两类东西:数据
与操作数据的过程
。本章只处理简单的数值数据,将注意力集中在过程的构造。本书采用lisp方言scheme进行教学,之所以选择lisp,是因为:
计算过程的lisp描述(称为过程)本身又可以作为lisp的数据来表示和操作。
现在许多威力强大的程序设计技术,都依赖于填平在“被动的”数据和“主动的”过程之间的传统划分。鉴于lisp可以将过程作为数据进行处理的灵活性,使它成为探索这些技术最方便的现存语言之一。
程序设计的基本要素
一个强有力的编程语言,应该提供下面三种机制,编程者利用它们来组织自己有关计算过程的思想:
- 基本表达式形式
- 组合机制
- 抽象机制
scheme使用前缀表示法,这和我们平常的编程语言不一样,需要适应。
本小节依此讲解了下面知识:
- 基本的表达式,环境和变量
- 组合式的求值
- 过程的定义
- 复合过程求值的替换模型(应用序与正则序)
- 条件表达式和谓词
- 过程抽象
概念本身比较简单,我们需要明确下面几个点:
- 表达式求值过程就是表达式语义的实现
- 代换模型给出了过程定义和过程应用的一种语义
很多 Scheme 过程的行为可以用这个模型描述 后面会看到,更复杂的过程需要用扩充的语义模型(像一些语法糖衣)
- 代换模型只是为了帮助直观理解过程应用的行为
它并没有反映解释器的实际工作过程 实际解释器的情况后面讨论,基于环境实现
- 本课程要研究解释器工作过程的一组模型
代换模型最简单,容易理解,但不足以解释所有的实际程序 其局限性是不能解释带有可变数据的程序 后面将介绍更精细的模型
无论是 C 还是 Scheme,都没规定运算对象的求值顺序。这意味着 假定它们采用某种特殊顺序都是不正确且不可靠的。所以我们不要写只有按特定求值顺序才能得到所需结果的表达式!
|
|
本小节以牛顿法求平方根展示了如何通过简单的过程构造复杂的过程。关于牛顿法的代码及优化方案我git笔记中找到。
过程与它们所产生的问题
本小节主要讲解了下面几个概念:
- 过程的内部定义与块结构(上面的牛顿法我已经使用)
- 分析过程(静态、动态)产生的计算过程(动态,行为)
- 计算过程的类型
线性递归 线性迭代 树形递归
- 计算的代价
本小节主要是熟悉过程,知道常见过程的分类,以及能够明确各种过程所占用的资源。
这里有意思的是习题1.19,通过矩阵相乘的方式来算斐波那契数,大家可以去了解下。
###找零钱
关于这个题目我在git库上的笔记已经有了比较详细的介绍,这里不再赘述。
- 题目描述与递归的解法
- 迭代解法的讨论
- 时空复杂度分析可参考习题1.14
求素数
这里求素数有两种解法,分别是:
这里比较有意思的是习题1.28,这道题介绍了一种费马检测的不会被欺骗的变形,称为Miller-Rabin检查。 到现在我还不知道为什么MR检查不会被欺骗,感觉应该是和费马定量等价的,无非就是等式两边同时除以了a而已。后面想明白后,我会再即时更新。
除了上面两个外,本章还用了下面两个例子:
- 求幂
- 最大公约数
|
|
用高阶函数过抽象
这一小节可算是第一章的重点,在这小节里,过程既可以作为参数传给过程,又可以作为过程的返回值,通过不断抽象,得到一系列高阶过程,前面的牛顿法,不定点都可以轻松用高阶过程来实现。
这里主要是做相应的习题来巩固自己的理解,如果你是第一次接触高阶函数,我相信你一定会大喊:“还能这么玩呀”!
总结
经过2个月的时间陆陆续续把这第一章看完,现在过了2周再来写这篇总结,效果还是蛮好的,很多东西一下就能够回想起来,不过也发现之前的漏洞,一些点当时没深究,放下了也就没有然后了。这次总结发现了些,这个周末一定补充上。
如果你也在读sicp,希望你能坚持下去,让我们一起享受编程的奥妙。
PS:在写这篇总结时,在简书上我找到这么一篇文章仍距遥远,知易行难,不知该作者的功力有多深厚才能有这么深的见解。 世界这么大, 让我们抓紧从SICP开始行动起来吧。
第二章 构造数据抽象
https://liujiacai.net/blog/2015/09/20/sicp-chapter2-summary/
文章目录
到今天为止终于把第二章看完了,相比于第一章,感觉难点少了些,这章主要是通过大量例子(主要有图形语言、区间运算、符号求导、集合的表示、通用型算术运算)来熟悉构造数据抽象的相关技能。下面来回顾总结一下第二章。
数据抽象的意义
在第一章中,只是进行了一些数值演算,这是比较简单的数据,并没有体现出数据抽象的意义,本章一开始就通过有理数的运算这个例子引出了数据抽象的意义。 数据抽象的基本思想,就是设法构造出一些使用复合数据对象的程序,使它们可以像操作数值等简单数据类型一样操作“抽象数据”。通过构造复合数据(与构造复合过程类似)可以达到下面的效果:
- 降低程序间的耦合度
- 提高设计的模块性
- 增强语言表达能力,为处理计算问题提供更多手段和方法。
第二章主要围绕下面两个部分展开:
- 如何构造复合数据对象(通过数据组合)
- 如何处理复合数据对象
其他一些细节点还包括:
- 复合数据如何支持以“匹配和组合”方式工作的编程接口
- 通过定义数据抽象,进一步模糊“过程”和“数据”的差异
- 符号表达式的处理,这种表达式的基本部分是符号而不是数 通用型(泛型)操作,使同样操作可能用于不同的数据
- 数据制导(导向/驱动)的程序设计,方便新数据类的加入
过程抽象与数据抽象
一个过程描述了一类计算的模式,又可以作为元素用于实现其他(更复 杂的)过程。因此过程是一种抽象——过程抽象。过程抽象的优势在于:
- 屏蔽计算的实现细节,可以用任何功能/使用形式合适的过程取代
- 规定了使用方式,使用方只依赖于抽象的使用方式约定
数据抽象的情况类似。 一个数据抽象实现一类数据所需的所有功能,又像基本数据元素一样可以作为其他数据抽象的元素。主要优势:
- 屏蔽一种复合数据的实现细节
- 提供一套抽象操作,使用组合数据的就像是使用基本数据
- 数据抽象的接口(界面)包括两类操作:构造函数和选择函数。构造函数基于一些参数构造这类数据,选择函数提取其内容
后面将说明(在第三章),如果需要支持基于状态的程序设计,那么就需要增加另外 一类变动操作(mutation,修改操作)。
数据抽象的语言支持
一般来说,实现数据抽象,编程语言需要提供下面三种机:
- 粘合机制,支持把一组数据对象组合成一个整体(通过闭包实现)
- 操作定义机制,定义针对组合数据的操作(通过scheme内置的define实现)
- 抽象机制,屏蔽实现细节,使组合数据能像简单数据一样使用(通过数据导向的程序设计风格实现)
处理复合数据的一个关键概念是闭包
,这里的闭包概念来自抽象代数,指的是
通过数据对象组合起来得到的结果,还可以通过同样的操作再进行再次组合。
这个概念和我们在JavaScript等现代编程语言中的概念(一种为表示带有自由变量的过程而用的实现技术)不一样,要注意区分。
序对pair
Scheme的基本复合结构称为“序对”,序对本身也是数据对象,可以用于构造更复杂的数据对象(也就是表,list)。例如
|
|
常规过程性语言都没有内部的表数据类型,但是我们在算法与数据结构课上,一般都用C语言实现过各种表(单向双向链表,环形链表等)结构,C++的标准STL库的list,Java集合框架中的List。
表及其相关概念是从 Lisp 开始开发,现已经成为很多技术的基础:
- 动态存储管理已经成为日常编程工作的基本支持
- 链表的定义和使用是常用技术(想想Java 中你用了多少次ArrayList吧)
- 有关表的使用和操作,以及各种操作的设计和实现,都可以从Lisp的表结构学习许多东西
- 基于map、reduce的hadoop
- 高阶表操作对分解程序复杂性很有意义
序对的操作
由于序对是构造复杂数据对象的基础,所有掌握序对的操作也就显得尤为重要,在学习这些操作时,我们可以感受到函数式编程的奇妙。 废话不多说,直接看代码list.scm看代码吧。
数据导向 vs. 消息传递
这两种方式是本章着重接受的两种数据抽象方式,分别对象函数式编程(数据导向)与面向对象编程(消息传递),具体辨析可参考习题2.76。
示例
由于本章大部分内容都是用具体例子来讲解,下面我就再一一回顾遍。大部分内容都在我Github的读书笔记中,这里相当于个索引,具体例子可参考给出的相应链接。
有理数运算
通过这里例子,引入数据抽象的意义。具体代码见rational.scm。
介绍完这个例子后,书上提出了一个重要的问题,“什么是数据”,在有理数运算这个例子,中我们能看到的就是一个构造函数make-rat
,两个选择函数numer
与demon
。首先我们要明确,并不是任意三个过程都能构成有理数的实现,需要满足
|
|
任意满足这一条件的三个函数,都能作为有理数表示的基础。 一般来说,一种数据对象的构造函数和选择函数都要满足一定条件。scheme中底层的数据结构序对也满足这一特点。
|
|
Church数
习题2.06引出Church数,完全用过程实现整数算术系统,关于这一点,推荐大家看我之前写的编程语言的基石——Lambda calculus,绝对能够颠覆你的思维。
区间运算
见相应的习题解答,推荐看习题2.14_2.16
八皇后问题
经典的问题,参考习题2.42。
图形语言
Huffman编码树
复数的表示
两种坐标的实现,参考代码lib/generic_arithmetic.scm
通用型算术包的实现
符号代数——多项式算术
从“强制”引出的问题可以看出强类型语言的劣势,现在像python、javascript等弱类型的语言这么火,很大程序上就是由于弱类型语言的编译器把“强制”的工作给做了,程序员根本不用去关心。当然,如果所有“强制”工作都让编译器去做,也是不合适的,具体如何选择,就需要综合多种因素了。 我本身经验还不是很丰富,就不乱说了,如果你有这方面的亲身体会,可以留言,我会及时更新。
代码实现参考lib/poly.scm与习题2.92。
扩充练习:有理函数
经过前面多项式算术的习题,我基本已经败下阵来了,实在是吸收消化不了了,改日再战。
总结
这一次看第二章,陆陆续续用了2个月,基本上耗费平时下班+周末双休的时间,收获还是挺大的。 不过由于本章习题量有些大,而且一开始像一些基础的过程put
、get
都没有,所以自己只是随便写写,没法运行,所以前面看的不好,这章习题之简的联系很紧密,前面没做好,后面只能呵呵了。
有一段时间让这些题弄的很烦,一点不想做,还好我这时候分散了下注意力,看了些其他的东西,像java集合框架的一些代码、the little schemer第九章的Y算子推导(虽然还没看懂),由于带着放松心情看的,遇到不懂的地方我就跳过去,没有深究,基本上达到了放松的目的。之后在网上把那些基础的过程都实现出来,再去做那些习题就顺多了。
这一章的内容,我之前多多少少了解过,所以大部分内容看起来还是比较顺畅的,就是习题多了点,由于当初给自己定的目标,所以还是慢慢的把所有习题(有理函数的除外)做了一遍。还是一点就是做后面的题时,忘了前面的知识点,需要平时没事多去翻翻,毕竟现在还达不到烂熟于心的地步。
下周一小组内再过一遍,向着第三章进军了。
第三章 模块化、对象和状态
https://liujiacai.net/blog/2015/12/26/sicp-chapter3-summary/
历时三个月,终于把第三章看完了。这三月发生了太多的意外,本文不打算说了,后面在写 2015 年终总结时再来谈谈这三个月的事情。如今回过头来看看第三章的内容,好像也不怎么难,只是内容涉及的面稍微广一些而已,下面来回顾总结一下第三章。
主旨
第三章的标题为模块化、对象和状态
,主要讨论与状态有关的编程问题。前面两章,讨论的问题主要是:
- 如何组合基本过程和基本数据
- 如何构造各种复合对象(组合过程/数据)
- 抽象在控制和处理程序复杂性中的重要作用
但对于程序设计而言,上面这三种手段还不够用,有效设计大型系统,还需要一些组织系统的原则,这体现在下面两方面:
- 只有一集高效算法,不足以构造出良好的大型系统
- 系统的功能分解,结构组织和管理与算法一样重要(或更甚之)
为了系统化地完成设计,特别需要一些模块化策略。模块化就是把复杂系统分解为一些边界清晰、易于独立理解的部分;每个部分的内部成分之间关系较密切,内聚力强;不同部分具有良 好的功能分离,相互之间的交互清晰、容易认识和处理;良好模块化分解出的部分可以分别设计,分别开发和维护。
假设构造一个系统的目标是希望模拟一个真实世界的系统,一种有效策略就是基于被模拟系统的结构
去设计程序的结构。这主要包括下面三个方面:
- 针对实际物理系统中的每个对象,构造一个对应的程序对象
- 针对实际系统里的每种活动,在计算系统里实现一种对应操作
- 让所开发的系统的活动比较直接地反映被模拟系统的活动
采用这种设计系统策略,有一个重要问题必须考虑:
真实世界的系统是变化的(相应的,人的认识也不断深入)
这些变化在人工系统里的反映,通常是需要在系统里增加新对象或新操作,或者需要修改已有对象和操作的行为。
为了有效完成模拟,我们希望构造出的模拟系统在遇到变化时,做到下面两点:
- 在修改时只需要局部做,不需要大范围改变程序
- 在扩充时只需简单加入对象或操作,局部修改/加入相关操作
设计策略
本章针对上述目标,将讨论两种系统的组织策略:
- 把系统看成是由一批相互作用的对象组成
真实系统中的对象随着时间的进展不断变化,模拟它们的系统对象也吸引相应地变化
- 把系统看作一种信号处理系统
关注流过系统的信息流
基于对象的设计
基于对象,需要关注计算对象可以怎样变化而又同时保持其标识。这是一种新的计算模型,带来许多本质性变化,包括有关计算的基本观点,基本操作,抽象的计算模型及其实现。
赋值
为了说清楚让一个计算对象具有随时间变化状态,贯穿本章的例子是:银行账号。一个账号对于我们系统设计中的一个对象,对同一个对象调用同一方法,返回的结果缺不一致。例如,假设开始时账户有100元钱,在不断调用“取钱”过程时,得到结果是不一样的。
|
|
这样的计算模型如果使用第一章介绍的替换计算模型
,是不可能做到的,为此,本章引入了一新的计算模型,在该模型中,变量不在仅仅是某个值的名字,更准确的说,此时的变量标识了一个值的地址,这很像 C语言中的指针,面向对象中的值引用。
环境计算模型
|
|
关于环境计算模型,核心点就两个:
- 过程声明时,其外围指针指向其运行时的环境
- 过程调用时,其外围指针指向声明时外围指针指向的那个环境
我自己尝试着用 Java 实现了一个 Scheme 方言,其中对这个环境模型也进行了模拟,大家不清楚的可以看看我这篇文章的介绍。
用变动的数据做模拟
在前两章中,没有赋值的概念,那时对于一种数据结构,我们只需明确其构造函数
与选择函数
即可使用该数据结构,在之前两章中,我们介绍了“表”、“树”这两种数据结构,引入了赋值后,一个数据结构多了一种函数,即修改函数
,利用修改函数,3.3 小节介绍了“变动的表”、“队列”、“表格”三种新的数据结构。
变动的表
变动的表这一数据结构,主要是借助set!
,实现了set-car!
与set-cdr!
,进而可以实现变动的表,其中比较有意思的是习题3.19,让我们在O(1)
空间复杂度检查一个表中是否包含环,这也是面试题中经常出现的一道,大家一定要掌握。基本思路就是
设置两个指针,一个一次走一步,另一个一次走两步,然后如果两个指针相等,那么就说明有环存在。
更进一步,如果一个表中有环的存在,如何找出那个环的交叉点(即如何找出下图中的m
点)。如果不清楚,可以参考我习题3.19 的解答。
队列
队列是一个“先进先出”的数据结构,这里主要是引入首尾指针的思想来加速对队列末端的访问。队列的实现可以参考我 Github 库的/exercises/03/lib/queue.scm。 其中习题3.23让我们实现一双向链表,一种很实用的队列的变种,大家一定要自己做一下。
表格
这里的表格和我们Java中的Map、Python中的dict类型比较类似。
其中比较有意思对是习题3.25,让我们推广一维表格、二维表格的概念,实现任意多个关键码的表格,比较有趣。
数字电路的模拟器
这是本章一个比较实际的例子,其背景是
数字系统(像计算机)都是通过连接一些简单元件构造起来的,这些元件单独看起来功能都很简单,它们连接起来形成的网络就可能产生非常复杂的行为。
从上面这个半加器可以看出
由于各个门部件延迟的存在,使得输出可能在不同的时间产生,有关数字电路的设计的许多困难都源于此。
这里的模拟器主要包含下面两部分:
- 构造电路的基本构件,像反门、与门、或门
- 传递数字信号的连线
除了上面两部分,为了模拟门部件延时的效果,本系统引入待处理表。这三部分都是用Scheme的过程实现,用内部状态表示该对象的改变,具体代码可以参考simulator.scm。
其中比较有意思的是习题3.31,大家可以好好想想。
|
|
约束的传播
本章另一个比较实用的例子,之前我们的过程都是单向,我们只能通过一个过程的输入获得其输出,但是这里给我们展示了如何构建一个约束系统,是的我们可以从任意方向求过程的未知数的值。
在讲解这个实例时,3.3.5小节引入一新语言的设计,这种语言将使我们可以基于各种关系进行工作。
我们在第一章里面就知道了,任何一门语言都必须提供三种机制:基本表达形式
、组合的方法
与抽象的方法
。针对本系统的语言的基本元素就是各种基本约束
,像adder
、multiplier
、constant
。用 Scheme 过程来实现基本约束也就自动地为该新语言提供了一种复合对象的抽象方式。
整个约束系统,我个人觉得主要是理解process-forget-value
过程中为什么要调用process-new-value
,这是串联起整个约束系统很重要的一步。书上是这么解释的:
只所以需要这一步,是因为还可能有些连接器仍然有自己的值(也就是说,某个连接器过去所拥有的值原来就不是由当前对象设置的)
|
|
整个约束系统的代码可以在propagation.scm找到。
并发,时间是一个本质问题
这一小节主要讲解引入赋值
这一行为后,并发程序可能出现的问题,其实这里的东西我们在平常的编程中多多少少有些了解,主要是如何保证操作的原子性。
更进一步,如果保证 n 对象间交互的原子性呢?这应该就是现在比较热门的一领域:分布式系统中,如何保证数据的一致性,后面有精力可以看看看业界使用最广泛的 zookeeper 的实现原理。
书上进一步扩展,讲述了并发问题与物理学的联系。有种发现一世界未解之谜的感觉,摘抄如下:
从本质上看,在并发控制中,任何时间概念都必然与通信有内在的密切联系。有意思的是,时间与通信之间的这种联系也出现在相对论里,在那里的光速(可能用于同步事件的最快信号)是与时间和空间有关的基本常量。在处理时间和状态时,我们在计算模型领域所遭遇的复杂性,事实上,可能就是物理世界中最基本的复杂性的一种反映。
流
流是另一种模拟现实物理世界的设计策略,其核心思想就是用数学概念上的函数来表示一现实物体的改变,比如对象X,可以用X(t)
来表示,如果我们想集中关心的是一个个时刻的x,那么就可以将它看作一个变化的量。如果关注的是这些值的整个时间史,那么就不需要强调其中的变化——这一函数本身是没有改变的。
这里流,较之前的表而言,主要是引入force
、delay
两个过程,将其延时求值。有了延时求值,我们就可以做很多之前不能做的事情,比如实现一个表示所有正整数的无穷流
|
|
流计算模式的使用
流方法极富有启发性,因为借助于它去构造系统时,所用的模块划分方式可以与采用赋值,围绕着状态变量组织系统的方式不同。例如,我们可以将整个的时间序列作为有关的目标,而不是去关注状态变量在各个时刻的值。这将使我们更方便地组合与比较来自不同时刻的状态的组合。
将迭代操作表示为流操作
|
|
序对的无穷流
将流作为信号
流的弊端
总结
本章一开始就提出了其目标,那就是构造一些计算模型,使其结构能够符合我们对于试图去模拟的真实世界的看法。我们学到了两种方式:
- 将这一世界模拟为一集相互分离的、受时间约束的、具有状态的相互交流的对象
- 将它模拟为单一的、无时间也无状态的统一体
每种方式都具有强有力的优势,但就其自身而言,有没有一种方式能够完全令人满意。如何整合这两个系统,是现在一重要难题。
第四章 元语言抽象
https://liujiacai.net/blog/2016/04/23/sicp-chapter4-summary/
文章目录
本书的前三章分别讨论了数据抽象
、过程抽象
、模块化
三种程序设计的技术,这些都是编程的问题,一直采用的是 Scheme 作为编程语言。如果遇到的问题更复杂,或者需要解决某领域的大量问题,有可能发现现实可用的语言(Lisp,或其他)都不够满意或不够方便,因此第四章主要就是讲述如何设计和实现一门新语言。
第四章首先介绍了一个解释器(本书中文翻译为“求值器”)最核心的部分(eval与apply),然后基于这个核心,做了一系列的扩展,下面让我们一起回顾总结下。
元语言抽象
4.1 小节告诉我们语言的解释器自身也是一个过程
而已,送给它的输入是相应语言的表达式(即程序),它就会完成该表达式要求做的动作。所以我们完全可以用 Scheme 写出一个 Scheme 的解释器,学习求值器实现,有助于理解语言本身和语言实现中的问题。而且 Scheme 语言有强大的符号处理能力,特别适合用于做这种工作。
在阅读4.1章节之前,我用 Java 尝试实现了一个 Scheme 解释器 JCScheme,之前也写过文章介绍,最核心的就是下面两个过程:
- eval 在一个环境里的求值一个表达式
- apply 将一个过程对象应用于一组实际参数
后面的 amb 解释器、查询语言的解释器都是在这个基本循环的基础上改造来的。
图灵机
4.1.5 小节中,将程序看成一种抽象的机器的一个描述,按照这种观点,求值器可以看作一部非常特殊的机器,它要求以一部机器的描述作为输入,给定了一个输入后,求值器就能够规划自己的行为,模拟被描述机器的执行过程。
这样,有关“原则上说什么可以计算(忽略掉时间与空间的实践性问题)”的概念就是与语言或者计算机无关了,它反映的是一个有关可计算性
的基本概念。图灵1936年的论文阐述了这个问题,并声称:
任何“有效过程”都可以描述为这种机器的一个程序
而后图灵实现了一台通用机器,即一台图灵机,其行为就像是所有图灵机程序的求值器。
这一求值器是违反直觉的,因为它是由一个相对简单的过程实现,却能去模拟可能比求值器本身还复杂的各种程序,通用求值器的存在是计算的一种深刻而美妙的性质。递归论是数理逻辑的一个分支,这一理论研究计算的逻辑极限,《GEB》里也探讨了一些思想,有兴趣的可以把这本书加入书单。
其他领域中也有这种通用功能的东西,像电子书、音乐播放器等都属于“专用的通用设备”,计算机(一个编程语言就是一种抽象的计算机)比它们更进一步:它能模拟自己。
这里比较有意思的是习题4.15的停机问题,本小节所有解释器的代码可以参考main.scm。
JIT
4.1.7小节中将语法分析与执行分开,这里的做法类似于高级语言的解释和编译:
- 直接解释,就是一遍遍分析程序代码,实现其语义。例如最早的 BASIC 语言实现
- 编译把实现程序功能的工作分为两步:
- 通过一次分析生成一个可执行的程序
- 在此之后可以任意地多次执行这个程序
这里做的是从 Scheme 源程序(元循环解释器/求值器处理的“数据”)到 Scheme 可执行程序的翻译,这是一种 Just In-time Translation,JIT(即时翻译),目前成熟的Java虚拟机都采用这种技术来提高执行效率。在实际中应用,还需进一步考虑整体效率问题。
惰性求值
4.1 小节实现的求值器采用的应用序求值(过程应用之前完成对所有参数的求值),4.2 小节修改之前的求值器,使之能够按照正则序求值,也称为惰性求值。惰性求值的可以用来实现第三章介绍的流,而且也可以避免下面的问题:
|
|
这里要做的修改不是很多,只是在过程的参数上包一层,加一个trunk
,真正需要计算时在求值,核心代码可以参考 trunk.scm,此外,处于性能方便的考虑,一般会对求过的参数进行缓存。
我自己实现的 JCScheme 也支持这种正则序求值,感兴趣的参考。
amb 非确定性计算
amb 的名字来自 ambiguous(歧义,多义),4.3小节在 Scheme 里扩充非确定性计算功能。非确定性计算里最关键的思想:
-
允许一个表达式有多个可能的值
-
在求值这种表达式时,求值器可以自动选出一个值 可能从可以选的值中任意选出一个。还需要维持与选择相关的轨迹(知道哪些元素已经选过,哪些没选过。在后续计算中要保证不出现重选的情况)
-
如果已做选择不能满足后面的要求,求值器就会回到有关的表里再 次选择,直至求值成功;或者所有选择都已用完时求值失败
非确定性计算的过程将通过求值器自动进行的搜索实现,选择和重新选择的方法和实际过程都隐藏在求值器的实现里,程序员不需要关心,不需要做任何与之相关的事情,这一修改的意义深远,语言扩充了,语义有重要改变。
非确定性计算与流处理
非确定性求值和流处理有相似的地方,现在比较一下非确定性求值和流处理中时间的表现形式:
-
流处理中,通过惰性求值,松解潜在的(有可能是无穷的)流和流元素 的实际产生时间之间的紧密联系
- 造成的假象是整个流似乎都存在
- 元素的产生并没有严格的时间顺序
-
非确定性计算的表达式表示对一批“可能世界”的探索
- 每个世界由一串选择确定
- 求值器造成的假相:时间好像能分叉
- 求值器保存着所有可能的执行历史
- 计算遇到死路时退回前面选择点转到另一分支,换一个探索空间
continuation 继续
“继续”是一种过程参数,它总是在过程的最后一步调用。带有“继续”参数的过程不准备返回,过程的最后一步是调用某个“继续”过程。“继续”是“尾调用”,调用过程的代码已经全部执行完毕。
amb 分析器产生的执行过程要求三个参数:
- 一个环境
- 一个成功继续
- 一个失败继续
执行过程的体求值结束前的最后一步总是调用这两个过程之一
- 如果求值工作正常完成并得到结果,就调用由“成功继续”参数 得到的那个过程
- 如果求值进入死胡同,就调用“失败继续”参数过程
|
|
这里需要注意的是要恢复破坏性操作(如赋值等),必须设法保存恢复信息。
|
|
amb 求值器的完整代码可以参考main-amb.scm。
逻辑程序设计
本书一开始就强调了:
数学处理说明式知识;计算机科学处理命令式知识
程序语言要求用算法的方式描述解决问题的过程,大多数程序语言要求用定义数学函数的方式组织程序:
- 程序要描述“怎么做”的过程
- 所描述的计算有明确方向,从输入到输出
- 描述经计算的表达式,给出了从一些值算出结果的方法(和过程)
- 定义过程描述如何从参数计算出结果
但是也有例外,第三章介绍的约束传递系统中的计算对象是约束关系,没有明确计算方向和顺序,它的基础系统要做很多工作以支持相应的计算。4.3小节的非确定性程序求值器里的表达式可有多个值,求值器设法根据表达式描述的关系找出满足要求的值。
逻辑程序设计可看作上面想法的推广,一个“是什么”的描述可能蕴涵许多“怎样做”的过程。考虑 append:
|
|
可认为,这个程序表达了两条规则:
- 对任何一个表
y
,空表与其拼接得到的表是y
本身 - 任何表
u
,v
,y
,z
,(cons u v)
与y
拼接得到(cons u z)
的条件是 v
与y
的拼接得到z
append 的过程定义和上述两条规则都可以回答下面问题:
- 找出
(a b)
和(c d)
的append
这两条规则还可以回答(但 append 过程不行):
- 找出一个表
y
使(a b)
与它的拼接得到(a b c d)
- 找出所有拼接起来将得到
(a b c d)
的表x
和y
在逻辑式程序语言里,可以写出与上面两条规则直接对应的表达式,求值器可以基于它得到上面各问题的解。但各种逻辑语言(包括本小节介绍的)都有缺陷,简单提供“做什么” 知识 有时会使求值器陷入无穷循环,或产生了不是用户希望的行为,这个领域最新的方向是constraint programming。
查询系统
本系统所使用的语言为查询语言,该语言的三要素分别是:
-
基本元素
,简单查询1
(job ?persion (computer programmer))
-
组合手段
,复合查询1 2 3
(and (job ?persion (computer programmer)) (address ?persion ?where))
-
抽象手段
,规则1 2 3 4 5 6
(rule ⟨conclusion⟩ ⟨body⟩) (rule (lives-near ?persion-1 ?persion-2) (and (address ?persion-1 (?town . ?rest-1)) (address ?persion-2 (?town . ?rest-2)) (not (same ?persion-1 ?persion-2))))
可以认为一条规则表示了很大(甚至无穷大)的一集断言,其元素是 由 <conclusion>
求出的所有满足 <body>
的赋值。对简单查询,如果其中变量的某个赋值满足某查询模式,那么用这个赋值实例化模式得到的断言一定在数据库里但满足规则的断言不一定实际存在在数据库里(推导出的事实)。
将逻辑看作程序
规则可看作逻辑蕴涵式:若对所有模式变量的赋值能满足一条规则的体, 则它就满足其结论。可认为查询语言就是基于规则做逻辑推理。还是用 append
为例:
|
|
有了上面的规则,可以做许多查询
|
|
这些例子展示了不同方向的计算,正是前面提出希望解决的问题。
查询系统原理
查询系统的组织围绕着两个核心操作:
模式匹配(pattern match)
,操作实现简单查询和复合查询合一(unification)
,是模式匹配的推广,用于实现规则
逻辑程序设计和数理逻辑
查询语言的组合符对应于常用逻辑连接词,查询操作看起来也具有逻辑 可靠性(例如,and 查询要经过两个子成分处理等) 但这种对应关系并不严格,因为查询语言的基础是求值器,其中隐含着控制结构和控制流程,是采用过程的方式解释逻辑语句。
这种隐含的控制结构我们有可能利用,例如,要找程序员的上司,下面两种写法都行:
|
|
如果公司里的有关上司关系的事实比有关程序员的事实更多,第一种写法的查询效率更高。
逻辑程序设计的目标是开发一种技术,把计算问题分为“要计算什么”和 “怎样计算”两个相互独立的子问题,方法是:
- 找出逻辑语言的一个子集,其
- 功能足够强,足以描述人们想考虑的某类计算
- 又不过分的强,有可能为它定义一种过程式的解释
- 实现一个求值器(解释器),执行对用这种逻辑子集写出的规则和 断言的解释(实现其语义,形式上是做推理)
上面提出的两方面性质保证了逻辑程序设计语言程序的有效性。
本小节的查询语言是这种想法的一个具体实施:
- 查询语言是数理逻辑的一个可以过程式解释的子集
- 一个断言描述一个简单事实
- 一条规则表示一个蕴涵,能使规则体成立的情况都使结论成立
- 规则有自然的过程式解释:要得到其结论,只需确定其体成立
not 问题
|
|
这两个查询会得到不同结果(与逻辑里的情况不同):
- 第一个查询找出所有与
(supervisor ?x ?y)
匹配的条目,从得到的框架中删去 ?x 满足(job ?x (computer programmer))
的框架 - 第二个查询从初始框架流(只包含一个空框架)开始检查能否扩展 出与
(job ?x (computer programmer))
匹配的框架。显然空框架可扩展,not 删除流中的空框架得到空流,查询最后返回空流
逻辑程序语言里的 not 反映的是一种“封闭世界假说”,认为所有知识都包含在数据库里,凡是没有的东西其 not 都成立。这显然不符合形式化的数理逻辑,也不符合人们的直观推理。
查询语言的完整代码,可以参考这里.
总结
首先恭喜我“完成”第四章,算算大概用了4个月,太多不定因素了,不过还好自己找到了当初的感觉,坚持了下来。
这一章的内容很多,毕竟是设计一门语言,而且还介绍了两种大变种,把书上的代码调通就要花好久,不过也确实开了眼界,通过最基本的eval、apply 循环扩展出了 amb 非确定性求值器与逻辑语言求值器,其实这章的难度并不大,只是涉及的内容广而已。
通过看完这章,发现了很多本质性的东西,像 Node.js 里面的 callback、Python 里面的 generator 不都是 continuation 的糖衣嘛,JIT 也不过尔尔,这章更偏向的是元编程领域,通过 DSL 来减轻业务代码的逻辑,想象如果语言本身就支持分布式事务,程序员要少写多少代码呢。
第五章 寄存器机器里的计算
https://liujiacai.net/blog/2016/05/21/sicp-chapter5-summary/
经过第四章元语言抽象的洗礼,我们已经能够深谙编译器内部的原理,核心就是eval-apply
循环,只是说基于这个核心可以有各种延伸,像延迟求值、amb 不定选择求值、逻辑求值等等,有了这层的理解,我们应该能够透过各种花哨的语法糖,看出其本质来,像 Node.js 中的 Promise、 Python 中的 coroutine,都是 continuation 的一种应用而已。
但是,我们还无法解释子表达式的求值怎样返回一个值,以便送给使用这个值的表达式,也无法解释为什么有些递归过程能产生迭代型的计算过程,而另一些递归过程却生产递归型的计算。就其原因,是因为我们所实现的求值器是 Scheme 程序,它继承并利用了基础系统的控制结构。要想进一步理解 Scheme 的控制结构,必须转到更低的层面,研究更多细节,而这些,就是第五章的主要内容。
寄存器机器的设计
为了探讨底层的控制结构,本章基于传统计算机的一步一步操作,描述一些计算过程,这类计算机称为寄存器机器,它的主要功能就是顺序的
执行一条条指令,操作一组存储单元。本章不涉及具体机器,还是研究一些 Scheme 过程,并考虑为每个过程设计一个特殊的寄存器机器。
第一步工作像是设计一种硬件体系结构,其中将:
- 开发一些机制支持重要程序结构,如递归,过程调用等
- 设计一种描述寄存器机器的语言
- 做一个 Scheme 程序来解释用这种语言描述的机器
寄存器机器包含数据通路(寄存器和操作)
和确定操作顺序的控制器
。书中以 gcd 算法为例介绍:
|
|
为了描述复杂的过程,书中采用如下的语言来描述寄存器机器:
|
|
子程序
直接带入更基本操作的结构,可能使控制器变得非常复杂,希望能够作出某种安排,维持机器的简单性,而且避免重复的结构,比如如果机器两次用GCD,最好公用一个 GCD 部件,这是可行的,因为任一时刻,只能进行一个 GCD 操作,只是输入输出的寄存器不一样而已。思路:
调用 GCD 代码前把一个寄存器(如 continue)设置为不同的值,在 GCD 代码的出口根据该寄存器跳到正确位置
具体代码与图示可参考:2015-05-12_subroutes.md
采用堆栈实现递归
|
|
表面看计算阶乘需要嵌套的无穷多部机器,但任何时刻只用一部。要想 用同一机器完成所有计算,需要做好安排,在遇到子问题时中断当前计算,解决子问题后回到中断的原计算。注意:
- 进入子问题时的状态与处理原问题时不同(如 n 变成 n-1)
- 为了将来能继续中断的计算,必须保存当时状态(当时 n 的值)
控制问题:子程序结束后应该返回哪里?
- continue 里保存返回位置,递归使用同一机器时也要用这个寄存 器,给它赋了新值就会丢掉当时保存其中准备返回的位置
- 为了能正确返回,调用前也需要把 continue 的值入栈
阶乘算法的具体解释与图示可参考:2016-05-16_recursion_stack.md
这里比较难理解的是 fib 算法,因为这里涉及到两次递归调用:
|
|
习题5.6、习题5.11 对这一算法进行了分析与修改,建议大家看看。
一个寄存器机器模拟器
这是5.2小节的内容,主要是用一种寄存器机器语言(即上面描述 gcd、fib 的语言)描述的机器构造一个模拟器。这一模拟器是一个 Scheme 程序,采用第三章介绍的消息传递的编程风格,将模拟器封装为一个对象,通过给它发送消息来模拟运行。
这一模拟器的代码主要可以分为两部分:assemble.scm、machine.scm,有需要的可以结合书中解释看看。
这里让我为之惊叹的一点是,本书作者采用之前一贯的风格,用 Scheme 实现了一台寄存器机器,和第四章中各种解释器一样,通过层层数据抽象,让你觉得计算机也没那么深不可测,也是可以通过基本过程构造出来的。
其他内容
为了实现 Scheme 解释器,还需要考虑表结构的表示和处理(5.3节内容):
- 需要实现基本的表操作
- 实现作为运行基础的巧妙的存储管理机制 后面讨论有关的基础技术(可能简单介绍)
有了基本语言和存储管理机制之后,就可以做出一部机器(5.4节内容),它能
- 实现第四章介绍的元循环解释器
- 而且为解释器的细节提供了清晰的模型
这一章的最后(5.5节内容)讨论和实现了一个编译器
- 把 Scheme 语言程序翻译到这里的寄存器机器语言
- 还支持解释代码和编译代码之间的连接,支持动态编译等
这里由于时间与精力的缘故,最后三小节没有进行细致的阅读,在将来需要时再回来阅读。
总结
终于到了这一天,历时一年多,终于还是把这本书看完了,算是了了一个心结,大概在大三的时候就知道了这本书,算算到现在完整的看完,有四年时间了。在最近这一年中,这本书带给我了无数的灵感与启发,这种感觉真的无法描述,只有你亲自体会。
此时此刻印象最深的有两点:
- 对现实世界中时间与空间的模拟,没有最优解,不管是延时求值的流还是封装属性的对象,都有其局限性。也可能是我们人类对世界的认识还不够。摘抄书中P219一段话:
从本质上看,在并发控制中,任何时间概念都必然与通信有内在的密切联系。有意思的是,时间与通信之间的这种联系也出现在相对论里,在那里的光速(可能用于同步事件的最快信号)是与时间和空间有关的基本常量。在处理时间和状态时,我们在计算模型领域所遭遇的复杂性,事实上,可能就是物理世界中最基本的复杂性的一种反映。
- 深刻认识了
计算
二字的含义,明白了计算机的局限性(停机问题),并不是所有问题都有解的;
当然,并不是说看完这本书就是“武林高手”了,今后还是要在不断实践中总结、积累,成为一位真正的 hacker。
好了,今天写到这里,作为万里长城的一个终点。
=============== End