我并不喜欢王垠这个人,甚至有些反感,但他的某些博文我很喜欢,也因此而受益,Hmm…
*Tip. 非原文摘录,各别语句进行了删减和改动,建议看原文(点击章节标题)。
Blog Essays
什么是“对用户友好”
Any intelligent fool can make things bigger, more complex, and more violent. It takes a touch of genius - and a lot of courage - to> move in the opposite direction.
– Albert Einstein
任何聪明的傻瓜都能把事情做得更大、更复杂、更暴力,而在相反的方向上前进则需要一点天才和很大的勇气。
“对用户不友好”的背后,其实是程序设计的不合理使得它们 缺少抽象 ,而不是用户的问题。
如何对用户更加友好呢? 统一、抽象!
GTF - Great Teacher Friedman
程序语言的研究者们往往追逐一些“新概念”,却未能想到很多这些新概念早在几十年前就被 Friedman 想到了。
知识的深度是无止境的。
Friedman 研究一个东西的时候总是全身心的投入,执着的热爱。
在 Friedman 的课上,我利用它们(如 closure 、CPS 等概念)来完成有实际意义的目标,才真正的体会到这些概念的内涵和价值。
一个例子就是课程进入到没几个星期的时候,我们开始 写解释器 来执行简单的 Scheme 程序。然后我们把这个解释器 进行 CPS 变换 ,引入全局变量作为 “寄存器” (register),把 CPS 产生的 continuation 转换成 数据结构(也就是堆栈) 。最后我们得到的是一个 抽象机 (abstract machine),而这在本质上相当于一个真实机器里的中央处理器(CPU)或者虚拟机(比如 JVM)。所以我们其实从无到有,“发明”了 CPU!从这里,我才真正的理解到寄存器,堆栈等的本质,以及我们为什么需要它们。我才真正的明白了,冯诺依曼体系构架为什么要设计成这个样子。后来他让我们去看一篇他的好朋友 Olivier Danvy 的论文,讲述如何从各种不同的解释器经过 CPS 变换得出不同种类的抽象机模型。这是我第一次感觉到程序语言的理论对于现实世界的巨大威力,也让我理解到, 机器并不是计算的本质 。机器可以用任何可行的技术实现,比如集成电路,激光,分子,DNA…… 但是无论用什么作为机器的材料, 我们所要表达的语义,也就是计算的本质,却是不变的。
当然, 重新发明 东西并不会给我带来论文发表,但是它却给我带来了更重要的东西,这就是 独立的思考能力 。一旦一个东西被你“想”出来,而不是从别人那里 “学”过来,那么你就知道这个想法是 如何产生 的。这比起直接学会这个想法要有用很多,因为你知道这里面 所有的细节和犯过的错误 。而最重要的,其实是由此得到的 直觉 。如果直接去看别人的书或者论文,你就很难得到这种直觉,因为一般人写论文都会把直觉埋藏在一堆符号公式之下,让你看不到背后的真实想法。如果得到了直觉,下一次遇到类似的问题,你就有可能很快的利用已有的直觉来解决新的问题。
什么是语义学
一个程序的“语义” 通常是由另一个程序(“解释器”)决定的 。 程序只是一个数据结构 ,通常表示为语法树(abstract syntax tree)或者指令序列 。这个数据结构本身其实没有意义,是解释器让它产生了意义,对同一个程序可以有不同的解释。
解释器接受一个“程序”(program),输出一个“值”(value)。这个所谓的“值”可以具有非常广泛的含义。它可能是一个整数,一个字符串,也有可能是更加奇妙的东西。
CPU 其实也是一个解释器,它的输入是以二进制表示的机器指令,输出是一些电信号。人脑也是一个解释器,它的输入是图像或者声音,输出是神经元之间产生的“概念”。
所以“语义学”,基本上就是研究各种解释器。
解释器的原理其实很简单,但是结构非常精巧微妙,如果你从复杂的语言入手,恐怕永远也学不会。最好的起步方式是写一个基本的 lambda calculus 的解释器。lambda calculus 只有三种元素,却可以表达所有程序语言的复杂结构。
专门讲语义的书很少,现在推荐一本我觉得深入浅出的:《Programming Languages and Lambda Calculi》。只需要看完前半部分(Part I 和 II,100 来页)就可以了。这书好在什么地方呢?它是从非常简单的布尔表达式(而不是 lambda calculus)开始讲解 什么是递归定义,什么是解释,什么是 Church-Rosser,什么是上下文 (evaluation context) 。在让你理解了这种简单语言的语义,有了足够的信心之后,才告诉你更多的东西。比如 lambda calculus 和 CEK,SECD 等抽象机 (abstract machine)。理解了这些概念之后,你就会发现所有的程序语言都可以比较容易的理解了。
怎样写一个解释器
待细读……
解密设计模式
有些人问我,你说学习操作系统的最好办法是学习程序设计。那我们是不是应该学习一些“设计模式”(design patterns)?
总的来说,如果光从字面上讲,程序里确实是有一些“模式”可以发掘的。因为你总是可以借鉴以前的经验,用来构造新的程序。你可以把这种经验叫做“模式”。
可是自从《设计模式》(通常叫做 GoF,“Gang of Four”,“四人帮”)这本书在 1994 年发表以来,“设计模式”这个词有了新的,扭曲的含义。它变成了一种教条,带来了公司里程序的严重复杂化以及效率低下。
照搬模式东拼西凑,而不能抓住事物的本质,没有“灵感”,其实是设计不出好东西的。
Peter Norvig 在 1998 年就做了一个演讲,指出在“动态语言”里面,GoF 的 20 几个模式,其中绝大部分都“透明”了。也就是说,你根本感觉不到它们的存在。
谈 Linux, Windows 和 Mac
这段时间受到很多人的来信。他们看了我很早以前写的推崇 Linux 的文章,想知道如何“抛弃 Windows,学习 Linux”。天知道他们在哪里找到那么老的文章,真是好事不出门…… 我觉得我有责任消除我以前的文章对人的误导,洗清我这个“Linux 狂热分子”的恶名。
学习操作系统最好的办法是 学会(真正的)程序设计思想 ,而不是去“学习”各种古怪的工具。所有操作系统,数据库,Internet,以至于 WEB 的设计思想(和缺陷),几乎都能用程序语言的思想简单的解释。
一个好的工具,应该只有少数几条需要记忆的规则,就像象棋一样。
有些人鄙视图形界面,鄙视 IDE,鄙视含有垃圾回收的语言(比如 Java),鄙视一切“容易”的东西。他们却不知道,把自己沉浸在别人设计的繁复的规则中,是始终无法成为大师的。
容易的东西不一定是坏的,而困难的东西也不一定是好的。
学习计算机(或者任何其它工具),应该“只选对的,不选难的”。记忆一堆的命令,乌七八糟的工具用法,最后脑子里什么也不会留下。 学习“原理性”的东西,才是永远不会过时的。
谈语法
使用和研究过这么多程序语言之后,我觉得几乎不包含多余功能的语言,只有一个: Scheme。
我觉得 Scheme (Lisp) 的基于“S 表达式”(S-expression)的语法,是世界上最完美的设计。为什么我喜欢这样一个“全是括号,前缀表达式”的语言呢?这是出于对语言结构本质的考虑。
其实,我觉得语法是完全不应该存在的东西。即使存在,也应该非常的简单。
语法 其实只是对 语言的本质结构,“抽象语法树”(abstract syntax tree,AST) 的一种编码。一个良好的编码,应该极度简单,不引起歧义,而且应该容易解码。在程序语言里,这个“解码”的过程叫做“语法分析”(parse)。
为什么我们却又需要语法呢?
因为受到现有工具(操作系统,文本编辑器)的限制,到目前为止,几乎所有语言的程序都是用字符串的形式存放在文件里的。为了 让字符串能够表示“树”这种结构 ,人们才给程序语言设计了“语法”这种东西。
但是人们喜欢耍小聪明,在有了基本的语法之后,他们开始在这上面大做文章,使得简单的问题变得复杂……
最老的是 Fortran 的程序,最早的时候都是用打孔机打在卡片上的,所以它其实是几乎没有语法可言的。
自己想一下,如果要表达一颗“树”,最简单的编码方式是什么?就是用括号把每个节点的“数据”和“子节点”都括起来放在一起。Lisp 的设计者们就是这样想的。他们把这种完全用括号括起来的表达式,叫做“S 表达式”(S 代表 “symbolic”)。这貌似很“粗糙”的设计,甚至根本谈不上“设计”。奇怪的是,在用过一段时间之后,他们发现自己已经爱上了这个东西,再也不想设计更加复杂的语法。于是 S 表达式就沿用至今。
首先,把所有的结构都用括号括起来,轻松地避免了别的语言里面可能发生的“歧义”。程序员不再需要记忆任何“运算符优先级”。
其次,把“操作符”全都放在表达式的最前面,使得基本算术操作和函数调用,在语法上发生 完美的统一 ,而且使得程序员可以使用几乎任何符号作为函数名。
在其他的语言里,函数调用看起来像这个样子:
f(1)
,而算术操作看起来是这样:1+2
。在 Lisp 里面,函数调用看起来是这样(f 1)
,而算术操作看起来也是这样(+ 1 2)
。你发现有什么共同点吗?那就是f
和+
在位置上的对应。实际上,加法在本质也是一个函数。这样做的好处,不但是突出了加法的这一本质,而且它让人可以用跟定义函数一模一样的方式,来定义“运算符”!这比起 C++ 的“运算符重载”强大很多,却又极其简单。
Lisp 的很多其它的设计,比如“垃圾回收”,后来被很多现代语言(比如 Java)所借鉴。可是人们遗漏了一个很重要的东西:Lisp 的语法,其实才是世界上最好的语法。
程序语言的常见设计错误 - > 片面追求短小
我的程序的“短小”是建立在 语义明确,概念清晰 的基础上的。在此基础上,我力求去掉冗余的,绕弯子的,混淆的代码,让程序更加直接,更加高效的表达我心中设想的“模型”。这是一种在概念级别的优化,而程序的短小精悍只是它的一种“表象”。
我的这种短小往往是在 语义和逻辑层面 的,而不是在语法上死抠几行代码。我绝不会为了程序显得短小而让它变得难以理解或者容易出错。
1.自增减操作
从理论上讲, 自增减操作本身就是错误的设计 。因为它们把对变量的“读”和“写”两种根本不同的操作,毫无原则的合并在一起。这种对读写操作的混淆不清,带来了非常难以发现的错误。相反,一种等价的,“笨”一点的写法, i = i + 1
,不但更易理解,而且在逻辑上更加清晰。
有些人很在乎 i++
与 ++i
的区别,去追究 i++
与 ++i
谁的效率更高。这些其实都是徒劳的。比如,i++
与 ++i
的效率差别,其实来自于早期 C 编译器的愚蠢。
因为 i++
需要在增加之后返回 i
原来的值 ,所以它其实被编译为:
(tmp = i, i = i + 1, tmp)
但是在 =for (int i = 0; i < max; i++)= 中,其实你并不需要在 =i++= 之后得到它自增前的值。所以有人说,在这里应该用 =++i= 而不是 =i++= ,否则你就会浪费一次对中间变量 =tmp= 的赋值。
而其实呢,一个良好设计的编译器应该在两种情况下都生成相同的代码。
# 在 i++ 的情况,代码其实先被转化为
for (int i = 0; i < max; (tmp = i, i = i + 1, tmp))
# ↓↓↓
# 由于 tmp 这个临时变量从来没被用过,
# 所以它会被编译器的“dead code elimination”消去,
# 编译器最后实际上得到了
for (int i = 0; i < max; i = i + 1)
所以,“精通”这些细微的问题,并不能让你成为一个好的程序员。很多人所认为的高明的技巧,经常都是因为早期系统设计的缺陷所致。一旦这些系统被改进,这些技巧就没什么用处了。
真正正确的做法其实是:完全不使用自增减操作,因为它们本来就是错误的设计。
2.赋值语句返回值
在几乎所有像 C,C++,Java 的语言里,赋值语句都可以被作为值。
y = 0
不应该具有一个值。它的作用应该是“赋值”这种“动作”,而不应该具有任何“值”。即使牵强一点硬说它有值,它的值也应该是 void
。这样一来 x = y = 0
和 if (y = 0)
就会因为“类型不匹配”而被编译器拒绝接受,从而避免了可能出现的错误。
“解决问题”与“消灭问题”
如果你仔细观察就会发现,很多“难题”,其实是“人造”出来的,而不是“必然”的。它们的存在,往往是由于一些早期的“设计错误”。
如果我们转换一下思路,或者改变一下“设计”,很多问题就可以不解自消。这就是我所谓的“消灭问题”的能力。
所以,在解决问题之前,我们应该先问自己三个问题:
- 这问题是否真的“存在”?
- 如果解决了这个问题,会给我和他人在合理的时间之内带来什么实际的好处?
- 这问题是否可以在简单的改变某些“设计”或者“思路”之后,不复存在?
Lisp 已死,Lisp 万岁!
1.Lisp 的优点:
Lisp 的语法是世界上最精炼,最美观,也是语法分析起来最高效的语法。这是 Lisp 独一无二的,其他语言都没有的优点。有些人喜欢设计看起来很炫的语法,其实都是自找麻烦。为什么这么说呢,请参考这篇《谈语法》 。
Lisp 是第一个可以 在程序的任何位置定义函数 ,并且可以 把函数作为值传递 的语言。这样的设计使得它的表达能力非常强大。这种理念被 Python,JavaScript,Ruby 等语言所借鉴。
Lisp 有世界上最强大的宏系统(macro system)。这种宏系统的表达力几乎达到了理论所允许的极限。如果你只见过 C 语言的“宏”,那我可以告诉你它是完全没法跟 Lisp 的宏系统相提并论的。
Lisp 是世界上第一个使用垃圾回收( garbage collection)的语言。这种超前的理念,后来被 Java,C# 等语言借鉴。
想不到吧,现代语言的很多优点,其实都是来自于 Lisp — 世界上第二古老的程序语言。所以有人才会说,每一种现代语言都在朝着 Lisp 的方向“进化”。
如果你相信了这话,也许就会疑惑,为什么 Lisp 今天没有成为主流?为什么 Lisp Machine 会被 Unix 打败?其实除了商业原因之外,还有技术上的问题。
2.Dynamic Scoping
早期的 Lisp 其实普遍存在一个非常严重的问题:它使用 dynamic scoping 。
所谓 dynamic scoping 就是说,如果你的函数定义里面有 “自由变量” ,那么这个自由变量的值,会随着函数的“调用位置”的不同而发生变化。
(setq f
(let ((x 1))
(lambda (y) (* x y))))
这里的 x
对于函数 (lambda (y) (* x y))
来说就是个“自由变量”(free variable),因为它不是它的参数。
……
Tips: 详细论证过程就参考原文……
话说回来,为什么早期的 Lisp 会使用 dynamic scoping 呢?
原来,Emacs Lisp 直接把函数定义处的 S 表达式 '(lambda (y) (* x y))
作为了函数的“值”!
如果你在 emacs 里面显示 f
的值,它会打印出:
'(lambda (y) (* x y))
这说明 f
的值其实是一个 S 表达式,而不是像 Scheme 一样的“闭包”(closure)。
简单倒是简单,麻烦事接着就来了。调用 f 的时候,比如 (funcall f 2)
,y 的值当然来自参数 2,可是 x 的值是多少呢?答案是:不知道!不知道怎么办?到“外层环境”去找呗,看到哪个就用哪个,看不到就报错。所以你就看到了之前出现的现象,函数的行为随着一个完全无关的变量而变化。如果你单独调用 (funcall f 2)
就会因为找不到 x 的值而出错。
那么正确的实现函数的做法是什么呢?是制造“闭包”(closure)!这也就是 Scheme,Common Lisp 以及 Python,C# 的做法。
在函数定义被解释或者编译的时候,当时的自由变量(比如 x)的值,会跟函数的代码绑在一起,被放进一种叫做“闭包”的结构里。比如上面的函数,就可以表示成这个样子: (Closure ‘(lambda (y) (* x y)) ‘((x . 1)))
。
在这里我用 (Closure …)
表示一个“结构”(就像 C 语言的 struct)。它的第一个部分,是这个函数的定义。第二个部分是 ‘((x . 1))
,它是一个 “环境” ,其实就是一个从变量到值的映射(map)。利用这个映射,我们记住函数定义处的那个 x 的值,而不是在调用的时候才去瞎找。
3.Lexical Scoping
与 dynamic scoping 相对的就是“lexical scoping”。我刚才告诉你的闭包,就是 lexical scoping 的实现方法。
你也许发现了,Lisp 其实不是一种语言,而是很多种语言。这些被人叫做“Lisp 家族”的语言,其实共同点只是它们的“语法”:它们都是基于 S 表达式。如果你因此对它们同样赞美的话,那么你赞美的其实只是 S 表达式,而不是这些语言本身。
因为 一个语言的本质应该是由它的语义决定的,而跟语法没有很大关系。 你甚至可以给同一种语言设计多种不同的语法,而不改变这语言的本质。
Chez Scheme 的传说
在我看来,早期 Lisp 编译器出现的主要问题,其实在于对编译的本质的理解,以及编译器与解释器的根本区别。
解释器之所以大部分时候比编译器慢,是因为解释器“问太多的问题”。 每当看到一个构造,解释器就会问:“这是一个整数吗?”“这是一个字符串吗?”“这是一个函数吗?”…… 然后根据问题的结果进行不同的处理。这些问题,在编译器的理论里面叫做 “解释开销” (interpretive overhead)。
编译的本质,其实就是在程序运行之前进行“静态分析”,试图一劳永逸的回答这些问题。
早期的 Lisp 编译器,以及现在的很多 Scheme 编译器出现的问题其实在于,它们并没有干净的消除这些问题,甚至根本没有消除这些问题。
编译的过程,就是将输入程序经过一系列的变换之后,转化为机器代码。
什么是“脚本语言”
其实“脚本语言”与“非脚本语言”并没有语义上,或者执行方式上的区别。它们的区别只在于它们设计的初衷:脚本语言的设计,往往是作为一种临时的“补丁”。相反,“非脚本”的通用程序语言,往往由经过严格训练的专家甚至一个小组的专家设计,它们从一开头就考虑到了“通用性”,以及在大型工程中的可靠性和可扩展性。
“脚本”这个概念是如何产生的?
使用 Unix 系统的人都会敲入一些命令,而命令貌似都是“一次性”或者“可抛弃”的。然而不久,人们就发现这些命令其实并不是那么的“一次性”,自己其实一直在重复的敲入类似的命令,所以有人就发明了“脚本”这东西。它的 设计初衷是“批量式”的执行命令 ,你在一个文件里把命令都写进去,然后执行这个文件。可是不久人们就发现,这些命令行其实可以用更加聪明的方法构造,比如定义一些变量,或者根据系统类型的不同执行不同的命令。于是,人们为这脚本语言加入了变量,条件语句,数组,等等构造。“脚本语言”就这样产生了。
Scheme 编程环境的设置
关于语言的思考
怎么说呢,我觉得每个程序员的生命中都至少应该有几个月在静心学习 Haskell。学会 Haskell 就像吃几天素食一样。每天吃素食显然会缺乏全面的营养,但是每天都吃荤的话,你恐怕就永远意识不到身体里的毒素有多严重。
我今天想说其实就是,没有任何一种语言值得你用毕生的精力去“精通”它。“精通”其实代表着“脑残”——你成为了一个高效的机器,而不是一个有自己头脑的人。你必须对每种语言都带有一定的怀疑态度,而不是完全的拥抱它。 每个人都应该学习多种语言 ,这样才不至于让自己的思想受到单一语言的约束,而没法接受新的,更加先进的思想。这就像每个人都应该学会至少一门外语一样,否则你就深陷于自己民族的思维方式。有时候这种民族传统的思想会让你深陷无须有的痛苦却无法自拔。
原因与证明
一个东西具有如此的性质,并不是因为你证明了它。这性质是它天生就有的,不管你是否能证明它。
了大部分的教育过分的重视了“证明”,却忽略了比证明更重要的东西——“原因”。
原因往往比证明来得更加简单,更加深刻,但却更难发现。 对于一个事实往往有多种多样的证明,然而导致这个事实的原因却往往只有一个。如果你只知道证明却不知道原因,那你往往就被囚禁于别人制造的理论里面,无法自拔。你能证明一个事物具有某种特性,然而你却没有能力改变它。你无法对它加入新的,好的特性,也无法去掉一个不好的特性。你也无法发明新的理论。有能力发明新的事物和理论的人,他们往往不仅知道“证明”,而且知道“原因”。
古人说的“知其然”与“知其所以然”的区别,也就是同样的道理吧。
丘奇和图灵
丘奇代表了“逻辑”和“语言”,而图灵代表着“物理”和“机器”。完全投靠丘奇,或者完全投靠图灵,貌似都是错误的做法。
据我的经验,丘奇的理论让很多事情变得简单,而图灵的机器却过度的复杂。丘奇所发明的 lambda calculus
以及后续的工作,是几乎一切程序语言的理论基础。
图灵机永远的停留在了理论的领域,绝大多数被用在“计算理论”(Theory of Computation)中。
计算理论其实包括两个主要概念: “可计算性理论”(computability) 和 “复杂度理论”(complexity) 。
这两个概念在通常的计算理论书籍(比如 Sipser 的经典教材)里,都是用图灵机来叙述的。其实几乎所有计算理论的原理,都可以用 lambda calculus
,或者程序语言和解释器的原理来描述。
所谓“通用图灵机”(Universal Turing Machine),其实就是一个 可以解释自己的解释器 ,叫做“元解释器”(meta-circular interpreter)。
然而我的“元解释器”却是基于 lambda calculus
的,所以我后来发现了一种方法,可以完全的用 lambda calculus
来解释计算理论里面几乎所有的定理。
在我的头脑里面并存着丘奇和图灵的影子。我觉得丘奇的
lambda calculus
是比图灵机简单而强大的描述工具,然而我却又感染到了图灵对于“物理”和“机器”的执着。我觉得逻辑学家们对lambda calculus
的解释过于复杂,而通过把它理解为物理的“电路元件”,让我对lambda calculus
做出了更加简单的解释,把它与“现实世界”联系在了一起。
所以到最后,丘奇和图灵这两种看似矛盾的思想,在我的脑海里得到了和谐的统一。这些精髓的思想帮助我解决了许多的问题。
我和权威的故事
Donald Knuth
有一句话说得好:“跟真正的大师学习,而不是跟他们的徒弟。”如果你真的要学一个算法,就应该直接去读那算法的发明者的论文,而不是转述过来的“二手知识”。二手的知识往往把发明者原来的动机和思路都给去掉了,只留下苍白无味,没有什么启发意义的“最后结果”。
我跟 Knuth 的最后一次“联系”是在我就要离开清华的时候。我从 email 告诉他我觉得中国的研究环境太浮躁了,不是做学问的好地方,想求点建议。结果他回纸信说:“可我为什么看到中国学者做出那么多杰出的研究?计算机科学不是每个人都可以做的。如果你试了这么久还不行,那说明你注定不是干这行的料。”还好,我从来没有相信他的这段话,我下定了决心要证明这是错的。多年的努力还真没有白费,今天我可以放心的说,Knuth 你错了,因为我已经在你引以为豪的多个方面超过了你。
Unix
所谓的“Unix 哲学”,也就是进程间通信主要依靠无结构字符串,造成了一大批过度复杂,毛病众多的工具和语言的产生: AWK,sed,Perl,……
Lisp 程序员早就明白这个道理,所以他们尽一切可能避免使用字符串。他们设计了 S 表达式,用于结构化的传输数据。实际上 S 表达式不是“设计”出来的,它是每个人都应该首先想到的,最简单的可以 表示树结构 的编码方法。Lisp 的设计原则里面有一条就是:Do not encode。它的意思是,尽量不要把有用的数据编码放进字符串。
Go 语言
……
Cornell
……
图灵奖
说到这里应该有人会问这个问题,我是不是也属于那种没找到导师走投无路的人。答案是,对的,我确实没有在 Cornell 找到可以做我导师的人。
……
再见了,权威们
几经颠簸的求学生涯,让我获得了异常强大的力量。我的力量不仅来自于老师们的教诲,而且在于我自己不懈的追求,因为机会只亲睐有准备的头脑。
程序语言与……
程序语言的设计类似于其它很多东西的设计,有些微妙的地方只有用过更好的设计的人才能明白。
……
程序语言与减肥
我的方法就是一句话:让每天吃进去的热量比消耗的少一些,但是不至于难受,另外适当运动来增加热量的消耗。很显然嘛,根据热力学定律,每天消耗的能量比摄入的多,多出来的部分只能通过分解你身上的物质(脂肪)来产生。
程序员的心理疾病
1.无自知之明
由于程序员的工作最近几年比较容易找,工资还不错,所以很多程序员往往只看到自己的肚脐眼,看不到自己在整个社会里的位置其实并不是那么的关键和重要。很多程序员除了自己会的那点东西,几乎对其它领域和事情完全不感兴趣,看不起其他人……
2.垃圾当宝贝
按照 Dijkstra 的说法,“软件工程”是穷途末路的领域,因为它的目标是: 如果 我不会写 程序的话,怎么样才 能写出 程序?
为了达到这个愚蠢的目的,很多人开始兜售各种像减肥药一样的东西。面向对象方法,软件“重用”,设计模式,关系式数据库,NoSQL,大数据…… 没完没了。
3.宗教斗争
为什么有人说在软件行业里需要不停地“学习”,因为不断地有人为了制造新的理念而制造新的理念。
……
4. 以语言取人
很多程序员都以自己会用最近流行的一些新语言为豪,以为有了它们自己就成了更好的程序员。他们看不到,用新的语言并不能让他们成为更好的程序员。其实最厉害的程序员无论用什么语言都能写出很好的代码。在他们的头脑里其实只有一种很简单的语言,他们首先用这种语言把 问题建模 出来,然后根据实际需要“翻译”成最后的代码。这种在头脑里的建模过程的价值,是很难用他最后用语言的优劣来衡量的。
……
一个对 Dijkstra 的采访视频
(可以访问 YouTube 或者从源地址下载 MPEG1,300M)
现在看来,任何一个语言里面没有递归函数都是不可思议的事情,然而在 1950-60 年代的时候,居然很少有人知道它有什么用!所以你就发现,所谓的“主流”和“大多数人”一直都是比较愚蠢的。现在,同样的故事发生在 lambda 身上。多年以后,没有 lambda 的语言将是不可接受的。
在这里只摘录他提到的几个要点:
软件测试可以确定软件里有 bug,但却不可能用来确定它们没有 bug。
程序的优雅性不是可以或缺的奢侈品,而是决定成功还是失败的一个要素。优雅并不是一个美学的问题,也不是一个时尚品味的问题,优雅能够被翻译成可行的技术。牛津字典对 elegant 的解释是: pleasingly ingenious and simple。如果你的程序真的优雅,那么它就会容易管理。第一是因为它比其它的方案都要短,第二是因为它的组件都可以被换成另外的方案而不会影响其它的部分。很奇怪的是,最优雅的程序往往也是最高效的。
为什么这么少的人追求优雅?这就是现实。如果说优雅也有缺点的话,那就是 你需要艰巨的工作才能得到它,需要良好的教育才能欣赏它 。
当没有计算机的时候,编程不是问题。当有了比较弱的计算机时,编程成了中等程度的问题。现在我们有了巨大的计算机,编程就成了巨大的问题。
我最开头编程的日子跟现在很不一样,因为我是给一个还没有造出来的计算机写程序。造那台机器的人还没有完工,我在同样的时间给它做程序,所以没有办法测试我的代码。于是我发现自己做的东西必须要能放进自己的脑子里。
我的母亲是一个优秀的数学家。有一次我问她几何难不难,她说一点也不难,只要你用“心”来理解所有的公式。如果你需要超过 5 行公式,那么你就走错路了。
学术腐败是历史的必然
学术腐败是历史的必然,是人类历史的发展趋势和技术进步的结果。
为什么这么说呢?
- 首先想想在资本主义社会里人靠什么过活?钱
- 一般人怎么得到钱?工作
- 谁是人最大的工作竞争对手?机器,电脑,互联网,机器人……
- 自己的工作被机器取代了怎么办?寻找机器干不了的工作!
- 什么是机器仍然干不了,而且不久的将来也干不了的工作?搞研究!
- 搞研究是为了什么?制造更高效更智能的机器!
然后你就明白了,这是一个让人类越来越痛苦的怪圈。
关系式模型的实质
……
谈创新
有人告诉我,我所说的很多事情只是在已有的事物上面挑出毛病来,那不能引起真正的“创新”。
什么是创新?创新真的那么重要吗,它的意义何在?
世界上并不缺少创新,而是创新过剩了!大量的所谓“创新”,让人们的生活变得纷繁复杂,导致他们需要记住更多事物的用法,而无法专注于利用已有的设施,最大限度的享受生活的乐趣。
最缺乏创造力的人,往往是最爱标榜创新的。
创新往往也是与良好的设计理念背道而驰的。一个好的设计,总是力求减少“新”的感觉,而着重于让整个设计浑然一体,天衣无缝,用起来顺手。最好的设计就是让设计的目标消失掉,或者感觉不到它的存在。
……
美国和中国
在这里提到美国的优秀设计,并不是说我更喜欢美国。每次提到这些,总有朋友感觉不平,仿佛觉得我是“美帝的走狗”一样。 我其实对任何国家都没有特别的感情和归属感,我的感情只针对个人,而不是国家。实际上,我认为国家这种东西是不必要存在的。 美国人对我显然没有很多中国人对我好,然而 技术和设计是没有国界的 ,好的东西不学就等于永远落后。很多中国人喜欢用所谓的“民族自豪感”来代替理性的思考,看不到自己的问题。中国为什么到现在还属于第三世界国家,恐怕就有这里面的原因。没有用心,就不能提高。中国的经济发展了,国家的总资产可以说已经很多了,然而有很多东西不是钱就可以买来的,它需要用心设计。看,我在美国受了这么多的苦和委屈才学会了这些,如果你们不理解消化,那多可惜啊。
一味的试图创新而不仔细思考,是人们的生活由于各种“新事物”而变得复杂的重要原因。
只有你能从已有的东西里面看到实质的问题,你才有可能达到天衣无缝的设计。设计不需要全新的,它必须最大限度的让人可以方便的生活,而不需要记忆很多不必要的指令。否则如果你不吸取历史的教训,做出所谓“全新”的设计,那么你很有可能不是解决了问题,而是制造了问题。我觉得有一句话说得好,忘记历史就是毁灭未来。
所谓“人为错误”
在我看来,整个软件行业基本就是建立在一堆堆的设计失误之上。做程序员如此困难和辛苦,大部分原因就是因为软件系统里面积累了大量前人的设计失误,所以我们需要做大量的工作来弥补或者绕过。
然而一般程序员都没有意识到这里面的设计错误,知道了也不敢指出来,他们反而喜欢显示自己死记硬背得住这些稀奇古怪的规则。这就导致了软件行业的“皇帝的新装现象”——没有人敢说工具的设计有毛病,因为如果你说出来,别人就会认为你在抱怨,那你不是经验不足,就是能力不行。
我体会很深的一个例子就是 Git 版本控制工具。有人很把这种东西当回事,引以为豪记得住如何用一些稀奇古怪的 Git 命令(比如 git rebase, git submodule 之类)。好像自己知道了这些就真的是某种专家一样,每当遇到不会用这些命令的人,都在心底默默地鄙视他们。 作为一个比 Git 的作者还要高明的程序员,我却发现自己永远无法记住那些命令 。在我看来,这些命令晦涩难懂,很有可能是因为没设计好造成的。因为如果一个东西设计好了,以我的能力是不可能不理解的。可是 Linus Torvalds 的名气之大,威望之高,有谁敢说:“我就是不会用你设计的破玩意儿!你把我怎么着?
怎样尊重一个程序员
1.认识和承认技术领域的历史遗留糟粕
很多不尊重人现象的起源,都是因为某些人偏执的相信某种技术就是世界上最好的,每个人都必须知道这些东西,否则他就不是一个合格的程序员。
如果你对计算机科学理解到一定程度,就会发现我们其实仍然生活在计算机的石器时代。特别是软件系统,建立在一堆历史遗留的糟糕设计之上。
各种蹩脚脑残的操作系统(比如 Unix,Linux),程序语言(比如 C++,JavaScript,PHP,Go),数据库,编辑器,版本控制工具,…… 时常困扰着我们,这就是为什么你需要那么多的所谓“经验”和“知识”。
2.分清精髓知识和表面知识,不要太拿经验当回事
在任何领域,都只有少数知识是精髓的,另外大部分都是表面的,肤浅的,是从精髓知识衍生出来的。
精髓知识和表面知识都是有用的,然而它们的分量和重要性却是不一样的。所以必须区分精髓知识和表面知识,不能混为一谈,对待它们的态度应该是不一样的。由于表面知识基本是死的,而且很容易从精髓知识推导衍生出来。我们不应该因为自己知道很多表面知识,就自以为比掌握了精髓知识的人还要强。不应该因为别人不知道某些表面知识,就以为自己高人一等。
……
编程的宗派
总是有人喜欢争论这类问题,到底是“函数式编程”(FP)好,还是“面向对象编程”(OOP)好……
1.面向对象编程(Object-Oriented Programming)
如果你看透了表面现象就会发现,其实“面向对象编程”本身没有引入很多新东西。
所谓“面向对象语言”,就是经典的“过程式语言”(比如 Pascal),加上一点抽象能力。所谓“类”和“对象”,基本是过程式语言里面的记录(record,或者叫结构,structure),它 本质其实是一个从名字到数据的“映射表”(map) 。
你可以用名字从这个表里面提取相应的数据。
所谓“对象思想”(区别于“面向对象”),实际上就是对这种数据访问方式的进一步抽象。
“对象思想”的价值,它让你可以通过“间接”(indirection,或者叫做 “抽象” )来 改变 =point.x= 和 =point.y= 的语义,从而让使用者的代码 完全不用修改 。虽然你的实际数据结构里面 可能没有 x 和 y 这两个成员,但由于 =.x= 和 =.y= 可以被重新定义 ,所以你可以通过改变 .x 和 .y 的定义来“模拟”它们。在你使用 =point.x= 和 =point.y= 的时候,系统内部其实在运行两片代码(所谓 getter),它们的作用是从 r 和 angle 计算出 x 和 y 的值。这样你的代码就感觉 x 和 y 是实际存在的成员一样,而 其实它们是被临时算出来的 。
对象思想的价值也就到此为止了。你见过的所谓“面向对象思想”,几乎无一例外可以从这个想法推广出来。
“对象思想”作为数据访问的方式,是有一定好处的。然而“面向对象”(多了“面向”两个字),就是把这种本来良好的思想东拉西扯,牵强附会,发挥过了头。
很多面向对象语言号称“所有东西都是对象”(Everything is an Object), 把所有函数都放进所谓对象里面,叫做“方法”(method),把普通的函数叫做“静态方法”(static method) 。
实际上呢,就像我之前的例子,只有极少需要抽象的时候,你需要使用内嵌于对象之内,跟数据紧密结合的“方法”。其他的时候,你其实只是想表达数据之间的变换操作,这些完全可以用普通的函数表达,而且这样做更加简单和直接。
这种把所有函数放进方法的做法是本末倒置的,因为函数并不属于对象。 绝大部分函数是独立于对象的,它们不能被叫做“方法”。强制把所有函数放进它们本来不属于的对象里面,把它们全都作为“方法”,导致了面向对象代码逻辑过度复杂。
面向对象语言不仅有自身的根本性错误,而且由于面向对象语言的设计者们常常是半路出家,没有受到过严格的语言理论和设计训练却又自命不凡,所以经常搞出另外一些奇葩的东西。比如在 JavaScript 里面,每个函数同时又可以作为构造函数(constructor),所以每个函数里面都隐含了一个 this 变量,你嵌套多层对象和函数的时候就发现没法访问外层的 this,非得“bind”一下。Python 的变量定义和赋值不分,所以你需要访问全局变量的时候得用 global 关键字,后来又发现如果要访问“中间层”的变量,没有办法了,所以又加了个 nonlocal 关键字……
有些人问我为什么有些语言设计成那个样子,我只能说,很多语言设计者其实根本不知道自己在干什么。
2.函数式编程(Functional Programming)
有人盲目的相信函数式编程能够奇迹般的解决并发计算的难题,而看不到实质存在的,独立于语言的问题。
函数式编程当然提供了它自己的价值。函数式编程相对于面向对象最大的价值,莫过于对于函数的正确理解。
在函数式语言里面,函数是“一类公民”(first-class)。它们可以像 1, 2, “hello”,true,对象…… 之类的“值”一样,在任意位置诞生,通过变量,参数和数据结构传递到其它地方,可以在任何位置被调用。这些是很多过程式语言和面向对象语言做不到的事情。
很多所谓“面向对象设计模式”(design pattern),都是因为面向对象语言没有 first-class function,所以导致了 每个函数必须被包在一个对象里面才能传递到其它地方 。
函数式编程的另一个贡献,是它们的类型系统。
函数式语言对于类型的思维,往往非常的严密。函数式语言的类型系统,往往比面向对象语言来得严密和简单很多,它们可以帮助你对程序进行严密的逻辑推理。然而类型系统一是把双刃剑,如果你对它看得太重,它反而会带来不必要的复杂性和过度工程。
3.符号必须简单的对世界建模
在我的心目中其实只有一个概念,它叫做“编程”(programming),它不带有任何附加的限定词(比如“函数式”或者“面向对象”)。我研究的领域称叫做“Programming Languages”,它研究的内容不局限于某一个语言,也不局限于某一类语言,而是所有的语言。在我的眼里, 所有的语言都不过是各个特性的组合 。所以最近出现的所谓“新语言”,其实不大可能再有什么真正意义上的创新。我不喜欢说“发明一个程序语言”,不喜欢使用“发明”这个词,因为不管你怎么设计一个语言,所有的特性几乎都早已存在于现有的语言里面了。我更喜欢使用“设计”这个词,因为虽然一个语言没有任何新的特性,它却有可能在细节上更加优雅。
编程最重要的事情,其实是让写出来的符号,能够简单地对实际或者想象出来的“世界”进行建模。
一个程序员最重要的能力,是直觉地看见符号和现实物体之间的对应关系。不管看起来多么酷的语言或者范式,如果必须绕着弯子才能表达程序员心目中的模型,那么它就不是一个很好的语言或者范式。
关于建模的另外一个问题是,你心里想的模型,并不一定是最好的,也不一定非得设计成那个样子。
有些人心里没有一个清晰简单的模型,觉得某些语言“好用”,就因为它们能够对他那种扭曲纷繁的模型进行建模。所以你就跟这种人说不清楚,为什么这个语言不好,因为显然这个语言对他是有用的!
所谓软件工程
有人把软件工程领域的本质总结为:“How to program if you cannot?”(如果你不会编程,那么你如何编程?)我觉得这句话说得很好,因为我发现软件工程这整个领域,基本就是吹牛扯淡卖“减肥药”的。软件行业的大部分莫名其妙的愚昧行为,很多是由所谓“软件工程专家”发明的。
打破软件工程幻觉的一个办法,就是实地去看看“专家”们用自己的方法论做出了什么好东西。你会惊奇的发现,这些提出各种新名词的所谓“专家”,几乎都是从不知道什么旮旯里冒出来的民科。他们跟真正的计算机科学家或者高明的程序员没有任何关系,也没有做出过什么有技术含量的东西,他们根本没有资格对别人编程的方式做出指导。这些人做出来少数有点用的东西(比如 JUnit),其实非常容易,以至于每个初学编程的人都应该做得出来。一个程序员见识需要低到什么程度,才会在乎这种人说的话?
可世界上就是有这样划算的行当,虽然写不出好的代码,对计算的理解非常肤浅,却可以通过嘴里说说,得到评价别人“代码质量”的权力,占据软件公司的管理层位置。久而久之,别人还以为他们是什么泰斗。你仔细看过提出 Design Pattern 的“四人帮”(GoF),做出过什么有实质价值的东西吗?提出“DRY Principle”的作者,做出过什么吗?再看看 Agile,Pair Programming,TDD 的提出者?他们其实不懂很多编程,写出文章和书来也是极其肤浅。
DRY 原则的误区
简言之,DRY(Don’t Repeat Yourself)原则鼓励对代码进行抽象,但是鼓励得过了头
1.抽象与可读性的矛盾
代码的“抽象”和它的“可读性”(直观性),其实是一对矛盾的关系。适度的抽象和避免重复是有好处的,它甚至可以提高代码的可读性,然而如果你尽“一切可能”从代码里提取模板,甚至把一些微不足道的“共同点”也提出来进行“共享”,它就开始有害了。
这是因为, 模板并不直接显示在“调用”它们的位置 。提取出模板,往往会使得阅读代码时不能一目了然。如果由此带来的直观性损失超过了模板所带来的好处时,你就应该考虑避免抽象了。
2.抽象的时机问题
抽象的思想,关键在于“发现两个东西是一样的”。然而很多时候,你开头觉得两个东西是一回事,结果最后发现,它们其实只是肤浅的相似,而本质完全不同。 防止过早抽象 的方法其实很简单,它的名字叫做“等待”。
谈程序的正确性
100% 可靠的代码,这是多么完美的理想!然而它并不存在!!!
1.衡量程序最重要的标准
许多人其实不明白一个重要的道理: 你得先写出程序,才能开始谈它的正确性 。看一个程序好不好,最重要的标准,是看它能否有效地解决问题,而不是它是否正确。如果你的程序没有解决问题,或者解决了错误的问题,或者虽然解决问题但却非常难用,那么这程序再怎么正确,再怎么可靠,都不是好的程序。
正确不等于简单,不等于优雅,不等于高效。一个不简单,不优雅,效率低的程序,就算你费尽周折证明了它的正确,它仍然不会很好的工作。
2.如何提高程序的正确性
话说回来,虽然程序的正确性相对于解决问题,处于相对次要的地位,然而它确实是不可忽视的。
如果你深入研究过程序的逻辑推导就会知道,测试和形式化证明的能力都是非常有限的。
那么提高程序正确性最有效的方法是什么呢?在我看来,最有效的方法莫过于对代码反复琢磨推敲,让它变得简单,直观,直到你一眼就可以看得出它不可能有问题。
对 Parser 的误解
1. 什么是 Parser
所谓 parser,一般是指把某种格式的文本(字符串)转换成某种数据结构的过程。
最常见的 parser,是把程序文本转换成编译器内部的一种叫做“抽象语法树”(AST)的数据结构。也有简单一些的 parser,用于处理 CSV,JSON,XML 之类的格式。
之所以需要做这种从字符串到数据结构的转换,是因为编译器是无法直接操作“1+2”这样的字符串的。实际上, 代码的本质根本就不是字符串 ,它本来就是一个具有复杂拓扑的数据结构,就像电路一样。“1+2”这个 字符串只是对这种数据结构的一种“编码” ,就像 ZIP 或者 JPEG 只是对它们压缩的数据的编码一样。
这种编码可以方便你把代码存到磁盘上,方便你用文本编辑器来修改它们,然而你必须知道,文本并不是代码本身。 所以从磁盘读取了文本之后,你必须先“解码”,才能方便地操作代码的数据结构。
对于程序语言,这种解码的动作就叫做 parsing ,用于解码的那段代码就叫做 parser 。
2.Parser 在编译器中的地位
那么貌似这样说来,parser 是编译器里面很关键的一个部分了?显然,parser 是必不可少的,然而它并不像很多人想象的那么重要。Parser 的重要性和技术难度,被很多人严重的夸大了。
我喜欢把 parser 称为“万里长征的第 0 步”,因为等你 parse 完毕得到了 AST,真正的编译技术才算开始。
一个编译器包含许多的步骤:语义分析,类型检查/推导,代码优化,机器代码生成,…… 这每个步骤都是在对某种中间数据结构(比如 AST )进行分析或者转化,它们完全不需要知道代码的字符串形式。也就是说,一旦代码通过了 parser,在后面的编译过程里,你就可以完全忘记 parser 的存在。
Parser 虽然必不可少,然而它比起编译器里面最重要的过程,是处于一种辅助性的地位。
AST 数据结构才是程序本身,而程序的文本只是这种数据结构的一种编码形式。
3.Parser 技术发展的误区
很多人盲目地设计复杂的语法,然后用越来越复杂的 parser 技术去 parse 它们,这就是 parser 技术仍然在发展的原因。
制造复杂难懂的语法,没有什么真正的好处。不但给程序员的学习造成了不必要的困难,让代码难以理解,而且也给 parser 的作者带来了严重的挑战。
4.编译原理课程的误导
一般大学里上编译原理课,都是捧着一本大部头的“龙书”或者“虎书”,花掉一个学期 1/3 甚至 2/3 的时间来学写 parser。由于 parser 占据了大量时间,以至于很多真正精华的内容都被一笔带过:语义分析,代码优化,类型推导,静态检查,机器代码生成,…… 以至于很多人上完了编译原理课程,记忆中只留下写 parser 的痛苦回忆。
我从来就不认为自己是“编译器”专业的,我认为自己是“PL 专业”。编译器领域照本宣科成分更多一些,PL 专业更加注重本质的东西。
如果你想真的深入理解编译理论,最好是从 PL 课程的读物,比如 EOPL 开始。
我可以说 PL 这个领域,真的和编译器的领域很不一样。请不要指望编译器的作者(比如 LLVM 的作者)能够设计出好的语言,因为他们可能根本不理解很多语言设计的东西,他们只是会实现某些别人设计的语言。可是反过来,理解了 PL 的理论, 编译器的东西只不过是把一种语言转换成另外一种语言(机器语言)而已 。工程的细枝末节很麻烦,可是当你掌握了精髓的原理,那些都容易摸索出来。
5.我写 parser 的心得和秘诀
很多人都觉得写 parser 很难,一方面是由于语言设计的错误思想导致了复杂的语法,另外一方面是由于人们对于 parser 构造过程的思维误区。很多人不理解 parser 的本质和真正的用途,所以他们总是试图让 parser 干一些它们本来不应该干的事情,或者对 parser 有一些不切实际的标准。当然,他们就会觉得 parser 非常难写,非常容易出错。
……
所以你看到了,parser 并不是编译器,它甚至不属于编译里很重要的东西。
Parser 的研究其实是在解决一些根本不存在或者人为制造的问题。复杂的语法导致了复杂的 parser 技术,它们仍然在给计算机世界带来不必要的困扰和麻烦。对 parser 写法的很多误解,过度工程和过早优化,造成了很多人错误的高估写 parser 的难度。
图灵的光环
编程的智慧
编程是一种创造性的工作,是一门艺术。精通任何一门艺术,都需要很多的 练习和领悟 ,所以这里提出的“智慧”,并不是号称一天瘦十斤的减肥药,它并不能代替你自己的勤奋。
1.反复推敲代码*
有些人喜欢炫耀自己写了多少多少万行的代码,仿佛代码的数量是衡量编程水平的标准。然而,如果你总是匆匆写出代码,却从来不回头去推敲,修改和提炼,其实是不可能提高编程水平的。
就像文学作品一样,代码是不可能一蹴而就的。灵感似乎总是零零星星,陆陆续续到来的。
所以如果反复提炼代码已经不再有进展,那么你可以暂时把它放下。过几个星期或者几个月再回头来看,也许就有焕然一新的灵感。这样反反复复很多次之后,你就积累起了灵感和智慧,从而能够在遇到新问题的时候直接朝正确,或者接近正确的方向前进。
2.写优雅的代码*
人们都讨厌“面条代码”(spaghetti code),因为它就像面条一样绕来绕去,没法理清头绪。
那么优雅的代码一般是什么形状的呢?
如果我们忽略具体的内容,从大体结构上来看,优雅的代码看起来就像是一些整整齐齐,套在一起的盒子。
优雅的代码的另一个特征是,它的逻辑大体上看起来,是枝丫分明的树状结构(tree)。这是因为程序所做的几乎一切事情,都是信息的传递和分支。你可以把代码看成是一个电路,电流经过导线,分流或者汇合。
3.写模块化的代码*
有些人吵着闹着要让程序“模块化”,其实并不理解什么叫做“模块”。肤浅的把代码切割开来,分放在不同的位置,其实非但不能达到模块化的目的,而且制造了不必要的麻烦。
真正的模块化,并不是文本意义上的,而是逻辑意义上的。
一个模块应该像一个电路芯片,它有定义良好的输入和输出。实际上一种很好的模块化方法早已经存在,它的名字叫做“函数”。每一个函数都有明确的输入(参数)和输出(返回值),同一个文件里可以包含多个函数,所以你其实根本不需要把代码分开在多个文件或者目录里面,同样可以完成代码的模块化。
想要达到很好的模块化,你需要做到以下几点:
1) 避免写太长的函数
如果发现函数太大了,就应该把它拆分成几个更小的。
2) 制造小的工具函数
如果你仔细观察代码,就会发现其实里面有很多的重复。这些常用的代码,不管它有多短,提取出去做成函数,都可能是会有好处的。有些帮助函数也许就只有两行,然而它们却能大大简化主要函数里面的逻辑。
3) 每个函数只做一件简单的事情
有些人喜欢制造一些“通用”的函数,既可以做这个又可以做那个,它的内部依据某些变量和条件,来“选择”这个函数所要做的事情。这种“复用”其实是有害的。
如果一个函数可能做两种事情,它们之间共同点少于它们的不同点,那你最好就写两个不同的函数,否则这个函数的逻辑就不会很清晰,容易出现错误。
如果你发现两件事情大部分内容相同,只有少数不同,多半时候你可以把相同的部分提取出去,做成一个辅助函数。
4) 避免使用全局变量和类成员(class member)来传递信息,尽量使用局部变量和参数
依赖全局的数据,函数不再有明确的输入和输出,依赖于当前的上下文。全局的数据还有可能被其他代码改变,代码变得难以理解,难以确保正确性。
4.写可读的代码*
有些人以为写很多注释就可以让代码更加可读,然而却发现事与愿违。注释不但没能让代码变得可读,反而由于大量的注释充斥在代码中间,让程序变得障眼难读。而且代码的逻辑一旦修改,就会有很多的注释变得过时,需要更新。修改注释是相当大的负担,所以大量的注释,反而成为了妨碍改进代码的绊脚石。
实际上,真正优雅可读的代码,是几乎不需要注释的。
如果你发现需要写很多注释,那么你的代码肯定是含混晦涩,逻辑不清晰的。其实,程序语言相比自然语言,是更加强大而严谨的,它其实具有自然语言最主要的元素:主语,谓语,宾语,名词,动词,如果,那么,否则,是,不是,…… 所以如果你充分利用了程序语言的表达能力,你完全可以用程序本身来表达它到底在干什么,而不需要自然语言的辅助。
有少数的时候,你也许会为了绕过其他一些代码的设计问题,采用一些违反直觉的作法。这时候你可以使用很短注释,说明为什么要写成那奇怪的样子。这样的情况应该少出现,否则这意味着整个代码的设计都有问题。
如果没能合理利用程序语言提供的优势,你会发现程序还是很难懂,以至于需要写注释。
所以我现在告诉你一些要点,也许可以帮助你大大减少写注释的必要:
1) 使用有意义的函数和变量名字
如果你的函数和变量的名字,能够切实的描述它们的逻辑,那么你就不需要写注释来解释它在干什么。比如:
// put elephant1 into fridge2
put(elephant1, fridge2);
2) 局部变量应该尽量接近使用它的地方
这种短距离,可以加强读者对于这里的“计算顺序”的理解。读者就就清楚的知道,这个变量并不是保存了什么可变的值,而且它算出来之后就没变过。
如果你看透了局部变量的本质——它们就是电路里的导线,那你就能更好的理解近距离的好处。变量定义离用的地方越近,导线的长度就越短。你不需要摸着一根导线,绕来绕去找很远,就能发现接收它的端口,这样的电路就更容易理解。
3) 局部变量名字应该简短
因为它们处于局部,再加上第 2 点已经把它放到离使用位置尽量近的地方,所以根据上下文你就会容易知道它的意思。
4) 不要重用局部变量
不过仍然需要注意,变量定义和最终使用距离不要太远,否则,就应该考虑其他方式。
5) 把复杂的逻辑提取出去,做成“帮助函数”
有些人写的函数很长,以至于看不清楚里面的语句在干什么,所以他们误以为需要写注释。如果你仔细观察这些代码,就会发现不清晰的那片代码,往往可以被提取出去,做成一个函数,然后在原来的地方调用。由于函数有一个名字,这样你就可以使用有意义的函数名来代替注释。
举一个例子:
...
// put elephant1 into fridge2
openDoor(fridge2);
if (elephant1.alive()) {
...
} else {
...
}
closeDoor(fridge2);
...
如果你把这片代码提出去定义成一个函数:
void put(Elephant elephant, Fridge fridge) {
openDoor(fridge);
if (elephant.alive()) {
...
} else {
...
}
closeDoor(fridge);
}
这样原来的代码就可以改成:
...
put(elephant1, fridge2);
...
更加清晰,而且注释也没必要了。
6) 把复杂的表达式提取出去,做成中间变量
Pizza pizza = makePizza(crust(salt(), butter()),
topping(onion(), tomato(), sausage()));
// ↓↓↓
Crust crust = crust(salt(), butter());
Topping topping = topping(onion(), tomato(), sausage());
Pizza pizza = makePizza(crust, topping);
有效地控制了单行代码的长度,而且由于引入的中间变量具有“意义”,步骤清晰,变得很容易理解。
7) 在合理的地方换行
5.写简单的代码*
程序语言都喜欢标新立异,提供这样那样的“特性”,然而有些特性其实并不是什么好东西。很多特性都经不起时间的考验,最后带来的麻烦,比解决的问题还多。
并不是语言提供什么,你就一定要把它用上的。实际上你只需要其中很小的一部分功能,就能写出优秀的代码。我一向反对“充分利用”程序语言里的所有特性。
实际上,我心目中有一套最好的构造。不管语言提供了多么“神奇”的,“新”的特性,我基本都只用经过千锤百炼,我觉得值得信赖的那一套。
现在针对一些有问题的语言特性,我介绍一些我自己使用的代码规范,并且讲解一下为什么它们能让代码更简单。
1) 避免使用自增减表达式( =i++,++i,i–,–i= )
这种自增减操作表达式其实是历史遗留的 设计失误 。
它们把读和写这两种完全不同的操作,混淆缠绕在一起,把语义搞得乌七八糟。含有它们的表达式,结果可能取决于求值顺序,所以它可能在某种编译器下能正确运行,换一个编译器就出现离奇的错误。
其实这两个表达式完全可以分解成两步,把读和写分开:一步更新 i 的值,另外一步使用 i 的值。
foo(i++);
// ↓↓↓
let t = i;
i += 1;
foo(t);
// -------
foo(++i);
// ↓↓↓
i += 1;
foo(i);
不难看出, =i++= 其实是使用更新前的值,而 =++1= 是使用更新后的值。
拆开之后的代码,含义完全一致,却清晰很多。到底更新是在取值之前还是之后,一目了然。
自增减表达式只有在两种情况下才可以安全的使用。一种是在 for 循环的 update 部分,比如 =for(int i = 0; i < 5; i++)= 。另一种情况是写成单独的一行,比如 =i++;= 。这两种情况是完全没有歧义的。
你需要避免其它的情况,比如用在复杂的表达式里面,比如 =foo(i++),foo(++i) + foo(i),……= 没有人应该知道,或者去追究这些是什么意思。
2) 永远不要省略花括号
3) 合理使用括号,不要盲目依赖操作符优先级
4) 避免使用 =continue= 和 =break=
循环语句(for,while)里面出现 =return= 是没问题的,然而如果你使用了 =continue= 或者 =break= ,就会让循环的逻辑和终止条件变得复杂,难以确保正确。
出现 continue 或者 break 的原因,往往是对循环的逻辑没有想清楚。如果你考虑周全了,应该是几乎不需要 continue 或者 break 的。如果你的循环里出现了 continue 或者 break ,你就应该考虑改写这个循环。
改写循环的办法有多种:
- 如果出现了 continue ,你往往只需要把 continue 的条件反向,就可以消除 continue ;
- 如果出现了 break ,你往往可以把 break 的条件,合并到循环头部的终止条件里,从而去掉 break ;
- 有时候你可以把 break 替换成 return,从而去掉 break ;
- 如果以上都失败了,你也许可以把循环里面复杂的部分提取出来,做成函数调用,之后 continue 或者 break 就可以去掉了。
6.写直观的代码*
我写代码有一条重要的原则:如果有更加直接,更加清晰的写法,就选择它,即使它看起来更长,更笨,也一样选择它。
比如,人们往往容易滥用了逻辑操作 =&&= 和 =||= 的短路特性。这两个操作符可能不执行右边的表达式,原因是为了机器的执行效率,而不是为了给人提供这种“巧妙”的用法。这两个操作符的本意,只是作为逻辑操作,它们并不是拿来给你代替 if 语句的。
……
7.写无懈可击的代码*
在之前一节里,我提到了自己写的代码里面很少出现只有一个分支的 if 语句。我写出的 if 语句,大部分都有两个分支。使用这种方式,其实是为了无懈可击的处理所有可能出现的情况,避免漏掉 corner case。所以我的代码很多看起来是这个样子:
// 推荐 ✔
if (...) {
if (...) {
...
return false;
} else {
return true;
}
} else if (...) {
...
return false;
} else {
return true;
}
缺了 else 分支的 if 语句,控制流自动“掉下去”,到达最后的 return true
。这种写法看似更加简洁,避免了重复,然而却很容易出现疏忽和漏洞。
// 不推荐 ✘
if (...) {
if (...) {
...
return false;
}
} else if (...) {
...
return false;
}
return true;
嵌套的 if 语句省略了一些 else,依靠语句的“控制流”来处理 else 的情况,是很难正确的分析和推理的。如果你的 if 条件里使用了 && 和 ||
之类的逻辑运算,就更难看出是否涵盖了所有的情况。
由于疏忽而漏掉的分支,全都会自动“掉下去”,最后返回意想不到的结果。即使你看一遍之后确信是正确的,每次读这段代码,你都不能确信它照顾了所有的情况,又得重新推理一遍。这简洁的写法,带来的是反复的,沉重的头脑开销。这就是所谓“面条代码”,因为程序的逻辑分支,不是像一棵枝叶分明的树,而是像面条一样绕来绕去。
另外一种省略 else 分支的情况是这样:
// 不推荐 ✘
String s = "";
if (x < 5) {
s = "ok";
}
写这段代码的人,脑子里喜欢使用一种“缺省值”的做法。s 缺省为 null
,如果 x<5
,那么把它改变(mutate)成“ok”。这种写法的缺点是,当 x<5
不成立的时候,你需要往上面看,才能知道 s 的值是什么。这还是你运气好的时候,因为 s 就在上面不远。很多人写这种代码的时候,s 的初始值离判断语句有一定的距离,中间还有可能插入一些其它的逻辑和赋值操作。
// 推荐 ✔
String s;
if (x < 5) {
s = "ok";
} else {
s = "";
}
// 这个情况比较简单,你还可以把它写成这样
// (对于更加复杂的情况,我建议还是写成 if语句为好)
String s = x < 5 ? "ok" : "";
8.正确处理错误*
使用有两个分支的 if 语句,只是我的代码可以达到无懈可击的其中一个原因。这样写 if 语句的思路,其实包含了使代码可靠的一种 通用思想:穷举所有的情况 ,不漏掉任何一个。
……
9.正确处理 null 指针*
……
10.防止过度工程*
过度工程即将出现的一个重要信号,就是当你过度的思考“将来”,考虑一些还没有发生的事情,还没有出现的需求。另外一种过度工程的来源,是过度的关心“代码重用”。过度地关心“测试”,也会引起过度工程。
根据这些,我总结出来的防止过度工程的原则如下:
- 先把眼前的问题解决掉,解决好,再考虑将来的扩展问题;
- 先写出可用的代码,反复推敲,再考虑是否需要重用的问题;
- 先写出可用,简单,明显没有 bug 的代码,再考虑测试的问题。
给 Java 说句公道话
Java 超越了所有咒骂它的“动态语言”!
Java 的“继承人”没能超越它!
Java 没有特别讨厌的地方。
编程使用什么工具是重要的,然而工具终究不如自己的技术重要。很多人花了太多时间,折腾各种新的语言,希望它们会奇迹一般的改善代码质量,结果最后什么都没做出来。选择语言最重要的条件,应该是“够好用”就可以,因为项目的成功最终是靠人,而不是靠语言。既然 Java 没有特别大的问题,不会让你没法做好项目,为什么要去试一些不靠谱的新语言呢?
我为什么不再做 PL 人
PL 看似计算机科学最精髓的部分,事实确实也是这样的。没有任何一个其它领域,可以让你对程序的本质形成如此深入的领悟。
……
测试的道理
在长期的程序语言研究和实际工作中,我摸索出了一些关于测试的道理。
在我心目中,代码本身的地位大大的高于测试。我不忽视测试,但我不会本末倒置,过分强调测试,我并不推崇测试驱动开发(TDD)。
PS.我不怎么写测试……
现在我就把这些自己领悟到的关于测试的道理总结一下,其中有一些是鲜为人知或者被误解的。
- 不要以为你处处显示出“重视代码质量”的态度,就能提高代码质量;
- 真正的编程高手不会被测试捆住手脚;
- 在程序和算法定型之前,不要写测试;
- 不要为了写测试而改变本来清晰的编程方式;
- 不要测试“实现细节”,因为那等同于把代码写两遍;
- 并不是每修复一个 bug 都需要写测试;
- 避免使用 mock,特别是多层的 mock;
- 不要过分重视“测试自动化”,人工测试也是测试;
- 避免写太长,太耗时的测试;
- 一个测试只测试一个方面,避免重复测试;
- 避免通过比较字符串来进行测试;
- 认知“测试能帮助后来人”的误区。
……
经验和洞察力
很多人很在乎“经验”,比如号称自己在某领域有 30 年的经验,会用这样那样的技术。我觉得经验是有价值的,我也有经验,各个领域的都有点。然而我并不把经验放在很重要的位置,因为我拥有大部分人都缺乏而且忽视的一种东西:洞察力(insight)。
什么是洞察力?洞察力就是透过现象看到本质的能力。
其实,经验和洞察力并不是矛盾的,王垠想表达的是他得到了“道”,所以可以很快的掌握“术”。
如何掌握所有的程序语言
重视语言特性,而不是语言
任何一种“语言”,都是各种“语言特性”的组合。
举一些语言特性的例子:
- 变量定义
- 算术运算
- for 循环语句,while 循环语句
- 函数定义,函数调用
- 递归
- 静态类型系统
- 类型推导
- lambda 函数
- 面向对象
- 垃圾回收
- 指针算术
- goto 语句
- ……
对于初学者来说,其实没必要纠结到底要先学哪一种语言,再学哪一种。
初学者往往不理解, 每一种语言里面必然有一套“通用”的特性 。比如变量,函数,整数和浮点数运算,等等。这些是每个通用程序语言里面都必须有的,一个都不能少。你只要通过“某种语言”学会了这些特性,掌握这些特性的根本概念,就能随时把这些知识应用到任何其它语言。你为此投入的时间基本不会浪费。所以初学者纠结要“先学哪种语言”,这种时间花的很不值得,还不如随便挑一个语言,跳进去。
如果你不能用一种语言里面的基本特性写出好的代码,那你换成另外一种语言也无济于事。你会写出一样差的代码。
很多初学者不了解,一个高明的程序员如果开始用一种新的程序语言,他往往不是去看这个语言的大部头手册或者书籍,而是先有一个需要解决的问题。手头有了问题,他可以用两分钟浏览一下这语言的手册,看看这语言大概长什么样。然后,他直接拿起一段例子代码来开始修改捣鼓,想法把这代码改成自己正想解决的问题。在这个简短的过程中,他很快的掌握了这个语言,并用它表达出心里的想法。
在这个过程中,随着需求的出现,他可能会问这样的问题:
- 这个语言的“变量定义”是什么语法,需要“声明类型”吗,还是可以用“类型推导”?
- 它的“类型”是什么语法?是否支持“泛型”?泛型的 “variance” 如何表达?
- 这个语言的“函数”是什么语法,“函数调用”是什么语法,可否使用“缺省参数”?
- ……
注意到了吗?上面每一个引号里面的内容,都是一种语言特性(或者叫概念)。这些概念可以存在于任何的语言里面,虽然语法可能不一样,它们的本质都是一样的。
这些实际问题都是随着写实际的代码,解决手头的问题,自然而然带出来的,而不是一开头就抱着语言手册看得仔仔细细。
掌握了语言特性的人都知道,自己需要的特性,在任何语言里面一定有对应的表达方式。 如果没有直接的方式表达,那么一定有某种“绕过方式”。如果有直接的表达方式,那么它只是语法稍微有所不同而已。所以,他是带着问题找特性,就像查字典一样,而不是被淹没于大部头的手册里面,昏昏欲睡一个月才开始写代码。
掌握了通用的语言特性,剩下的就只剩某些语言“特有”的特性了。
研究语言的人都知道,要设计出新的,好的,无害的特性,是非常困难的。所以一般说来,一种好的语言,它所特有的新特性,终究不会超过一两种。如果有个语言号称自己有超过 5 种新特性,那你就得小心了,因为它们带来的和可能不是优势,而是灾难!
最好的语言研究者,往往不是某种语言的设计者,而是某种 关键语言特性的设计者 (或者支持者)。
合理的入门语言
所以初学者要想事半功倍,就应该从 一种“合理”的,没有明显严重问题的语言 出发, 掌握最关键的语言特性,然后由此把这些概念应用到其它语言 。哪些是合理的入门语言呢?我个人觉得这些语言都可以用来入门:
- Scheme
- C
- Java
- Python
- JavaScript
那么相比之下,我不推荐用哪些语言入门呢?
- Shell
- PowerShell
- AWK
- Perl
- PHP
- Basic
- Go
- Rust
掌握关键语言特性,忽略次要特性
为了达到我之前提到的融会贯通,一通百通的效果,初学者应该专注于语言里面最关键的特性,而不是被次要的特性分心。
……
自己动手实现语言特性
在基本学会了各种语言特性,能用它们来写代码之后,下一步的进阶就是去实现它们。只有实现了各种语言特性,你才能完全地拥有它们,成为它们的主人。否则你就只是它们的使用者,你会被语言的设计者牵着鼻子走。
有个大师说得好, 完全理解一种语言最好的方法就是自己动手实现它,也就是自己写一个解释器来实现它的语义 。但我觉得这句话应该稍微修改一下: 完全理解一种“语言特性”最好的方法就是自己亲自实现它 。
注意我在这里把“语言”改为了“语言特性”。你并不需要实现整个语言来达到这个目的,因为我们最终使用的是语言特性。 只要你自己实现了一种语言特性,你就能理解这个特性在任何语言里的实现方式和用法。
举个例子,学习 SICP 的时候,大家都会亲自用 Scheme 实现一个面向对象系统。用 Scheme 实现的面向对象系统,跟 Java,C++,Python 之类的语言语法相去甚远,然而它却能帮助你理解任何这些 OOP 语言里面的“面向对象”这一概念,它甚至能帮助你理解各种面向对象实现的差异。
这种效果是你直接学习 OOP 语言得不到的,因为在学习 Java,C++,Python 之类语言的时候,你只是一个用户,而用 Scheme 自己动手实现了 OO 系统之后,你成为了一个创造者。
类似的特性还包括类型推导,类型检查,惰性求值,如此等等。我实现过几乎所有的语言特性,所以任何语言在我的面前,都是可以被任意拆卸组装的玩具,而不再是凌驾于我之上的神圣。
总结
写了这么多,重要的话重复三遍: 语言特性,语言特性,语言特性,语言特性! 不管是初学者还是资深程序员,应该专注于语言特性,而不是纠结于整个的“语言品牌”。只有这样才能达到融会贯通,拿起任何语言几乎立即就会用,并且写出高质量的代码。
解谜计算机科学
解谜英语语法
学习的智慧
1.死知识,活知识
大部分人从学校,从书籍,从文献学知识,结果学到一堆“死知识”。要检验知识是不是死的,很简单。如果你遇到前所未见的问题,却不能把这些知识运用出来解决问题,那么这些知识就很可能是死的。
死知识可能来源于真正聪明的人,但普通人往往是间接得到它。从知识的创造者到你之间,经过了多次的转手倒卖。就算你直接跟知识的鼻祖学习都不容易得到真传,普通人还得经过多次转手。每一次转手都损失里面的信息含量,增加“噪音”,甚至完全被误传。所以到你这里的时候,里面的“信噪比”就很低了。这就是为什么你学了东西,到时候却没法用出来。
追根溯源之后,你会发现这知识最初的创造者经过了成百上千的错误。
没有这些失败的经验,你就少了所谓“思路”,那你是不大可能从一个知识发展出新的知识的。
死知识是脆弱的。面对现实的问题,死知识的拥有者往往不知所措,他们的内心充满了恐惧。
世界上最重大的发现,往往产生于对非常基础的问题的思考。
活知识必须靠自己创造出来,要经过许许多多的失败。如果没有经过失败,是不可能得到活知识的。
2.知识的来源
知识的来源最好是自己的头脑,但也不尽然。有些东西成本太高,没条件做实验就没法得到,所以还是得先获取现成的死知识。
有些人说到“学习”,总是喜欢认认真真上课,抄笔记,看书。有些人喜欢勾书,把书上整整齐齐画满了横杠。兢兢业业不辞辛苦,最后却发现没学会什么。
为什么会这样呢?
首先因为他们没有理智的选择知识的来源。其次,他们不明白如何有效的“提取”知识。这第一点属于“品位”问题,第二点则属于“方法”问题。
很多人没有意识到,对于同一个问题有很多不同的书,不同的作者对于问题的见解深度是不一样的。如果一个主题你看得头大,最好的办法是放下这书,去寻找对同一主题的更简单的解释。这些东西可以来源于网络,也可以来自其它书籍,也可以来自身边的人。
同时保留多个这样的资源,你就可以对任何主题采用同样的“广度优先”搜索,获得深入理解的机会就会增加。
3.英语的重要性
不是我崇洋媚外,可是实话说,这几年中文内容虽然改进了很多,可是很多方向上的专业程度还是比英文的低很多,很多不准确甚至根本就是错的。
我不排斥看中文内容,但我建议不要片面的只看中文内容。事无巨细都应该同时参考英文信息,多方面分析之后再做决定。生活的决策如此,专业知识的学习当然也一样。对于同一个知识点,看到中文的时候你最好搜索它的英文,对比各种资料,这样你就更容易得到准确的信息。
Talk is not cheap
“苦干,用代码说话,忽视想法”,是很多程序员的误区。
人的思想不一定需要代码来证明,甚至很多的想法无法简单的用代码表示,只有靠人的头脑才能想得清楚。思想是首要的,代码只是对思想的一种实现。
我们先得要有思想(算法),才可能有代码。
代码不能代替思想交流和讨论。代码不能清晰的表达一个人的想法,也不能显示一个人的思维深度。
代码是死的,它是对已有问题的解决方案。而你想要知道的是这个人在面对新的问题的时候,他会怎样去解决它。所以你必须知道这个人的思维方式,看清楚他是否真的知道他声称“精通”的那些东西。
我不是编译器专家
我不是编译器专家,而且我看不起编译器这个领域。我一般不会居高临下看低其它人,然而对于认识肤浅却又自视很高的人,我确实会表示出藐视的态度。现在我的态度是针对编译器这整个领域。真的,我看这些人不顺眼很多年了。
就最后研究的领域,我是一个编程语言(PL)研究者,从更广的角度来看,我是一个计算机科学家。
IT 业人士经常混淆编程语言(PL)和编译器两个领域,而其实 PL 和编译器是很不一样的。真懂 PL 的人去做编译器也会比较顺手,而编译器专业的却不一定懂 PL。为什么呢?因为 PL 研究涵盖了计算最本质的原理,它不但能解释语言的语义,而且能解释处理器的构架和工作原理 。当然它也能解释编译器是怎么回事,因为编译器只不过是把一种语言的语义,利用另外一种语言表达出来,也就是翻译一下。PL 研究所用的编程范式和技巧,很多可以用到编译器的构造中去,但却比编译器的范畴广阔很多。
深入研究过 PL 的人,能从本质上看明白编译器里在做什么。所以编译器算是 PL 思想的一种应用,然而 PL 的应用却远远不止做编译器。
实际上做编译器是很无聊的工作,大部分时候只是把别人设计的语言,翻译成另外的人设计的硬件指令。所以编译器领域处于编程语言(PL)和计算机体系构架(computer architecture)两个领域的夹缝中,上面的语言不能改,下面的指令也不能改,并没有很大的创造空间。
我的事业计划
为了建立起最高水准的,真正的教育机构,我的初期计划是做一个顾问或者导师。
在计划中的课程内容可能包括:
- 计算机科学入门
- 掌握所有的编程语言
- C++,Java,Python,JavaScript,Haskell
- 编程的智慧——如何写出优雅的代码
- 算法
- 编程语言理论
- 操作系统
- 计算机体系构架
- 编译器设计和实现
- 函数式编程
- 逻辑式编程
- 机器学习(深度学习,计算机视觉等)
- ……
每一个课程,我都会试图用最简单直观的方式来讲解。
如何阅读别人的代码
比起阅读代码,我更喜欢别人给我讲解他们的代码,用简单的语言或者图形来解释他们的思想。有了思想,我自然知道如何把它变成代码,而且是优雅的代码。很多人的代码我不会去看,但如果他们给我讲,我是可以接受的。
如果有同事请我帮他改进代码,我不会拿起代码埋头就看,因为我知道看代码往往是事倍功半,甚至完全没用。我会让他们先在白板上给我解释那些代码是什么意思。我的同事们都发现,把我讲明白是需要费一番工夫的。因为我的要求非常高,只要有一点不明白,我就会让他们重新讲。还得画图,我会让他们反复改进画出来的图,直到我能一眼看明白为止。如果图形是 3D 的,我会让他们给我压缩成 2D 的,理解了之后再推广到 3D。
我无法理解复杂的,高维度的概念,他们必须把它给我变得很简单。
所以跟我讲代码可能需要费很多时间,但这是值得的。我明白了之后,往往能挖出其他人都难以看清楚的要点。给我讲解事情,也能提升他们自己的思维和语言能力,帮助他们简化思想。很多时候我根本没看代码,通过给我讲解,后来他们自己就把代码给简化了。节省了我的脑力和视力,他们也得到了提高。
我最近一次看别人的代码是在 Intel,我们改了 PyTorch 的代码。那不是一次愉悦的经历,因为虽然很多人觉得 PyTorch 好用,它内部的代码却是晦涩而难以理解的。
PyTorch 之类的深度学习框架,本质上是某种简单编程语言的解释器,只不过这些语言写出来的函数可以求导而已。
很多人都不知道,有一天我用不到一百行 Scheme 代码就写出了一个「深度学习框架」,它其实是一个小的编程语言。虽然没有性能可言,没有 GPU 加速,功能也不完善,但它抓住了 PyTorch 等大型框架的本质——用这个语言写出来的函数能自动求导。这种洞察力才是最关键的东西,只要抓住了关键,细节都可以在需要的时候琢磨出来。几十行代码反复琢磨,往往能帮助你看透上百万行的项目里隐藏的秘密。
很多人以为看大型项目可以提升自己,而没有看到大型项目不过是几十行核心代码的扩展,很多部分是低水平重复。几十行平庸甚至晦涩的代码,重复一万次,就成了几十万行。看那些低水平重复的部分,是得不到什么提升的。
造就我今天的编程能力和洞察力的,不是几百万行的大型项目,而是小到几行,几十行之短的练习。
不要小看了这些短小的代码,它们就是编程最精髓的东西。反反复复琢磨这些短小的代码,不断改进和提炼里面的结构,磨砺自己的思维。逐渐的,你的认识水平就超越了这些几百万行,让人头痛的项目。
所以我如何阅读别人的代码呢?Don’t。如果有条件,我就让代码的作者给我讲,而不是去阅读它。如果作者不合作,而我真的要使用那个项目的代码,我才会去折腾它。那么如何折腾别人的代码呢?我有另外一套办法。
英语学习的一些经验
对智商的怀疑
计算机科学入门班报名
1.为什么重视“零基础”教育
有些人可能不大明白我为什么喜欢讲“零基础”课程。一方面,真正好的教育应该是能让完全无基础的人顺利掌握的。就像爱因斯坦说:“如果你不能给一个六岁小孩解释清楚,那你并不真的懂。” 所以“零基础”的学生能够检验我是否达到了这个“真懂”的目标。
实际上, 我的很多深刻理解,都是通过反复琢磨非常基础的概念获得的 ,而不是通过很“高级”,很复杂的概念。我最常用的“心理模型”,其实跟初学者第一节课学的内容差不多。
在我心里并没有“初学者”和“资深者”的差别。我发现很多工作了几十年的工程师,很多连最基本的概念都是一知半解的,这也许就是为什么他们在工作中无法找准正确的方向,经常瞎撞。
2.课程内容
课程计划涵盖计算机科学的主要思想,大概会包括以下内容:
- 基础语言构造,包含最常用几种语言的主要特性。
- 递归思想,递归数据结构的处理。
- 基本数据结构,少量基础算法。
- 函数式编程基本思想。
- 抽象的思维方式。
- 基础的解释器原理。
如果从书籍的覆盖面来看,我试图包括以下书籍的精华内容:
- SICP(前 4 章)
- The Little Schemer
- A Little Java, A Few Patterns
3.你将受到的训练
- 掌握系统化的思维方法,严密的推理技巧;
- 写出简单,优雅,容易理解,可靠的代码;
- 从无到有,不依赖于任何语言的特性,解决各种计算问题的思路。
新丑陋的中国人
计算机科学基础班(第三期)报名
……
课程大纲,下面简要说一下课程的内容:
教学语言
课程目前使用 JavaScript 作为教学语言,但并不是教 JavaScript 语言本身,不会使用 JavaScript 特有的任何功能。课程教的思想不依赖于 JavaScript 的任何特性,它可以应用于任何语言,课程可以在任何时候换成任何语言。学生从零开始,学会的是计算机科学最核心的思想,从无到有创造出各种重要的概念,直到最后实现出自己的编程语言和类型系统。
课程强度
课程的设计是一个逐渐加大难度,比较辛苦,却很安全的山路,它通往很高的山峰。要参加课程,请做好付出努力的准备。在两个月的时间里,你每天需要至少一个小时来做练习,有的练习需要好几个小时才能做对。跟其他的计算机教学不同,学生不会因为缺少基础而放弃,不会误入歧途,也不会掉进陷阱出不来。学生需要付出很多的时间和努力,但没有努力是白费的。
第一课:函数
跟一般课程不同,我不从所谓“Hello World”程序开始,也不会叫学生做一些好像有趣而其实无聊的小游戏。
一开头我就讲最核心的内容:函数。
关于函数只有很少几个知识点,但它们却是一切的核心。只知道很少的知识点的时候,对它们进行反复的练习,让头脑能够自如地对它们进行思考和变换,这是教学的要点。我为每个知识点设计了恰当的练习。
第一课的练习每个都很小,只需要一两行代码,却蕴含了深刻的原理。练习逐渐加大难度,直至超过博士课程的水平。我把术语都改头换面,要求学生不上网搜索相关内容,为的是他们的思维不受任何已有信息的干扰,独立做出这些练习。练习自成系统,一环扣一环。后面的练习需要从前面的练习获得的灵感,却不需要其它基础。有趣的是,经过正确的引导,好些学生把最难的练习都做出来了,完全零基础的学生也能做出绝大部分,这是我在世界名校的学生里都没有看到过的。具体的内容因为不剧透的原因,我就不继续说了。
第二课:递归
递归可以说是计算机科学(或数学)最重要的概念。
我从最简单的递归函数开始,引导理解递归的本质,掌握对递归进行系统化思考的思路。递归是一个很多人自以为理解了的概念,而其实很多人都被错误的教学方式误导了。很多人提到递归,只能想起“汉诺塔”或者“八皇后”问题,却不能拿来解决实际问题。很多编程书籍片面强调递归的“缺点”,教学生如何“消除递归”,却看不到问题的真正所在——某些语言(比如 C 语言)早期的函数调用实现是错误而效率低下的,以至于学生被教导要避免递归。由于对于递归从来没有掌握清晰的思路,在将来的工作中一旦遇到复杂点的递归函数就觉得深不可测。
第三课:链表
从零开始,学生不依赖于任何语言的特性,实现最基本的数据结构。
第一个数据结构就是链表,学生会在练习中实现许多操作链表的函数。这些函数经过了精心挑选安排,很多是函数式编程语言的基本函数,但通过独立把它们写出来,学生掌握的是递归的系统化思路。这使得他们能自如地对这类数据结构进行思考,解决新的递归问题。
与一般的数据结构课程不同,这个课程实现的大部分都是「函数式数据结构」,它们具有一些特别的,有用的性质。因为它们逻辑结构清晰,比起普通数据结构书籍会更容易理解。与 Haskell 社区的教学方式不同,我不会宗教式的强调纯函数的优点,而是客观地让学生领会到其中的优点,并且发现它们的弱点。学会了这些结构,在将来也容易推广到非函数式的结构,把两种看似不同的风格有机地结合在一起。
第四课:树结构
从链表逐渐推广出更复杂的数据结构——树。
在后来的内容中,会常常用到这种结构。树可能是计算机科学中最常用,最重要的数据结构了,所以理解树的各种操作是很重要的。我们的树也都是纯函数式的。
第五课:计算器
在熟悉了树的基本操作之后,实现一个比较高级的计算器,它可以计算任意嵌套的算术表达式。算术表达式是一种“语法树”,从这个练习学生会理解“表达式是一棵树”这样的原理。
第六课:查找结构
理解如何实现 =key-value= 查找结构,并且亲手实现两种重要的查找数据结构。我们的查找结构也都是函数式数据结构。这些结构会在后来的解释器里派上大的用场,对它们的理解会巩固加深。
第七课:解释器
利用之前打好的基础,亲手实现计算机科学中最重要,也是通常认为最难理解的概念——解释器。
解释器是理解各种计算机科学概念的关键,比如编程语言,操作系统,数据库,网络协议,Web 框架。计算机最核心的部件 CPU 其实就是一个解释器,所以解释器的认识能帮助你理解「计算机体系构架」,也就是计算机的“硬件”。你会发现这种硬件其实和软件差别不是很大。你可以认为解释器就是「计算」本身,所以它非常值得研究。对解释器的深入理解,也能帮助理解很多其它学科,比如自然语言,逻辑学。
第八课:类型系统
在解释器的基础上,学生会理解并实现一个相当高级的类型系统(type system)和类型检查器(typechecker)。
这相当于实现一个类似 Java 的静态类型语言,但比 Java 在某些方面还要高级和灵活。我们的类型系统包含了对于类型最关键的要素,而不只是照本宣科地讲解某一种类型系统。当你对现有的语言里的类型系统不满意的时候,这些思路可以帮助你设计出自己的类型系统。学生会用动手的方式去理解静态类型系统的原理,其中的规则,却不含有任何公式。
类型系统的规则和实现,一般只会在博士级别的研究中才会出现,可以写成一本厚书(比如 TAPL 那样的),其中有各种神秘的逻辑公式。而我的学生从零开始,一节课就可以掌握这门技术的关键部分,实现出正确的类型系统,并且推导出正确的公式。有些类型规则是如此的微妙,以至于微软这么大的公司在 21 世纪做一个新的语言(TypeScript),仍然会在初期犯下类型专家们早已熟知的基本错误。上过这个课程的很多同学,可以说对这些基础原理的理解已经超过了 TypeScript 的设计者,但由于接受的方式如此自然,他们有一些人还没有意识到自己的强大。
关于面向对象
虽然课程不会专门讲“面向对象”的思想,但面向对象思想的本质(去掉糟粕)会从一开头就融入到练习里。上过课的同学到后来发现,虽然我从来没直接教过面向对象,而其实他们已经理解了面向对象的本质是什么。在将来的实践中,他们可以用这个思路去看破面向对象思想的本质,并且合理地应用它。
奖励练习
途中我会通过“奖励练习”的方式补充其它内容。比如第二期的课程途中,我临时设计了一个 parser 的练习,做完了其它练习的同学通过这个练习,理解了 parser 的原理,写出了一个简单但逻辑严密的 parser。奖励练习之所以叫“奖励”,因为并不是所有学生都能得到这个练习,只有那些付出了努力,在其他练习中做到融会贯通,学有余力的学生才会给这个练习。这样会鼓励学生更加努力地学习。
一个朋友看了我的课程内容说,这不叫“基础班”,只能叫“大师班”。他不相信零基础的学生能跟上,但事实却是可行的。
为什么不能即是“基础班”又是“大师班”呢?
有句话说得好,大师只不过是把基础的东西理解得很透彻的人而已。
我希望这个基础班能帮助人们获得本质的原理,帮助他们看透很多其它内容。所以上了“基础班”,可能在很长时间之内都不需要“高级班”了,因为他们已经获得了很强的自学能力,能够自己去探索未知的世界,攀登更高的山峰。