如果您在多个平台上部署,未定义的行为是否只是一个问题?

Is undefined behavior only an issue if you are deploying on several platforms?

大多数关于 未定义行为 (UB) 的讨论都在谈论如何有一些平台可以做到这一点,或者一些编译器可以做到这一点。

如果您只对一种平台和一种编译器(相同版本)感兴趣,并且您知道您将使用它们多年怎么办?

除了代码,没有任何变化,UB 不是实现定义的。

一旦 UB 针对该体系结构和编译器显示出来并且您已经测试过,您是否可以假设从那时起无论编译器第一次对 UB 做什么,它都会每次都这样做?

注意:我知道未定义的行为非常非常糟糕,但是当我在某人编写的代码中指出 UB 时情况,他们问了这个,我没有什么好说的了,如果你必须升级或移植,所有的 UB 都将非常昂贵。

似乎有不同的行为类别:

  1. Defined - 这是符合标准的行为
  2. Supported - 这是记录支持的行为 a.k.a 实施已定义
  3. Extensions - 这是一个文档添加,支持低级别 popcount、分支提示等位操作属于此类
  4. Constant - 虽然没有记录,但这些行为将 可能在给定平台上保持一致,例如字节顺序, sizeof int 虽然不可移植但可能不会改变
  5. Reasonable - 通常是安全的,通常是遗留的,从 无符号到有符号,使用指针的低位作为临时space
  6. Dangerous - 读取未初始化或未分配的内存,返回 临时变量,在非 pod class
  7. 上使用 memcopy

似乎 Constant 在一个平台上的补丁版本中可能是不变的。 ReasonableDangerous 之间的界线似乎正在向 Dangerous 移动越来越多的行为,因为编译器在优化方面变得更加积极

不,那不安全。首先,您必须修复 一切 ,而不仅仅是编译器版本。我没有具体的例子,但我猜想不同的(升级的)OS,甚至升级的处理器可能会改变 UB 结果。

此外,即使向您的程序输入不同的数据也会改变 UB 行为。例如,越界数组访问(至少没有优化)通常取决于数组后内存中的内容。 UPD:有关此问题的更多讨论,请参阅 Yakk 的精彩回答。

更大的问题是优化和其他编译器标志。 UB 可能会根据优化标志以不同的方式表现出来,并且很难想象有人总是使用相同的优化标志(至少您将使用不同的标志进行调试和发布)。

UPD:刚刚注意到您从未提到修复编译器 version,您只提到修复 compiler 本身。那么一切就更不安全了:新的编译器版本肯定会改变 UB 的行为。来自 this series of blog posts:

The important and scary thing to realize is that just about any optimization based on undefined behavior can start being triggered on buggy code at any time in the future. Inlining, loop unrolling, memory promotion and other optimizations will keep getting better, and a significant part of their reason for existing is to expose secondary optimizations like the ones above.

您所指的更可能是实现定义而不是未定义的行为。前者是标准没有告诉你会发生什么,但如果你使用相同的编译器和相同的平台,它应该工作相同。例如,假设 int 的长度为 4 个字节。 UB 是更严重的事情。那里的标准什么也没说。对于给定的编译器和平台,它可能有效,但也可能仅在某些情况下有效。

一个例子是使用未初始化的值。如果您在 if 中使用未初始化的 bool,您可能会得到 true 或 false,并且它可能总是您想要的,但代码会以几种令人惊讶的方式中断。

另一个示例是取消引用空指针。虽然它可能在所有情况下都会导致段错误,但标准并不要求程序每次 运行.

时甚至都产生相同的结果。

总而言之,如果您正在做的事情是实现定义的,那么如果您只针对一个平台开发并且您测试过它可以工作,那么您是安全的。如果您正在做的事情是 未定义的行为,那么您可能在任何情况下都不安全。可能它有效,但没有任何保证。

任何类型的未定义行为都存在一个根本问题:它由消毒剂和优化器诊断。编译器可以悄无声息地将与那些版本相对应的行为从一个版本更改为另一个版本(例如,通过扩展其曲目),突然间您的程序中将出现一些无法追踪的错误。应该避免这种情况。

不过,您的特定实施 "defined" 存在未定义的行为。您的机器可以定义左移负数位,并且在那里使用它是安全的,因为记录的功能的重大更改很少发生。一个更常见的例子是 strict aliasing:GCC 可以通过 -fno-strict-aliasing.

禁用此限制

OS 更改、无害的系统更改(不同的硬件版本!)或编译器更改都可能导致以前的 "working" UB 无法工作。

但比那更糟

有时更改不相关的编译单元,或同一编译单元中的远距离代码,可能会导致以前的 "working" UB 无法工作;例如,两个定义不同但签名相同的内联函数或方法。一个在 linking 期间被悄悄丢弃;并且完全无害的代码更改可以改变丢弃哪个。

在一个上下文中工作的代码在不同的上下文中使用时可能会突然停止在同一个编译器、OS 和硬件中工作。这方面的一个例子是违反强别名;编译后的代码在 A 点调用时可能会工作,但在内联时(可能在 link 时间!)代码可能会改变含义。

你的代码,如果是一个更大项目的一部分,可以有条件地调用一些第三方代码(比如,一个 shell 在文件打开对话框中预览图像类型的扩展)来改变一些标志的状态(浮点精度、语言环境、整数溢出标志、除以零行为等)。您的代码以前运行良好,现在表现出完全不同的行为。

其次,许多未定义的行为本质上是不确定的。在指针被释放后访问它的内容(甚至写入)可能是 99/100 的安全,但是 1/100 页面被换出,或者在你到达它之前在那里写了其他东西。现在你有内存损坏。它通过了您所有的测试,但您对可能出现的问题缺乏完整的了解。

通过使用未定义的行为,您承诺完全理解 C++ 标准、您的编译器在那种情况下可以做的一切,以及运行时环境可以做出反应的每一种方式。每次构建程序时,您都必须审核生成的程序集,而不是 C++ 源代码,可能是整个程序!您还承诺所有阅读该代码或修改该代码的人都具备该知识水平。

有时候还是值得的。

Fastest Possible Delegates 使用 UB 和有关调用约定的知识成为真正快速的非拥有 std::function 类型。

Impossibly Fast Delegates 参加比赛。它在某些情况下更快,在其他情况下更慢,并且符合 C++ 标准。

为了提高性能,使用 UB 可能是值得的。您很少会从这种 UB 黑客中获得性能(速度或内存使用)以外的东西。

我见过的另一个例子是当我们不得不用一个只接受函数指针的可怜的 C API 注册一个回调时。我们会创建一个函数(未经优化编译),将其复制到另一个页面,修改该函数中的指针常量,然后将该页面标记为可执行,从而允许我们秘密地将指针与函数指针一起传递给回调。

另一种实现方式是使用一些固定大小的函数集(10?100?1000?100 万?)所有这些函数都在全局数组中查找 std::function 并调用它。这将限制我们在任何时候安装的此类回调的数量,但实际上已经足够了。

这基本上是一个关于特定C++实现的问题。 "Can I assume that a specific behavior, undefined by the standard, will continue to be handled by ($CXX) on platform XYZ in the same way under circumstances UVW?"

我认为你要么明确说明你正在使用的编译器和平台,然后查阅他们的文档看他们是否做出任何保证,否则这个问题根本无法回答。

未定义行为的全部意义在于 C++ 标准没有指定会发生什么,因此如果您正在从标准中寻找某种保证它是 "ok",您将找不到它。如果您问 "community at large" 是否认为它安全,那主要是基于意见。

Once the UB has manifested for that architecture and that compiler and you have tested, can't you assume that from then on whatever the compiler did with the UB the first time, it will do that every time?

只有编译器厂商保证你能做到,否则,不行,那是一厢情愿。


让我尝试以稍微不同的方式再次回答。

众所周知,在正常的软件工程中,乃至整个工程中,程序员/工程师被教导根据标准做事,编译器编写者/零件制造商生产符合标准的零件/工具,并且在最后你在 "under the assumptions of the standards, my engineering work shows that this product will work" 处生产了一些东西,然后你测试它并发布它。

假设你有一个疯狂的 jimbo 叔叔,有一天,他把他所有的工具都拿出来,一大堆两乘四,工作了几个星期,在你的后院做了一个临时过山车。然后你 运行 它,果然它不会崩溃。你甚至 运行 十次,它都没有崩溃。现在jimbo不是工程师,所以这不是按标准做的。但是,如果连十次都没有崩溃,那就意味着它是安全的,你可以开始收取public的门票了,对吧?

在很大程度上,什么是安全的什么不安全是一个社会学问题。但是如果你只想把它变成一个简单的 "when can I reasonably assume that no one would get hurt by me charging admission, when I can't really assume anything about the product" 问题,我会这样做。假设我估计,如果我开始对 public 收费,我会 运行 X 年,到时候,可能会有 100,000 人乘坐它。如果它基本上是一个有偏见的抛硬币,不管它是否破损,那么我想看到的是 "this device has been run a million times with crash dummies, and it never crashed or showed hints of breaking." 然后我可以相当合理地相信,如果我开始收取 public 的入场费,任何人受伤的可能性都非常低,即使没有涉及严格的工程标准。那只是基于统计和力学的一般知识。

关于你的问题,我想说的是,如果你发布的代码具有未定义的行为,没有人支持,无论是标准、编译器制造商还是其他任何人,这基本上是 "crazy uncle jimbo" 工程,并且只有 "okay" 如果您根据统计和计算机的一般知识进行大量增加的测试以验证它是否满足您的需求。

"Software that doesn't change, isn't being used."

如果您要用指针做一些不寻常的事情,可能有一种方法可以使用强制转换来定义您想要的内容。由于他们的天性,他们 而不是 是 "whatever the compiler did with the UB the first time"。 例如,当您引用未初始化指针指向的内存时,您每次 运行 程序都会得到一个不同的随机地址。

未定义的行为通常意味着您正在做一些棘手的事情,最好换一种方式来完成任务。 例如,这是未定义的:

printf("%d %d", ++i, ++i);

很难知道这里的意图是什么,应该重新考虑。

从历史上看,C 编译器通常倾向于以某种可预测的方式运行,即使标准未要求这样做。例如,在大多数平台上,空指针和指向死对象的指针之间的比较将简单地报告它们不相等(如果代码希望安全地断言指针为空并在不为空时捕获,则很有用)。标准不要求编译器做这些事情,但历史上可以轻松完成这些事情的编译器已经这样做了。

不幸的是,一些编译器编写者认为如果在指针有效非空时无法进行此类比较,则编译器应省略断言代码。更糟糕的是,如果它还可以确定某些输入会导致使用无效的非空指针访问代码,它应该假设永远不会接收到此类输入,并省略所有将处理此类输入的代码。

希望这种编译器行为会成为昙花一现。据推测,它是由对 "optimize" 代码的渴望驱动的,但对于大多数应用程序而言,健壮性比速度更重要,并且让编译器弄乱代码以限制错误输入或差事程序行为造成的损害是一个秘诀灾难。

然而,在那之前,在使用编译器时必须非常小心,仔细阅读文档,因为不能保证编译器编写者不会决定支持有用的行为不太重要,这些行为虽然广泛支持,不是标准强制要求的(例如能够安全地检查两个任意对象是否重叠),而不是利用每一个机会来消除标准不需要它执行的代码。

环境温度等因素会改变未定义的行为,这会导致旋转硬盘延迟发生变化,进而导致线程调度发生变化,进而会改变正在评估的随机垃圾的内容。

简而言之,除非编译器或 OS 指定行为(因为语言标准没有),否则不安全。

虽然我同意即使您不针对多个平台也不安全的答案,但每条规则都可以有例外。

我想举两个例子,我相信允许未定义/实现定义的行为是正确的选择。

  1. 单发程序。它不是一个旨在供任何人使用的程序,而是一个小型且快速编写的程序,用于计算或生成某些东西 now。在这种情况下,"quick and dirty" 解决方案可能是正确的选择,例如,如果我知道我的系统的字节序并且我不想费心编写与其他字节序一起工作的代码。例如,我只需要它来执行数学证明,以了解我是否能够在我的其他面向用户的程序中使用特定公式。

  2. 非常小的嵌入式设备。最便宜的微控制器有几百字节的内存。如果您开发带有闪烁 LED 的小玩具或音乐明信片等,每一分钱都很重要,因为它将以非常低的单位利润生产数百万。处理器和代码都不会改变,如果您必须为下一代产品使用不同的处理器,您可能不得不重写代码。在这种情况下,未定义行为的一个很好的例子是,有些微控制器保证在加电时每个内存位置的值为零(或 255)。在这种情况下,您可以跳过变量的初始化。如果您的微控制器只有 256 字节的内存,那么适合内存的程序和不适合内存的代码就会有所不同。

任何不同意第 2 点的人,请想象一下如果你将这样的话告诉你的老板会发生什么:

"I know the hardware costs only $ 0.40 and we plan selling it for $ 0.50. However, the program with 40 lines of code I've written for it only works for this very specific type of processor, so if in the distant future we ever change to a different processor, the code will not be usable and I'll have to throw it out and write a new one. A standard-conforming program which works for every type of processor will not fit into our $ 0.40 processor. Therefore I request to use a processor which costs $ 0.60, because I refuse to write a program which is not portable."

换一种方式思考。

未定义的行为总是不好的,永远不要使用,因为你永远不知道你会得到什么。

不过,你可以用

来调节它

行为可以由除语言规范之外的各方定义

因此,您永远不应该依赖 UB,但是您可以找到其他来源,其中声明在您的情况下,某种行为是编译器的定义行为。

Yakk 给出了关于快速委托的很好的例子 类。根据规范,在这些情况下,作者明确声称他们从事未定义的行为。然而,他们随后会解释为什么行为比这更好定义的商业原因。例如,他们声明成员函数指针的内存布局不太可能在 Visual Studio 中更改,因为微软不喜欢的不兼容性会导致业务成本激增。因此他们声明行为是 "de facto defined behavior."

在 pthreads(由 gcc 编译)的典型 linux 实现中可以看到类似的行为。在某些情况下,他们会假设允许编译器在多线程场景中调用哪些优化。这些假设在源代码的注释中明确说明。 "de facto defined behavior?" 好吧,pthreads 和 gcc 是相辅相成的。向 gcc 添加破坏 pthreads 的优化将被认为是不可接受的,因此没有人会这样做。

但是,您不能做出相同的假设。你可能会说 "pthreads does it, so I should be able to as well." 然后,有人进行了优化,并更新 gcc 以使用它(可能使用 __sync 调用而不是依赖 volatile)。现在 pthreads 继续运行...但是您的代码不再运行了。

还要考虑 MySQL(或者是 Postgre 吗?)他们发现缓冲区溢出错误的情况。溢出实际上已经在代码中被捕获了,但是它使用了未定义的行为,所以最新的 gcc 开始优化整个检查。

因此,总而言之,寻找定义行为的替代来源,而不是在未定义时使用它。找到一个你知道 1.0/0.0 等于 NaN 的原因是完全合理的,而不是导致出现浮点陷阱。但是,如果没有首先证明它是您和您的编译器的有效行为定义,请不要使用该假设。

拜托哦拜托哦请记住我们不时升级编译器。

在不破坏代码的情况下更改代码需要阅读和理解当前代码。依赖未定义的行为会损害可读性:如果我无法查找它,我应该如何知道代码的作用?

虽然程序的可移植性可能不是问题,但程序员的可移植性可能是。如果您需要雇用某人来维护该程序,您将希望能够简单地寻找具有 [=14] 经验的 '<语言 x> 开发人员=]' 非常适合您的团队,而不必寻找有能力的 '具有 <应用领域> 经验的开发人员知道(或愿意学习)平台 x.y.z 版本的所有未定义行为内在函数 foobar 结合使用,同时在 furbleblawup[=27 上有 baz =]'.

Nothing is changing but the code, and the UB is not implementation-defined.

更改代码足以触发优化器针对未定义行为的不同行为,因此可能有效的代码很容易因为看似微小的更改而中断,这些更改会暴露更多优化机会。例如,允许内联函数的更改,What Every C Programmer Should Know About Undefined Behavior #2/3 中对此进行了很好的介绍:

While this is intentionally a simple and contrived example, this sort of thing happens all the time with inlining: inlining a function often exposes a number of secondary optimization opportunities. This means that if the optimizer decides to inline a function, a variety of local optimizations can kick in, which change the behavior of the code. This is both perfectly valid according to the standard, and important for performance in practice.

编译器供应商在围绕未定义行为进行优化方面变得非常积极,升级可能会暴露以前未利用的代码:

The important and scary thing to realize is that just about any optimization based on undefined behavior can start being triggered on buggy code at any time in the future. Inlining, loop unrolling, memory promotion and other optimizations will keep getting better, and a significant part of their reason for existing is to expose secondary optimizations like the ones above.