未定义行为值得吗?(关于IT技术)

27

由于未定义行为的存在,许多糟糕的事情已经发生并且仍在发生(或者没有发生,谁知道呢,一切皆有可能)。我理解这是为了给编译器留下一些灵活性以进行优化,也可能是为了使C ++更容易移植到不同的平台和架构。然而,由未定义行为引起的问题似乎过于严重,无法通过这些论点来证明其合理性。还有其他关于未定义行为的论点吗?如果没有,为什么未定义行为仍然存在?

编辑:为我的问题添加一些动机:由于与不那么精通C ++的同事发生了几次不愉快的经历,我已经开始习惯尽可能地使我的代码更加安全。对每个参数进行断言、坚定的const正确性等等。我试图留下尽可能少的空间来使用我的代码方式错误,因为经验表明,如果有漏洞,人们将利用它们,然后他们会指责我的代码质量差。我认为使我的代码尽可能安全是一个好的实践。这就是为什么我不理解为什么会出现未定义行为。请问能否举个例子,说明不能在运行时或编译时检测到的未定义行为,而不带来相当大的开销?


5
最近似乎“未定义行为”很流行……这是春天的哲学精神吗? - Matthieu M.
1
如果您将所有编译时的“未定义行为”替换为“不得翻译”,并将所有运行时的“未定义行为”替换为“调用abort()”或类似操作,则作为应用程序编写者,您仍然必须避免导致其发生的任何结构。如果您想在某些情况下定义行为为某些较小的事情,那么无论您是否具有UB,都没有任何区别。您必须在当前实现没有要求的情况下定义(并让其他人同意)行为。 - CB Bailey
2
就目前而言,@Charles,如果您意外地调用了未定义的行为,您可能会在一个系统上得到完全按预期工作的程序,但在另一个系统上返回微妙错误的答案。如果未定义的行为被定义了,那么至少您会立即知道出了什么问题,要么是由于崩溃,要么是由于始终错误的结果。 - Rob Kennedy
1
@CharlesBailey:如果一个程序应该将音视频文件从一种格式转换为另一种格式,那么即使输入文件格式不正确导致输出文件充满“随机”像素和声音,或者导致程序退出,这也可能是可以接受的,但这并不意味着它们可以重新格式化硬盘。允许代码指定在溢出情况下哪些行为是可接受的和不可接受的,将允许一些简单的优化,远远超出了那些严格检查所有输入以确保不会发生溢出的程序所能实现的范围。 - supercat
有符号 int 溢出被视为未定义行为的最重要的好处之一是,使用 int 循环计数器索引数组仍然可以将其优化为指针增量或其他操作,而无需额外检查循环是否一定会在不包装的情况下终止。请参见 Is there some meaningful statistical data to justify keeping signed integer arithmetic overflow undefined? 和 http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html。 - Peter Cordes
显示剩余3条评论
11个回答

11

我认为人们对 C/C++ 的关注点在于速度高于一切的哲学。

这些语言是在计算能力非常有限的时代创建的,你需要尽可能地进行优化才能得到可用的东西。

指定如何处理未定义行为将意味着首先检测它,然后当然要指定正确的处理方式。但是,检测它违反了语言的速度优先哲学!

今天,我们是否仍需要快速的程序?对于我们中那些使用非常有限资源(嵌入式系统)或面临非常严苛约束(响应时间或每秒事务数)的人来说,我们确实需要尽可能多地挤出性能。

我知道“将问题扔给更多硬件”这样的座右铭。我们在工作中有一个应用:

  • 期望的答案时间?小于100毫秒,并带有数据库调用(感谢memcached)。
  • 每秒交易数量?平均1200次,峰值在1500/1700次。

它在大约40个巨兽上运行:8个双核心opteron(2800MHz)和32GB的RAM。此时更多硬件变得困难,因此我们需要优化的代码和允许优化的语言(我们限制了在其中添加汇编代码)。

我必须说,我并不太关心未定义行为。如果您的程序调用 UB,则需要修复实际发生的任何行为。当然,如果立即报告它,那么修复它将更容易:这就是调试版本的作用。

因此,也许我们应该学会使用语言,而不是专注于未定义行为:

  • 不要使用未经检查的调用
  • (对于专家)不要使用未经检查的调用
  • (对于大师)您确定这里真的需要一个未检查的调用吗?

一切都变得更好了 :)


1
好的,但这假设您可以通过决定避免未定义的行为。您可能会因为打字错误而获得未定义的行为,然后,在编译和运行第一次时,您可能会在技术上破坏系统。 - Kyle Strand
@KyleStrand:确实检测未定义行为很困难。编译器可以检测到一些情况,静态分析工具也可以,然后还有运行时仪器(Sanitizers)和其他检查工具(ALF、Valgrind)用于测试运行...这就是为什么我现在关注Rust,因为我担心C++永远无法避免UB(它并不是为此设计的,似乎也无法进行后期改进)。 - Matthieu M.
2
我很高兴你提到了Rust。它让我对编程的未来充满了希望。 - Kyle Strand
C语言的设计并不是为了将速度置于一切之上,而是为了让程序员利用低级别结构,这些结构在历史上通常可以比其他方式更快地完成许多任务。当标准建议实现可以在没有要求的情况下以“环境特征的记录方式”处理代码时,这不仅仅是一种理论上的可能性。使该语言有用的是历史上实现会在有意义的情况下以这种方式行事,从而使得... - supercat
没有其他的理由去做。 - supercat

10

关于未定义行为,我的看法是:

标准规定了语言的使用方式以及实现在正确使用时应该如何反应。然而,覆盖所有功能的每种可能使用方式将需要大量工作,因此标准只能止步于此。

然而,在编译器实现中,你不能仅仅“止步于此”,代码必须转换为机器指令,不能留下空白。在许多情况下,编译器可以抛出错误,但这并不总是可行的:有些情况下,检查程序员是否做错了事情需要额外的工作(例如:调用析构函数两次 - 要检测这一点,编译器必须统计某些函数被调用的次数,或者添加额外的状态等)。因此,如果标准没有定义,而编译器却让它发生,那么不幸的时候可能会发生一些巧妙的事情。


1
嗯,比如说Java提供了一个参考实现。这是最明确的定义。为什么这里不这样做呢?如果必须在某个时候定义它,为什么不尽早定义它呢? - Björn Pollex
5
我不会假装自己是所有C++内部专家,但事实上,C++程序比Java更接近底层运行,这可能是一个重要原因。语言规范必须为在不同硬件上实现留出空间。 - Carson Myers
1
这更多是一种哲学问题。C++意味着速度,要勇往直前。额外的检查与这种哲学不相符。 - Matthieu M.
1
@Matthieu:并不完全正确。C ++意味着允许速度,并将谨慎作为一种选择。正如Stroustrup所说,您可以在快速实现的基础之上构建安全性,但是您无法在安全实现的基础之上构建速度,假设安全和速度冲突。如果您想快速访问向量元素,则使用[]。如果您想要受检访问,则使用.at()。它们都在标准中。 - David Thornley
3
我不反对提供两种方式,我反对的是大多数开发者不需要速度,却使用不安全的访问索引[]的惯用方式... 因此,我更喜欢有一种安全的惯用方式,以及一种不安全但更快速的方式供那些真正需要速度的人使用。 - Matthieu M.
显示剩余2条评论

6
问题不是由未定义行为引起的,而是由导致其发生的代码编写引起的。答案很简单-不要编写那种代码-这并不是什么高深的科学。
至于:
一个无法在运行时或编译时检测到的未定义行为的示例,需要付出相当大的开销。
现实世界中的问题:
int * p = new int;
// call loads of stuff which may create an alias to p called q
delete p;

// call more stuff, somewhere in which you do:
delete q;

在编译时检测这个问题是不可能的。在运行时,它仅仅是非常困难,需要内存分配系统进行更多的簿记(即更慢,占用更多的内存),而如果我们只是说第二个 delete 是未定义的,那么情况就不同了。如果你不喜欢这样,也许 C++ 不是适合你的语言-为什么不转换到 Java 呢?


3
没错,但是如果一个所谓的“特性”导致了如此多的工作时间浪费,那么你不禁会想为什么它不被移除。一定有相当惊人的好处才能证明其存在的必要性。 - Björn Pollex
4
@Space_C0wb0y 这是一种行为,由于这对委员会来说很不方便,会损害语言的可移植性或使编译器难以编写,因此没有被定义。我的意思是... 这不是一项功能,而是缺少某些不必要或不可信的功能。 - Carson Myers
2
你的回答来自程序员的角度,而问题则是从语言设计的角度提出的。问:“为什么存在这种不好的东西?” 答:“避免使用它。” - Ross
12
不,这不是政治问题,而是工程问题。并非所有的事情都可以在合理的条件下得到检查。假设将对无效指针的解引用从未定义的行为更改为已知错误,则标准将要求所有实现在每个指针解引用周围执行检查以产生该错误。我不只是谈论对空指针的解引用,而是所有指针。无论何时看到 *p,您都必须验证 p 是否是指向有效内存块的指针,这需要运行时跟踪所有分配的内存以进行检查。 - David Rodríguez - dribeas
5
在追踪漏洞上所失去的金钱数量:从标准角度来看,未定义行为并不意味着它必须在您特定的实现中是未定义的。许多实现在调试版本中具有特定代码以诊断错误。不同的实现将提供更大的诊断支持,以尝试抓住更大的市场份额。没有标准化意味着同一实现可以在调试模式下对迭代器进行边界检查,并同时拥有快速的未经检查的发布版本。 - David Rodríguez - dribeas
显示剩余7条评论

5

未定义行为的主要来源是指针,这就是为什么C和C++有很多未定义行为的原因。

考虑以下代码:

char * r = 0x012345ff;
std::cout << r;

这段代码看起来很糟糕,但它是否应该发出错误?如果那个地址确实是可读的,也就是说这是我某种方式获取的值(可能是设备地址等),那么怎么办?
在这种情况下,我们无法知道操作是否合法,如果不合法,行为确实是不可预测的。另外,在一般情况下C ++是考虑到“零开销规则”而设计的(请参见C ++的设计和演化),因此它不能对实现检查边界情况等施加任何负担。您应始终记住,这种语言的设计和使用不仅局限于桌面,还用于资源有限的嵌入式系统。

4

许多被定义为未定义行为的事情,如果不是不可能,也很难通过编译器或运行时环境进行诊断。

那些容易的已经变成了定义-未定义行为。考虑调用一个纯虚方法:这是未定义的行为,但大多数编译器/运行时环境都会以相同的术语提供错误:调用了纯虚函数。在我所知道的所有环境中,调用纯虚方法调用是运行时错误的事实标准。


2
请记住,如果您想要“标准化”它,那么您解决的问题比您创建的问题还多。消息必须发送到std::cout还是std::cerr?如果它们被重定向怎么办?如果纯虚拟调用发生在重定向的streambuf内部怎么办?消息必须是英文的还是可以本地化的?最致命的是:我的应用程序用户无论如何都不会理解它。 - MSalters
@ybungalobill:未定义行为并不意味着它必须是不可预测的。事实上,我知道的所有编译器在调用纯虚方法时都会提供合理的诊断消息。通过强制调用std::terminate,您并没有真正帮助解决问题,因为终止处理程序可以由用户设置,并且它没有任何手段来知道什么导致系统调用terminate - David Rodríguez - dribeas
@David:“知道是什么导致系统调用终止”仅在调试时有用,但前者对于节省几百万美元、挽救人类生命等方面非常有用... - Yakov Galka
@David:我不太明白我们在争论什么……我认为UB是好的。我只是说,与C++的其他几个方面不同,纯虚函数调用的情况很容易定义,并且没有性能惩罚。这不是煽动言论。实际上,我的工作与航空电子有关。硬件和软件都会发生故障。测试是概率性的,而证明则不然。重新启动系统(保证在10秒内完成)将使您进入确定性的领域,这可能是您最好的选择。 - Yakov Galka
@ybungalobill:在您的实现中调用纯虚函数是否未定义?去看看文档。你可能会发现它是完全定义的。我也不知道我们在讨论什么,但我的观点是,如果你在标准中定义了一个行为,它最好足够好以帮助你调试错误,而不是调用terminate定义一个特定的行为的问题在于,您不允许提供不同更好的行为。正如您已经说的,未定义只意味着当没有其他保证时才为未定义 - David Rodríguez - dribeas
显示剩余6条评论

3
标准中有一些行为是“不确定的”,这样可以让各种实现方式得以存在,而不会给这些实现带来额外的负担,也不会限制程序员必须采取的措施来防止这些情况的出现。
在很长一段时间内,避免这种负担是 C 和 C++ 的主要优势,适用于大量项目。
现在计算机速度比 C 语言发明时快了数千倍,检查数组边界或使用几兆字节的代码来实现沙箱运行时等开销对于大多数项目来说似乎并不重要。此外,由于我们的程序每秒处理多兆字节的潜在恶意数据,因此超越缓冲区的成本已经增加了数倍。
因此,有点令人沮丧的是没有一种语言具备 C++ 所有有用的特性,并且除此之外,每个编译后的程序的行为都是定义明确的(受实现特定行为的影响)。但只是有点令人沮丧——实际上,在 Java 中编写行为混乱的代码并不难理解,尽管它可能无法保证安全。同样,编写不安全的 Java 代码也并不困难,只是不安全通常仅限于泄露敏感信息或授予应用程序错误的特权,而不是放弃 JVM 所在的操作系统进程的完全控制。
因此,我认为良好的软件工程需要在所有语言中保持纪律,区别在于当我们的纪律失败时会发生什么以及其他语言(例如性能和 C++ 特性)对此进行了多少收费。如果其他语言提供的保险对您的项目有价值,请使用它。如果 C++ 提供的特性值得冒着不确定行为的风险,请使用 C++。我认为试图争论 C++ 的好处是否“合理”,好像它是每个人都一样的全局属性,并没有多大意义。这些优点在 C++ 语言设计的参考范围内是合理的,即您不必为自己不使用的东西付费。因此,正确的程序不应该变慢,以便使不正确的程序获得有用的错误消息而不是 UB,并且大多数情况下,不应该定义不寻常情况的行为(例如 32 位值的 <<32),如果这需要在委员会希望高效支持 C++ 的硬件上显式检查不寻常情况。
再看一个例子:我认为英特尔专业 C 和 C++ 编译器的性能优势并不足以证明购买它的成本。因此,我没有购买它。这并不意味着其他人会做出与我相同的计算,或者说我将来总是会做出相同的计算。

2
重要的是要清楚未定义行为和实现定义行为之间的区别。实现定义行为为编译器编写者提供了机会,以便利用其平台添加语言扩展。这些扩展在编写能够在现实世界中运行的代码方面是必要的。
另一方面,未定义行为存在于难以或不可能在不对C语言进行重大更改或产生巨大差异的情况下工程化解决方案的情况下。一个例子来自BS在谈论此事的页面上所述。
int a[10];
a[100] = 0; // range error
int* p = a;
// ...
p[100] = 0; // range error (unless we gave p a better value before that assignment)

范围错误是未定义行为。它是一个错误,但标准无法对平台如何处理此错误进行明确定义,因为每个平台都不同。无法将其设计为错误,因为这将需要在语言中包括自动范围检查,这将需要对语言的特性集进行重大更改。即使在编译时或运行时,p[100] = 0 错误对于语言来说也更加难以生成诊断,因为编译器无法在没有运行时支持的情况下知道 p 实际指向什么。

如果一个结构以单元素数组arr结尾,符合标准的编译器是否可以将任何形式为arr[i]的引用替换为arr[0]? 我不知道单元素数组是否足够常见,值得开发利用,但是代码arr[0]肯定比arr[i]的代码更快更紧凑。我的猜测是,对于i!= 0,即使在结构之外分配存储空间,arr[i]也会是未定义行为,但是即使结构黑客不合法,它也很常见,因此编译器应该适应它。 - supercat

2
编译器和编程语言是我最喜欢的话题之一。过去,我进行了一些与编译器相关的研究,发现了很多次“未定义行为”。
C++ 和 Java 非常流行。这并不意味着它们拥有出色的设计。它们被广泛使用,是因为它们冒险以牺牲设计质量来获得认可。Java 采用了垃圾收集、虚拟机和无指针外观。它们在某种程度上是先驱,无法从许多先前的项目中学习。
在 C++ 的情况下,主要目标之一是为 C 用户提供面向对象编程。即使是 C 程序也应该使用 C++ 编译器进行编译。这带来了很多令人讨厌的问题,而且 C 已经存在许多歧义。C++ 的重点是力量和流行,而不是完整性。不多的语言可以给你多重继承,C++ 给了你这个功能,尽管不是非常完美。未定义行为将永远支持它的荣耀和向后兼容性。
如果你真的想要一个强大而且定义清晰的语言,你必须去找其他地方。不幸的是,这并不是大多数人关心的主要问题。例如,Ada是一种很棒的语言,其中清晰和定义明确的行为非常重要,但很少有人关注该语言,因为它的用户群非常狭窄。我对这个例子有偏见,因为我真的很喜欢这种语言,我在我的博客上发布了一些关于它的内容(链接),但如果你想了解更多关于语言定义如何帮助你在编译之前减少错误的知识,请查看这些幻灯片
我并不是说C++是一种糟糕的语言!它只是有着不同的目标,我喜欢使用它。你也有一个庞大的社区、优秀的工具以及许多其他伟大的东西,如STL、Boost和QT。但你的怀疑也是成为一名优秀的C++程序员的根源。如果你想在C++方面做得很好,这应该是你关注的问题之一。我鼓励你阅读前面的幻灯片,还有this critic。它将帮助你更好地理解那些语言没有达到你期望的时候。
顺便提一下,未定义行为完全违反了可移植性。例如,在Ada中,你可以控制数据结构的布局(而在C和C++中,它可能会根据机器和编译器而改变)。线程是语言的一部分。因此,移植C和C++软件会给你带来更多的痛苦而不是乐趣。

2
然而奇怪的是,大量高度可移植的软件都是用C和C++编写的——我估计比其他任何语言都多。 - anon
1
@Neil。如果你在C/C++中切换编译器,你可能会改变结构的布局(最糟糕的情况是微处理器)。如果你在Linux中使用线程,当转向Windows时,你将不得不使用不同的库。Ada没有这些问题(也没有很多其他问题),但由于其难度和缺乏流行度,没有人使用它。人们喜欢C和C++可以轻松地射击自己的脚或者炸掉它,因为两者都可以立即低级访问所有CPU的强大功能。虽然其他语言的可移植性可能更容易,但即使我也会选择C/C++,只是因为它们的流行程度。 - SystematicFrank
2
在撰写此评论时,我注意到在单击“未定义行为”标签后,Stack Overflow 中的所有问题(仅有一个例外)都与C或C ++标签相关。 - SystematicFrank
由于未定义行为是C和C++标准的一个重要部分:1.3.13. [defns.undefined] 行为,如可能在使用错误的程序结构或错误的数据时发生,对此国际标准没有任何要求。[...] [注意:允许的未定义行为范围从完全忽略该情况并产生不可预测的结果,到在环境中具有特定文档方式的翻译或程序执行时表现出相应行为[...] —结束备注 ] - Sebastian Mach
1
@phresnel:不幸的是,超现代哲学贬低了“...以一种环境特有的记录方式行为”的建议。鉴于unsigned char ch = getchar(); if (ch < 216) printf("Hey"); ch*=(ch*ch*ch*ch);,超现代哲学认为,能够从最后一个语句推断出ch将小于216(因此printf应始终执行)的价值超过了让代码对所有ch值执行模256算术的价值。 - supercat
许多程序中 UB 的常见来源是在标准定义某类操作的行为时,其他地方又说重叠的某类操作会引发 UB。在某些情况下,两个声明都适用的情况下,很明显应该优先考虑定义的行为,以至于没有人会认真建议任何理智的实现应该做出不同的选择。不幸的是,没有一般方法可以知道哪些情况足够“明显”,以至于编译器编写者将把它们视为已定义。 - supercat

1
这是我的最爱:在使用非空指针进行delete操作后(不仅是解引用,还包括强制类型转换等),会导致未定义行为(参见this question))。
以下是可能遇到未定义行为的情况:
{
    char* pointer = new char[10];
    delete[] pointer;
    // some other code
    printf( "deleted %x\n", pointer );
}

现在,在我所知道的所有架构上,以上代码都可以正常运行。教编译器或运行时执行这种情况的分析非常困难和昂贵。不要忘记,在delete和使用指针之间可能有数百万行代码。将指针设置为null后立即进行delete可能很昂贵,因此这也不是一种通用解决方案。

这就是为什么存在UB的概念。您不希望在代码中出现UB。它可能有效,也可能无效。在这个实现上有效,在另一个实现上则会出错。


1
@alan2here:删除操作出了什么问题? - sharptooth
1
如果没有问题,就不会有像Java这样具有自动清理功能的语言,但最终这是一个观点问题。在C++中跟踪需要删除的内容并正确清理它可能并不容易,这取决于您如何编写代码,这可能永远不会成为您的问题,或者它可能会将简单的程序变成高度复杂、有缺陷的程序。在Java中,如果您可以引用某个东西,比如在您的示例中使用“指针”,那么它就存在。 - alan2here
3
这并不是试图访问已释放对象的问题 - 仅仅打印地址已经是未定义行为。这也不是delete的问题 - 而是开发者的问题。 - sharptooth
1
啊,我现在明白了,你并不是试图访问指针末尾的对象,只是打印地址,这应该只会打印地址,对吧?我很惊讶这会产生未定义行为。这与数组有关吗?也许使用std::vector和cout就不会有问题了。在Java中,你仍然不必责怪开发人员,因为你不会遇到这种情况。但是在C++中似乎不会出现UB,可能只是C++的奇怪之处。我听说它并没有太大变化,但我希望新的C++标准能够澄清这类问题。 - alan2here
2
@alan2here:这里是实际可能出现问题的解释: https://dev59.com/jnI-5IYBdhLWcg3wbHm-#1866543 - sharptooth
显示剩余2条评论

1

几年前我曾经问过自己同样的问题。当我试图为一个写入空指针的函数的行为提供适当的定义时,我立刻停止了考虑。

并非所有设备都有受保护内存的概念。因此,你不可能依靠系统通过段错误或类似方式来保护你。并非所有设备都有只读内存,因此你不能简单地说写入操作什么也不做。我能想到的唯一其他选择是要求应用程序在没有系统帮助的情况下引发异常[或中止等]。但在这种情况下,编译器必须在每个内存写入之前插入代码来检查空指针,除非它可以保证指针自上次列表内存写入以来没有更改。这显然是不可接受的。

因此,将行为定义为未定义是我能够得出的唯一合乎逻辑的决定,而不是说“符合C++标准的编译器只能在具有受保护内存的平台上实现。”


"那显然是不可接受的。" 我认为这并不是那么清楚明白。我曾经使用过某些CPU上的Java JIT,它确实做到了这一点。性能很好。C和C++程序员几乎被定义为不能接受这种情况的人,但没有特别的理由说明他们(我们 - 对于某些项目,我也包括在内)应该是(a)众多的,或者(b)总是正确地排除它;-) - Steve Jessop
@Steve:我同意,大多数情况下检查是完全可以接受的,唯一的问题是在一些紧密循环中,我们需要未经检查的版本来使事情运行更快(或注定失败)。不幸的是,程序员通常以二进制方式思考,并且经常在任何地方使用相同的习惯用语,因此即使不需要速度,也会编写相同的未经检查的调用例程。:'( - Matthieu M.
Java的方法是尽可能让JIT在循环外提取边界检查。你说得对,有些情况下程序员知道边界不会超出,但证明对编译器/JIT来说太难了,而且性能成本也很高。所以,如果不存在一种可以省略检查的语言,那将是不可接受的,必定会有人发明一种语言,并快速使用它来省略边界检查,在编译器无法证明边界不会超出的情况下,因为它是*错误的;-) - Steve Jessop
在每次访问指针之前,您不需要检查它是否为“null”。您只需要在可能修改后检查它。 - anton_rh

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接