Posts archived in CompSci

虚拟机的前世今生

上节我们提到了 LISP 中, 因为 eval 的原因, 发展出了运行时环境这样一个概念。基于这个概念,日后发展出了虚拟机技术。但这段历史并不是平铺直叙的,实际上,这里面还经历了一个非常漫长而曲折的过程, 说起来也是非常有意思的。 这一节我们就着重解释虚拟机的历史。

我们 21 世纪的程序员,凡要是懂一点编程技术的,基本上都知道虚拟机字节码这样两个重要的概念。 所谓的字节码 (bytecode),是一种非常类似于机器码的指令格式。这种指令格式是以二进制字节为单位定义的(不会有一个指令只用到一个字节的前四位),所以叫做字节码。所谓的虚拟机,就是说不是一台真的计算机,而是一个环境,其他程序能在这个环境中运行,而不是在真的机器上运行。现在主流高级语言如 Java, Python, PHP, C#,编译后的代码都是以字节码的形式存在的, 这些字节码程序, 最后都是在虚拟机上运行的。

1. 虚拟机的安全性和跨平台性

虚拟机的好处大家都知道,最容易想到的是安全性和跨平台性。安全性是因为现在可执行程序被放在虚拟机环境中运行,虚拟机可以随时对程序的危险行为,比如缓冲区溢出,数组访问过界等等进行控制。跨平台性是因为只要不同平台上都装上了支持同一个字节码标准的虚拟机,程序就可以在不同的平台上不加修改而运行,因为虚拟机架构在各种不同的平台之上,用虚拟机把下层平台间的差异性给抹平了。我们最熟悉的例子就是 Java 了。Java 语言号称 一次编写,到处运行(Write Once, Run Anywhere),就是因为各个平台上的 Java 虚拟机都统一支持 Java 字节码,所以用户感觉不到虚拟机下层平台的差异。

虚拟机是个好东西,但是它的出现,不是完全由安全性和跨平台性驱使的。

2. 跨平台需求的出现

我们知道,在计算机还是锁在机房里面的昂贵的庞然大物的时候,系统软件都是硬件厂商附送的东西(是比尔盖茨这一代人的出现,才有了和硬件产业分庭抗礼的软件产业),一个系统程序员可能一辈子只和一个产品线的计算机打交道,压根没有跨平台的需求。应用程序员更加不要说了,因为计算机很稀有,写程序都是为某一台计算机专门写的,所以一段时间可能只和一台庞然大物打交道,更加不要说什么跨平台了。 真的有跨平台需求,是从微型计算机开始真的普及开始的。因为只有计算机普及了,各种平台都被广泛采用了,相互又不互相兼容软件,才会有软件跨平台的需求。微机普及的历史,比 PC 普及的历史要早10年,而这段历史,正好和 UNIX 发展史是并行重叠的。

熟悉 UNIX 发展史的读者都知道, UNIX 真正普及开来,是因为其全部都用 C,一个当时绝对能够称为跨平台的语言重写了一次。又因为美国大学和科研机构之间的开源共享文化,C 版本的 UNIX 出生没多久,就迅速从原始的 PDP-11 实现,移植到了 DEC,Intel 等平台上,产生了无数衍生版本。随着跨平台的 UNIX 的普及, 微型计算机也更多的普及开来,因为只需要掌握基本的 UNIX 知识,就可以顺利操作微型计算机了。所以,微机和 UNIX 这两样东西都在 1970年 到 1980 年在美国政府,大学,科研机构,公司,金融机构等各种信息化前沿部门间真正的普及开来了。这些历史都是人所共知耳熟能详的。

既然 UNIX 是跨平台的,那么,UNIX 上的语言也应当是跨平台的 (注: 本节所有的故事都和 Windows 无关,因为 Windows 本身就不是一个跨平台的操作系统)。UNIX 上的主打语言 C 的跨平台性,一般是以各平台厂商提供编译器的方式实现的,而最终编译生成的可执行程序,其实不是跨平台的。所以,跨平台是源代码级别的跨平台,而不是可执行程序层面的。 而除了标准了 C 语言外,UNIX 上有一派生机勃勃的跨平台语言,就是脚本语言。(注:脚本语言和普通的编程语言相比,在能完成的任务上并没有什么的巨大差异。脚本语言往往是针对特定类型的问题提出的,语法更加简单,功能更加高层,常常几百行C语言要做的事情,几行简单的脚本就能完成

3. 解释和执行

脚本语言美妙的地方在于,它们的源代码本身就是可执行程序,所以在两个层面上都是跨平台的。不难看出,脚本语言既要能被直接执行,又要跨平台的话,就必然要有一个“东西”,横亘在语言源代码和平台之间,往上,在源代码层面,分析源代码的语法,结构和逻辑,也就是所谓的“解释”;往下,要隐藏平台差异,使得源代码中的逻辑,能在具体的平台上以正确的方式执行,也就是所谓的“执行”。

虽说我们知道一定要这么一个东西,能够对上“解释”,对下“执行”,但是 “解释” 和 “执行” 两个模块毕竟是相互独立的,因此就很自然的会出现两个流派:把解释和执行设计到一起把解释和执行单独分开来 这样两个设计思路,需要读者注意的是,现在这两个都是跨平台的,安全的设计,而在后者中字节码作为了解释和执行之间的沟通桥梁,前者并没有字节码作为桥梁。

4. 解释和执行在一起的方案

我们先说前者,前者的优点是设计简单,不需要搞什么字节码规范,所以 UNIX 上早期的脚本语言,都是采用前者的设计方法。 我们以 UNIX 上大名鼎鼎的 AWK 和 Perl 两个脚本语言的解释器为例说明。 AWK 和 Perl 都是 UNIX 上极为常用的,图灵完全的语言,其中 AWK, 在任何 UNIX 系统中都是作为标准配置的,甚至入选 IEEE POSIX 标准,是入选 IEEE POSIX 卢浮宫的唯一同类语言品牌,其地位绝对不是 UNIX 下其他脚本语言能够比的。这两个语言是怎么实现解释和运行的呢? 我从 AWK 的标准实现中摘一段代码您一看就清楚了:

int main(int argc, char *argv[]) {
  ...
  syminit();
  compile_time = 1;
  yyparse();
  ...
    if (errorflag == 0) {
      compile_time = 0;
      run(winner);
    }
  ...
}

其中, run 的原型是
run(Node *a)   /* execution of parse tree starts here */

winner 的定义是:
Node    *winner ;    /* root of parse tree */

熟悉 Yacc 的读者应该能够立即看出, AWK 调用了 Yacc 解析源代码,生成了一棵语法树。按照 winner 的定义, winner 是这棵语法树的根节点。 在“解释”没有任何错误之后,AWK 就转入了“执行” (compile_time 变成了 0),将 run 作用到这棵语法树的根节点上。 不难想像,这个 run 函数的逻辑是递归的(事实上也是),在语法树上,从根依次往下,执行每个节点的子节点,然后收集结果。是的,这就是整个 AWK 的基本逻辑: 对于一段源代码, 先用解释器(这里awk 用了 Yacc 解释器),生成一棵语法树,然后,从树的根节点开始,往下用 run 这个函数,遇山开山,遇水搭桥,一路递归下去,最后把整个语法树遍历完,程序就执行完毕了。(这里附送一个小八卦,抽象语法树这个概念是 LISP 先提出的,因为 LISP 是最早像 AWK 这样做的,LISP 实在是属于开天辟地的作品!)Perl 的源代码也是类似的逻辑解释执行的,我就不一一举例了。

5. 三大缺点

现在我们看看这个方法的优缺点。 优点是显而易见的,因为通过抽象语法树在两个模块之间通信,避免了设计复杂的字节码规范,设计简单。但是缺点也非常明显。最核心的缺点就是性能差,需要资源多,具体来说,就是如下三个缺点。

缺点1因为解释和运行放在了一起,每次运行都需要经过解释这个过程。假如我们有一个脚本,写好了就不修改了,只需要重复的运行,那么在一般应用下尚可以忍受每次零点几秒的重复冗余的解释过程,在高性能的场合就不能适用了。

缺点2因为运行是采用递归的方式的,效率会比较低。 我们都知道,因为递归涉及到栈操作和状态保存和恢复等,代价通常比较高,所以能不用递归就不用递归。在高性能的场合使用递归去执行语法树,不值得。

缺点3,因为一切程序的起点都是源代码,而抽象语法树不能作为通用的结构在机器之间互传,所以不得不在所有的机器上都布置一个解释+运行的模块。 在资源充裕的系统上布置一个这样的系统没什么,可在资源受限的系统上就要慎重了,比如嵌入式系统上。 鉴于有些语言本身语法结构复杂,布置一个解释模块的代价是非常高昂的。本来一个递归执行模块就很吃资源了,再加一个解释器,嵌入式系统就没法做了。所以,这种设计在嵌入式系统上是行不通的。

当然,还有一些其他的小缺点,比如有程序员不喜欢开放源代码,但这种设计中,一切都从源代码开始,要发布可执行程序,就等于发布源代码,所以不愿意公布源代码的商业公司很不喜欢这些语言等等。但是上面的三个缺点,是最致命的,这三个缺点,决定了有些场合,就是不能用这种设计。

6. 分开解释和执行

前面的三个主要缺点,恰好全部被第二个设计所克服了。在第二种设计中, 我们可以只解释一次语法结构,生成一个结构更加简单紧凑的字节码文件。这样,以后每次要运行脚本的时候, 只需要把字节码文件送给一个简单的解释字节码的模块就行了。因为字节码比源程序要简单多了,所以解释字节码的模块比原来解释源程序的模块要小很多;同时,脱离了语法树,我们完全可以用更加高性能的方式设计运行时,避免递归遍历语法树这种低效的执行方式;同时,在嵌入式系统上,我们可以只部署运行时,不部署编译器。 这三个解决方案,预示了在运行次数远大于编译次数的场合,或在性能要求高的场合,或在嵌入式系统里,想要跨平台和安全性,就非得用第二种设计,也就是字节码+虚拟机的设计。

讲到了这里,相信对 Java, 对 PHP 或者对 Tcl 历史稍微了解的读者都会一拍脑袋顿悟了: 原来这些牛逼的虚拟机都不是天才拍脑袋想出来的,而是被需求和现实给召唤出来的啊!

我们先以 Java 为例,说说在嵌入式场合的应用。Java 语言原本叫 Oak 语言,最初不是为桌面和服务器应用开发的,而是为机顶盒开发的。SUN 最初开发 Java 的唯一目的,就是为了参加机顶盒项目的竞标。嵌入式系统的资源受限程度不必细说了,自然不会允许上面放一个解释器和一个运行时。所以,不管Java 语言如何,Java 虚拟机设计得直白无比,简单无比,手机上,智能卡上都能放上一个 Java 运行时(当然是精简版本的)。 这就是字节码和虚拟机的威力了。

SUN 无心插柳,等到互联网兴起的时候, Java 正好对绘图支持非常好,在 Flash 一统江湖之前,凭借跨平台性能,以 Applet 的名义一举走红。然后,又因为这种设计先天性的能克服性能问题,在性能上大作文章,凭借 JIT 技术,充分发挥上面说到的优点2,再加上安全性,一举拿下了企业服务器市场的半壁江山,这都是后话了。

再说 PHP。PHP 的历史就包含了从第一种设计转化到第二种设计以用来优化运行时性能的历史。 PHP 是一般用来生成服务器网页的脚本语言。一个大站点上的PHP脚本, 一旦写好了,每天能访问千百万次,所以,如果全靠每次都解释,每次都递归执行,性能上是必然要打折扣的。 所以,从 1999年的 PHP4 开始, Zend 引擎就横空出世,专门管加速解释后的 PHP 脚本, 而对应的 PHP 解释引擎,就开始将 PHP 解释成字节码,以支持这种一次解释,多次运行的框架。 在此之前, PHP 和 Perl, 还有 cgi, 还算平分秋色的样子,基本上服务器上三类网页的数量都差不多,三者语法也很类似,但是到了 PHP4 出现之后,其他两个基于第一种设计方案的页面就慢慢消逝了, 全部让位给 PHP。 你读的我的这个 WordPress 博客,也是基于 PHP 技术的,底层也是 Zend 引擎的。 著名的 LAMP 里面的那个 P, 原始上也是 PHP,而这个词真的火起来,也是 99年 PHP4 出现之后的事情。

第二种设计的优点正好满足了实际需求的事情,其实不胜枚举。比如说 在 Lua 和 Tcl 等宿主语言上也都表现的淋漓尽致。像这样的小型语言,本来就是让运行时为了嵌入其他语言的,所以运行时越小越好,自然的,就走了和嵌入式系统一样的设计道路。

7. 结语

其实第二种设计也不是铁板一块,里面也有很多流派,各派有很多优缺点,也有很多细致的考量,下一节,如果不出意外,我将介绍我最喜欢的一个内容: 下一代虚拟机:寄存器还是栈。

说了这么多,最后就是一句话,有时候我们看上去觉得一种设计好像是天外飞仙,横空出世,其实其后都有现实,需求等等的诸多考量。虚拟机技术就是这样,在各种需求的引导下,逐渐的演化成了现在的样子。

终于放暑假了, 有心情来八卦了. 我主要想八卦一下高级语言的设计思想和各种范式的来龙去脉, 也就是回答这个问题: 编程语言为什么会发生成现在这个样子哩? 这里面的奥妙又在哪里哩? 我尝试着把这个系列的八卦写下去, 包括虚拟机的设计, 线程的设计, 栈和寄存器两大流派的来龙去脉等等, 也算是完成年初给大家许下的诺言.

高级编程语言的创始纪上写道:”初, 世间无语言, 仅电路与连线. 及大牛出, 天地开, 始有FORTRAN, LISP. ALGOL 随之, 乃有万种语.” 我们都知道, LISP 是基于递归函数的, FORTRAN 是做科学计算的. 现在的C 等等, 都比较像 FORTRAN 不像 LISP. 可是很少有人知道, 最初, FORTRAN 是不支持函数递归调用的, 而LISP是一生下来就支持的, 所有高级语言里面的递归调用, 都是逐渐从 LISP 那里学来的. 这段尘封的历史非常有趣, 值得八卦一番.

一般人学编程, 除了写 Hello World 之外, 人生写的第二个程序, 不是阶乘就是菲波拉契数列, 要不就是汉洛塔. 而这几个程序, 基本上都是因为函数的递归调用才显得简单漂亮. 没有递归的日子里, 人民非常想念您. 可是, 第一版的 FORTRAN 就居然居然不支持递归. 细心的读者要问了, 不支持递归的语言能图灵完全么? 当然可以, 图灵机就是没递归的典型的例子. 但是没递归调用的程序会很难写, 尤其像汉诺塔这种. 那么, FORTRAN 他怎么就悍然不支持递归呢, 让我们回到 1960 年.

话说当年, IBM 是计算机行业的领军者. 那时候的计算机, 都是比柜子还大的大家伙, 至于计算能力嘛, 却比你的手机还弱. 那时候计算机所做的最多的事情, 不是发邮件打游戏, 而是作计算. 作计算嘛, 自然需要一种和数学语言比较接近的编程语言. 于是, 1960年, IBM 就捣鼓出了 FORTRAN, 用行话说, 就是公式翻译系统. 这个公式翻译系统, 就成了世界上第一个编程语言. 这个编程语言能做数学计算, 能作条件判断, 能 GOTO. 用现在的眼光看, 这个语言能构模拟图灵机上的一切操作, 所以是图灵完全的. 学过数值计算的同学都知道, 科学计算无非就是一大堆数学计算按照步骤进行而已. 所以, 一些控制判断语句, 数学公式加上一个数组, 基本上就能完成所有的科学计算了. IBM 觉得这个语言够用了, 就发布了 FORTRAN 语言规范, 并且在自家的大型机上实现了这个语言. 

在实现这个语言的时候, IBM 的工程师要写一个 FORTRAN 编译器 (请注意那时候的大型机没有操作系统). 那时候的编译器都是用机器语言或者很低级的汇编语言写成的, 所以编译器要越简单越好. 这些工程师觉得, 弄一个让用户运行时动态开辟内存的机制太麻烦了, 所以干脆, 强迫用户在写程序的时候, 就要定好数组的大小, 变量的类型和数目. 这个要求并不过分, 因为在科学计算中, 数组的维度, 用到的变量等, 在计算之前, 就是可以知道大小的. 用现在的话说, 就是不能动态开辟内存空间, 也就相当于没有 malloc 的 C, 或者没有 new 的 C++. 这样的好处是, 一个程序要多少内存, 编译的时候就知道的一清二楚了. 这个主意看上去很聪明, 不过 IBM 的工程师比你想得更加聪明, 他们想, 既然一个程序或者子程序要多少内存在编译的时候都知道了, 我们干脆就静态的把每个子程序在内存中的位置, 子程序中参数, 返回值和局部变量放的位置, 大小都定好, 不久更加整齐高效么. 是的, 我们都知道, 在没有操作系统管理的情况下, 程序的内存策略越简单越好, 如果内存放的整整齐齐的, 计算机的管理员就能够很好的管理机器的内存, 这样也是一件非常好的事情. (再次强调, 当年还没有操作系统呢, 操作系统要等到 1964年发布的 IBM 360 才有, 具体开发一个操作系统之难度可参考< 人月神话>).

可是, 聪明的读者一下子就看出来了, 这样静态的搞内存分配, 就递不成归不了了. 为啥呢. 试想, 我有个 Fib 函数, 用来计算第 N 个菲波拉契数. 这个函数输入一个整数, 返回一个整数, FORTRAN 编译器帮我把这个函数给静态分配了. 好, 我运行 Fib(5) 起来, FORTRAN 帮我把 5 存在某个专门给输入参数的位置. 我在 Fib(5) 里面递归的调用了Fib(4), FORTRAN 一看, 哈, 不还是 Fib 么, 参数是 4, 我存. 这一存, 新的参数4, 就把原来的 5 给覆盖掉了, 新的返回值, 也把原来的返回值给覆盖掉了. 大事不好了, 这么一搞, 新的调用的状态居然覆盖了老的调用, 这下, 就没法返回原来的 Fib(5) 了, 这样一搞, 怎么递归啊?

IBM 这些写编译器的老前辈们, 不是不知道这个问题, 而是压根就鄙视提出这个问题的人: 你丫科学计算递归什么呀, 通通给我展开成循环, 展不开是你数学没学好, 想用递归, 你就不要用 FORTRAN 了. 那时候 IBM 乃是老大, 只有他们家才生产大型机, 老大发话, 下面的消费者只能听他的.

既然软件不支持, 硬件也就可以偷工减料嘛, 所以, 硬件上, 就压根没有任何栈支持. 我们都知道, 计算机发展史上, 软件和硬件是相互作用的. 我们现在也很难猜测, 是IBM 的软件工程师因为 IBM 的硬件工程师没有在硬件上设计出堆栈所以没有能在 FORTRAN 里面设计出递归调用呢, 还是 IBM 的硬件工程师觉得既然软件没要求, 我就不设计了呢? 不管怎么样, 我们看到的是, 1960 年前, 所有的机器的硬件都没有直接支持栈的机制. 熟悉CPU的都知道, 现代 CPU 里面, 都有两个至关重要的地址寄存器, 一个叫做 PC, 用来标记下一条要执行的指令的位置, 还有一个就是栈顶指针 SP. 如果没有后者, 程序之间的调用就会非常麻烦, 因为需要程序员手工维护一个栈, 才能保证程序之间调用最后还能正确的返回. 而当年, 因为 FORTRAN 压根就不支持递归, 所以支持 FORTRAN 的硬件, 就省去了栈指针了. 如果一个程序员想要递归调用, 唯一的实现方法, 就是让程序员借用一个通用寄存器作为栈指针, 自己硬写一个栈, 而且不能用 FORTRAN.

因为 FORTRAN 不支持递归调用, 按照自然规律, 自然会有支持递归的语言在同时代出现. 于是, 很快的, LISP 和 ALGOL 这两个新语言就出道了. 我们只说 LISP. 它的创始人 John McCarchy 是 MIT 教授, 也是人工智能之父, 是学院派人物. 他喜欢丘齐的那一套 Lambda 演算, 而非图灵的机械构造. 所以, LISP 从一开始, 就支持递归的调用, 因为递归就是 lambda 演算的灵魂. 但是有两大问题摆在 McCarchy 面前. 一是他的 LISP 理论模型找不到一个可以跑的机器, 二是他的 LISP 模型中有一个叫做 eval 的指令, 可以把一个字符串当成指令在运行时求值, 而这个, 当时还没有人解决过. 按照 Paul Graham 大叔在他的 Hackers and Painters 里面的说法, McCarchy 甚至压根就不想实现这个 eval 指令, 因为当 IBM 的一个叫 Steve Russell的工程师宣称要实现 eval 的时候, McCarthy 还连连摇手说理论是理论, 实际是实际, 我不指望这个能被实现. 可是, Russell 居然就把这两个问题一并给解决了(这哥们也是电子游戏创始人, 史上第一个电子游戏就是他写的, 叫 Space War). 他的方法, 说来也简单, 就是写了一个解释器, 让 LISP 在这个解释器里面跑. 这个创举, 让传统上编译-> 运行 的高级语言流程, 变成了 编写-> 解释执行的流程, 也就是著名的 REPL 流程. 他做的事情, 相当于在IBM 的机器上用机器码写了一个通用图灵机, 用来解释所有的 LISP 指令. 这个创举, 就让 LISP 从理论走到了实践.

因为有了运行时的概念, LISP 想怎么递归, 就可以怎么递归, 只要运行时支持一个软件实现的栈就可以了. 上面我也说了, 也就是写解释器的人麻烦一点而已, 写LISP程序的人完全就可以不管下层怎么管理栈的了. 同时, 有了解释器, 也解放了原来动态分配空间的麻烦, 因为现在所有的空间分配都可以由解释器管理了, 所以, 运行时环境允许你动态的分配空间. 对空间分配的动态支持, 随之就带来了一项新技术: 垃圾收集器. 这个技术出现在 LISP 里面不是偶然的, 是解释器的自然要求和归宿. 在 FORTRAN 上本来被绕过的问题, 就在 LISP 里面用全新的方法被解决了. LISP 的划时代意义和解释器技术, 使得伴随的很多技术, 比如抽象语法树, 动态数据结构, 垃圾收集, 字节码等等, 都很早的出现在了 LISP 中, 加上 LISP 本身规则很少, 使用起来非常灵活, 所以, 每当有一项新技术出现, 特别是和解释器和运行时相关的一项新技术出现, 我们就会听到有人说, “这玩意儿 LISP 里早就有了”, 这话, 是有一定道理的.

除了上面的软件模拟之外, MIT 还有一派在作硬件模拟, 这一派, 以后发展成了灿烂一时的 LISP machine, 为日后所有虚拟机理论铺开了一条新路. 这一派在70, 80年代迅速崛起, 然后随着 PC 的兴起又迅速的陨落, 让人唏嘘不已.

最后附送一个八卦: 1960 年的时候, 高级语言编程领域也发生了一件大事, 即 ALGOL 60 的提出. ALGOL 是划时代的标准, 我们今天用的 C/Java 全是 ALGOL 家族的. ALGOL 注意到了 FORTRAN 的不支持递归的问题, 于是从一开始, 就订立标准支持递归. 但是, 处理递归需要很小心的安排每个函数每次调用的地址和所谓的活动窗口(Active Frame), 而并不是每个编译器都是牛人写的, 所以在处理递归这样一个新事物上, 难免会出点小问题和小 BUG. 这时候, 搞笑的高爷爷(Knuth) 出场了, 他提出了一个测试, 叫做 “是男人就得负67″. (The man or boy test). 恕我功底不深, 不能给各位读者把这个男人测试的关窍讲清楚, 但是, 我知道, 这个测试, 乃是看 ALGOL 60 编译器有没有正确的实现递归和外部引用的. 照高爷爷的说法, 真的男人要能得到正确答案, 不是男人的就得不到正确答案. 当然, 高爷爷当时自己也没有男人编译器, 所以自己猜了一个 -121, 后来, 真的男人编译器出来了, 正确答案是 -67. 可见, 高爷爷的人脑编译器, 也不是男人编译器…

各位欲知详情的, 猛点这个.

19 comments

中了篇 IJCAI

如题, 很高兴, 回国请大家吃饭. 过几天再送服务器空间 :)

很多天前和 zuola 聊天, 偶然提到正则表达式, zuola 说, 会正则表达式的都是牛人. 我说, 其实不难, 买本书看看就会了. 这几天, zuola 又在我博客上留言说会正则表达式才是真的程序员, 因此我想, 还是写篇比较浅显的教程, 让 zuola 同学快速成为牛人吧.

对于普通人来说, 正则表达式是比较难的. 从我个人的体验来看也是一样. 这个难, 主要在于两方面: 1. 接受正则表达式的思维方式; 2. 熟悉表达式里面各种各样的符号的用法. 第一点的难度在于这是个新东西, 和以前的知识结构不一样; 第二点的难度在于各种各样的环境下都对最基本的正则表达式做了很多扩展, 引入了各种各样的新的符号, 这样, 就使得学的时候一下子面对太多的复杂度不知所措. 举例来说, 大多数教程把 ^$*+-[](){}|.?\ 这些符号全部放到一起讲, 全然不分他们的层次关系, 导致学习者云里雾里. 同时, 不同的工具又定义了自己的特殊规则, 使得学习曲线更加陡峭. 因此, 我打算把正则表达式的知识点, 分几个不同的层次, 一一剖析. 在这一部分中, 我把正则表达式琐碎的细节一一剔除, 希望看到这篇文章的, 愿意学习正则表达式的读者, 能够迅速从这些繁琐的细节中解脱出来, 掌握其本质.

首先说正则表达式是什么. 正则表达式是一种描述性的语言, 用来概括一类字符串 (或者说一个字符串集合). 我们当然可以用自然语言来描述一类字符串, 比如我们说, 以 “010 开头的电话号码”, “夹在HTML 的 <b> 和 </b> 中间的内容”, “含有 hello 的字符串”, “负数”, “IP地址” “邮箱地址”, 等等. 其实在实际应用中, 我们也常常有这个需求, 比如说提取一篇邮件中所有的 email 地址 (查找), 或者把提取某类电话号码, 升个位, 加个区号什么的 (替换). 人当然可以做这个事情, 但是这个事情重复且单调, 又并不需要太多的智力, 因此, 计算机是最好的工具. 但是问题是, 我们怎么能够告诉计算机, 我们对哪类字符串感兴趣呢? 计算机科学家就帮我们设计了一种让人能够简单的写出来, 表达我们人类想表达的含义, 而计算机又恰好能够很容易的理解和处理的一种表达式, 这就是正则表达式了. 从人和计算机的角度说, 正则表达式是一种人和计算机都能轻松处理的约定, 用来描述一类具有某个性质的字符串.

正则表达式它既有倾向于人的思考方式的一面, 也有倾向于计算机工作原理 (有限自动机) 的一面. 因此, 传统意义上, 如果想真正理解正则表达式, 就要从理解计算机原理入手. 所幸的是, 我们普通用户, 在日常使用中, 并不需要了解计算机的原理, 因为这么多年技术的发展给了正则表达式很多新特性, 让正则表达式越来越脱离计算机的局限, 变得更加适合复杂的任务, 但这样的代价是正则表达式的细节越来越繁杂了, 对于初学者来说更加难学了. 因此我们在这里, 先讲本质, 后谈细节.

最基本的正则表达式, 只有三句话:

一个字符串是一个正则表达式, 比如 aaa, 就是一个正则表达式, 它描述了一个字符串集合, 这个字符串集合里面只有 aaa 这一个元素

两个正则表达式可以直接串起来, 比如 aaabbb 其实, 是由六个正则表达式 a a a b b b 接起来组成的. 我们先笼统的说, 接起来就等于把描述的内容接起来, 等一下再详细解释接起来的含义.

两个字符串, 比如 aaa 和 bbb, 用 | 连起来, 变成了 aaa|bbb, 也构成一个正则表达式, 它描述的字符串集合是原来分别的并集, 比如 aaa|bbb 描述了一个集合, 这个集合里面有 {aaa, bbb} 两个字符串.

好了, 就这两三话, 就可以解释正则表达式最基本的思维方式了: 用一个表达式, 去描述一类字符串(或者说, 一个集合).

光有这两个, 还不够强大, 因为上面的正则表达式, 我写几个, 就描述了几个字符串, 也就是说, 描述来, 描述去, 都是有限的集合, 不能描述无限的集合. 而我们想要描述的整数啊, 域名啊, 邮箱地址啊, 都是一切就有可能的, 因此, 我们有必要引入一个新的记号, 能够描述无限的集合,

一个正则式 X 可以加上一个 *, 用来描述任意多个原来 X 描述的字符串拼起来的字符串.

这句话比较费解, 我们用例子来说明一下, 比如 a* 这个正则表达式, 我们知道 a 描述了一类字符, 这类字符里面只有一个 a, 所以, a* 描述了一个或者多个 a.

我们再看 a | b* , 按照定义, 这个正则表达式描述了 a 和 b, bb, bbb 等. 如果我们引入一个括号, 写成 (a|b)* , 那么 a|b 就变成一个整体, 描述了 a 或者 b, 这时候, (a|b)* 就是一切只由 a, b 组成的字符串. 这里的括号, 是为了避免歧义, 表示 * 是作用在 a|b 整体上的. 这时候, (a|b) 描述了 a 和 b, 整体加了一个 *, 意味者我们可以任意选 a 或者 b 一个接一个拼起来, 所以, aba, aab 都是在 (a|b)* 的那一类里面的. 注意, * 可以匹配 0 个, 就是说, 这里面包含了什么都没有. 比如说 ab*c 也描述了 ac, 因为中间可以有 0 个 b. 如果您想至少要一个b, 可以写成 abb*c.

为了帮助您理解接起来, 我们再看一个复杂的例子, o(n|ff). 我们知道, n|ff 描述了 n 或者 ff. 当我们直接把 o 接在前面的时候, 描述的是 on 或者 off. 就是说, 接起来的时候, 要把 o 和后面每种情况都组合一次. 我们再看 (a|o)(n|ff). 前面描述的是 a 或者 o, 后面描述的是 n 或者 ff, 接起来, 描述了 an, aff, on, off.

我们都知道, 正则表达式描述的是一类字符串, 所以, X 和 Y 在接起来变成 XY 以后, 自然的变成了描述 每一种 X 里面的字符串和 Y里面字符串接起来的情况. 同样, * 好像把 X 和自己接起来多次一样 (可以是任意次), 每次只要接起来的是X里面的字符串, 就一定被 X* 所表述.

(熟悉集合的朋友立即知道 正则表达式是用一个表达式代表了一个集合, X|Y 等价于两个集合的并集, 而 XY 拼起来等价于他们所有的元素 x, y 拼起来的集合).

好了, 恭喜您, 您已经学会正则表达式了. 真的, 你已经全部学会了正则表达式的知识. 不过不着急, 我们先回顾一下正则表达式的要点:

1. 正则表达式由普通的字符, 以及几个特殊的字符, 即 括号 (), 或者 | 和 星号 * 组成. 用来描述一类字符.
2. | 表示或者. 如果有两个正则表达式 X 和 Y, 那么 X|Y 就描述了原来 X 描述的和 Y 描述的.
3. 正则表达式可以接起来, 变成一个更长的, 描述了一个各个部分被那些被接起来的正则表达式描述的字符串.
4. () 是为了避免歧义.

我们上面说的这四个, 就是 100% 如假包换的正则表达式了. 以后的, 都是为了更加方便的使用正则表达式, 而又引入的一些扩展. 恰恰是这些扩展, 让初学者陷入了细节的泥潭. 我们在下一节, 一个一个的来对付诸如 +, [, -, ], ^, $, {m}, 等这些非基本的高级的功能. 需要强调的是, 这些高级的功能, 其实都只是为了人书写方便, 而且是完全可以用我们这里说的最基本的几个规则代替的. 这些高级功能, 我们下节再讲.

练习:
写出匹配以下性质字符串的正则表达式:

1. 字符串 2009

2. 周曙光同学有两个名字, 分别叫做 zola 和 zuola, 人们常常混淆. 请帮周曙光同学设计一个正则表达式, 可以帮他匹配自己的名字.

3. 二进制数字 (最少有一位, 但只含有 0 或者 1的)

4. 非零的十进制数字 (有至少一位数字, 但是不能以0开头)

练习软件:

有一些比较好的软件帮你学习正则表达式, 我推荐初学者用 egrep. 可以在 windows 下用, 具体用法是在命令行 打入 egrep “正则表达式” 文件名
egrep 会把文件里面和正则表达式匹配的行 (该行含有一个字符串, 被正则表达式描述了) 打出来. egrep -o “正则表达式” 文件名 的话就会只打出那个完全匹配的字符串, 而不是行. 另外, 在 Linux 下可以用 grep –color “表达式” 文件名, 这样, 匹配上的那个字符串, 会被高亮显示出来.

练习文件:

0108200920088964
zuola -d
zooooola
world hello -012012 2009
0909 zola zhou
0101001
zuola

(把这个文件存成文本文件, 用 windows 的朋友可以放在您的 “我的文档” 里面, 因为 cmd 就是从那里开始运行. 然后您下载一下 egrep 做实验)

答案:

1. 2009
2. z(|u)ola [或者您可以写成 zuola|zola]
3. (0|1)(0|1)*
4. (1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)*

你会看到第四题的答案很笨拙, 居然写了这么长. 后面的大部分细节, 就是为了诸如此类的写得更加简洁一点.

Update:

1. 按照 AW 的留言和他的博客上的读者留言, 这个在线网站可以在线测试正则表达式:

http://gskinner.com/RegExr/

2. 如果要论正则表达式方面的参考书的话, 我推荐 < 精通正则表达式>, 中文版余晟同学翻译的, 质量上乘. 这本书可能是正则表达式方面唯一的一本圣经了, 上次我也是直接推荐给 zuola. 本来我是想打算写完了所有的初级教程再推荐的, 所以在本文初稿中没有提到这本参考书.

3. 才和 zuola 聊天, 他说要讲点具体的 blogger 用到的例子. 其实我之所以没在这篇文章里面讲, 就是因为这样的例子, 都是和应用程序结合的, 需要 sed, htaccess, awk 或者 linux 管道的具体知识, 我就是想解开这些知识的耦合. 一下子看着天书一样的 sed 替换表达式, 是很难一下子学会的. 他的建议是非常有价值的, 可能在本系列最后, 我会补充一篇 blogger 常用的正则表达式用例.

上篇我们说到 Tit for Tat 的策略有一个极大的漏洞, 是什么样的漏洞呢? 我们不妨先用通俗的例子理解一下.

假如现实生活中有两个人 A 和 B, 都是认为自己非常理智, 而且严格执行”以牙还牙”策略的人遇到了一起, 会发生什么样的事情呢? 我们按照他们初始的策略, 分三种情况讨论.

1. 假如 A 某次不小心招惹了一下 B (执行了被 B 解读为 D 的策略), 按照 B 的策略, 必然会在下一轮执行 D 策略 (报复). 而 A 对 B 初始是执行 C 策略 (合作) 的. 在 B 报复之后, A 下一轮就会采用报复. 而相反的, B 在本轮看到 A 合作之后, 下一轮就会报复. 如此往返. 不难看出, A 和 B 会陷入彼此报复的怪圈当中, 用大白话说, 就是所谓的冤冤相报何时了.  更加糟糕的是, 博弈的双方都认为自己是完全理智而且愿意合作的, 但是就是因为正好彼此差了一步, 因此从A的角度看B, A 会认为 B 是一个完全不懂得合作的蠢货 (A 提出合作的时候B正好报复). B 看 A 也一样. 现实生活中我们也能发现这种例子, 比如两个性格很强的人遇到了, 在某件事情上不投合, 结果成了一辈子的仇人, 还互相认为对方是傻X. 此时, 双方都得不到期望的最大受益.

2. 如果一开始双方都采用 D 策略, 则可以遇见, 这样的 D 策略将持续下去, 没有一方会主动的让步, 因为先让步的一方必然吃亏. 现实中, 我们也能观察到这样的事情, 即博弈双方仇怨越积越深, 最后到了不可化解的地步. 此时, 双方都陷入了囚徒困境.

3. 如果博弈的双方一开始都采取 C(合作)策略. 那么, 博弈双方则能够永远的友好合作下去, 获得最大的受益. 此时, 双方获取的受益都最大化了.

从上面的分析我们可以看到, 在多轮囚徒困境的情况下, 如果有多个 Tit-for-Tat 策略参与, 那么每个的受益, 极端的依赖于初始状态的设定. 在数学和计算机科学中, 这样的系统, 叫做”初值敏感系统”. 一般认为, “初值敏感系统”是非常不好的系统, 原因在于缺乏”鲁棒性”. 这里我走一下题, 解释一下初值敏感系统和鲁棒性这两个概念.

大家都知道有一个叫做”蝴蝶效应”的东西, 大体是说, 一只蝴蝶在巴西扇动翅膀,有可能会在美国的德克萨斯引起一场龙卷风. 原因在于, 这只蝴蝶翅膀扇动的气流, 引起的一个小小的搅动, 可能会在系统中被各种各样的因素放大, 最后演变成一个非常显著的效应. 中国也有一句古话, 叫做差之毫厘, 谬以千里, 说的都是, 初始的微小变化, 都能引起最后结果的显著不同. 我们这里的初值敏感系统, 和蝴蝶效应也是类似的, 能从小的摄动引发出显著的后果的. 比如大家都知道, 在”一只馒头引发的血案”中, A 在很不经意的情况下, 对B 采用了 D 策略(抢了馒头), B 由此产生了报复, 搞得 A 国破家亡.

显然, 面对这样的系统, 人类即使有模型, 也是很难预测未来的, 因为初值条件在测量上的一点点微小的误差, 都能造成预测的结果的巨大不同. 为了表征这个特性, 我们把”不对初值敏感”的特性成为鲁棒性 (Robustness). (这个鲁棒, 您可以直接理解为山东大棒, 结实, 抗得住外界的一些摄动).

聪明的读者要说了, 即使系统不鲁棒, 我们能不能设计好初值, 使得系统沿着最好的方向演化呢? 答案是不能. 因为任何一个客户端拥有的上传和下载的带宽都是有限的, 有限的资源必然会导致资源的竞争, 从而导致必然某些请求不能满足. 在这种情况下, D 策略是不可避免的. 况且, 网络情况复杂多变, 即使双方都有意采取 C 策略, 很可能因为网络的复杂性, 双发获得的受益不对等, 从而引发一方采取 D 策略. 所以, 如果 Tit for Tat 这种初值敏感策略放到 P2P 客户端中, 结果是不可想像的, 因为这时候每个客户端都是碰不得的刺猬, 一旦在某个时间点某个节点出现了差错, 很可能整个系统都陷入”冤冤相报”的死结, 让整个网络没法完成文件的传输, 反而忙着互相报复和自我保护.

从上面的分析我们看出, 靠精心设计初值来维护这个系统是不现实的, 我们需要设计的, 是一个好的策略, 使得不管初值怎么变, 系统中每个个体依然能够获得较大的收益.  那么, 怎样设计这个鲁棒的系统呢? 我们从极端的两个例子开始, 一种是不管别人怎么出牌, 永远合作的; 另一种是或者不管别人怎么策略, 永远背叛的. 这两个都很鲁棒, 都很”彪悍”. 但是毫无疑问, 效用不见得最大化.

从这两个极端的例子表现不怎么好来看, 我们的确应该要根据对手的策略选择自己的策略, 同时又不能非常的依赖于对手的策略(否则就初值敏感了). 那么, 最简单的方法就是: 我们以一定的概率去执行以牙还牙, 但是也允许以一定的概率不管上次选什么, 这次和对手选择合作(跳出怪圈). 这样, 因为随机性的引入, 对初值的依赖就随着时间的流逝越来越小了.

在多个人的环境中, 我们的确愿意和对手选择随机合作, 但是因为资源的限制, D 是不可避免的. 但是我们不会让 D 永远下去, 我们每轮和随机的对手选择一次随机的合作, 这样就不会被怪圈所左右. 这个就是 bt 协议跳出冤冤相报的精髓. 一旦知道了这个, 本文思想就差不多介绍完了. 下面就是程序员的编码工作了. 下面的内容完全是基于 Bram Cohen (bt 协议创始人) 的经典论文 “Incentives Build Robustness in BitTorrent” ( http://www.bittorrent.org/bittorrentecon.pdf ) 里面的内容展开的. 我只介绍和博弈论有关的部分. 读英文更加习惯的读者直接看原论文比读下面的文章更加好.

首先说点背景知识, bt 把文件看成一块一块的, 并且用一定的排序算法决定现在能够下载哪一块. 其次, bt 协议同时和多个机器之间建立 TCP 连接, 但是采用堵塞的方法控制传输. 因为建立连接代价比较大, 所以 bt 协议维持连接不变, 在其上采用 choking (堵塞) 的方法来执行 D 策略, 采用 un-choking 的方法 来执行 C 策略, 而不是每次都重新建立和取消连接. IP 协议在这方面有天然的优势.

每次, BT 协议选择 k (通常为7, 限速的情况下为2, 3, 或 4) 个其他的客户端来执行 C 策略(即给上传). 在上一轮中给出最多下载的那些节点, 在本轮将被执行 C 策略(注意到有的节点上一轮并没有给上传, 即从C 到 D). 同时, 为了避免其他的更加好的节点被忽视, 每 m 轮, BT 客户端选择一个随机的 choke 了的节点执行 C 策略 (即从 D 到 C. 同时, 因为资源限制, 必然有一个被 choke 了, 即从 C 到 D).

那么, 什么时候执行 D 呢? 在 BT 协议中, 假如连续 n 轮, 都没有从一个节点收到任何下载, 在 bt 术语中, 这个叫做 snubbed. 这时候, 则该节点认为自己被那个结点执行D策略了. 作为报复, 自己也停止对该节点的上传(即以牙还牙, 从 C 到 D. ). 除非等到下次随机的选到了那个节点(再次到 C ).

这就是 bt 的协议关于博弈论的全部. 其中, 一轮持续时间在现在的实现中是 10 秒. m 为 3, n 为 6. 目前暂不清楚 Bram Cohen 是否通过实验得到这些参数, 有兴趣的读者可以自己查阅 bt 源代码, 改一下, 看看哪个更加好. 同时, 因为其他客户端采用的是 Tit for Tat, 想把自己的客户端改成 吸血bt 是不可行的, 也占不到别人便宜.

PS: 有兴趣的读者可阅读 bt 源代码中的 Choker.py. BT 源代码用 Python 写成, 比较好懂.