公开课B站链接:【CS公开课】计算机程序的构造和解释(SICP)【中英字幕】【FoOTOo&HITPT&Learning-SICP】
视频简介
视频是《计算机程序的构造和解释(SICP)》(后面简称SICP)两位作者在1986年给惠普公司员工培训的课程(所以视频中看到各种年龄的学员),课程内容就是围绕SICP书中内容进行讲课(顺序有所不同),SICP曾经是MIT本科第一门课的教材,现在被python语言描述替代(此前用的是Lisp方言Scheme)。本文不打算深入书中内容,单纯讲讲视频。
目前视频我看到Lec6b:流 II,剩余8p未看,如果看完能理解的话会更新本文。
视频主要讲了以下内容:
函数式编程;
面向对象封装数据;
模糊数据与过程(Procedure)的边界;
匹配模式;
封装底层数据,使用统一的语言进行调用,语言会判断类型使用合适的方法;
引入赋值语句后引发的复杂性后果,如带入了时间的概念,部分过程会随着时间变化而得到不同的结果;
引入赋值语句后,对应对象也时刻在变化(Lec5b:计算对象,这节课我还是不能理解这个标题的含义);
对比在处理数据时,抛弃时间后思考程序的简洁方式,同时也指出了目前函数式编程无法解决的问题;
通过惰性求值来支持流处理无限的数据,并且达到与命令式编程的高效(当问题可以脱离时间后仍能解决的情况下)
推荐观看人员
主观推荐:没有看过这门课的所有编程人员
客观推荐:对编程有热情且有耐心的人,一直使用命令式编程想了解函数式编程的人
花费时长
每节课60~70分钟,共20节课,保守估计至少需要23小时
每节课的感受和一些想法
Lec1a Lisp概览
介绍了Scheme语言的相关知识,主要介绍了基本元素的声明,过程的调用,分支语句。由于几乎不使用Lisp语言的特性,所以很容易理解。可能有人会对(+ 1 3)这种符号前置的语句看得比较别扭,现在的语言都是携程1+3的,我是这样理解的,将+号理解成过程的调用就好了:将+替换成add方法,也就是(add 1 3),将1+3变换成add(1,3),那么阅读的顺序就一样了。
Lisp
(+ 1 3)
(DEFINE (ADD a b) (+ a b))
(ADD 1 3)
javascript
function add(a,b){return a+b;}
add(1,3);
Lec1b 计算过程
通过一些数学的例子去写成程序执行,提出了代换模型,用于解析程序的执行情况。
例如
定义:(DEFINE (ADD-SELF X)(+ X X)) 调用: (ADD-SELF 5)
等于将5代入定义中的X即可求解得10
第一次看这门课的时候,我觉得很奇怪,这不是显而易见的吗?平时的工作中调试也是将值代入进行分析,查bug。直到Lec5a才发现这个替代模型只有在纯函数下才生效,一旦引入了赋值,替代模型将不再有用。
不过这节课也说明了代换模型可以替换一切定义好的基本元素,包括过程,这里开始强调其实过程与数据并没有想象中分得这么清晰,他们之间的边界很模糊。
Lec2a 高阶过程
本节课使用加法进行讲解如何对过程进行抽象,我想我可以使用javascript来讲述
/// 求和
function sum_int(a, b) {
if (a > b) return 0;
else return a + sum_int(a + 1, b);
}
/// 平方求和
function sum_square(a, b) {
if (a > b) return 0;
else return square(a) + sum_square(a + 1, b);
}
/// 平方定义
function square(a) {
return a * a;
}
可以看出求和和平方求和只有else有略微不同,那么我们可以将程序继续分解成更小的模块,使得我们的求和更为通用
/// 求和标记
/// fn_item 获取元素的真正值
/// a 累加的首下标
/// fn_next 获取下一个下标的方法
/// b 累加的尾下标
function sum(fn_item, a, fn_next, b) {
if (a > b) return 0;
else return fn(a) + sum(fn_item, fn_next(a), fn_next, b);
}
/// 自增过程定义
function inc(item) {
return item++;
}
/// 求和
function sum_int(a, b) {
return sum(function (item) { return item; }, a, inc, b);
}
/// 平方求和
function sum_square(a, b) {
return sum(function (item) { return item * item; }, a, inc, b);
}
对于sum这种接受过程的过程就叫高阶过程。其实在工作中经常使用高阶过程,例如js的回调函数,再特例一点就是JQ中AJAX的成功、失败回调方法。
Lec2b 复合数据
本节课开始进入巫师的世界,使用Wishful Thinking(我称为敢想就能编下去,方法) 的编程方法(先假设已经具有某种功能,这样就可以在这一层次上构建程序,具体这一功能如何实现,与当前用它无关)。
视频开头简单地把分数的加减乘除法则过了一遍,然后开始讨论如何使用程序写出其定义,先想出一种数据结构可以存放两个值,左边是分子,右边是分母。通过CONS来构造这个数据,通过CAR和CDR来选择数据
(CONS 1 2) ===>得出一个包含1和2的数据,这个数据怎么存放不管,因为这不是我们要考虑的事情,我们只管怎么把1或2取出来使用
取值过程
(CAR (CONS 1 2)) ===>1
(CDR (CONS 1 2)) ===>2
就通过这三个方法构造了分数的加减乘除。
这里有学员提出了疑问
1、为什么我们将两个数放在一起,用的时候又原原本本地拿出来使用?
教授:因为我想让分子和分母绑定在一起,并命名为X,那么我就能通过X来取值。如果有10个分数,不组合起来的话,就存在20个毫无规则的分子与分母存在。对数据的抽象可以减低复杂性。
2、CONS构造方法只能关联两个数吗?
很明显不是,看以下语句就可以知道了
(CONS 1 2) 将此表示为包含两个数
(CONS 1 (CONS 2 3)) 这里分两部分 左侧包含一个数 右侧包含一个序对 序对的左侧为2 右侧为3,只要愿意 可以一直加下去,最后命名
通过Wishful Thinking方法,我们学会了数据抽象的重要性,不过对于我们从面向对象编程入门的人来说,数据抽象就相当于声明一个不含行为的类,所以很好理解(视频是1986年,当时可能就不怎么好理解了)
但是在视频63:48中,教授说出了秘密,此前一直强调CONS过程可以构造一个序列数据,其实并不存在。
并不存在什么特殊数据,CONS返回的是一个lambda表达式,详细实现见下图。
最后强调一句,过程与数据并不严格区分。
在视频的最后一位学员提出了一个较为哲学的问题:
当我们调用(CONS 1 2)两次的时候,这两个是同一个过程还是不同过程呢?
Lec3a Escher的例子(此课还需要消化)
程序很多时候是解决一些重复性的问题,而这些重复性问题往往有细微的区别。
如下图,我们很快就能找出规律,图片是由一张图片通过不断旋转,放大缩小后填充出来的,那我们应该怎么去生成这张图片呢?
在视频前期使用了map高阶过程,中期开始利用了过程的组合,因为含有闭包性质,通过递归能够不断地进行变换,达到我们所要的效果,由于我没有很好的例子去用语言描述,有兴趣的请看视频。
不过我建议先理解一下过程的组合是怎么回事。
这里我推荐一些做法帮助理解:
目前为止课程所提及的都是纯函数,可以等价于数学中的函数,如果存在任何x使得 f(x)=y 任何y使得 g(y)=z
那么就能推出 g(f(x))=z
javascript中简单的函数组合定义
var compose = function(f,g) {
return function(x) {
return f(g(x));
};
};
可以看出compose也是一个高阶过程。
Lec3b 符号化求导程序
本节课前半部分就是将高中知识导数规则程序化,随后开始对求导结果进行分析。
在实现了功能之后,求导的结果往往是未进行化简的,也就是说,虽然答案正确,但并不是最终想要的答案。如果在求导程序下继续添加化简语句,将变得十分复杂且难以编写。
在这里教授给出的方案是,既然我们通过Wishful Thinking来调用了一些过程,那么我们就可以修改这些过程的实现来为结果进行化简。
对于熟悉面向对象设计的同学可以利用“接口(interface)”来理解:我确定了签名后,调用即可,无需关心内部是怎么实现的。
在本例求导程序中,我们的主过程“求导”,并不会进行修改,而是修改里面调用的过程的实现。
以上就是本节课的主要内容了。
看到我的描述您可能会奇怪,这和本节课的题目“符号化”有什么关系呢?
对,一开始我也没弄懂,可能现在我还没弄懂,我在这节课回看了几遍之后,简单说说这个符号化的理解:在视频的18:19,教授使用了一个判断表达式首位是否为'+ 的谓词语句,在视频的最后也强调了这个'+的重要性——现在我们通过语言分析表达式来去运行程序,也就是能通过语言去解析语言,这是一个很强大的功能。
加上此前时不时说的一句:当我们的语言未能轻松做到我们想要做的事情时,我们就创造一个语言(难怪这些人一言不合就给Lisp搞个方言)
所以我认为本节课的重点应该有3个
1.将问题最小化进行解决
2.分层隔离实现
3.初窥语言解析语言的效果
Lec4a 匹配模式
这节课开始讲一些比较难的东西,第一次看的时候并没看懂。
可以通过正则表达式类比理解,也可以把只能匹配固定值的switch语句看成是超级弱化版的匹配模式。
由于这个在我日常工作使用的两种语言 javascript和C#并没有实现(C#7的switch能简单判断表达式了,不过还不算真正的匹配模式),所以还没真正领会到实际的作用。如果有新的见解再更新本节
Lec4b 通用运算符
本节课出现了两个术语“基于类型的分派”和“数据导向编程”,前一个可以用多态类比理解,后一个可以使用表驱动法来理解,但并不是完全一模一样。
本节课主要描述了这样一个场景,很多人在共同编写一个库,每个人通过自己的理解去实现自己的功能,怎么样组织代码使得更加清晰?更具体描述是:数学中很多元素可以进行相加,如有理数,复数,向量等,但相加的规则各不相同,能否通过定义一个统一的ADD方法,只要传入两个正确类型的参数,就会返回一个正确的结果?
/// 定义统一的相加过程,过程体未确定
(DEFINE (ADD X Y)(...))
/// 调用 传入整数
(ADD 1 2) =>结果:3
// 调用 传入复数
(ADD (1+1i) (2+3i) ) =>结果: 3+4i
这里简单说一下思路,有兴趣直接看课程,很精彩的。
首先每种类型都做一个类型标记,如有理数就会如以下构建
(CONS '有理数 1)
然后给有理数实现一个加法过程
(DEFINE (有理数ADD X Y) (+ X Y))
当调用最高级的ADD时会先判断传入参数的类型,如果判断X Y均为相同类型,则调用该类型定义好的方法
代码就不贴了,可以通过中介者模式来帮助理解。
这里没有提到“数据导向编程”,还是那句,有兴趣直接看视频,很精彩的。
至于课程题目的通用运算符,我理解并不是类似于+-*/的符号,而就是ADD过程。当然在C#可以通过运算符重载来实现,那就是更好啦。
Lec5a 赋值、状态和副作用
公开课共20节,第8节才讲到了赋值过程 SET!(请注意这个感叹号是一个约定,代表有副作用的过程最后就要加一个感叹号,就好像一个谓词过程最后就带一个问好?一样),仔细想想,难道之前我们没用过赋值语句吗?
此时,一位大卫(我也记住他的名字了!)的学员提出了一个问题:DEFINE、LET、SET!这三者有什么不同?
虽然教授通过多个角度来进行讲解,但我还是用自己的理解来尝试回答这个疑问。
首先课程题目中的赋值,展开来说应该是对已定义的变量(variable)进行赋值。
这里包含两个事实:1.一个已定义的变量;2.对这个变量进行赋值
也就是这个变量在某一个时刻被改变了!
如果您能明白我上一句话的意思,那么三者的关系就很好理解
DEFINE和LET 均为定义语句,即声明有这么一个变量并将其首次赋值,而DEFINE为全局(Global)定义,LET是某个过程中的上下文进行定义,换做我们常用的话就是生命周期在某个块级作用域中。
而SET!是对一个已存在某个作用域中的变量的值进行改变,即题目中的“赋值”。
因为定义一个变量,在某个时刻进行改变在我们的命令式编程中实在是太常见了,常见到我们会无意识地使用,出现这种疑问也是很正常的。
而函数式编程风格强调数据不可变,每次对数据的改变都不会直接更新,而是返回一个新的数据,这个数据与指定改变的毫无联系。这里可以用C#中的string类型进行类比,在C#中字符串是不可变类型。
有以下两条语句
string a="字符串a";
a=a+"新的字符串数据";
string类型是引用类型,所以第一次赋值时a指向了一个存放"字符串a"的地址,假设地址为a1
当执行到第二句 a=a+"新的字符串数据" 时,程序会开辟一个新的地址a2,并且将a1中的值拷贝出来与"新的字符串数据"拼接后放进去,再将变量a的指向改为指向地址a2。有兴趣可以读 CLR via C#
在程序运行的表现中,看起来是就地更新了变量a,但其实每一次赋值都会新建一个数据,旧数据一旦创建就永远不会被改变,因为没有引用,会在某个时刻被GC回收。
为什么要强调数据的不可变呢?因为好理解,能够用代换模型对程序进行解读。
一旦引入了赋值,就不得不引入时间的概念,一旦引入时间的概念,就存在先后执行,并发,状态共享等等问题。
这里就不展开来说了,对本节课进行一下我自己的总结:
1.赋值带来很多问题,慎用
2.如果一个问题能够脱离时间进行处理,请尽可能使用脱离时间编写程序的方案,后面课程会展开讨论
3.带副作用的语句一定要小心小心再小心
4.函数式编程并不能解决全部问题,要将带副作用的语句进行隔离,后面课程会展开讨论
Lec5b 计算对象
本节课对我来说比较难懂,暂时未能抓住主旨。不过通过闭包来解决引用的副作用我是看懂了。
Lec6a 流 I 、 Lec6b 流 II
本节课开始就是函数式编程的主场了,经过实际的使用下来再回头看这节课,我觉得非常爽,同时也解答了我一些疑问,如利用惰性求值来处理无限的数据。
先对课程进行总结:
1.使用map filter reduce三板斧对数据进行处理
2.构建一种流数据类型,它的头是值,尾是一个待求值的过程(未真正有值)
3.惰性求值的实现使得函数式的效率与命令式不相上下
4.在流数据的尾使用记忆高阶过程进行处理,使得无需多次对同一个过程进行计算,即缓存
5.流不适用的领域
后记
我是通过函数式编程找到了这篇推荐老赵书托(2):计算机程序的构造与解释
经过了这么多年的发展,函数式已经得到了很多实际的应用,我的环境是使用javascript语言,在IE8下使用了underscore.js库,其他主流浏览器使用的是lodash.js库,在我看来lodash.js支持惰性求值,就是性能上的差距,其他api基本一致。而C#会使用Linq来处理数据,使用回调函数来设计功能。
以下我列出在课程中未提及(或者是我没认真听课)的一些函数式方法
1.Option 和Either 我认为主要是解决命令式风格中 抛出异常的处理方式
2.柯里化 and 偏函数应用
这两个高阶过程在很多文章中都有描述,也很容易混乱,按照我的理解
柯里化是将一个多个参数的函数变成一个每次接受一个参数的函数
而偏函数应用是接收一个n元函数与1~n-1个参数,返回一个新的函数
*注:javascrip中,underscore.js只实现了偏函数应用,而lodash实现了柯里化和偏函数应用
3.一些很实用的高阶函数,如对谓词进行取反的negate,包裹函数wrap,节流器throttle,防抖动的节流器debounce等等
最后说一句,命令式和函数式是共存的,不能一味追求使用函数式来解决问题。
如果对文中有什么意见或见解,可在下方进行评论,谢谢,感谢阅读,谢谢。