铁路与编程有什么联系呢?
乍一看二者似乎没任何关系。毕竟随着蒸汽机车的诞生,早在 19 世纪初就进入了现代铁路时代。自此直到 100 多年以后,计算机专家才开始使用机器语言编程。二者完全不是一个时代的产物。但实际上,从编程语言入门,到数据结构与算法,再到操作系统原理、编译原理等计算机系的专业课,总能看到铁路,或者说总能看到能用铁路来类比的概念。
今天我们就从简单到复杂,沿着程序员的成长路径,看看编程世界中的几条铁路吧。
初级铁路——帮助初学者理解程序流程和数据结构的铁路
火车总是沿着预定的轨道行驶,每条线路都有明确的起点和终点,行驶的过程中还可能遇到分支。这一点倒是和程序很像,每个程序都有明确的执行路径,从入口(如主函数)开始,经过各种分支、循环和函数调用,最后结束。因此,对于编程的初学者,铁路能直观地表示出程序的流程。
例如,这段铁路就可视化了更新数据库(UpdateDb
)的流程。绿色的轨道表示正常的流程,只有数据通过校验(Validate
)并且数据库工作正常,新数据才能覆盖旧数据。否则”火车“就要驶入红色的轨道,意味着程序进入了异常的流程。
熟悉了程序的顺序执行、分支和循环这 3 种主要流程后,就可以学习数据结构与算法了。栈是一种比较基础的数据结构,它的结构和操作也可以通过铁路来可视化。
图中用两段在末端交汇的铁路表现出了栈这种数据结构的特点——先进后出。先驶入底端轨道的列车车厢(相当于数据)只能稍后驶离,因为没有穿墙术,没法穿越排在它前面的车厢。
使用铁路来类比栈这一想法据说是由 E.W. Dijkstra(艾兹赫尔·维伯·戴克斯特拉,1930年5月11日-2002年8月6日)提出的。这位来自荷兰的计算机科学家可不得了,他对计算机科学产生了深远的影响,计算机专业课中的最短路径算法、用于解决进程同步问题的信号量都出自他的手。你不想、不敢或不能在代码中写 goto
语句也是因为他,因为他的论文 Go To Statement Considered Harmful(《GOTO声明的危害》)批评了goto
语句的不良影响。
我感觉 E.W. Dijkstra 应该是个铁道迷,他不仅建议用铁路来类比栈的结构和操作,就连他提出的信号量 semaphore 这个词本身,以及针对信号量的两种操作的命名(操作系统原理课程中的 PV 原语)也是和铁路相关的术语。在本文的最后,我们将再次看到 E.W. Dijkstra 的另一项成就,依然与铁路有关。
中级铁路——用来描述编程语言语法的铁路图
要成为能够独当一面的程序员,只熟悉一门编程语言是远远不够的。在学习一门新语言时,要先掌握语法,而语法是个比较复杂的概念,无法用一两句话说清。
入门级的图书往往会用具体的句型和示例来介绍语法,这就如同通过学校的英文教材学英语,难以接触到语法的本质。好在程序员比较熟悉正则表达式,可以用它来描述语法。比如,SQL 中最简单的 SELECT
语句的语法可以这样描述,
SELECT\s.*FROM\s.*WHERE\s.*
那复杂一些的 SELECT
语句该如何用正则表达式描述呢?也许是这样,
(?i)SELECT\s+[\w\*,\s]*\s+FROM\s+\w+(?:\s+JOIN\s+\w+)?\s+(?:WHERE\s+.+?)?(?:\s+ORDER\s+BY\s+\w+)?(?:\s+LIMIT\s+\d+)?
已经有些不好解读了吧,那能够匹配 JSON 的正则表达式又该怎么写呢?
这个别说咱们写不对了,就连 JSON 之父 Douglas Crockford(道格拉斯·克罗克福德)也是多次修改了他自己编写的正则表达式(和对正则表达式的操作),才能正确判断字符串是否是安全的 JSON。最初的判断只有 2 行,
// https://web.archive.org/web/20060226161035/http://www.json.org/json.js
!(/[^,:{}\[\]0-9.\-+Eaeflnr-u \n\r\t]/.test(
text.replace(/"(\\.|[^"\\])*"/g, '')))
但经过不断完善,最终的判断膨胀到,
// https://web.archive.org/web/20101127064455/http://www.json.org/json.js
/^[\],:{}\s]*$/.
test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@').
replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']').
replace(/(?:^|:|,)(?:\s*\[)+/g, ''))
正则表达式越写越复杂,这背后的原因在于其表达能力上的限制,正则表达式无法描述括号匹配、嵌套结构和递归模式等复杂的语言结构。那该如何描述编程语言的语法呢?
还是以 JSON 为例,在 json.org 的首页上,同时使用了 2 种方式来描述 JSON 的语法。
对于 JSON 中的数组,页面的右侧用如下一段代码(术语叫作上下文无关文法)来描述,
array
'[' ws ']'
'[' elements ']'
elements
element
element ',' elements
element
ws value ws
ws
""
'0020' ws
'000A' ws
'000D' ws
'0009' ws
简单来说,这段代码表示,
array
可以是一个空数组[]
或者包含元素的数组[ elements ]
elements
可以是单个元素element
或者多个元素组成的列表element ',' elements
element
是由空白字符ws
包围的一个值value
ws
既可以是空格'0020'
、换行'000A'
、回车'000D'
、制表符'0009'
的排列组合,也可以为空
最不好理解的地方是“elements
可以是多个元素组成的列表 element ',' elements
”,因为这里用 elements
本身解释了 elements
,出现了循环(递归)。
Douglas Crockford 显然也意识到了这种描述方法虽然专业但不好理解(所谓的要理解递归需要先理解递归)。为了推广 JSON,使其成为取代 XML 的标准,需要让大部分程序员能够快速理解数组等 JSON 结构的样子。于是 Douglas Crockford 又画出了 JSON 中每种结构的铁路图。
这是 JSON 中数组的铁路图。想象一列火车沿着最上方的轨道呼啸而过,这就得到了一个空数组 []
(也可以夹杂一些空白字符,如 [ ]
);火车也可以经过 value
“车站”后就掉头往回开(沿着最下方的轨道),于是在经过了 ,
之后就会再次回到 value
“车站”,然后循环往复,不断经过 ,
和 value
这两“站”,最终不再掉头,选择上方的铁路开向终点 ]
,这就形成了包含多个 value
的数组。
铁路图不但简洁直观还有较强的表达能力,真是我们学习新语言语法的好工具。
高级铁路——后缀表示法与调度场算法
如果你是资深的程序员,是不是总想自创一门编程语言呢?这就需要再学习一些编译原理的知识了。
编译过程可以很复杂,如将一门新语言转化为可执行的程序,也可以很简单,仅仅是改变输入字符串的格式。算式的后缀表示法(postfix notation)就属于后者,是编译原理的入门课程常用的示例。
后缀表示法,也称为逆波兰表示法(Reverse Polish Notation,RPN),也是一种算式的表示方式,它不同于我们日常书写算式的方法(中缀表示法,infix notation),加减乘除等运算符要跟在数字的后面,而不是位于数字的中间。例如,3 + 4 × 2
的后缀表示法是 3 4 2 × +
。可这样书写有什么好处呢?
最大的好处是便于机械地计算,而这正是计算机擅长的。只要遇到一个运算符,就可以立刻将相应的运算作用于它前面的两个数字,然后用计算结果取代这两个数字,并继续看下一个运算符。这使得计算过程可以从左到右一次性完成,而无需考虑运算的优先级,甚至可以消除算式中表示优先级的括号。
还是以 3 + 4 × 2
为例,如果我们用计算器来计算,那么就必须先依次输入 4
、×
、2
、=
,因为“先乘除后加减”。然后把中间结果 8
记下来,再依次输入 3
、+
、8
、=
,才能得到最终结果。
但如果算式已改写为后缀表示法的 3 4 2 × +
,那么我们就可以忽略运算的优先级,从左到右不假思索地输入数字和运算符了。macOS 自带的计算器程序就支持后缀表示法,可使用快捷键 ⌘+R 开启该功能。
既然后缀表示法便于计算机处理,那该如何将我们习惯的算式转写为后缀表示法呢?对于 (3 + 4) × 5 - 6
,也许经过几次尝试,能够轻松得出后缀表示法是 3 4 + 5 × 6 -
,那
1 + 2 × (3 - 4)^2 + 5^(3 + 4 + 5)
,即
的后缀表示法又是什么呢?这恐怕就要花费一番工夫了吧。
又轮到铁道迷 E.W. Dijkstra 出手了,他发明了调度场算法(Shunting Yard Algorithm),用于将惯用的中缀表示法转换为后缀表示法。而该经典算法又又又一次和铁路相关。现实中的调度场(如下图所示)是铁路车站集中处理大量列车到达、解体、编组、出发等作业的区域。
下面是一段调度场算法的演示动画。我们可以看到,该算法是如何利用 3 段铁路,通过模拟列车解体并重新编组的过程,将来自 Input 段铁路上、采用中缀表示法的一列“火车”(就是刚刚提到的算式 1 + 2 × (3 - 4)^2 + 5^(3 + 4 + 5)
),重新排列成下方 Output 段铁路上采用后缀表示法的另一列“火车”。火车头不断地在 Input、Stack 和 Output 这 3 段铁路间牵引车箱,并将“装载着小括号”的车厢拉走。
通过反复观看这段动画,应该不难发现火车头的运动规律:
- 如果“车箱装载着数字”,就将其直接带到 Output 段铁路
- 如果”装载着运算符“,则要与停在 Stack 段铁路最右侧车厢上的运算符比较,再根据这两个运算符优先级的高低,决定下一步是把当前“车厢”直接带去 Output 段铁路,还是也停留在 Stack 段铁路
- ……
具体的规则可参考维基百科 wiki/调度场算法#详细的算法。
据传说,铁轨之间的标准宽度可以追溯到古罗马的马车道宽度,而那时的马车道的宽度是根据两匹马屁股的宽度决定的。而程序员往往也会是"屁股决定大脑",或在开发过程中权衡利弊做出取舍,或学着源码分析写着 CRUD。这也算是铁路和编程之间一种微妙的联系了吧。
胡译胡说 软件工程师、技术图书译者。译有《图解云计算架构》《图解量子计算机》《图解TCP/IP(第6版)》《计算机是怎样跑起来的》《自制搜索引擎》等。