【问题标题】:Is undefined behavior worth it?未定义的行为值得吗?
【发布时间】:2011-02-15 20:11:55
【问题描述】:

由于未定义的行为,许多不好的事情发生并继续发生(或者没有,谁知道,任何事情都可能发生)。我知道这是为了给编译器优化留出一些回旋余地,也可能是为了让 C++ 更容易移植到不同的平台和架构。然而,由未定义行为引起的问题似乎太大而无法通过这些论点来证明。未定义行为的其他论据是什么?如果没有,为什么还存在未定义的行为?

编辑 为我的问题添加一些动机:由于与较少 C++ 狡猾的同事的几次糟糕经历,我已经习惯于使我的代码尽可能安全。断言每一个论点,严格的 const 正确性和类似的东西。我尽量留出尽可能少的空间以错误的方式使用我的代码,因为经验表明,如果有漏洞,人们会使用它们,然后他们会打电话给我说我的代码不好。我认为让我的代码尽可能安全是一种好的做法。这就是为什么我不明白为什么存在未定义的行为。谁能给我一个在运行时或编译时无法检测到的未定义行为示例,而无需大量开销?

【问题讨论】:

  • 这些天来,未定义的行为似乎风靡一时……春天的哲学精神?
  • 如果您将所有编译时“未定义行为”替换为“不应翻译”,并将所有运行时“未定义行为”替换为“调用 abort()”或类似内容,作为应用程序编写者,您d 仍然必须避免导致它的任何构造。如果您想在某些情况下将行为定义为不那么激烈,那么无论您是否有 UB 都没有什么不同。在目前对实现没有要求的情况下,您必须定义(并让其他所有人同意)行为。
  • @Matthieu M.:目前可以观察到的这种愤怒激发了这个问题。
  • 就像今天一样,@Charles,如果你不小心调用了未定义的行为,你可能会得到一个在一个系统上完全按照预期工作的程序,但它在另一个系统上返回了微妙的错误答案。如果定义了未定义的行为,那么至少您会立即知道出现了问题,无论是由于崩溃还是由于始终错误的结果。
  • @CharlesBailey:如果一个程序应该将视听文件从一种格式转换为另一种格式,如果格式错误的输入文件生成充满“随机”像素和声音的输出文件,或者它们导致程序退出,但这并不意味着他们可以重新格式化硬盘。允许代码指定在溢出的情况下哪些行为可以接受和不可以接受,这将允许一些简单的优化,远远超出在严格检查所有输入以确保不会发生溢出的程序上可能实现的优化。

标签: c++ undefined-behavior


【解决方案1】:

我认为关注的核心首先来自于 C/C++ 的速度哲学。

这些语言是在原始力量稀缺的时候创建的,您需要进行所有可以使用的优化。

指定如何处理 UB 意味着首先检测它,然后当然指定正确的处理。然而,检测它违反了语言的速度第一哲学!

今天,我们还需要快速程序吗?是的,对于我们这些使用非常有限的资源(嵌入式系统)或非常严格的限制(响应时间或每秒事务数)工作的人来说,我们确实需要尽可能多地挤出。

我知道这句座右铭在问题上投入更多的硬件。我们有一个我工作的应用程序:

  • 预期的答复时间?不到 100 毫秒,中间有数据库调用(感谢 memcached)。
  • 每秒事务数?平均 1200,峰值在 1500/1700。

它可以在大约 40 个怪物上运行:8 个双核 opteron (2800MHz) 和 32GB RAM。在这一点上,用更多的硬件变得“更快”变得很困难,所以我们需要优化的代码,以及一种允许它的语言(我们确实限制在那里抛出汇编代码)。

我必须说无论如何我都不太关心 UB。如果您的程序调用了 UB,那么它需要修复实际发生的任何行为。当然,如果立即报告,修复它们会更容易:这就是调试版本的用途。

所以也许我们应该学习使用语言而不是专注于 UB:

  • 不要使用未经检查的调用
  • (针对专家)不要使用未经检查的调用
  • (针对大师)您确定您真的需要在这里进行未经检查的通话吗?

一切都突然好起来了:)

【讨论】:

  • 好的,但这假设您可以通过决定来避免未定义的行为。你可以从一个错字中得到未定义的行为,然后,从技术上讲,你可能会在你第一次编译和运行时破坏你的系统。
  • @KyleStrand:检测 UB 确实很困难。编译器检测一些实例,静态分析器也检测到,然后有运行时仪器(Sanitizers)和其他检查工具(ALF,Valgrind)用于测试运行......这就是我这些天关注 Rust 的原因,因为我害怕比 C++只是永远不会设法避免 UB(它不是设计来的,而且似乎无法改装)。
  • 很高兴你提到了 Rust。它让我对编程的未来充满希望。
  • C 并非旨在将速度放在首位,而是让程序员利用低级结构,这在历史上通常可以比其他方式更快地完成许多任务。当标准建议在标准没有强加要求的情况下,实现可以“以环境特征的书面方式”处理代码时,这不仅仅是理论上的可能性。从历史上看,使该语言有用的原因是,在有意义的情况下,实现会以这种方式表现......
  • ...没有理由不这样做。
【解决方案2】:

我对未定义行为的看法是这样的:

标准定义了如何使用语言,以及当以正确的方式使用时,实现应该如何反应。但是,要涵盖每个功能的所有可能用途需要大量工作,因此标准仅保留了它。

但是,在编译器实现中,您不能只是“保留它”,代码必须转换为机器指令,并且您不能只留下空白点。在许多情况下,编译器可能会抛出错误,但这并不总是可行的:在某些情况下,需要额外的工作来检查程序员是否做错了事情(例如:调用析构函数两次——以检测到这一点,编译器必须计算某些函数被调用了多少次,或者添加额外的状态,或者其他东西)。因此,如果标准没有定义它,而编译器只是让它发生,有时可能会发生机智的事情,如果你不走运的话。

【讨论】:

  • 嗯,Java 提供了一个参考实现。这是一个明确的定义。为什么这里不做呢?如果必须在某个时候定义,为什么不尽早定义呢?
  • 我不会假装自己是所有 C++ 内部的专家,但 C++ 程序比 Java 更接近金属的事实可能是一个很大的原因。语言规范必须为在截然不同的硬件上实现留出空间。
  • 更多的是哲学问题。 C++ 意味着速度,小心翼翼。额外的检查不符合这种理念。
  • @Matthieu:不准确。 C++ 意味着允许速度,并作为一种选择谨慎。正如 Stroustrup 所说,您可以在快速实现的基础上构建安全性,但假设安全性和速度发生冲突,您无法在安全实现的基础上构建速度。如果您想快速访问向量的元素,请使用[]。如果要检查访问权限,请使用.at()。他们都在标准中。
  • 我不反对同时提供两者的想法,我反对大多数开发人员不需要速度而是使用惯用的方式访问索引[],这也是不安全的...因此,对于那些真正需要速度的人,我更愿意采用一种安全的惯用方式,而另一种不安全的方式。
【解决方案3】:

问题不是由未定义的行为引起的,它们是由编写导致它的代码引起的。答案很简单——不要写那种代码——不这样做并不完全是火箭科学。

至于:

未定义行为的示例 在运行时无法检测到或 编译时间不长 开销

一个现实世界的问题:

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;

在编译时检测到这一点是不可能的。在运行时,它只是极其困难,并且需要内存分配系统做更多的簿记(即更慢并占用更多内存),而不是我们简单地说第二次删除未定义的情况。如果您不喜欢这个,也许 C++ 不是适合您的语言 - 为什么不切换到 java?

【讨论】:

  • 确实如此,但是,如果“功能”会导致大量工作时间的浪费,那么您一定会想知道为什么不直接删除它。必须有一个令人印象深刻的优势来证明这一切。
  • @Space_C0wb0y 它的行为尚未定义,因为它给委员会带来不便,它会损害语言的可移植性或使编译器难以编写。我的意思是......这不是一个功能,它缺少某些不需要或不合理的功能。
  • 您的答案是从程序员的角度出发,而问题是从语言设计的角度提出的。问:“为什么会有这种坏东西存在?”答:“避免它”
  • 不,这不是政治,而是工程。并非一切都可以在合理的条件下进行检查。假设取消引用无效指针从未定义行为更改为已知错误。那么标准将要求所有实现围绕每个指针取消引用执行检查以产生该错误。而且我说的不仅仅是取消引用 null,而是 all 指针。每当您看到 *p 时,您都必须验证 p 是指向有效内存块的指针,这需要运行时跟踪所有分配的内存以进行该检查。
  • 关于跟踪错误的损失金额:从标准的角度来看未定义的行为并不意味着它必须在您的特定实现中未定义。许多实现在调试版本中有特定的代码来诊断错误。不同的实现将提供更大的诊断支持,以尝试抢占更大的市场份额。没有标准化意味着相同的实现可以在调试模式下对迭代器进行边界检查,同​​时拥有一个快速的未经检查的发布版本。
【解决方案4】:

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

考虑这段代码:

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

这段代码看起来很糟糕,但它应该发出错误吗?如果该地址确实是可读的,即它是我以某种方式获得的值(可能是设备地址等)怎么办?

在这种情况下,没有办法知道操作是否合法,如果不合法,它的行为确实是不可预测的。

除此之外:一般而言,C++ 的设计考虑了“零开销规则”(请参阅​​ The Design and Evolution of C++),因此它不可能给实现带来任何负担来检查极端情况等。您应该始终保持请记住,这种语言是经过设计的,并且确实不仅用于桌面,还用于资源有限的嵌入式系统。

【讨论】:

    【解决方案5】:

    许多被定义为未定义行为的事情即使不是不可能被编译器或运行时环境诊断出来也很难。

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

    【讨论】:

    • 为什么没有人将事实上的标准变成真正的标准?
    • 这会有什么不同?也就是说,实现是符合标准的(嗯,任何东西都符合 UB),并且用户可以获得他们需要的信息。有什么需要修改标准?如果它没有损坏,请不要修复它——您可能只是以不同的方式损坏它。
    • 请记住,如果你想“标准化”它,你很快就会发现比你解决的问题更多的问题。消息必须发送到 std::cout 或 std::cerr 吗?如果他们被重定向怎么办?如果纯虚拟调用发生在重定向 streambuf 内怎么办?信息必须是英文的还是可以本地化的?最可恶的是:我的应用程序用户无论如何都不会理解它。
    • @MSalters:纯虚拟调用实际上很容易定义为调用std::terminate(),这比未定义的行为要好(重启火星探测器总比让它以不可预知的方式离开要好)并被困在岩石中)。
    • @ybungalobill:未定义的行为并不意味着它必须是不可预测的。事实上,当您调用纯虚方法时,我所知道的所有编译器都会提供合理的诊断信息。通过强制调用std::terminate,您并没有真正提供帮助,因为终止处理程序可以由用户设置,并且它无法知道是什么导致系统致电terminate
    【解决方案6】:

    该标准未定义“某些”行为,以便允许各种实现,而不会给这些实现增加检测“某些”情况的开销,或者给程序员增加防止这些情况发生所需的约束的负担.

    曾几何时,避免这种开销是 C 和 C++ 对于大量项目的主要优势。

    计算机现在的速度比发明 C 时快几千倍,而且诸如始终检查数组边界或拥有几兆字节的代码来实现沙盒运行时之类的开销似乎不像是对大多数项目来说意义重大。此外,由于我们的程序每秒处理数兆字节的潜在恶意数据,因此(例如)超出缓冲区的成本增加了几个因素。

    因此有些令人沮丧的是,没有一种语言具有 C++ 的所有有用特性,并且还具有定义每个编译程序的行为(取决于特定于实现的行为)的属性。但只是在某种程度上——在 Java 中编写行为如此令人困惑的代码实际上并不是那么困难,以至于从调试的 POV 来看,即使不是安全性,它也可能是未定义的。编写不安全的 Java 代码也不难——只是不安全通常仅限于泄露敏感信息或授予对应用程序不正确的权限,而不是放弃对运行 JVM 的操作系统进程的完全控制。

    所以我认为好的软件工程需要所有语言的纪律,不同之处在于当我们的纪律失败时会发生什么,以及我们被其他语言收取多少费用(在性能和占用空间以及您喜欢的 C++ 功能方面) ) 为保险。如果其他语言提供的保险对您的项目来说是值得的,那就接受吧。如果 C++ 提供的特性值得为未定义行为的风险付出代价,请选择 C++。我认为尝试争论的意义不大,就好像它是一个对每个人都一样的全局属性,C++ 的好处是否“证明”成本是合理的。它们在 C++ 语言设计的参考条款范围内是合理的,即您无需为不使用的东西付费。因此,不应该让正确的程序变慢,以便不正确的程序得到有用的错误消息而不是 UB,并且大多数时候不应该定义异常情况下的行为(例如,32 位值的&lt;&lt; 32)(例如结果为 0),如果这需要在委员会希望“有效”支持 C++ 的硬件上明确检查异常情况。

    再看一个例子:我认为英特尔专业 C 和 C++ 编译器的性能优势不足以证明购买它的成本是合理的。因此,我没有买它。并不意味着其他人会做出与我相同的计算,或者我将来总是会做出相同的计算。

    【讨论】:

      【解决方案7】:

      编译器和编程语言是我最喜欢的主题之一。过去我做了一些与编译器相关的研究,发现很多次未定义的行为

      C++ 和 Java 非常流行。这并不意味着他们有一个伟大的设计。它们被广泛使用,因为它们冒着损害设计质量的风险只是为了获得认可。 Java 追求垃圾收集、虚拟机和无指针外观。他们是部分先驱,无法从以前的许多项目中学习。

      就 C++ 而言,主要目标之一是为 C 用户提供面向对象的编程。甚至 C 程序也应该使用 C++ 编译器进行编译。这产生了很多令人讨厌的开放点,而 C 已经有很多模棱两可的地方。 C++ 强调的是力量和受欢迎程度,而不是完整性。没有多少语言给你多重继承,C++ 给你,虽然不是以一种非常优美的方式。未定义的行为将始终存在以支持其荣耀和向后兼容性。

      如果您真的想要一种健壮且定义明确的语言,您必须寻找其他地方。可悲的是,这不是大多数人的主要关注点。例如,Ada 是一门很棒的语言,其中明确和明确的行为很重要,但由于其用户群狭窄,几乎没有人关心该语言。我对这个例子有偏见,因为我真的很喜欢那种语言,我发布了一些 on my blog 但是如果你想了解更多关于语言定义如何帮助减少错误,甚至在你编译之前看看 these slides

      我并不是说 C++ 是一种糟糕的语言!它只是有不同的目标,我喜欢和它一起工作。您还拥有一个庞大的社区、很棒的工具以及更多很棒的东西,例如 STL、Boost 和 QT。但你的怀疑也是成为优秀 C++ 程序员的根本。如果您想精通 C++,这应该是您关心的问题之一。我鼓励您阅读之前的幻灯片以及this critic。当语言没有达到您的预期时,它将帮助您理解那些时间。

      顺便说一句。未定义的行为完全违背了可移植性。例如,在 Ada 中,您可以控制数据结构的布局(在 C 和 C++ 中,它可以根据机器和编译器进行更改)。线程是语言的一部分。所以移植 C 和 C++ 软件会给你带来更多的痛苦而不是快乐

      【讨论】:

      • 奇怪的是,大量高度可移植的软件是用 C 和 C++ 编写的——我估计比任何其他语言都多。
      • @Neil。使用 C/C++,如果您切换编译器,您可能会改变结构的布局(最糟糕的微处理器)。如果您在 Linux 中使用线程,则在使用 Windows 时将不得不使用不同的库。 Ada 没有这些问题(也没有很多其他问题),但没有人使用它,因为它的难度和不受欢迎。您可以轻松地击中您的脚 (C) 或将其击飞 (C++),这是人们喜欢的,因为两者都可以立即低级访问其 CPU 的所有功能。其他语言的可移植性可能更容易,但即使我也会选择 C/C++ 只是因为它们很受欢迎
      • 在发表此评论时,我注意到在单击“未定义行为”标签后,Stack Overflow 中的所有问题(除了一个)都与 C 或 C++ 标签相关
      • 因为未定义的行为是 C 和 C++ 标准的组成部分:1.3.13。 [defns.undefined]behavior, such as might arise upon use of an erroneous program construct or erroneous data, for which this International Standard imposes no requirements. [...] [ Note: permissible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment[...] —end note ]
      • @phresnel:不幸的是,超现代哲学不赞成“......行为......以环境的记录方式特征”的建议。鉴于unsigned char ch = getchar(); if (ch &lt; 216) printf("Hey"); ch*=(ch*ch*ch*ch);,超现代哲学表明,能够从最后一条语句中推断出ch 将小于216(因此 printf 应该始终执行)的价值超过了让代码执行 mod- ch 的所有值的 256 运算。
      【解决方案8】:

      明确未定义行为和实现定义行为之间的区别很重要。实现定义的行为使编译器编写者有机会向语言添加扩展,以利用他们的平台。为了编写在现实世界中工作的代码,此类扩展是必要的。

      另一方面,UB 存在于在不对语言进行重大更改或与 C 有很大差异的情况下很难或不可能设计解决方案的情况。取自 page where BS talks about this 的一个示例是:

      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)
      

      范围错误为 UB。这是一个错误,但标准未定义平台应如何处理此问题,因为标准无法定义它。每个平台都不一样。它不能被设计成错误,因为这将需要在语言中包括自动范围检查,这将需要对语言的功能集进行重大更改。 p[100] = 0 错误对于语言来说更难生成诊断,无论是在编译时还是运行时,因为如果没有运行时支持,编译器无法知道 p 真正指向的内容。

      【讨论】:

      • 如果结构以单元素数组arr 结尾,符合标准的编译器可以用arr[0] 替换任何arr[i] 形式的引用吗?我不知道单元素数组是否常见到特别值得利用,但肯定arr[0] 的代码可能比arr[i] 的代码更快、更紧凑。我的猜测是,arr[i] 将是 i!=0 的未定义行为,无论是否在结构之外分配存储,但即使 struct hack 是非法的,编译器也应该适应它。
      【解决方案9】:

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

      并非所有设备都有受保护内存的概念。因此,您不可能依靠系统通过段错误或类似的方式来保护您。并非所有设备都具有只读存储器,因此您不能说写入根本不执行任何操作。我能想到的唯一其他选择是要求应用程序在没有系统帮助的情况下引发异常[或中止,或其他东西]。但是在这种情况下,编译器必须在每次内存写入之前插入代码以检查是否为空,除非它可以保证指针在列表内存写入之后没有改变。这显然是不可接受的。

      因此,让行为未定义是我能做出的唯一合乎逻辑的决定,不用说“兼容的 C++ 编译器只能在具有受保护内存的平台上实现。”

      【讨论】:

      • “这显然是不可接受的”。我不认为这一切都那么清楚。我曾使用过在某些 CPU 上执行此操作的 Java JIT。性能很好。 C 和 C++ 程序员几乎被定义为不可接受的人,但没有特别的理由认为他们(我们——我包括我自己的一些项目)应该(a)很多,或者(b)总是正确的排除它;-)
      • @Steve:我同意,大多数时候检查是完全可以接受的,唯一的问题是在许多紧密的循环中,我们需要未经检查的版本来使事情运行得更快(或注定要失败)。不幸的是,程序员从二进制的角度思考,并且经常在任何地方使用相同的习语,因此即使不需要速度,也要编写相同的未经检查的调用例程:'(
      • Java 方法是让 JIT 提升边界检查尽可能在循环之外。你是对的,在某些情况下程序员知道不会超出界限,但是编译器/JIT 无法生成证明,并且性能成本会很高。所以,好吧,如果不存在一种可以省略检查的语言,那将是不可接受的,有人会发明一种。并快速使用它来省略边界检查,以防编译器无法获得不会超过边界的证明,因为它是错误 ;-)
      • 您无需在每次访问指针之前检查null 的指针。只有在可能的修改后才需要检查它。
      【解决方案10】:

      这是我最喜欢的:在你对非空指针使用 delete 之后,使用它(不仅是解引用,还包括 castin 等)是 UB(参见 this question)

      你怎么会遇到UB:

      {
          char* pointer = new char[10];
          delete[] pointer;
          // some other code
          printf( "deleted %x\n", pointer );
      }
      

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

      这就是为什么会有 UB 的概念。您不想在代码中使用 UB。也许有效,也许无效。在这个实现上工作,在另一个上中断。

      【讨论】:

      • @alan2here: 删除问题到底在哪里?
      • 如果没有问题,就不会有像 Java 这样具有自动清理功能的语言,尽管最终还是见仁见智。跟踪需要在 C++ 中正确删除和清理的内容绝非易事,这取决于您的编码方式,这对您来说可能永远不会成为问题,或者它可能会将简单的程序变成高度复杂的错误程序。在 Java 中,如果您可以引用某些内容,例如在示例中使用“指针”,那么它就存在。
      • @alan2here:这不是试图访问已释放对象的问题 - 只是打印地址已经是 UB。这不是delete 的问题 - 这是开发人员的问题。
      • 啊,我现在可以看到您没有尝试访问指针末尾的对象,只是打印地址,这应该只是打印地址,不是吗?我很惊讶这会产生 UB。是否与数组有关,也许 std::vectors 和 cout 不会有问题。您仍然不必责怪 Java 开发人员,您不会发生这种情况。但它看起来不像你会在 C++ 中获得 UB,一定只是 C++ 的怪事。我听说它并没有太大变化,但我希望新的 C++ 标准能稍微清除这样的东西。
      • @alan2here:这是对实际可能出错的解释:stackoverflow.com/questions/1866461/…
      【解决方案11】:

      有时未定义的行为是好的。以大整数为例。

      union BitInt
      {
          __int64 Whole;
          struct
          {
              int Upper;
              int Lower; // or maybe it's lower upper. Depends on architecture
          } Parts;
      };
      

      规范说,如果我们最后一次读取或写入 Whole,那么从 Parts 读取/写入是未定义的。

      现在,这对我来说有点傻,因为如果我们不能触及工会的任何其他部分,那么一开始就没有任何意义,对吧?

      但无论如何,也许有些函数会使用 __int64 而其他函数会使用两个分开的 int。而不是每次都转换我们可以使用这个联合。我认识的每个编译器都以非常清晰的方式处理这种未定义的行为。所以在我看来,未定义的行为在这里并不是那么糟糕。

      【讨论】:

      • 正如您自己在评论中指出的那样,此行为取决于架构的字节序(以及字段填充和字段大小)。所以它适用于某些平台,但不适用于其他平台。如果你留在一个可以工作的平台上,那对你来说很好。但是你依赖于特定的平台架构和编译器实现。
      • 是的,这是真的。但是你明白我所说的“那么,如果它提供的唯一好处就是未定义的行为,为什么还要有联合呢?”我要指出的是 union 关键字存在,因为并非所有未定义的行为都是不好的。
      • 联合将多种类型压缩到单个内存块中。这种用法非常有用,并且不需要任何对未定义行为的依赖。
      • @ProgramMax: 9.5 确实说明了这一点,只有在谜语中你必须破译:9.5.1:“在联合中,最多有一个数据成员可以随时处于活动状态,即,任何时候最多可以将一个数据成员的值存储在一个联合中。”
      • C++ 中有大量未定义的行为,其中大部分是由于遗漏而未定义的。
      猜你喜欢
      • 2011-05-15
      • 2020-06-15
      • 2011-05-19
      • 1970-01-01
      • 1970-01-01
      • 2021-03-13
      • 1970-01-01
      • 2011-08-23
      相关资源
      最近更新 更多