CS61a & SICP Review

Brian Harvey在cs61a 2011spring第一节课的开场白是:“这里是cs61a,世界上最好的计算机课。”接着他又说道:“并不是因为我,而是因为我们用的教材——《计算机程序的构造与解释》(SICP)。“即使这样也无法掩盖Brian Harvey作为一名特立独行的教授。他喜欢西兰花和披头士,讨厌芝士和齐柏林飞艇,有时会坐在讲台上讲历史故事,有时会教学生说一种把元音移到词尾的Pig Latin。尽管到了2011年,Brian Harvey仍坚持黑板粉笔授课。cs61a 2011spring的课程官网也没有任何样式化,因为Brian认为添加美化的代码是对网络带宽的浪费。Brian Harvey还用了两节课来放70年代施乐大佬Alan Kay的讲座视频,并说:“他的思想放到当下也远未过时。“就是这样一位奇特的教授,教授了SICP这样一本奇特的书。

我从未见过SICP这样讲述编程的方式:前半部分用大量的数学例子介绍编程的概念,后半部分通过实现解释器来讲解程序运作的过程。Brian Harvey把课程总结为4个编程范式:函数式编程,面向对象,服务器-客户端,逻辑编程。仅仅4个编程范式当然不能囊括SICP和课程的全部内容,但编程范式不失为一个理解本书内容的好角度。下面总结了SICP个章节涉及的内容:

函数式编程

函数式编程的“函数”近似于数学中函数的概念:函数的输出完全取决于输入,而不依赖也不改变外部环·境。而输出依赖于外部环境的叫作“过程”。函数式编程的思想可以总结为:函数是第一等类型,即函数也是数据,可以作为参数或返回值。所以,支持函数式编程的语言需要提供函数的“字面量”——匿名函数lambda。scheme通过递归来实现循环,如果说人脑适应顺序执行语句的思考方式,但计算机则更适应递归的思考方式。多用递归的方式思考,把问题拆分为子问题,可以写出精简却功能强大的程序。在函数间传递函数,用于提炼代码的通用模式(generalize pattern);函数不依赖外部环境的性质也使函数式编程尤其适合并发。

数据抽象

scheme只有一种数据结构,链表;而链表的每个节点是一个二元对,其中左值指向数据,右值指向下一个二元对。但是可以通过打标签的方式定义抽象数据类型和操作,隐藏链表操作完成封装。

map, filter, accumulate是操作列表常用的函数,这三个函数处理数据可以组成如信号流图的结构。比较高阶的用法是嵌套的map,比如生成一定范围的整数对(i, j),在内层使用flatmap。

打标签实现数据抽象的一个例子是用列表来实现树。具体实现不表,但可以把每个节点当作一棵子树的抽象,利用递归,很短的代码可以实现强大的功能,如遍历所有节点的treemap,以及引申的deeplist-map。

另一种与数据有关的编程范式是数据导向编程。书中的例子是实现一个包括整数,有理数,实数和复数的多类型算术系统,数字类型只在底层区分,而顶层统一使用operate函数,传递任意的运算符和运算数,不区分数字类型。在底层,类型通过打标签实现。输入的所有可能性是一张运算数类型x运算符构成的二维表格,纵向是运算数二元对(a, b)的所有可能组合,横向是运算符,则表格一共有96项,显然不可能为每一项实现一个函数。在这里,数据导向编程是将函数集中存储在内存里的一个二维表格,operate函数通过运算符和二元对(a, b)来索引操作函数。我们只需要实现16个同类型的操作;剩下的不同类型操作,通过类型提升解决。所以,数据导向编程指(随时变化的)数据决定程序的行为。通过外部配置文件来自定义程序的行为实际上也是一种数据导向编程。

面向对象

对象维护各自的本地状态,所以面向对象相比于适合计算特定问题的函数式编程,更适合模拟多个对象的交互以及随时间的变化。即使scheme没有原生的面向对象支持,但借助lambda和环境闭包,scheme也可以实现面向对象。一种实现方式是对象实际是消息的分发函数,把消息转发给成员方法,而环境闭包封装了成员变量。实例化对象时返回该分发函数,静态成员变量是在对象实例生成器外部定义的变量被闭包,成员变量则是内部定义声明的变量。有两个特殊的成员变量指针,self用于访问自身,parent指向隐藏的父类对象用于实现继承和多态。

环境用于保存函数运行的实例使用的形参绑定和闭包变量。函数也可以看作一种抽象数据结构,在底层则由两部分组成:函数定义和指向环境。调用函数时会生成一个新环境指向定义所在的环境,所以函数调用中可以访问其定义环境中的变量,这个特性称为词法作用域(lexical scope)。相对的有动态作用域(dynamic scope),即函数调用是产生指向的是它被调用的环境,可以访问其调用环境中的变量。

面向对象的问题来源于它对赋值的依赖,赋值指改变内存中变量的值。引入赋值前,我们期待相同函数给定相同的输入,会返回相同的输出。但引入赋值后,这一期待不再成立。此时程序加入了时间的因素,在不同时间调用函数得到不同的输出,由此引发了并发等问题。数据导向编程和面向对象都依赖于赋值,不过数据导向编程在程序初始化时完成所有内存赋值,在后续仍可以使用函数式编程。课程用一个服务器-客户端程序来引入并发编程,这里不表。

使用流也可以模拟时间变化,同时避免赋值。流中每个元素表示一个时刻的取值。与列表结构相似,流也由二元对组成,不过左值指向数据,右值指向下一个延迟求值的二元对。延迟求值可以由lambda实现,即把值包裹在lambda中,求值时调用。由于延时求值,流允许循环定义,从而生成无限长度的流。多次使用的流可以利用一种叫作备忘的技术来保存每个元素的值,用空间换时间。备忘的技术也可以应用到递归,用循环实现则是动态规划算法。一个应用流的例子是筛法求前任意个的素数。

求值器花式实现

求值器是一种特殊的程序,存在两个输入,一个是函数,另一个是数据。求值器将数据应用到函数返回输出。自求值器(metacircular evaluator)是指用某语言实现自身的求值器,比如用scheme实现scheme。scheme自求值器的基本程序结构是eval和apply函数的相互递归。eval负责处理语法,而apply负责应用语义。scheme有四种值,self-evaluating,variable,special forms和procedures,其中self-evaluating和variable是原子的,procedures可以进一步分为原生的和自定义的。lambda,if,cond,let等都属于special forms由专门的函数处理。

分析求值器用于提高程序运行性能,即在定义阶段先对函数进行一定的分析返回一个lambda,实际调用时才提供所需env调用该lambda。分析求值器类似编译的阶段,把一部分计算提前进行。延迟求值器则改求值的应用序为正则序。应用序指函数调用前先求参数的值;正则序指完整传入表达式,在最后关头才求值。不确定求值器提供一个特殊的special form,amb。amb输入任意个数的参数,每个参数生成一条支线并返回运行,支线有失败的可能性。不确定求值器支持以声明的方式解决问题,即只有符合条件的支线可以输出,否则失败。

逻辑编程是查询求值器使用的编程范式。查询求值器维护一个隐藏的数据库,使用声明和查询两种操作来操作。可以声明规则基于已有数据产生新的数据。规则可以递归,可以实现类似append的操作,这使得逻辑编程的表达力不输其他编程范式。

尾声

我花费了大概一个月的时间完成该课程和配套作业、projects。我没选择python而选择scheme版本是受到TeachYourself-cs对Brian Harvey强烈推荐的影响;另外我有点担心我自身的python经验和随之产生思维惯性会对吸收新概念有影响。scheme并没有令我失望,其语法非常精简,容易上手,写出来的程序有一种别致的美感。但scheme版本的课程视频年代久远,画面很模糊,有时甚至看不清程序和板书,幸好程序和黑板上的图表都在讲义上都能找到。

作业来源于Brian Harvey勾选书中部分习题和额外提供的题目,虽然量偏大,但是难度适中,而且有些题目补充了正文内容,非常值得一做。我把cs61a 2011 spring所有作业和projects完成并放在了github上:(https://github.com/yanmulin/self-teaching-courses/tree/master/cs61a)[https://github.com/yanmulin/self-teaching-courses/tree/master/cs61a]

另外我参考了黄建宏和scheme-community的题解,这里面有题目的解析可供:(http://community.schemewiki.org/?sicp-solutions)[http://community.schemewiki.org/?sicp-solutions],(http://sicp.readthedocs.io/)[http://sicp.readthedocs.io/]