文章

[中文翻译] Goto 语句有害论:历史回顾

[中文翻译] Goto 语句有害论:历史回顾

简介

这篇文章是对 Edsger W. Dijkstra 于 1968 年寄给 ACM通讯(CACM)的一封信的讨论与分析,信中他呼吁要把 goto 语句从编程语言中废除。自刊登 40 年以来,这封信变得很有名(或者说声名狼藉,这取决于你对 goto 语句的感受),它既可能是编程领域里最常被引用的文献,也可能是在此领域中最少被读过的文献。

大多数程序员或许都听过“永远不要使用 goto 语句”这句格言,但时至今日,没有多少计科专业的学生们能知晓当年 Edsger W. Dijkstra 发表反对言论时所处的历史背景。现代编程的主流观念已然接受了“goto 语句是邪恶的”这一迷思,但阅读原始文档还是很有启发作用的,并且你会意识到这种教条般的信仰完全搞错了重点。

这篇文章是写于那个使用 goto 语句来手动模拟迭代循环、“if-then”和其它控制结构的年代,当时这种做法极为盛行。当时的许多编程语言并不支持我们现在看起来理所当然的基本流程控制语句,即使有,也只是提供了非常受限的形式。Dijkstra 并没有说所有 goto 的用法都是不好的,而是说正确地使用这些高层级的控制结构,可以替代绝大部分 goto 的盛行用法。Dijkstra 仍然允许在更复杂的流程控制结构中使用 goto。

背景

Dijkstra 和其他人如 C. A. R. Hoare、Niklaus Wirth 等在早期为程序设计这门新兴学科所做工作的重要性是怎么赞扬都不为过的,在把计算机科学建立为一个严肃学科和把算法编程作为数学和逻辑学的一个正式分支时,他们的贡献起到了极其重要的作用。

在严肃计算机科学的发展早期,Dijkstra 和他的大多数同事一样,是个受过严格数学训练的学者。毫不意外地,之后在计算机科学中他所从事的大部分工作是试图以牢固的数学和逻辑学基础,将程序设计发展为一门严谨的工程学科。Dijkstra 希望能设计出能够证明程序正确性的编程语言,而他所使用的理论称为“形式化验证(Formal Verification)“,指的是可以设计出编程结构的一个小集合(if-then-else 和循环语句、基本数据类型等),足够强大以用于解决任何编程任务,并且可以在数学上证明其正确性(即不存在逻辑错误)。

这一始于上世纪五十年代晚期的举动,在精神层面与数学界中比它更早的”希尔伯特计划(Hilbert’s Programme)“是何其相似。“希尔伯特计划”是由 David Hilbert 提出,试图基于一组有限的公理,即只以自然数和基本的逻辑算术规则,将所有数学形式化并包容在一个完备的公理系统中的一项计划。可惜的是,哥德尔不完备定理无情地粉碎了 David Hilbert 的梦想,它证明了数学的真相(与假相)远远超出了逻辑证明的范畴。

还可以将形式化验证的尝试与牛顿力学进行类比,在那个牛顿运动定律被广泛接受的年代,人们普遍相信,在物理学的世界里一切都是确定的,所有的运动最终都可以在给予足够的质量、动量信息的前提下,通过计算得到任意精度的结果。可惜,海森堡、玻尔和其他物理学家的发现带来了量子力学,并终结了牛顿力学的信仰,宣告着深至粒子层面的所有物理现象本质上都是概率论和随机的,因此是不可预测的。

如出一辙的是,形式化验证的目标最终看起来也是不切实际的,Dijkstra 随后便放弃了对程序可证明性的研究,转而研究起正确的“程序推导方法”,这项技术的设计初衷是为了让程序员能以一种有条理的方式构建程序,并且保证它们表现出正确的行为。这个研究领域与“自顶向下设计(top-down design)”的技术和“功能分解(functional decomposition)”有着许多的共同点。

还有应该注意的一点是,我们今日所习以为常的许多计算机编程术语在 1968 年还没有被正式地确立。在当时有很多争论和探讨,讨论着诸如“应该用什么术语来描述计算机编程中的概念”等问题,而我们今天所使用的大部分术语经过多年才被广泛接受。

Dijkstra 是个十足的学院派,他在写作时倾向于使用充斥着各种学术词汇的措辞,这或许在某种程度上解释了他那封著名的信件至今没什么人读过的原因。值得指出的一点是,Dijkstra 很是鄙视把程序中的问题形容成”bug“的用法,他更偏向于使用”error“来指代。当然了,时至今日这两个术语都还在编程的语境中使用,但是其含义有所不同。”error“的含义没有什么变化,指的是由个人或机器导致的错误;”bug“的含义变得更加具体了,只应用在人造系统的领域里,特别是在可编程计算机中,用于指代设计上的特定错误或者意外的执行结果。术语”debug“也随之诞生,用来表示寻找和解决系统中”bugs“的行为,如果我们只是使用”error“的话,就不会发明出这么好的术语了。

第一部分 - Dijkstra 的信件,带注释

下面就是 Dijkstra 那封在 1968 年寄给 ACM通讯 的著名信件,以历史角度来讨论信件细节的注释也会一并列出。


Goto 语句有害论 - Edsger W. Dijkstra

ACM通讯 重印,Vol. 11, No. 3, March 1968, pp. 147-148

版权所有 ©1968,国际计算机学会(ACM)

David R. Tribble 注释

致编辑:

多年来我时常发现,程序员的优秀程度是他们写出的程序里 goto 语句密度的递减函数。最近我找到了 goto 语句的使用具有如此灾难性效果的原因,而且我愈发确信 goto 语句应该从所有的“高层级”编程语言中废除(也就是所有编程语言——或许除了纯机器码)。在那个时候我并不是很重视这个发现——现在我将这个想法公之于众,因为最近越来越多关于这个问题的讨论促使我这么做。

Dijkstra 介绍了这封信的主题,即他发现 goto 语句的出现对程序来说通常都是有害的,Dijkstra 还认为一个程序员使用的 goto 越多,他就越是一个糟糕的程序员。

他提议应该在所有的高级编程语言中禁用 goto 语句,他甚至还暗示所有的编程语言(可能还包括机器码)都应当淘汰 goto,尽管让人疑惑这到底该如何实现。

我的第一个观点是,尽管当程序员构建完一个正确的程序后,他的编程活动就结束了,但在他的程序控制下所发生的进程1才是其编程活动的真正核心内容,正是这一进程需要达成预期的效果,正是这一进程在动态行为中需要满足设定的规格要求。然而,程序一经作成,相应进程的“执行”就被委托给了机器。

这段充满了各种学术措辞的文字简直就是 Dijkstra 学术写作风格的典范。

这段话其实就是在说,一个程序员实际的编程活动不单纯是写程序,而是在控制代码执行于实际机器上的行为。然而他表示,一旦程序员写出了一个可工作的程序,程序的实际执行过程就完全由机器本身控制了。

Dijkstra 使用术语”正确(correct)”来描述一个程序没有错误,或用现在的说法就是没有 bug。这种措辞反映出当时的一种信念,即能够写出被“形式化验证”的代码,也就是说,这些代码可以被归属为一系列数学和逻辑上的操作,而这些操作可以证明其代码要么含有错误,如逻辑错误、约束错误和不变量错误等,要么不存在错误,因此是“可证为正确(provably correct)”。而在前文背景这一节提到,现今的计算机科学家——至少程序员并不会用这些术语来思考如何编程。

我的第二个观点是,我们的智力更倾向于掌握静态关系,而对随时间变化进程的可视化能力则相对较弱。出于这个原因,我们应该像明智的程序员能意识到自身的局限一样,尽最大的努力缩短静态程序和动态进程之间的概念鸿沟,使得(在文本空间中呈现的)程序和(在时间上呈现的)进程之间的关系尽可能地简明。

这里 Dijkstra 观察出,相比于动态关系,人类更擅长于视觉化静态关系。因此他主张,我们在用代码表达时应该尽量缩小两者之间的差异,这样程序动态(非恒定)的样子在源代码自身的结构中就很清晰了。

这在绝大多数现有的编程语言中是普遍适用的,这些语言大多采用线性、逐语句执行的方式。然而,当我们审视现实世界中编程任务所需应对的复杂性时,Dijkstra 的原则在某些范围中是不完全成立的,例如多任务处理、多线程、中断处理、易失性硬件寄存器、虚拟内存分页、设备时延、实时事件处理……仅举几例,今天的编程问题已不能够用简单的“一次只执行一行”的程序执行模型来妥善处理了。

现在让我们思考如何表征一个进程的进展。(你可以以一种非常具体的方式来思考这个问题:考虑一个随时间连续做出动作的进程,在某个任意动作后停止,那么为了能够将进程重新执行到完全相同的节点,我们需要修正哪些数据?)若程序文本仅是(如)赋值语句的单纯拼接(在本次讨论中所涉及的内容视为对单个动作的描述),那么在程序文本中指出两个连续动作描述之间的点就足以描述过程的进展。

Dijkstra 开始构建“程序执行”(他称之为“进程的进展”)的规范定义,随后的讨论类似于”序列点(sequence points)“的定义,序列点是由C/C++(以及其他语言)采用的程序执行模型的规范定义。

必须时刻牢记的一点是,我们今日所习以为常的许多术语在当时还没有被正式地确立,并且也没有被普遍接受的语言或伪代码用于讨论算法和程序。当然,如今的一位笔者会使用一门具体的语言,如 C、Java、Pascal、LISP,或与这些语言之一极其相似的一种伪代码作为“通用语”,来阐述编程概念。

(在没有 goto 语句的情况下,我可以允许自己在上一句的最后三个词(连续动作描述)中存在语法歧义:若我们将其解析为“连续的(动作描述)”,那意思是在文本空间中连续;若我们将其解析为“连续动作(的描述)”,那意思是在时间上连续。)我们不妨称指向文本中合适位置的指针为“文本索引”。

以典型的学术风格,Dijkstra 运用了一点语言上的巧妙手法,使得“连续动作描述”能够被有歧义地解析为两种不同的含义。这反映了他之前提到的编程任务的双重性质,这些关系到单条语句(或动作)在另一条后执行的顺序性质,也就是说,允许程序的源代码被组织为一系列的独立语句(动作),以反映语句在时间尺度上执行的顺序性质。

他的术语“文本索引”本质上是“程序计数器”。然而,他正在尝试的是超越仅追踪当前执行线程位置的方式,直接建立起源代码文本中语句与程序执行状态之间的明确联系,所以或许更好的术语是“语句指针”。

当我们在程序中加入条件子句(if B then A)、选择子句2if B then A1 else A2)、C. A. R. Hoare 引入的选择子句3case[i] of (A1, A2, …, An)),或者 J. McCarthy 提出的条件表达式(B1 -> E1, B2 -> E2, …, Bn -> En),进程的进展依然可以用一个文本索引来表征。

Dijkstra 引入了更多复杂的流程控制语句,如 if-then-else 条件语句和 case(亦称 selectswitch)选择语句,并注意到这些并没有改变文本索引(或语句指针)的基本性质。

这反映了一个事实:在当时,人们正致力于为编程语言以及整个编程理论制定出最精简的流程控制结构组合。大多数结构都与 ALGOL 所支持的控制结构相似,这并非巧合,因为许多参与或影响 ALGOL 设计的人都是撰写了大量相关编程著作的学者。

所有这些努力的主要目标是创造一个术语体系,使其不仅适用于实际的编程语言,而且还能直接用于编程算法的数学表述。如上文背景一节所述,这反映了人们的一种信念,即程序可以用一种能够在数学上证明其正确性的形式写出。

一旦在我们的语言中引入过程4,我们就必须承认单一的文本索引已不再足够使用了。当文本索引指向过程体的内部时,只有同时指明所引用的是该过程的哪一次调用,才能表征动态进展。引入过程后,我们可以通过一个文本索引的序列来表征进程的进展,这个序列的长度等于过程调用的动态深度。

这一言论表明,如果程序使用了“子程序(subroutines)”(也被称为“过程(procedures)”、“函数(functions)”或“方法(methods)”),那么仅通过一个语句指针是不足以定义程序的执行状态的。为了解决这种额外的复杂性,Dijkstra 定义了“文本索引的序列”。

这反映了在现代术语中所称的“调用栈”概念,它是一个程序计数器的数组(也称为“返回地址”),每个计数器都指明了发起过程调用的最后一条语句。不过由于他正在构建文本索引与程序执行状态之间的显式联系,所以更准确的说法是将调用栈视为语句指针的数组。语句指针的所需数量其实就是在执行到当前活跃点时过程调用的次数,也就是调用栈的深度。

现在让我们来探讨重复子句,如 while B repeat Arepeat A until B。逻辑上讲,这些子句现在是多余的,因为我们可以借助递归过程来表达重复。但出于实际考虑我并不想排除它们:一方面,重复子句可以在当今的有限设备上轻松实现;另一方面,“归纳”这种推理模式让我们能很好地保持对重复子句所生成过程的理解。

Dijkstra 进一步加入了重复控制流语句,他顺带指出,这类重复语句完全没有必要,因为它们可以用等效的递归调用代替。

这反映出一个事实:在当时,递归非常流行,并且被许多人(尤其是那些更注重学术研究的人)认为是一种表达程序和算法的更优形式。这种趋势的原因在于递归定义具有严谨的数学基础——具体来说,就是“递归公式”和“递归关系”,它们用于处理通过递归方式定义的序列,其中的每个元素都是通过用序列中的先前元素以更简单的形式来定义的。两个经典的例子是阶乘函数,n! = n(n-1)!,以及斐波那契数列,Fi = Fi-2 + Fi-1

这是一个典型的学术性言论,理论上讲,确实任何循环语句都可以用递归调用代替,并且有些语言像 LISP 确实支持递归式的编程风格(也称为函数式编程)。然而,对于大多数编程应用而言,以及最终大多数编程语言实际上的支持情况来看,递归使用甚少(但仍然非常有用)。

Dijkstra 提到迭代语句可以在“有限设备”上实现,而实际上所有现有的机器都是具备这种能力的,无论其拥有多少虚拟内存。这是一种委婉的表达方式,意在表明某些形式的递归可能需要无限的资源(即无限的调用栈)。考虑一个典型的嵌入式应用,其中有一个主程序循环会轮询、处理一个事件,然后等待下一个事件,如此无限循环。像这样的无限循环当然可以用尾递归的方式写成一个过程调用,但这样做又有何意义呢?仅仅是为了递归而递归的更复杂形式,在大多数实际系统中是无法用于编程的。

Dijkstra 似乎在暗示迭代循环(的可归纳)语句在思维上比递归更难理解,而这只有数学家才会这么说。

随着重复子句的加入,文本索引不再足以描述进程的动态进展。不过,每次进入重复子句时,我们可以为它关联一个所谓的“动态索引”,它会不断地记录当前相对应重复的序数。由于重复子句(就像过程调用一样)可以嵌套使用,我们发现现在进程的进展总能通过文本索引和/或动态索引组成的(混合)序列唯一地表征。

随着重复控制结构的加入,我们需要一种能够指定除当前语句之外的更多内容的方法,还需要能够追踪每个循环当前执行到了哪一个迭代。因此就像嵌套的过程调用一样,我们必须使用一个“循环迭代栈”来追踪这些迭代次数,每个(嵌套的)循环都会有一个记录,Dijkstra 称其为“动态索引序列”。

所以,综合来看,我们有一个“文本索引序列(调用栈)”和一个“动态索引序列(循环迭代栈)”,它们共同定义了程序执行的当前状态。

(或许它们还能嵌套?)

关键在于,这些索引的值不受程序员控制:无论愿意与否,它们都会(由其程序编写或进程的动态演变)生成。它们为描述进程的进展提供了独立的坐标。

再次地,Dijkstra 阐述了显而易见的道理:一旦程序编写完成并运行,程序员就不再能够对实际的执行过程做任何控制了。执行过程是由其中任何时刻的调用栈和循环迭代栈中的内容所代表的——这就是 Dijkstra 所说的程序执行的“独立坐标”,我们也可以简单称其为程序的“状态”或“执行历史”。

为什么我们需要这样的独立坐标呢?原因在于——这似乎是顺序性进程所固有的——我们只能根据进程的进展来解释变量的值。假设我们想要统计一个初始为空的房间里的人数 n,那么可以在每次看到有人进入房间时将 n 增加一来实现。在我们观察到有人进入房间但尚未执行后续的增加 n 的操作这个间隙时刻,n 的值就等于房间里的人数减去一。

Dijkstra 指出,要知晓一个程序中某个变量的具体值,就必须精确掌握在特定时间点之前该程序执行过程的历史记录。换句话说,程序的执行应当是确定性的,而且在程序执行的任何时刻,都应当能够根据截止至该时刻的执行历史(或者程序状态的历史)推断出任何变量的值。

Dijkstra 引入了程序语句执行完成前的“间隙时刻(in-between moment)”这一概念。这与 C 和 C++ 等语言中规定的“序列点”概念类似,该概念明确界定了行为发生的具体时机、顺序以及(同样重要地)哪些行为未作规定。

程序的执行只有在特定的序列点处才有良好定义(well-defined),这些序列点通常出现在语句的末尾、函数调用之前以及子表达式计算过程中的特定点。在任意两个序列点之间,程序的状态不具备良好定义,这意味着到达下一个序列点之前,程序变量的值处于一种不确定(或“间隙”)状态。

Dijkstra 对此给出了一个简单例子:递增一个计数器,或像 n = n + 1 这样的语句。当这个语句实际执行时,存在一个执行点,此时 n 的先前值已经被读取并被加上 1,但新值尚未被写回变量 n 中。这就是他所指的“间隙”状态,或者在两个“序列点”之间的执行状态,在此期间变量 n 仍然保留其旧值而非新值。

goto 语句的无节制使用会直接导致很难找到一套有意义的坐标用于描述进程的进展。通常,人们也会考虑一些精心挑选的变量值,但这是不可能的,因为这些值的含义要根据(进程的)进展来理解!通过 goto 语句,人们当然仍能够用计数器记录自程序启动以来所执行的动作数量(即一种规范化的时钟),以此唯一地描述(进程的)进展。问题在于,这样的一种坐标虽然唯一,但毫无用处。在这样的坐标系中,要定义所有像 n 等于房间里的人数减一这样的进展点,就变得极其复杂了!

至此,我们终于触及 Dijkstra 关于 goto 语句这一低级用法的核心观点了。大体上,Dijkstra 认为在程序中“无节制地使用” goto 语句会让程序的执行状态和执行历史变得晦涩,以至于在任何给定时刻,调用栈和循环迭代栈的值都不足以确定程序变量的值。

这种混乱现象是由这样一个事实所导致的:不受限制的 goto 语句能够在循环尚未完成之前就将控制权转移至外面,同样也能将控制权转移到已经处于迭代状态的循环之中。这两种情况都使循环迭代栈中计数器被修改的方式变得复杂。

更进一步,还存在非局部 goto 的可能性,即控制权从当前执行的过程转移回先前被调用的过程中,这实际上会扰乱执行状态,因为调用栈中整个部分的值都被无效了。

Dijkstra 给出了一个控制权在“间隙时刻”从循环或过程中被转移出去的具体例子,这样一来,从那时起执行状态就变得不确定了。

另一种表述方式是:goto 能够让“程序不变式”失效,而它本来应该由程序结构保证不会被破坏的。他这里所使用的示例不变式是,计数器 n 总是表示房间内的人数。允许非结构化的 goto 改变执行路径,会导致该不变式失效(即不再不变),从而使 n 的值失去意义,或至少会导致从执行历史中确定其真实值变得极为困难。

现有的 goto 语句太过原始,以致容易让程序混乱不堪。人们可以将这些子句视为对使用 goto 的限制,并认可这种做法。我并非声称所提及的这些子句是完备无缺的,即它们能够满足所有要求,但无论提出何种子句(例如终止子句),它们都必须满足这样一个要求:能够维持一个独立于程序员的坐标系,以便有效且易于管理地描述进程。

Dijkstra 所指的“现有的” goto 语句,通常称为非结构化的 goto,也就是在其他结构化语言中对其使用方式没有任何限制的 goto 语句。

将 goto 的使用限制在少数几种简单的、结构清晰的控制结构上,例如提前退出循环、错误处理(也称为异常处理)等,可以使 goto 语句重新回到结构化控制流修改的领域内。但如果没有相应的规则来强制做出限制,那么由某种语言所提供的 goto 语句就不能称作是真正良好结构化的了。

Dijkstra 承认,一种语言所提供的流程控制结构并非都能满足所有的编程需求。这反映了 goto 在某些极为罕见的编程场景中依然有其适用之处,这些场景需要更复杂的流程控制。

Dijkstra 提到了“终止子句”,也就是现在普遍称为的“异常处理器”,他暗示这类东西背后实际上就是一些花哨的 goto,但它们可以被定义成在结构化语言的框架内良好运作的形式,也就是说,它们不会以随意的方式破坏执行状态。

像 Ada、C++、Java 等面向对象语言里的异常处理子句在很大程度上遵守了这一规则,因此在这些语言中抛出异常时,程序的执行状态(包括全局和局部变量、过程调用栈、堆等)会以干净和可预测的方式改变。

更原始的语言如 FORTRAN、COBOL、C、Pascal 等,或许会提供一些基本的异常处理机制,但使用这些机制并不能保证执行状态能够得到妥善保留,也不能保证已分配的资源能够得到正确释放。

很难在结尾时恰如其分地致谢。我是否应当根据那些影响过我思想的人来评判呢?很显然我并非完全不受 Peter Landin5 和 Christopher Strachey6 的影响。最后,我想详细记录(因为我记得非常清楚)在 1959 年初于哥本哈根举行的 ALGOL 会议之前的一次会议上,Heinz Zemanek 明确地表达了他对 goto 语句是否应与赋值在语法上享有同等地位的疑虑。在某种程度上,我得怪自己当时没有从他的话中得出结论。

这里 Dijkstra 致谢了那些促使他得出 goto 的危险性这一结论的人。在背景一节中提到,许多影响了 ALGOL 语言设计的人同时也参与了有关语言的恰当设计和恰当的程序控制流结构的讨论。

关于 goto 语句不可取的观点并不新鲜。我记得曾读过明确的建议,即将 goto 语句的使用限制在异常退出中,但一直未能找到出处,想必这是由 C. A. R. Hoare 提出的。在 [1,第3.2.1节] 中,Wirth 和 Hoare 在阐述 case 结构的必要性时也提出了类似观点:“和条件语句一样,它比 goto 语句和 switch 语句更能清晰地反映程序的动态结构,并且避免了在程序中引入大量标签的必要。”

Dijkstra 指出,goto 语句(仅)在用于“异常退出”(即我们所称的“致命异常”)时是可接受的。对于那些缺乏健壮的异常处理机制的语言,goto 在实践中可能是唯一的替代方案。

Dijkstra 提到了由 Hoare 和 Wirth 提出的 case(或 select)控制流结构的设计方案,如今我们对这些控制结构习以为常,但当时其优点仍存在争议。Dijkstra 提醒我们,提出它的初衷是作为对繁琐的多个 ifgoto 和标签的更优替代。

在 [2] 中,Guiseppe Jacopini 似乎证明了 goto 语句(在逻辑上)是多余的。然而,将任意流程图或多或少机械地转换为无跳转的流程图这种做法并不值得推荐,因为转换后的流程图不见得比原来的更清晰。

Dijkstra 提到了“流程图”,这反映了当时程序设计的前沿水平。从那以后,编程技术历经了结构化编程、自顶向下编程、面向对象编程、组件编程、切面编程等阶段的发展。然而,尽管在设计方面有了诸多进步,Dijkstra 关于非结构化程序流的主要观点在当时和现在依然同样适用。

必须指出的是,Dijkstra 对这一话题的最终结论似乎暗示,彻底从自己的程序中删除所有的 goto 并不是一个好主意。尽管他指出,已经证明在任何给定的程序中 goto 语句实际上都是多余的,Dijkstra 还是承认删除程序中的所有 goto 会让流程更难以理解。

实际上他所主张的观点是:程序中的某些 goto 可能是有用的,并且实际上能让程序更易于理解。因此,可以肯定地说,Dijkstra 认为 goto 语句是有害的,但并非致命的,也肯定不是毫无用处的

参考文献

  1. Wirth, Niklaus, and Hoare C. A. R.

    对 ALGOL 发展的贡献.

    Comm. ACM 9 (June 1966), 413-432.

  2. Böhm, Corrado, and Jacopini Guiseppe.

    流程图、图灵机以及仅有两个构成规则的语言.

    Comm. ACM 9 (May 1966), 366-371.

Edsger W. Dijkstra

Technological University

Eindhoven, The Netherlands


第二部分 - 结构化编程

在 Dijkstra 的信发表之后,编程语言是否已经发展到不再需要“goto”的程度了呢?

自 20 世纪 60 年代以来,编程理论出现了若干新的进展。编程学科经历了几个发展阶段,每次新的进步都被吹捧为更“优越”的编程方式。下面是一些此类进步的简要列表:

  • 结构化编程
  • 功能分解
  • 自顶向下设计与逐步求精
  • 自底向上设计
  • 迭代设计
  • 第三代语言
  • 第四代语言
  • 第五代语言
  • 面向对象编程
  • 组件化
  • 编程模式
  • 切面编程

每种方法都引发了编程理论的范式转移,影响着程序员们日常编写程序的方式,同时也改变着编程语言的设计方式以及其提供的特性。但所有这些进步都对在简单执行语句层次之上的程序结构产生了影响,也就是涉及到过程、数据对象、程序模块等的层次。在最低层次(即在顺序语句层面)进行编程的基本方法,依然与早期第一代编程语言(如 FORTRAN 和 COBOL)的方法是一致的。

以下各节描述了在当今大多数编程语言中都可用的程序流程结构,自结构化编程发展以来,这些在那时引入的结构基本上一直保持不变。这些结构将以伪代码而非具体语言展现。

if-then-else

所有结构化编程语言都提供 if-then 流程控制结构的某种形式:

if conditional_expression then
    statement1

以及 if-then-else 结构:

if conditional_expression then
    statement1
else
    statement2

对于非结构化的“测试并跳转(test-and-goto)”结构,上述第二种结构是其最直观的替代:

if conditional_expression then
    statement1
    goto endif1
else1:
    statement2
endif1:
    ...

if-then 语句可以用机器码来实现,大致像下面这样:

# if-then statement
    move expression, reg1
    jump not condition, label1
    statement1
label1:
    ...

类似地,对于 if-then-else 语句:

# if-then-else statement
    move expression, reg1
    jump not condition, label1
    statement1
    jump label2
label1:
    statement2
label2:
    ...

一个常见的编程惯用法是顺序编写多条 if-then 语句:

if condition1 then
    statement1
else if condition2 then
    statement2
else if condition3 then
    statement3
else
    statement4

在某些语言(尤其是一些古老的语言)中,写出多条 if-then 的序列会更为困难,因此最终写出来会像下面这样,功能上等效但可读性较差:

if condition1 then
    statement1
else
    if condition2 then
        statement2
    else
        if condition3 then
            statement3
        else
            statement4
        end
    end
end

有些语言为 else-if 组合提供了单独的关键词,如 elseifelsifelif,不过效果是一样的。有些语言还为多条 if-then 的序列最后的 else 子句提供了单独的关键词,如 otherwisedefault

select

大多数结构化编程语言都提供某种形式的多分支选择控制结构,通常使用 caseselectswitchexamineinspectchoosewhen 等等。这是用以取代多条 if-then 的结构,使其想表达的意思更清晰明了,也就是为给定的表达式值从多个选项中选取一个:

select expression in
    case constant1:
        statement1
case constant2: statement2
case constant3: statement3
default: statement4 end

这与前文展示的多条 if-then 语句序列等效,其中 default 选项充当最后一条 else 子句的角色。某些较老的语言没有提供 defaultotherwise 子句。

select 语句可用机器码实现,大致像下面这样:

# select statement
    move expression, reg1
    cmp  reg1, constant1
    jump not equal, label1
    statement1
    jump label4
label1:
    cmp  reg1, constant2
    jump not equal, label2
    statement2
    jump label4
label2:
    cmp  reg1, constant3
    jump not equal, label3
    statement3
    jump label4
label3:
    statement4
label4:
    ...

还有更高效的实现,例如使用跳转表中的索引,或者重新排列比较顺序以模拟展开后的二分搜索,等。有些 CPU 提供了特殊指令,通过利用一个小型跳转表,可以在机器码中直接实现 select 语句。

有些语言(特别是 C、C++、Java、C# 以及其他源自 C 的语言)允许更复杂的控制流,允许每个 case 语句“贯穿”到下个 case 子句。纯粹主义者认为这破坏了原本清晰的控制结构,而实用主义者则表示这在许多复杂的编程场景中能带来更高效的代码。

do-while

这是多数结构化编程语言提供的最简单的迭代形式,一般会提供两种变体,一种是在循环体之后7进行条件测试(这样会进行至少一次循环):

do
    statements
while conditional_expression

另一种形式将条件测试放在循环体之前8(这样会进行零或若干次循环):

while conditional_expression do
    statements
end

这些结构在功能上等效于下面使用 goto 的代码:

# do-while
loop:
    statements
    if conditional_expression
        goto loop

以及:

# while-do
loop:
    if not conditional_expression
        goto endloop
    statements
    goto loop
endloop:
    ...

这些结构可用机器码实现如下:

# do-while statement
label1:
    statements
    move expression, reg1
    jump condition, label1
    ...
# while-do statement
label1:
    move expression, reg1
    jump not condition, label2
    statements
    jump label1
label2:
    ...

有些语言提供 do-while 语句的其他变体,例如 do-until(这反转了条件测试的语义)。

有些语言允许更复杂的流程控制,提供了提前终止循环迭代的子句(break 语句),或跳过循环体的剩余部分并强制进入下一次循环迭代的子句(continue 语句)。这些结构能应对偶尔出现的“半循环(loop-and-a-half)”情况,将在下文讨论。

有些语言允许用标签化 break 结构来退出嵌套循环,这将在后续的 Example L-1 和 Example N-1 中讨论。

for 循环

大多数结构化编程语言都提供更复杂的迭代结构,允许一个计数器或数组索引在每次迭代时自增或自减。这种结构在功能上与 do-while 循环等效,但能更清晰地表达出循环迭代过程中控制实体(计数器或索引)的意图:

for i = low_value to high_value by increment do
    statements
end

这与下面使用 goto 的非结构化代码等效:

    i = low_value
loop:
    if i > high_value
        goto endloop
    statements
    i = i + increment
    goto loop
endloop:
    ...

这个结构可用机器码实现如下:

# for-loop statement
    move low_value, reg1
label1:
    cmp  reg1, high_value
    jump greater, label2
    statements
    add  increment, reg1
    jump label1
label2:
    ...

若想要完全支持 for 循环,一门语言应该能够处理负数的递增量。

如上所展示的 for 循环形式将迭代其循环体零或若干次,其他 for 循环的变体则在循环体之后测试控制变量(又称“循环索引”),这使得循环至少迭代一次。还有其他 for 循环的变体允许指定更多的循环计数器(或“循环索引”)。

常见的一个编程问题是处理“半循环”结构,也就是说,一个循环必须在某些条件下终止于循环体的中间,因此循环体的一部分将不会在最后的迭代中被执行。下面的代码使用了一个 break 语句来实现:

for i = low_value to high_value by increment do
    statements1
    if condition
        break
    statements2
end

有些语言专门给循环体在半途终止提供了特殊的语法:

for i = low_value to high_value by increment do
    statements1
exit when condition
    statements2
end

如同 break 语句,exit when 子句允许循环体在其前半段执行完毕,但剩余部分尚未执行之前就终止循环。这两种形式都取代了 goto 的显式使用,像下面这样:

for i = low_value to high_value by increment do
    statements1
    if condition
        goto endloop
    statements2
end
endloop:
    ...

一个相关的编程问题是,编写一个循环,当某些条件成立时,执行其循环体的一部分,然后跳过剩下的部分,强制循环进入下一次迭代。有些语言为此提供了 continue 语句:

for i = low_value to high_value by increment do
    statements1
    if condition
        continue
    statements2
end

这取代了 goto 的显式使用,如同下面这样:

for i = low_value to high_value by increment do
    statements1
    if condition
        goto nextloop
    statements2
nextloop:
end

其他方法

有多个结构化编程语言完全不提供 goto 语句,包括 Modula-2、Modula-3、Oberon、Eiffel 和 Java,这是基于假设它们提供的其他流程控制机制足以应对所有的编程任务,因此就永远不需要 goto 语句了。然而在设计编程语言时,这并不总是一个好的假设。语言的设计者不能预见到所有可能的编程场景,而提供一种“逃离机制”以摆脱常规控制结构,可以让程序员有能力在需要时绕过该语言所设定的语法限制。下面将更详细地举例讨论一些不完善的流程控制结构。

另外有一点值得注意的是,还存在一些编程语言完全不提供结构化流程控制结构。许多“函数式编程”语言,例如 LISP、Scheme 和 Prolog,并不提供除 if-then-else 之外的传统结构化流程控制结构,或仅提供了非常简单的形式。这些语言中的迭代通常是通过某种形式的递归来完成的,并且这些语言实际上是专门为递归任务和数据结构而设计的。但在结构化流程语句中所欠缺的,此类语言通常以提供强大的“动态编程”机制和极其灵活的非同质数据类型加以弥补。

第三部分 - Goto 还有必要吗?

优秀的编程语言设计原则是:一门语言应当提供足够全面且强大的一组流程控制结构,以便于相对轻松地为任何编程任务写出高效的代码。一门语言不应过分追求宏大,以至于提供太多不同的方式来完成同一项任务;同时,它也不应过于简陋,以至于缺乏足够的方式来表达编程想法。编程语言提供的这组流程控制语句和子句应该足够强大且灵活,以便程序员能够清晰且简洁地表达他的想法,而无需借助额外的控制变量,也无需对代码进行不自然的重新编排,仅仅为了避开语言的语法限制。

以下小节讨论了两种主要的编程问题,它们传统上使用 goto 语句来解决。这些问题是基于当前的编程技术来阐述的,目的是看看现有的编程语言是否足够先进而可以不使用 goto 就能解决。

退出循环

Dijkstra 呼吁彻底废除 goto 语句的观点在理论上可行,但在实践中呢?上面列出的控制流语句应对绝大多数编程逻辑已经足够了,但还有些编程场景需要更强大的结构。

goto 语句的一个普遍用法是用于提前退出循环,特别是在两层或更多层嵌套循环中退出时。C 语言提供了简单的循环退出机制,也就是 breakcontinue 语句。

Example L-1 - 使用 break 退出循环

// 含有提前退出的简单循环
for (;;) { int ch;
ch = read(); if (ch == EOF) break; // 退出循环
parse(ch); }

为了不使用这种机制提前退出循环,需要做出一点折衷:引入一个额外的标志(布尔)变量来表示循环结束。这会带来一个额外变量的开销以及每次循环开头的额外判断。

Example L-2 - 不使用 break 退出循环

// 不使用循环退出机制的简单循环
bool incomplete = true;
while (incomplete) { int ch;
ch = read(); if (ch == EOF) incomplete = false; // 不使用break else parse(ch); }

对于更复杂的循环退出(即从两层或更多层嵌套循环中退出)需要语言的额外支持。有些语言,例如 Java,提供了从带标签的循环中退出的能力。

Example N-1 - 退出嵌套循环

// [Java] // 退出嵌套循环
readLoop: for (;;) { char[] line;
line = readLine(); if (line.length > 0) { for (int i = 0; i < line.length; i++) { int ch;
ch = line[i]; if (ch == '#') break readLoop; // 退出到外层循环
parse(ch); } } else return; }

其他语言,例如 C/C++,并不提供退出嵌套循环的退出机制,因此就必须使用 goto 语句。

Example N-2 - 退出嵌套循环

// [C/C++] // 不使用标签化循环退出嵌套循环
for (;;) { char line[80]; int len;
len = readLine(line); if (len > 0) { for (int i = 0; i < len; i++) { int ch;
ch = line[i]; if (ch == '#') goto endReadLoop; // 退出到外层循环
parse(ch); } } else return; } endReadLoop:;

这在简洁程度上与 Java 版本不相上下,而且效率也一样高。

另一种方法是使用额外的变量和额外的 if 语句来避免使用 goto,如同 Example L-2。

Example N-3 - 不使用 goto 退出嵌套循环

// [C/C++] // 不使用 goto 退出嵌套循环
bool notDone = true;
while (notDone) { char line[80]; int len;
len = readLine(line); if (len > 0) { for (int i = 0; notDone && i < len; i++) { int ch;
ch = line[i]; if (ch == '#') notDone = false; // 退出到外层循环 else parse(ch); } } else return; }

异常处理

goto 语句的另一个常见应用是在异常处理中,或者用 Dijkstra 的话来说,就是“中止(abortion)语句”。有些语言(特别是比较新的面向对象语言)提供了异常处理机制,用于处理同步错误状态,而较老的语言不具备这种机制。

以下的代码是一个 C 语言函数,它通过利用 goto 语句相当有效地实现了错误处理与恢复。由于 C 语言没有任何异常处理机制,精心设计的 goto 语句提供了一种合理的替代方案。

考虑一个执行四个操作的函数:

  1. 分配一个控制对象。
  2. 保存指定文件名的一份拷贝。
  3. 打开指定的文件。
  4. 从打开的文件中读取头部块。

下面使用 C 语言编写的例子实现了这些操作:

Example E-1 - 使用 goto 实现的错误处理

// open_control() -- [C] // 打开一个文件并为其分配一个控制对象 // 成功则返回控制对象,失败返回 NULL
struct Control* open_control(const char* fname) { struct Control* ctl = NULL; FILE* fp = NULL;
// 1. 分配一个控制对象 ctl = malloc(sizeof(struct Control)); memset(ctl, 0, sizeof(struct Control)); if (ctl == NULL) // E-1 goto fail;
// 2. 保存文件名 ctl->name = malloc(strlen(fname) + 1); if (ctl->name == NULL) // E-2 goto fail; strcpy(ctl->name, fname);
// 3.打开指定文件 fp = fopen(fname, "rb"); if (fp == NULL) // E-3 goto fail; ctl->fp = fp;
// 4.读取文件的头部块 if (!read_header(ctl)) // E-4 goto fail;
// 成功返回 return ctl;
fail: // 出现失败,清理分配的资源 if (ctl != NULL) { if (ctl->fp != NULL) fclose(ctl->fp); // H-3 if (ctl->name != NULL) free(ctl->name); // H-2 free(ctl); // H-1 }
// 返回失败 return NULL; }

四个操作中任何一个都可能失败,并导致整个函数失败,然而在每次失败之后,资源都必须被释放。因此在 E-1 点发生的失败需要在 H-1 点有相应的清理代码,E-2 和 E-3 处的失败也同理。这些清理操作的执行顺序与相应的分配操作的执行顺序相反。

这种使用 goto 的方式通常被视为对 goto 的“正确”使用,特别地,使用 goto 进行错误处理通常被认为是可接受的编程风格,至少对 C 语言这种不提供异常处理控制结构的语言来说是这样。

然而有一点必须注意,要明确这些 goto 语句是用于错误处理的,例如为 goto 标签选择一个恰当的描述性名称。

如果我们遵循 Dijkstra 的原则,将所有的 goto 都删除,那我们会得到像下面这样的东西。

Example E-2 - 移除 goto 的错误处理

// open_control() -- [C, 第2版, 不含 goto] // 打开一个文件并为其分配一个控制对象 // 成功则返回控制对象,失败返回 NULL
struct Control* open_control(const char* fname) { struct Control* ctl = NULL; FILE* fp = NULL;
// 1. 分配一个控制对象 ctl = malloc(sizeof(struct Control)); memset(ctl, 0, sizeof(struct Control)); if (ctl == NULL) // E-1 { // 失败,清理 return NULL; }
// 2. 保存文件名 ctl->name = malloc(strlen(fname) + 1); if (ctl->name == NULL) // E-2 { // 失败,清理 free(ctl); // H-1 return NULL; } strcpy(ctl->name, fname);
// 3.打开指定文件 fp = fopen(fname, "rb"); if (fp == NULL) // E-3 { // 失败,清理 free(ctl->name); // H-2 free(ctl); // H-1 return NULL; } ctl->fp = fp;
// 4.读取文件的头部块 if (!read_header(ctl)) // E-4 { // 失败,清理 fclose(ctl->fp); // H-3 free(ctl->name); // H-2 free(ctl); // H-1 return NULL; }
// 成功 return ctl; }

这种错误处理风格的问题是最终会导致写出大量重复的清理代码。

这启发了另一种替代的风格,同样也不使用 goto 也同时避免了代码重复。

Example E-3 - 移除 goto 的错误处理

// open_control() -- [C, 第3版, 不含 goto] // 打开一个文件并为其分配一个控制对象 // 成功则返回控制对象,失败返回 NULL
struct Control* open_control(const char* fname) { struct Control* ctl = NULL; FILE* fp = NULL; int err = 0;
// 1. 分配一个控制对象 ctl = malloc(sizeof(struct Control)); memset(ctl, 0, sizeof(struct Control)); if (ctl == NULL) // E-1 err = 1;
// 2. 保存文件名 if (err == 0) { ctl->name = malloc(strlen(fname) + 1); if (ctl->name == NULL) // E-2 err = 2; else strcpy(ctl->name, fname); }
// 3.打开指定文件 if (err == 0) { fp = fopen(fname, "rb"); if (fp == NULL) // E-3 err = 3; else ctl->fp = fp; }
// 4.读取文件的头部块 if (err == 0) { if (!read_header(ctl)) // E-4 err = 4; }
// 测试是否成功 if (err == 0) return ctl;
// 失败,清理 if (err > 3) fclose(ctl->fp); // H-3 if (err > 2) free(ctl->name); // H-2 if (err > 1) free(ctl); // H-1
return NULL; }

注意到,和之前一样,清理操作的执行顺序与相应的分配操作的执行顺序相反。

这种错误处理的风格与 Example E-1 中采用的风格极为相似,简洁且清晰。然而,这需要一个额外的错误指示变量和额外的条件(if)语句。

如果用 C++ 编写 Example E-1 中的函数,就可以利用 C++ 支持的异常处理机制(即 try-catch 语句),使代码中的错误处理更清晰明了:

Example T-1 - 无 goto 的错误处理

// openControl() -- [C++] // 打开一个文件并为其分配一个控制对象 // 成功则返回控制对象,失败返回 NULL
Control* openControl(const char* fname) { Control* ctl = NULL; FILE* fp = NULL;
try { // 1. 分配一个控制对象 ctl = new Control; if (ctl == NULL) // E-1 throw 1;
// 2. 保存文件名 ctl->name = new char[::strlen(fname) + 1]; if (ctl->name == NULL) // E-2 throw 2; ::strcpy(ctl->name, fname);
// 3.打开指定文件 fp = ::fopen(fname, "rb"); if (fp == NULL) // E-3 throw 3; ctl->fp = fp;
// 4.读取文件的头部块 if (not ctl->readHeader()) // E-4 throw 4;
// 成功返回 return ctl; } catch (int err) { // 出现失败,清理分配的资源 if (ctl != NULL) { if (ctl->fp != NULL) ::fclose(ctl->fp); // H-3
if (ctl->name != NULL) delete[] ctl->name; // H-2
delete ctl; // H-1 }
// 返回失败 return NULL; } }

注意到失败后需要清理的工作数目与 Example E-1 完全一致,此外对 try-catch 语句的显式使用让这段用于错误处理和恢复的代码更为显而易见。

由于 C++ 是一门面向对象语言9,因此它允许程序员在对象分配和释放上拥有更明确的控制权,我们可以让 Control 对象的析构函数来执行大部分的清理操作。将 H-2 和 H-3 的操作移动到析构函数中,使我们能够编写更简洁的 catch 子句来处理失败。

Example T-2 - 无 goto 的错误处理

// openControl() -- [C++, 第2版] // 打开一个文件并为其分配一个控制对象 // 成功则返回控制对象,失败返回 NULL
Control* openControl(const char* fname) { Control* ctl = NULL; FILE* fp = NULL;
try { ... 与 Example T-1 相同 ... } catch (int err) { // 出现失败,清理 delete ctl; // H-1, H-2, H-3
// 返回失败 return NULL; } }
// Control::~Control() -- 析构函数
Control::~Control() { // 清理分配的资源 if (this->fp != NULL) ::fclose(this->fp); // H-3 this->fp = NULL;
if (this->name != NULL) delete[] this->name; // H-2 this->name = NULL; }

虽然 try-catch 方案比使用 goto 更简洁,但它的缺点是需要更多的心智负担来管理 trycatch 子句,这可能会很费(时间和人力)成本。10

有些语言,例如 Java,提供了 finally 子句作为 try-catch 语句的一部分,用于指定无论是否发生异常都必须执行的操作。

Example T-3 - finally 子句

// [Java]
void write3(Resource dest, Item[] data) { try { dest.acquire(); dest.write(data[0]); dest.write(data[1]); dest.write(data[2]); } catch (ResourceException ex) { // 发生失败,清理 log.error(ex); dest.reset(); } finally { // 始终执行 dest.release(); } }

其他语言,例如 Eiffel,在异常处理中提供了 retry 语句,以便在捕获异常后(可能采取了一些纠正措施)能够再次执行函数体。

结论 - Goto 之道

循环退出和异常处理语句明显使代码更可读且效率更高。当然,这些流程控制机制实际上只是伪装起来的花哨 goto 罢了,其底层实现是通过机器码的跳转指令来完成,然而它们不会破坏或混淆程序的执行状态。因此若用 Dijkstra 提出的能够确定性地追踪程序执行的原则来评判,它们对于高级语言来说是可接受的控制流机制。

Example T-2 和 N-1 表明,只要编程语言提供一组合理的控制结构,能够替代简单的 goto 语句,Dijkstra 的准则就能够实现。

Example E-1 和 N-2 表明了另一推论,若一门编程语言没有合理地提供足够强大的流程控制结构,那么就有一些编程问题只能通过诉诸于 goto 语句才能被合理地解决。

有些结构化编程语言完全不提供 goto 语句,如 Smalltalk、Eiffel 和 Java 提供了用于提前退出循环、退出嵌套循环和异常处理的控制语句,因此不太需要 goto。其他如 Modula-2 和 Oberon 的语言也不提供 goto,但却缺少充足的流程控制结构,导致不便于编写提前退出循环和异常处理的代码,似乎这类语言就是一些超出 Dijkstra 原则的语言实验,以失败告终。

Dijkstra 认为,非结构化的 goto 语句会不利于编写高质量的程序,这一观点仍然适用。一门恰当设计的语言应该提供足够强大的流程控制结构,使其能处理几乎所有编程问题。同样地,那些必须使用不充足提供灵活流程控制语句的语言的程序员,在使用非结构化的替代方案时应当保持谨慎。这就是 goto 之道:知晓何时应善用它,何时不应恶用它。


在分别之际,我忍不住要再举最后一个 goto 语句的例子。我在我使用的一个用于大型编译器项目的 LR parser 库中偶然发现了这段代码(大概在 1988 年)。这是凝结了编程简洁性与简单性的一颗非比寻常的小宝石。

Last Example - 不平凡的 Gotos

int parse() { Token tok;
reading: tok = gettoken(); if (tok == END) return ACCEPT; shifting: if (shift(tok)) goto reading; reducing: if (reduce(tok)) goto shifting; return ERROR; }

将这段代码以不含 goto 语句的方式重写就留给读者作为练习。

参考资料

A. Goto 有害论

​ Edsger W. Dijkstra

​ Letter to Communications of the ACM (CACM)

​ vol. 11 no. 3, March 1968, pp. 147-148.

​ Online at: www.acm.org/classics/oct95

B. Edsger W. Dijkstra 的传记

​ born May 1930, died Aug 2002

​ Wikipedia: en.wikipedia.org/wiki/Dijkstra

C. C. A. R. (Tony) Hoare 的传记

​ Wikipedia: en.wikipedia.org/wiki/C._A._R._Hoare

D. Niklaus Wirth 的传记

​ Wikipedia: en.wikipedia.org/wiki/Wirth

​ Home page: www.cs.inf.ethz.ch/~wirth

E. Goto 编程语句

​ Wikipedia: en.wikipedia.org/wiki/GOTO

F. 形式化验证

​ Wikipedia: en.wikipedia.org/wiki/Formal_verification

G. 程序推导

​ Wikipedia: en.wikipedia.org/wiki/Program_derivation

H. 霍尔逻辑

​ Wikipedia: en.wikipedia.org/wiki/Hoare_logic

I. 计算机编程的公理基础

​ C. A. R. (Tony) Hoare

Communications of the ACM (CACM)

​ v.12 n.10, Oct 1969

portal.acm.org/citation.cfm?doid=363235.363259

J. 编程语言讨论组

groups.google.com/groups/dir?q=comp.lang.*


  1. 译注:此处为 process,当时 Dijkstra 的定义为“顺序执行流”,现在的含义为操作系统的进程,此处采用现代译法。 ↩︎

  2. alternative clauses ↩︎

  3. choice clauses ↩︎

  4. 译注:此处为 procedure,指一段被调用的逻辑块,现在的含义为没有返回值的函数,即过程/子程序,此处译为过程。 ↩︎

  5. 彼得·兰丁,英国计算机科学家,他最早提出阿隆佐·邱奇的λ演算可以被用作计算机程序语言的模型,这后来成为函数式编程和指称语义的基础。 ↩︎

  6. 克里斯托弗·斯特雷奇,英国计算机科学家,编程语言理论先驱,最早提出了指称语义、参数多态、特设多态、虚拟化概念等理论。 ↩︎

  7. 译注:原文为 before,疑笔误 ↩︎

  8. 译注:原文为 after,疑笔误 ↩︎

  9. 译注:C++ 实际上是一门多范式语言,并不局限于面向对象。 ↩︎

  10. 译注:此处的 C++ 代码较为简陋且不符合最佳实践,实际上 C++ 在异常处理上可以做到基本没有心智负担。异常处理是另一个很大的话题。 ↩︎

本文版权归原作者 David R. Tribble 所有
仅供学习交流,如有侵权请联系删除