未定义行为背后的哲学


59

C \ C ++规范为编译器以自己的方式实现提供了许多开放的行为。这里经常有很多问题被问到同样的问题,并且我们有一些很好的帖子:

我的问题不是关于不确定行为是什么,还是真的不好。我确实知道该标准的危险和大多数相关的未定义行为引用,因此请不要发布有关该标准有多严重的答案。这个问题是关于为编译器实现留出许多行为的背后的哲学。

我读了一篇很棒的博客文章,指出性能是主要原因。我想知道性能是否是允许它的唯一标准,或者是否有其他因素会影响将事情开放给编译器实现的决定?

如果您有任何示例可以引用某个特定的未定义行为为编译器提供足够的优化空间,请列出它们。如果您知道性能以外的其他因素,请提供足够详细的答案。

如果您不理解该问题或没有足够的证据/来源来支持您的答案,请不要发布广泛猜测的答案。


7
谁曾听说过确定性计算机?
sova,2011年

1
正如litb的出色回答,programmers.stackexchange.com / a / 99741/192238 指出,此问题的标题和正文似乎有些不匹配:“开放给编译器以自己的方式实现的行为”通常称为实现定义的。当然,实现的作者可以定义实际的UB,但通常情况下,它们不会打扰(并完全优化它,等等)
underscore_d

Answers:


49

首先,我会注意到,尽管我在这里只提到了“ C”,但实际上同样适用于C ++。

提到戈德尔的评论部分(但仅部分)正确。

当您深入了解它时,C标准中未定义的行为在很大程度上只是指出了标准要定义的内容与未定义的内容之间的界限。

哥德尔定理(有两个)基本上说不可能定义一个数学系统,该数学系统可以(通过其自己的规则)被证明是完整且一致的。您可以制定规则使其完整(例如,他处理的是自然数的“正常”规则),否则就可以证明其一致性,但不能两者兼而有之。

对于诸如C之类的东西,它并不直接适用-在大多数情况下,对于大多数语言设计人员而言,系统完整性或一致性的“可证明性”并不是优先考虑的问题。同时,是的,他们可能(至少在某种程度上)受到了影响,因为他们知道定义一个“完美”的系统是不可能的,而这个系统应该是完整且一致的。知道这样的事情是不可能的,这可能会使其后退,稍作呼吸并决定他们将要定义的范围变得容易一些。

冒着再次被指责的风险,我将C标准描述为(部分)受两个基本思想支配:

  1. 该语言应支持尽可能广泛的各种硬件(理想情况下,所有“合理”的硬件都应降至合理的下限)。
  2. 该语言应支持针对给定环境编写尽可能广泛的各种软件。

第一个意思是,如果有人定义了一个新的CPU,则应该有可能为此提供良好,可靠,可用的C实现,只要设计至少合理地接近一些简单的准则即可-基本上,遵循冯·诺依曼(Von Neumann)模型的一般顺序,并至少提供一些合理的最小内存量,足以允许C实现。对于“托管”实现(在操作系统上运行的实现),您需要支持某种与文件相当接近的概念,并且其字符集应具有一定的最小字符集(需要91个)。

第二种意味着应该可以编写直接操作硬件的代码,因此您可以编写诸如引导加载程序,操作系统,无需任何操作系统即可运行的嵌入式软件之类的东西。最终在这方面存在一些限制,因此几乎任何限制实际的操作系统,引导加载程序等可能至少包含一点用汇编语言编写的代码。同样,即使是小型的嵌入式系统,也可能至少包括某种预编写的库例程,以允许访问主机系统上的设备。尽管很难定义精确的边界,但其目的是使对此类代码的依赖性保持最小。

语言中未定义的行为在很大程度上由语言支持这些功能的意图所驱动。例如,该语言允许您将任意整数转换为指针,并访问该地址处的任何内容。该标准不会试图说出您执行操作时将发生的情况(例如,即使从某些地址进行读取也可能具有外部可见的影响)。同时,它不会尝试阻止您执行此类操作,因为您需要使用某种可以使用C编写的软件。

还有一些其他设计元素驱动的不确定行为。例如,C的另一目的是支持单独的编译。例如,这意味着您打算使用一个链接器将各个部分“链接”在一起,该链接器大致遵循我们大多数人所看到的链接器的常用模型。特别是,无需知道语言的语义,就可以将单独编译的模块组合成一个完整的程序。

还有另一种类型的不确定行为(在C ++中比C更常见),其存在的原因仅仅是由于编译器技术的局限性-我们基本上知道的是错误,并且可能希望编译器将其诊断为错误,但是考虑到当前对编译器技术的限制,可以在任何情况下对它们进行诊断都令人怀疑。其中许多是由其他要求(例如,单独编译)驱动的,因此,这主要是在平衡相互冲突的要求之间达成的问题,在这种情况下,委员会通常选择支持更大的功能,即使这意味着缺乏诊断可能的问题,而不是限制确保诊断所有可能问题的能力。

这些意图上的差异驱动了C与Java或Microsoft的基于CLI的系统之间的大部分差异。后者被明确地限制为使用一组更为有限的硬件,或者要求软件模拟它们所针对的更具体的硬件。他们还特别打算防止对硬件的任何直接操纵,而是要求您使用JNI或P / Invoke之类的东西(以及用C语言编写的代码)来进行这种尝试。

回到戈德尔定理,我们可以得出类似的结论:Java和CLI选择了“内部一致”的选择,而C选择了“完全”的选择。当然,这是一个非常粗略的比喻-我怀疑任何人的企图的正式证明无论是内部一致性在任何情况下完整。但是,一般概念确实与他们所选择的内容非常吻合。


25
我认为戈德尔定理是一个红鲱鱼。他们用自己的公理来证明系统,这里不是这种情况:C不需要用C指定。完全有可能使用指定的语言(考虑使用Turing机器)。
poolie

9
抱歉,但我担心您会完全误解了戈德尔定理。它们处理了不可能在一致的逻辑系统中证明所有真实陈述的可能性。在计算方面,不完全性定理类似于说存在任何程序都无法解决的问题-问题类似于真实的陈述,证明的程序以及逻辑系统的计算模型。它与未定义的行为完全没有关系。请参阅此处的类比说明:scottaaronson.com/blog/?p=710
亚历克斯十布林克,

5
我应该注意,C语言实现不需要Von Neumann机器。完全有可能(甚至不是很困难)为哈佛体系结构开发C实现(并且我在嵌入式系统上看到很多这样的实现也不会感到惊讶)
bdonlan

1
不幸的是,现代C编译器理念将UB提升到了一个全新的水平。即使在准备处理某种特定形式的不确定行为的几乎所有可能的“自然”后果的程序中,并且至少无法识别(例如,捕获的整数溢出)它无法处理的那些情况,新哲学也赞成绕过除非将要发生UB否则无法执行的任何代码,将在大多数实现中本来可以正确执行的代码转换为“更有效”但完全是错误的代码。
supercat 2015年

20

C原理介绍

术语未指定行为,未定义行为和实现定义的行为用于对编写程序的结果进行分类,这些程序的属性标准不能或不能完全描述。采用此分类的目的是允许实现中的某些变化,这使实现的质量成为市场上的活跃力量,并允许某些流行的扩展,而不会消除对标准的崇高声望。该标准的附录F列出了属于这三种类别之一的那些行为。

未指定的行为使实施者可以自由翻译程序。这种纬度不会扩展到无法翻译程序的程度。

未定义的行为使实施者可以不捕获某些难以诊断的程序错误。它还确定了可能的符合语言扩展的领域:实现者可以通过提供正式未定义行为的定义来扩展语言。

实施定义的行为使实施者可以自由选择适当的方法,但要求向用户说明这一选择。通常,指定为实现定义的行为是指用户可以根据实现定义做出有意义的编码决策的行为。在确定实施定义应有多广泛时,实施者应牢记此标准。与未指定的行为一样,仅不翻译包含实现定义的行为的源就不足为响应。

重要的是程序的收益,不仅是实施的收益。如果符合标准的实现接受了依赖于未定义行为的程序,则该程序仍然可以符合标准。未定义行为的存在使程序可以使用显式标记为此类的不可移植功能(“未定义行为”),而不会变得不合格。基本原理说明:

C代码可以是不可移植的。尽管尽力为程序员提供编写真正可移植程序的机会,但委员会不想强迫程序员进行可移植的编写,以排除使用C作为``高级汇编程序''的能力:即编写特定于机器的功能代码是C的强项之一。正是这一原理在很大程度上促使人们在严格遵循程序遵循程序之间进行区分(第1.7节)。

并在1.7中指出

法规遵从性的三个定义用于扩大合格程序的范围,并区分使用单个实现的合格程序和可移植的合格程序。

严格合规的程序是最大可移植程序的另一个术语。目的是给程序员一个挣扎的机会,以使它们具有强大的可移植性的C程序,而不会贬低那些无法移植的,非常有用的C程序。因此副词严格。

因此,这个在GCC上可以正常工作的小肮脏程序仍然符合要求


15

与C相比,速度问题尤其重要。如果C ++做了一些有意义的事情,例如初始化大型原始类型数组,它将损失大量的C代码基准。因此,C ++会初始化其自己的数据类型,但将C类型保留原样。

其他未定义的行为只是反映了现实。一个例子是位数大于类型的移位。同一家族的几代硬件之间实际上是不同的。如果您有16位应用程序,则完全相同的二进制文件在80286和80386上会给出不同的结果。因此语言标准说我们不知道!

有些事情只是保持原样,例如未指定子表达式的求值顺序。最初,这被认为可以帮助编译器作者更好地进行优化。如今,编译器无论如何都足以解决问题,但是在现有编译器中利用自由度查找所有位置的成本太高了。


第二段为+1,这表明将某些内容指定为实现定义的行为会很尴尬。
David Thornley

3
位移只是接受未定义的编译器行为并使用硬件功能的一个示例。当计数大于类型时,为位移指定C结果将是微不足道的,但是在某些硬件上实现起来却很昂贵。
mattnz

7

作为一个例子,指针访问几乎必须是未定义的,而不必仅仅是出于性能原因。例如,在某些系统上,使用指针加载特定的寄存器将生成硬件异常。在SPARC上,访问未正确对齐的内存对象将导致总线错误,但是在x86上,它将“很慢”。在这种情况下,实际指定行为是很棘手的,因为底层的硬件决定了将要发生的事情,并且C ++可移植到如此多种类型的硬件中。

当然,它也使编译器可以自由使用特定于体系结构的知识。对于未指定的行为示例,有符号的值的右移可能是逻辑的或算术运算,具体取决于底层硬件,以允许使用任何可用的移位操作,而不会强制对其进行软件仿真。

我相信这也使编译器-编写器的工作更加轻松,但是我现在还不记得这个示例。如果我记得这种情况,将添加它。


3
可以指定C语言,以便它总是必须在具有对齐限制的系统上使用逐字节读取,并且必须为异常地址访问提供具有明确定义行为的异常陷阱。但是,当然,这一切都将是非常昂贵的(在代码大小,复杂性和性能方面),并且对理智,正确的代码没有任何好处。
R.,

6

简单:速度和便携性。如果C ++保证在取消引用无效指针时出现异常,则它将无法移植到嵌入式硬件中。如果C ++保证总是初始化的原语之类的其他东西,那么它会变慢,并且在C ++诞生之初,变慢确实是一件非常非常糟糕的事情。


1
??嵌入式硬件与异常有什么关系?
梅森惠勒

2
异常可能以对需要快速响应的嵌入式系统非常不利的方式锁定系统。在某些情况下,错误的读数对损坏的系统的损害要小得多。

1
@梅森:因为硬件必须捕获无效的访问。Windows很容易引发访问冲突,而对于没有操作系统只能死的无操作系统的嵌入式硬件来说,这更困难。
DeadMG 2011年

3
还要记住,并不是每个CPU都有一个MMU来防止硬件中的无效访问。如果您开始要求您的语言检查所有指针访问,那么您就必须在没有一个CPU的情况下在CPU上模拟一个MMU,因此每个内存访问都变得非常昂贵。
蓬松的

4

C是在具有9位字节且没有浮点单元的机器上发明的-假设它已要求字节为9位,字为18位并且浮点数应使用IEEE754之前的算术实现?


5
我怀疑您正在考虑使用Unix-C最初是在PDP-11上使用的,实际上这是非常传统的当前标准。我认为基本思想仍然存在。
杰里·科芬,

@杰里-是的,你是对的-我要老了!
马丁·贝克特

是的,恐怕是我们中最好的。
杰里·科芬,

4

我不认为UB的第一个理由是让编译器有优化的空间,而只是在架构比现在多样化的时候,可以对目标使用明显的实现(请记住,如果C是在A上设计的) PDP-11的架构有点儿熟悉,第一个端口是Honeywell 635,它不那么熟悉-字可寻址,使用36位字,6或9位字节,18位地址...至少使用2位补充)。但是,如果不是以优化为目标,那么显而易见的实现方式就不会包括添加运行时检查溢出,对寄存器大小进行移位计数,在表达式中使用别名来修改多个值。

考虑的另一件事是易于实施。当时的AC编译器是使用多个进程的多次传递,因为让一个进程处理所有事情是不可能的(程序可能太大)。要求进行严格的一致性检查是很困难的-尤其是当涉及多个CU时。(为此,使用了C编译器以外的另一个程序lint)。


我想知道从“允许程序员使用平台公开的行为”到“找到借口让编译器实现完全古怪的行为”的推动UB改变的哲学的原因吗?我还想知道在修改代码以使其在新的编译器下工作之后,这种优化最终能改善多少代码大小?如果在很多情况下向编译器添加这种“优化”的唯一作用是迫使程序员编写更大,更慢的代码,以避免编译器破坏它,我不会感到惊讶。
supercat 2015年

这是POV的漂移。人们对程序运行所在的计算机的了解越来越少,他们越来越关注可移植性,因此避免了依赖未定义,未指定和实现定义的行为。优化程序承受着巨大压力,要求它们在基准测试中获得最佳结果,这意味着要充分利用语言规范所留下的宽大处理。还有一个事实是,互联网-如今是SE,一次是Usenet-语言律师也倾向于对编译器作者的基本原理和行为持偏见。
AProgrammer

1
我感到奇怪的是,我看到的有关“ C假定程序员永远不会从事未定义行为”的陈述,这一事实历来都不是正确的。正确的说法是“ C假定程序员除非准备处理该行为的自然平台后果,否则它不会触发标准未定义的行为。鉴于C被设计为系统编程语言,因此其目的很大一部分是为了让程序员不用语言标准定义的系统特定的东西;的想法,他们从来没有这样做是荒谬的
supercat

在不同平台固有地执行不同操作的情况下,程序员应付出更多的努力来确保可移植性,这是一件好事,但是编译器作者浪费了每个人的时间,他们消除了从历史上可以合理地预期为将来所有编译器所共有的行为。给定整数iand n,使得n < INT_BITSi*(1<<n)不会溢出,我认为i<<=n;i=(unsigned)i << n;; 更清晰;在许多平台上它会比i*=(1<<N);。禁止编译器会带来什么?
supercat 2015年

虽然我认为标准允许在它称为UB的许多事物中使用陷阱(例如整数溢出)是很好的,并且有充分的理由使其不要求陷阱做任何可预测的事情,但我认为从每个可以想象的角度来看如果要求大多数形式的UB必须产生不确定的价值或记录它们保留做其他事情的权利的事实,而不必绝对要求记录其他可能发生的事实,则将改进标准。使一切都变成“ UB”的编译器将是合法的,但可能不受欢迎……
supercat

3

早期的经典案例之一是带符号整数加法。在某些使用中的处理器上,这会导致故障,而在其他处理器上,它将继续使用一个值(可能是适当的模块化值)。指定任何一种情况都意味着用于算术风格不佳的机器的程序将必须具有额外的代码(包括条件分支),以进行类似于整数加法的操作。


整数加法是一个有趣的例子。除了陷阱行为的可能性(在某些情况下会有用,但在其他情况下可能导致随机代码执行)之外,在某些情况下,编译器会根据未指定整数溢出进行包装的事实进行推理。例如,一个int16位且符号扩展移位昂贵的编译器可以(uchar1*uchar2) >> 4使用非符号扩展移位进行计算。不幸的是,一些编译器不仅将推理扩展到结果,而且扩展到操作数。
supercat 2015年

2

我要说的是关于哲学的问题比关于现实的问题要少-C一直是一种跨平台语言,并且该标准必须反映出这一点,并且在发布任何标准时都会有一个事实。在许多不同硬件上的大量实现。禁止必要行为的标准将被忽略或产生竞争的标准机构。


最初,许多行为都未定义,以允许不同的系统可能执行不同的操作,包括使用可能配置或可能无法配置的处理程序触发硬件陷阱(如果未配置,可能会导致任意不可预测的行为)。例如,要求负值向左移动而不是陷阱,将破坏为该系统设计并依赖于这种行为的任何代码。简而言之,它们是未定义的,以防止执行者提供他们认为有用的行为
supercat 2015年

但是,不幸的是,这种情况已经扭曲了,即使知道在特定情况下会做一些有用事情的处理器上运行的代码也无法利用这种行为,因为编译器可能会使用C标准不会(虽然平台会指定),但未指定将奇异世界重写应用于代码的行为。
supercat 2015年

1

某些行为无法通过任何合理的方式进行定义。我的意思是访问已删除的指针。检测到它的唯一方法是在删除后禁止指针值(将其值存储在某个位置,并且不允许任何分配函数再返回它)。这样的记忆不仅过大,而且长时间运行的程序也会导致用完的指针值用尽。


或者,您可以将所有指针分配为,weak_ptr并使指向deleted 的指针的所有引用无效...哦,等等,我们正在接近垃圾回收:/
Matthieu M.11年

boost::weak_ptr的实现是从此用法模式开始的一个很好的模板。正义只是对的弱计数起作用weak_ptrs,而不是在外部进行跟踪和使之无效,而弱计数基本上是对指针本身的引用计数。因此,您可以使无效,而不必立即将其删除。它不是完美的(您仍然可以有很多过期的s,无缘无故地维护基础),但是至少它是快速而有效的。weak_ptrshared_ptrshared_ptrweak_ptrshared_count
蓬松的

0

我将举一个示例,其中除了未定义的行为外,几乎没有其他明智的选择。原则上,任何指针都可以指向包含任何变量的内存,只有局部变量例外,编译器可以知道这些局部变量从未取过它们的地址。但是,为了在现代CPU上获得可接受的性能,编译器必须将变量值复制到寄存器中。完全在内存之外运行是无法启动的。

这基本上给了您两个选择:

1)在通过指针进行任何访问之前,将所有内容从寄存器中清除,以防万一指针指向该特定变量的内存。然后将所需的所有内容重新加载到寄存器中,以防万一通过指针更改了值。

2)对于何时允许使用指针对变量进行别名,以及何时允许编译器假定指针不对变量进行别名,有一套规则。

C选择选项2,因为1对于性能而言会很糟糕。但是,如果指针以C规则禁止的方式别名化变量,会发生什么情况?由于效果取决于编译器是否确实将变量存储在寄存器中,因此C标准无法绝对保证特定结果。


在说“允许编译器以X为真的方式工作”与说“任何X不为真的程序都会进行未定义的行为”之间存在语义上的区别,尽管不幸的是,这些标准并未明确区分。在许多情况下,包括您的别名示例,前一条语句将允许许多编译器优化,否则它们是不可能的。后者允许进行更多的“优化”,但是许多后者是程序员不希望的。
2015年

例如,如果某些代码将a设置foo为42,然后调用使用非法修改的指针将其设置foo为44的方法,则可以看到有益的说法是,直到下一次“合法”写入foo,尝试读取它才可能合法产生42或44,一个类似的表达式foo+foo甚至可以产生86,但是我看到允许编译器进行扩展甚至追溯的推理,将未定义的行为(其看似合理的“自然”行为都将是良性的)更改为许可证的好处要小得多。生成荒谬的代码。
supercat 2015年

0

从历史上看,未定义行为具有两个主要目的:

  1. 为避免要求编译器作者生成代码以处理从未发生过的情况。

  2. 为了允许在没有代码的情况下显式处理这种情况的可能性,实现可能具有各种“自然”行为,这在某些情况下会很有用。

作为一个简单的示例,在某些硬件平台上,尝试将两个总和太大而不能容纳在带符号整数中的正号整数相加会产生一个特定的负符号整数。在其他实现中,它将触发处理器陷阱。为了让C标准强制执行这两种行为,要求自然行为不同于该标准的平台的编译器必须生成额外的代码以产生正确的行为-该代码可能比进行实际添加的代码昂贵。更糟糕的是,这意味着想要“自然”行为的程序员将不得不添加更多的额外代码来实现它(并且额外的代码将再次比添加的代码更加昂贵)。

不幸的是,一些编译器作者采用了这样一种哲学,即编译器应竭尽所能地找到引起未定义行为的条件,并假定这种情况永远不会发生,则从中得出扩展的推论。因此,在具有32位的系统上int,给定的代码如下:

uint32_t foo(uint16_t q, int *p)
{
  if (q > 46340)
    *p++;
  return q*q;
}

C标准将允许编译器说,如果q为46341或更大,则表达式q * q将产生太大的结果而无法放入int,从而导致未定义行为,因此,编译器将有权假定不会发生,因此不需要增加*p。如果调用代码*p用作指示它应舍弃计算结果的指示符,则优化的效果可能是采用可以在几乎任何可以想象的方式发生整数溢出的系统上产生有意义结果的代码(捕获可能是丑陋,但至少是明智的做法),并将其转变为可能表现得毫无意义的代码。


-6

效率是通常的借口,但是无论借口如何,未定义的行为对于可移植性都是一个可怕的想法。实际上,未定义的行为变为未验证的,未陈述的假设。


7
OP指定了以下内容:“我的问题不是关于未定义行为是什么,还是真的很糟糕。我确实知道该标准的危害和大多数相关的未定义行为的引用,因此请不要发布有关它有多糟糕的答案。 。” 看来您没有阅读问题。
Etienne de Martel
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.