Steve Summit's Mail about Undefined Behavior

原作者 Steve Summit, 原文链接

本文最早于 1995 年 3 月 21 日,在一条关于未定义行为的帖子中进行讨论时发布。 我 (原作者 Steve,下同) 对文本进行了一些微小改动,以发布在网站上。
Newsgroups: comp.lang.c
From: scs@eskimo.com (Steve Summit)
Subject: Re: Quick C test - whats the correct answer???
Message-ID: <D5swGz.4Cv@eskimo.com>
Summary: why is undefined behavior undefined?
References: <fjm.58.000B63C1@ti.com> <D5JLGy.A63@tigadmin.ml.com>
Date: Tue, 21 Mar 1995 17:26:59 GMT

在帖子 <D5JLGy.A63@tigadmin.ml.com> 中,Jim Frohnhofer 写道:

在帖子 000B63C1@ti.com 中,Fred J. McCall 写道:

这是对的,所以你应该听他的(这也是我和其他人的一致意见)。 这个行为是未定义的,因为语言标准如此规定

很抱歉,但我在这里要插一句话。我认同“一个构造是未定义的, 仅仅是因为语言标准如此规定”,但我想知道为什么标准要如此规定, 也是合理合法的吧?

确实是,但你在组织语言时可能要小心。如果你粗心大意了(你的提问没有毛病, 但大多数人是粗心大意的),结果就会是,听上去你并不相信某个行为是未定义的, 或者说,你相信 —— 而且你的编译器恰好看上去同意 —— 这个行为有合理的含义。 然而,只要我们的脑子还清醒,就只能问“为什么这些行为未定义?”这样的抽象的、 智力上的问题,而不是“这些东西在实际中会有什么行为?”这样的实践问题。 这样,我们才能继续讨论。

我的答案一开始可能并不是你所需要的,但请耐心看完, 在结尾处我可能会提供让你满意的答案。

首先,大家或许应该了解一下,什么样的人会发表什么样的观点。我确信类似 Fred 上面那样的语气会激怒一些人。我完全同意,通常来说, 知道事情背后的原因总是好的,但是如果 comp.lang.c 讨论组中的资深用户, 那些最有经验、最成功的 C 程序员,都在不停地重复说“这是未定义的, 这就是未定义的,别管为什么,千万不要写这样的代码,这真的是未定义的”, 他们肯定也有理由这样说。即使他们并没有(或不能)说出这个理由,对你来说, 这种说法也是很有意义的。

我已经写了 15 年 C 程序,至少 10 年以前我就被认为是所谓的“专家”。近 5 年来, 我一直在维护 comp.lang.c FAQ 列表。我这个人在理解原因之前, 是完全搞不懂抽象概念的,当年我考数学的时候,我根本背不会公式, 总是现场推导。然而,就算这样,我还是没法告诉你为什么 i=i++ 是未定义的。 这是一个丑陋的表达式,它对我来说没有意义。我不知道它要干什么, 我也不想知道它要干什么,我绝对不会写这种东西, 我也不知道为什么会有人写这种东西。我感到很疑惑, 为啥有人会关心这种东西的行为,如果我在维护代码时看到这种东西我会直接重写。 当我 1980 年开始看 K&R 的第一版书学 C 语言的时候, 书里有一句话虽然只出现一次,但却牢牢刻在了我的脑子里,从未忘记。 如果谁忘记了这句话,肯定会出麻烦:

通常来说,必须特意记住有些代码是不能写的。 然而如果你不知道它们在不同机器上会如何表现, 无知可能会恰巧防止你写出这种代码。

碰巧, 非常 仔细地阅读 K&R 的书,就可以找到这种例子: 在第二章的末尾进行的讨论指出 a[i] = i++ 的行为是具体实现决定的, 而在 K&R 的第二版书中使用了 未指定 一词,同时 ANSI 标准规定这是未定义的。 我上面引述的那句话实际上是在说, 当我们发现自己不知道一段代码在不同机器上的行为时,我们需要特别注意, 这些代码或许在不同机器上根本不能工作。尽管如此,它的字面意思, 即无知可能会保护你,也是一句很好的格言。

这是我的第一个答案,我知道很多对此问题感兴趣的人不会满意,下面给出第二个。

据我所知,语言标准是委员会制定的,不是摩西从天上带下来的。 如果我想成为更好的 C 程序员,难道不应该知道为什么委员会要如此规定吗?

或许吧,但是,前提仍然是你足够小心。

假如说,你在想为什么 i++ * i++ 是未定义的。某些人会说, 这是因为委员们懒得定义副作用的发生次序。这是一个“好的”答案, 它不太完整,不太正确,但显然容易理解。因为你特别喜欢这种能理解的答案 (而不是“这就是未定义的,不要这么做”),你很容易接受并记住它。

所以,在下一周,你可能会发现自己写出了

i = 7;
printf("%d\n", i++ * i++);

然后你开始对自己说:“哇,这玩意或许是未定义的,上次是怎么说的来着? 这是未定义的,因为委员们懒得规定副作用的次序。那么如果第一个自增先执行, 答案是 7 乘上 8 就是 56 ,如果是第二个先执行,那就是 8 乘上 7 还是 56。 嘿!就算这是未定义的,我也能得到确定的答案,所以我可以这么写!”

这样,知道一个理由,并没有让你成为一个更好的程序员,反而让你变得更差, 因为有一天在你完全没有防范(或者有防范却没时间调试)的时候, 这段代码可能输出 49 ,或者 42 ,甚至“浮点异常 - 核心已转储”。

相反,如果你不知道它为什么是未定义的,而是死记住它就是未定义的, 你就会修改这段代码:

printf("%d\n", i * (i+1));
i += 2;

(或者改成其他的能正确表达你的意图的代码),这样你的代码就会变得可移植、 可靠、定义良好。

现在可能有的读者会觉得,

printf("%d\n", i++ * i++);

是个荒谬的例子,因为没人会写这种代码。这可能是对的,但这就是我在 FAQ 列表中用的例子(这是 FAQ 列表中关于求值顺序的未定义行为的最老的问题), 因为它能够清晰地展示出,当人们开始对未定义行为钻牛角尖 (但考虑问题又不细致)的时候,会搞出什么样的混乱, 我觉得这比一个“实际”的例子更好。 而且这就是几年前真的有人发到 comp.lang.c 的问题 (如果我仔细翻一翻或许能找到),那个人真的使用这样的论证: “求值顺序可能是未定义的,但无论编译器使用哪个顺序,答案都是我预期的”, i++ * i++ 问题(现在是列表中的问题 4.2)最早就是这么来的。 (译者:现在是 3.2。) (请牢记,不是求值顺序未定义, 而是整个表达式都未定义。)

在前面,我提出那些不知道为什么规定某些事情未定义的程序员可能比那些“知道” 的程序员更好,我不是说(至少现在不是)你不应该知道为什么它未定义,而是说, 如果你坚持要知道,就必须了解完整的细节,而不是只记住一些简化的“证明”。 另外,一定要小心,千万忍住,绝对不要因为你拥有这些知识, 就去试图猜测一个未定义的,你想在程序中使用的构造会有什么行为。 未定义行为是完全不可靠的,它就是字面上的含义,真的没有定义, 真的可以发生任何事情。 当然,有的人(特别是那些吵吵着非要知道为什么行为没有定义的人) 总是试图在未定义的状态下捞出一些已定义的东西,然后总是陷入麻烦。 最后那些为他们收拾烂摊子的人自然就会确信, 根本就不应该解释为什么行为是未定义的,我们直接说它就是未定义的, 理由或许应该说一句 无可奉告

这是第二个答案,我知道这还是不能令人满意, 因为我一直在说你可能不应该知道答案。

下面是第三个答案,我不会再继续拐弯抹角,而是会直接尝试解释原因。当然, 我必须承认,从我的观点来看,下面的答案更不会令人满意,且语言组织更差, 因为我们会讨论一些我平常不考虑的问题(我本人真心同意 K&R 的保持适度无知的建议。)

X3.159/ISO-9899 这样的国际标准文档是相当难编写的,即使是针对 C 这样相对简单的语言。当标准规定一些事情的时候,它必须准确无歧义地进行描述。 (如果是不准确的,那就必须是绝对不准确的,如果存在任何疑问或不确定性, 它就必须精确地规定这些不确定性涵盖的范围。)标准必须承受数年的反复审核, 包括语言律师、专门吹毛求疵的人、不得不从其他语言迁移过来的不情愿的人、 争先恐后的想要为老式电脑编写新编译器的人,以及社会上的喷子都会从标准中挑刺。 (如果你以为社会喷子不会关心编程语言之类的抽象构造, 说明你并没有关注过标准的起草过程)。

由于在标准中足够精确地说明事情如此之难,聪明的标准编写者 (特别是与现存实践密切关联的标准的编写者)不会说明任何不必要的事情。 那些所有人都会使用的语言特性(或能够合理地预期会有人使用的特性) 都必须绝对精确地说明, 但那些任何一个正常人 6000 年都用不到的特性就会被归类为垃圾。 (自然地,由于我们要求精确性,我们必须精确地说明, 我们已经决定的那些不精确特性边界在哪里。本段表明, Vroomfondel银河系漫游指南 中提出的要求并不是滑稽的。)

实践上,只有计算机程序产生副作用时,我们才能讨论“它做了什么”这个问题。 因此,可以说, 语言标准的任务就是定义你编写的程序和它产生的副作用之间的对应关系。 所以,我们应该特别关注副作用:如何在程序中表达它们? 语言标准如何定义它们的行为?

代码段

int i;
i = 7;
printf("%d\n", i);

包含明显的副作用,这段代码的效果太显然了, 语言标准的规定和你的想法完全一致。

然而

int a[10] = {0}, i = 1;
i = i++;
a[i] = i++;
printf("%d %d %d %d\n", i, a[1], a[2], a[3]);

有更多的副作用,但(我敢保证)它的行为不是显然的。第二行会干什么? 在第三行,我们决定对数组 a[] 的第几个单元赋值,是在自增 i 之前还是之后? 有多少个人就可能说出多少种观点,然而他们很可能无法相互认同, 因为这里的情况本来就不清晰。

进一步地讲,语言标准当然不能仅仅讨论 i = i++a[i] = i++ 这些单独的程序片段,因为会有无穷种这样的片段。 它必须一般地描述语言中不同基本要素的行为, 之后我们将这些基本要素的行为结合起来,从而确定整个程序的行为。 如果你认为你知道 a[i] = i++ 应该做什么,你不能仅仅定义它的行为, 相反,你必须写出一个通用的规则, 指明 所有 修改和使用同一对象的表达式应该做什么。 你还必须说服你自己,这条规则不仅仅适用于 a[i] = i++, 也可以适用于所有其他表达式(记住,有无限多个),即使是你没有考虑到的表达式。 最后,你 还要 说服其他人同意你的观点。

写 C 语言标准的人决定,他们不能这样做。相反,他们决定, a[i] = i++i++ * i++i = i++ 等表达式的行为是未定义的。他们这样做的理由是, 这些表达式太难定义,而且十分愚蠢,根本不值得定义。 当然,他们能够定义出,哪些表达式因为这样的原因而未定义行为。 你应该已经看过他们的定义,在这个帖子里已经有人说过:

表达式求值期间,在任何两个相邻的 sequence point 之间, 任何对象中存储的值至多被修改一次。另外,仅在计算它应该存储的新值时, 才能访问它修改前的值。

现在,我承认这些定义就和 a[i] = i++ 这样的表达式一样难以理解, 但是如果不下这样的定义,就会出现许多我们无法判断含义 (甚至无法判断它是否有含义)的表达式。或许我应该在之后详细讨论一下它们, 但目前我们仍然在试图解答“为什么有些东西(如不服从以上规则的表达式) 的行为未定义”这一问题。我的观点,正如前文所述,是:它们太难定义, 而且太愚蠢以至于不值得定义。(如果仍然有计数君的话,可以说这是第三个答案。)

现在你可能觉得我太过悲观,你可能觉得很容易定义 a[i] = i++ 或者 i++ * i++ 或者 i = i++ 应该做什么。 或许你认为它们应该按照运算符优先级和结合性确定的顺序严格执行, 或许你认为 i++ 在使用完 i 的值后应该在计算表达式的其余部分前立刻增加它。 (这些规则将会规定,决定对 a[] 的哪一个单元赋值时使用已经增加过的 i 值,i++ * i++ 中左边的自增先发生,且 i = i++ 最终 —— 注意这里要出现意外了 —— 等价于 i = i,除非,或许 ivolatile 的。) 然而 —— 请仔细听好我说的话,因为我在引述多数人的观点 —— 这些假设的 “定义良好的”规则,或许陈述足够简单,或许足够精确, 或许能广泛适用于我们感兴趣的各种场合,但是会导致非常烂的性能, 就像在 ANSI 标准化之前传统 C 语言曾经有过的那样。如果这样做, 编译器在优化时必须严格按照基于优先级和结合性的解析结果确定求值顺序, 结果就不能重新安排表达式的各个部分,以最好地使用目标机器的指令、 寄存器、流水线、并行化,以及其他各种功能。

当人们讨论为什么 i++ * i++ 未定义时,他们往往以现代的并行计算机作为例子, 这些计算机可能尝试同时计算两个 i++ ,而不是先执行第一个或第二个, 结果计算机就会陷入困惑。我们不讨论这种情况,仅仅想象一个简单的 CPU , 和我们的四功能计算器差不多,同时还有若干寄存器。现在我们来扮演编译器, 想象应该在计算器上按哪些键,从而根据上面假设的“良好”规则进行操作。

由于(假定的)“良好”规则告诉我们应该按什么顺序计算表达式, 我们的工作非常简单,首先计算乘法运算符的左边:i 的原始值。天哪, 在我们想要计算乘法前,突然发现我们必须先进行自增, 可是自增以后又会丢失原始值。所以我们必须把 i 的值存到一个临时寄存器里面, 对 i 的值加 1 ,然后再将新的值写回 i 。现在我们从临时寄存器中调出 i 的原始值,然后进行乘法… … 不对,我们必须对右半边也做同样的事情: 调出 i,保存到另一个临时寄存器,对它的值加 1 ,再写回 i。 现在我们终于可以从两个临时寄存器中调出两个值,再计算乘法。

如果我们删掉(再重复一遍,假设的,仅仅是为了本文讨论的,不是标准 C 的) 良好 规则,而是使用 ANSI 的规则,即我们只需要保证 i 的自增在下一个 sequence point 前完成,那么事情就非常简单了:调出 i ,记住我们需要自增它, 计算乘积,对乘积进行任何我们需要的操作,最后发现我们还需要对 i 自增两次, 所以进行两次自增,这样就执行了该表达式。

我从来没有真正进行过这一层面的分析,直到刚才,但这就是在问题 4.2 中, 例子

int i = 7;
print("%d\n", i++ * i++);

为什么可能输出 49 而不是 56 的原因(不,我不会试图说明为什么可能是 42。)

如果你现在还在读我的解释(已经写了 300 行了,我知道你可能已经放弃了, 但如果你坚持想要一个合理的解释,那你最好读完), 这是我的第四个也是最后一个答案:这些东西是未定义的,因为如果你非要定义它们, 编译器就必须变得神经质,并且生成一些死板的代码,它可能更大、更慢, 或者又大又慢。

最后,我还是得再次说明,我仍然不太喜欢这个最终的答案, 可能是因为我不是一个代码生成专家,也可能是因为我不太担心效率。 (另一方面,我是否喜欢这个答案或者你是否喜欢这个答案都没有关系, 这只是原因 之一 。)你可能还有一些疑问, 比如可能觉得编译器就应该对有多个副作用的表达式使用慢、大、死板的代码, 虽然其他人(即使是一些钻牛角尖的人)都认为不该写这种表达式。 或许你是对的,或许我们可以鱼与熊掌兼得, 或许我们可以为正常的小表达式生成超高效率的代码, 同时仍然明确定义那些乱七八糟的表达式的行为。或许我们可以这么做, 但现存标准下并不可以:它仍然 指明 这类乱七八糟的表达式是未定义的。 然而 C 标准在不断修订,或许,如果你认为这些表达式对你很重要, 你也可以说服标准委员会为它们声明一个准确定义的行为。祝你好运。

Steve Summit
Steve Summit
Software Engineer