这篇Stack Overflow帖子列出了C / C ++语言规范声明为“未定义行为”的情况的相当全面的列表。但是,我想了解为什么其他现代语言(例如C#或Java)没有“未定义行为”的概念。这是否意味着编译器设计者可以控制所有可能的方案(C#和Java)或不能控制(C和C ++)?
nullptr
)”烦人通过编写和/或采用建议的规范来定义行为”。:c
这篇Stack Overflow帖子列出了C / C ++语言规范声明为“未定义行为”的情况的相当全面的列表。但是,我想了解为什么其他现代语言(例如C#或Java)没有“未定义行为”的概念。这是否意味着编译器设计者可以控制所有可能的方案(C#和Java)或不能控制(C和C ++)?
nullptr
)”烦人通过编写和/或采用建议的规范来定义行为”。:c
Answers:
未定义的行为是仅回想起来才被认为是非常糟糕的主意之一。
最初的编译器取得了巨大的成就,并欣然欢迎对替代语言-机器语言或汇编语言编程的改进。这些问题是众所周知的,并且专门发明了高级语言来解决这些已知问题。(当时的热情非常高,以至于HLL有时被誉为“编程的结局”-好像从现在起我们只需要简单地写下我们想要的内容,编译器就可以完成所有实际工作。)
直到后来,我们才意识到新方法带来的新问题。远离运行代码的实际机器,这意味着有更多的可能性静默地做不到我们期望他们做的事情。例如,分配变量通常会使初始值不确定。这不是问题,因为如果您不想在变量中保留值,就不会分配变量,对吗?当然,可以期望专业的程序员不会忘记分配初始值,不是吗?
事实证明,使用功能更强大的编程系统可以实现更大的代码库和更复杂的结构,是的,许多程序员确实确实会不时地进行此类监督,并且由此产生的不确定行为成为一个主要问题。即使在今天,大多数安全漏洞从微小到可怕都是由于一种或另一种形式的未定义行为造成的。(原因是通常情况下,未定义的行为实际上实际上是由计算的下一个较低级别的事物定义的,了解该级别的攻击者可以利用该摆动空间来制作程序,不仅可以实现意料之外的事情,而且还可以他们打算。)
自从我们认识到这一点以来,就一直有一种驱逐高级语言中未定义行为的普遍动力,而Java对此尤为彻底(这相对容易,因为它被设计为始终在自己专门设计的虚拟机上运行)。像C这样的旧语言在不失去与大量现有代码的兼容性的情况下,很难像这样进行改进。
编辑:如前所述,效率是另一个原因。未定义的行为意味着编译器编写者在利用目标体系结构方面有很多余地,因此每种实现都可以尽可能快地实现每种功能。在今天的动力不足的机器上,这比今天的更为重要,当时程序员的薪水通常是软件开发的瓶颈。
int32_t add(int32_t x, int32_t y)
在C ++中编写一个安全地将两个数字相加的函数基本上是不可能的。围绕该参数的通常参数与效率有关,但经常散布一些可移植性参数(如“编写一次,在编写它的平台上运行...,在其他地方没有;-)”)。因此,大概有一个论点可能是:有些事情是不确定的,因为您不知道自己是在16位微型控制器上还是在64位服务器上(一个弱者,但仍然是一个论点)
基本上是因为Java和类似语言的设计者不希望其语言具有未定义的行为。这是一个折衷方案-允许不确定的行为可能会提高性能,但是语言设计人员将安全性和可预测性放在了更高的优先级。
例如,如果在C中分配数组,则数据是不确定的。在Java中,所有字节必须初始化为0(或其他一些指定的值)。这意味着运行时必须经过数组(O(n)操作),而C可以立即执行分配。因此,对于此类操作,C总是会更快。
如果使用数组的代码在读取之前仍要填充它,那么这对于Java来说基本上是浪费时间。但是,在先读取代码的情况下,在Java中可以获得可预测的结果,而在C中可获得不可预测的结果。
valgrind
准确显示未初始化值的使用位置。您不能valgrind
在Java代码上使用,因为运行时会进行初始化,从而使valgrind
s检查无效。
未定义的行为通过赋予编译器自由度在某些边界或其他条件下执行奇怪或意外的事情(甚至是正常的事情),从而实现了显着的优化。
看到http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html
使用未初始化的变量:在C程序中,这通常被称为问题源,有很多工具可以解决这些问题:从编译器警告到静态和动态分析器。通过不需要在变量进入范围时将所有变量初始化为零(例如Java),从而提高了性能。对于大多数标量变量,这将导致很少的开销,但是堆栈数组和malloc的内存将招致存储的内存集,这可能会非常昂贵,尤其是因为通常通常会完全覆盖存储。
有符号整数溢出:例如,如果对'int'类型的算术溢出,则结果不确定。一个示例是不能保证“ INT_MAX + 1”为INT_MIN。此行为启用对某些代码很重要的某些优化类别。例如,知道INT_MAX + 1是未定义的允许将“ X + 1> X”优化为“ true”。知道乘法“不能”溢出(因为这样做是不确定的)可以将“ X * 2/2”优化为“ X”。尽管这些看起来微不足道,但通常会通过内联和宏扩展来暴露这些事情。允许使用的更重要的优化是针对“ <=”循环,如下所示:
for (i = 0; i <= N; ++i) { ... }
在此循环中,如果溢出时未定义“ i”,则编译器可以假定循环将精确地迭代N + 1次,从而可以进行广泛的循环优化。另一方面,如果将变量定义为如果在溢出时回绕,则编译器必须假定循环可能是无限的(如果N为INT_MAX,则会发生这种情况)-然后会禁用这些重要的循环优化。由于太多代码使用“ int”作为归纳变量,因此这尤其会影响64位平台。
a + b
变为未定义状态允许add b a
在每种情况下都将其编译为本机指令,而不是潜在地要求编译器模拟某种其他形式的有符号整数算术运算。
HashSet
很棒。
<<
可能是困难的情况。
x << y
计算出某个类型的有效值,int32_t
但我们不会说哪个”。这允许实现者使用快速解决方案,但不会充当错误的前提条件,从而允许进行时间旅行样式优化,因为不确定性受限于此操作的输出-规范保证不会明显影响内存,易失性变量等。由表情评价。...
在C的早期,有很多混乱。不同的编译器对语言的处理方式不同。当有兴趣为该语言编写规范时,该规范需要与程序员在其编译器上所依赖的C相当向后兼容。但是其中一些细节是不可移植的,并且通常没有意义,例如,假设特定的字节序或数据布局。因此,C标准保留了许多未定义或实现特定行为的细节,这为编译器编写者留出了很大的灵活性。C ++建立在C之上,还具有未定义的行为。
Java试图成为比C ++更安全,更简单的语言。Java根据完整的虚拟机定义语言语义。这样就为未定义的行为留出了很少的空间,另一方面,这使得Java实现可能难以完成要求(例如,引用分配必须是原子的,或者整数如何工作)。Java支持潜在的不安全操作时,通常会在运行时由虚拟机检查它们(例如,某些强制转换)。
this
检查而回,理由是空?” this
的存在nullptr
是UB,因此可从来没有真正发生。)
JVM和.NET语言很容易:
尽管有一些选择的好处:
在提供逃生舱口的情况下,那些逃生舱口会引发全面的未定义行为。但是至少至少它们通常仅用于很少的很短的行程中,因此更易于手动验证。
unsafe
关键字或属性System.Runtime.InteropServices
)。通过将这些内容留给少数知道如何调试非托管内容的程序员,并且尽可能少地减少实用性,我们就可以减少问题。自上次与性能相关的不安全之锤以来,已经过去了10多年,但有时您必须这样做,因为实际上没有其他解决方案。
Java和C#的特征是,至少在开发的早期,供应商就占主导地位。(分别是Sun和Microsoft)。C和C ++是不同的;从一开始,他们就已经有多个相互竞争的实现。C也特别在异类硬件平台上运行。结果,实现之间存在差异。标准化C和C ++的ISO委员会可以就一个大的共同点达成共识,但是在实现不同的边缘,标准为实现留下了空间。
这也是因为在偏向另一种选择的硬件体系结构上选择一种行为可能会很昂贵-字节顺序是显而易见的选择。
真正的原因在于一方面C和C ++以及另一方面Java和C#(仅用于几个示例)在意图上存在根本差异。由于历史原因,此处的许多讨论都谈论C而不是C ++,但是(您可能已经知道)C ++是C的直接后代,因此它所说的有关C的说法同样适用于C ++。
尽管它们在很大程度上被遗忘了(有时甚至会否认它们的存在),但UNIX的第一个版本是用汇编语言编写的。C的大部分(如果不是唯一的)最初目的是将UNIX从汇编语言移植到高级语言。目的的一部分是用高级语言编写尽可能多的操作系统,或者从另一个方向看待该操作系统,以最大程度地减少用汇编语言编写的数量。
为此,C需要提供与汇编语言几乎相同级别的对硬件的访问。PDP-11(例如)将I / O寄存器映射到特定地址。例如,您将读取一个内存位置,以检查是否已在系统控制台上按下了某个键。当有数据等待读取时,在该位置设置了一位。然后,您将从另一个指定的位置读取一个字节,以检索已按下键的ASCII码。
同样,如果要打印一些数据,则将检查另一个指定的位置,并且在输出设备就绪时,将数据写入另一个指定的位置。
为了支持为此类设备编写驱动程序,C允许您使用某种整数类型指定任意位置,将其转换为指针,然后在内存中读取或写入该位置。
当然,这有一个非常严重的问题:从1970年代初开始,并不是地球上的每台机器都具有与PDP-11相同的内存布局。因此,当您使用该整数,将其转换为指针,然后通过该指针进行读取或写入时,没有人可以为您将要获得的东西提供任何合理的保证。仅举一个明显的例子,读写可能会映射到硬件中的单独寄存器,因此(与普通内存相反)如果您编写某些东西,然后尝试将其读回,则您所读的内容可能与您编写的内容不匹配。
我可以看到一些可能性:
其中1似乎很荒谬,几乎不值得进一步讨论。2基本上是在抛弃语言的基本意图。这实际上使第三种选择完全是他们可以合理考虑的唯一选择。
经常出现的另一点是整数类型的大小。C采取的“位置” int
应为体系结构建议的自然大小。因此,如果我正在编程32位VAX,int
则可能应该是32位,但是如果我正在编程36位Univac,int
则可能应该是36位(依此类推)。仅使用保证为8位大小倍数的类型为36位计算机编写操作系统可能是不合理的(甚至是不可能的)。也许我只是肤浅,但在我看来,如果我正在为36位计算机编写操作系统,则可能要使用支持36位类型的语言。
从语言的角度来看,这导致了更多未定义的行为。如果我取适合32位的最大值,那么加1会发生什么?在典型的32位硬件上,它将翻转(或可能引发某种硬件故障)。另一方面,如果它在36位硬件上运行,它将只是...添加一个。如果该语言要支持编写操作系统,则不能保证任何一种行为,您只需要允许类型的大小和溢出行为在一个与另一个之间变化即可。
Java和C#可以忽略所有这些。它们并非旨在支持编写操作系统。与他们一起,您有两种选择。一种是使硬件支持他们所需要的-因为他们要求8、16、32和64位的类型,所以只需构建支持这些大小的硬件即可。另一个明显的可能性是,该语言只能在提供所需环境的其他软件之上运行,而不管底层硬件可能想要什么。
在大多数情况下,这并不是一个非此即彼的选择。而是,许多实现都做了一点。通常,您可以在操作系统上运行的JVM上运行Java。通常,操作系统是用C编写的,而JVM是用C ++编写的。如果JVM在ARM CPU上运行,则该CPU包括ARM的Jazelle扩展的机会非常大,可以根据Java的需求更紧密地定制硬件,从而减少了在软件中的需求,并且Java代码运行得更快(或更少)。慢慢来)。
摘要
C和C ++具有未定义的行为,因为没有人定义可接受的替代方法,这些替代方法使它们可以执行预期的操作。C#和Java采用了不同的方法,但是该方法与C和C ++的目标相差甚远(如果有的话)。特别是,似乎都没有提供在大多数随意选择的硬件上编写系统软件(例如操作系统)的合理方法。两者通常都依靠现有系统软件(通常用C或C ++编写)提供的功能来完成其工作。
C标准的作者希望读者认识到他们认为显而易见的东西,并在已发布的基本原理中有所提及,但并没有直截了当地说:委员会不需要命令编译器作家来满足其客户的需求,因为客户应该比委员会更了解他们的需求。如果很明显,期望某些平台的编译器以某种方式处理构造,则没有人应该关心标准是否说该构造调用未定义行为。该标准未能强制要求兼容的编译器以任何方式有用地处理一段代码,这并不意味着程序员应该愿意购买不这样做的编译器。
这种语言设计方法在编译器作者需要将其产品出售给付费客户的世界中非常有效。在编译器编写者与市场影响隔离的世界中,它完全崩溃了。令人信服的是,是否存在适当的市场条件来像他们指导1990年代流行的语言一样来操纵一种语言,甚至更怀疑任何理智的语言设计师是否希望依靠这种市场条件。
C ++和c都具有描述性标准(无论如何都是ISO版本)。
仅存在于解释语言如何工作并提供关于语言是什么的单一参考。通常,由编译器供应商和库编写者来带路,并且一些建议会包含在主要的ISO标准中。
Java和C#(或我认为是Visual C#)具有规定性标准。他们会提前明确告诉您该语言的内容,其工作方式以及允许的行为。
更为重要的是,Java实际上在Open-JDK中具有“参考实现”。(我认为Roslyn可算作Visual C#参考实现,但找不到该源。)
在Java的情况下,如果标准中有任何歧义,Open-JDK会采用某种方式。Open-JDK的操作方式是标准的。
未定义的行为使编译器可以在各种体系结构上生成非常有效的代码。Erik的答案提到了优化,但超出了这一范围。
例如,带符号的溢出是C语言中未定义的行为。在实践中,期望编译器生成一个简单的带符号的附加操作码供CPU执行,并且该行为将与特定的CPU一样。
这使C在大多数体系结构上都能很好地执行并产生非常紧凑的代码。如果标准指定带符号的整数必须以某种方式溢出,那么行为不同的CPU将需要更多的代码来生成简单的带符号的加法。
这就是C中许多未定义行为的原因,也是为什么int
系统之间的大小之类的东西有所不同的原因。Int
取决于架构,通常选择大于的最快,最有效的数据类型char
。
早在C是新的时候,这些考虑就很重要。计算机的功能较弱,通常处理速度和内存有限。在性能确实很重要的地方使用了C,并且期望开发人员了解计算机如何足够好地运行,以了解这些未定义的行为在其特定系统上实际上将是什么。
诸如Java和C#之类的较新语言更喜欢消除未定义行为而不是原始性能。
从某种意义上说,Java也有它。假设您给Arrays.sort提供了不正确的比较器。它可以抛出检测到它的异常。否则,它将以某种无法保证特定的方式对数组进行排序。
同样,如果您从多个线程修改变量,结果也是不可预测的。
C ++只是走得更远,以使更多未定义的情况(或者Java决定定义更多的操作)并为其命名。
a
如果可以从中获得51或73,则同时将53和71分配给它将是未定义的行为,但是,如果只能获得53或71,则它是定义明确的行为。