26

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

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

4

11 回答 11

10

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

这些语言是在原始功能稀缺的时候创建的,您需要进行所有优化才能获得可用的东西。

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

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

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

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

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

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

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

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

一切都突然好起来了:)

于 2010-05-05T09:51:16.900 回答
9

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

该标准定义了如何使用语言,以及当以正确的方式使用时,实现应该如何反应。但是,要涵盖每个功能的所有可能用途需要做很多工作,所以标准只是把它留在那里。

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

于 2010-05-05T09:19:45.573 回答
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;

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

于 2010-05-05T09:18:04.500 回答
5

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

考虑这段代码:

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

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

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

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

于 2010-05-05T11:22:42.807 回答
4

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

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

于 2010-05-05T09:20:34.310 回答
3

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

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

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

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

所以我认为好的软件工程需要所有语言的纪律,不同的是当我们的纪律失败时会发生什么,以及我们被其他语言(在性能和足迹以及你喜欢的 C++ 特性方面)收取的保险费用反对。如果其他语言提供的保险对您的项目来说是值得的,那就接受吧。如果 C++ 提供的功能值得为未定义行为的风险付出代价,请选择 C++。我认为尝试争论的意义不大,就好像它是一个对每个人都一样的全局属性,C++ 的好处是否“证明”成本是合理的。它们在 C++ 语言设计的参考条款范围内是合理的,即您无需为不使用的东西付费。因此,<< 32如果需要在委员会希望“有效”支持 C++ 的硬件上明确检查异常情况,则不应定义 32 位值)。

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

于 2010-05-05T10:50:28.840 回答
2

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

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

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

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

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

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

于 2010-05-05T10:14:13.193 回答
2

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

另一方面,UB 存在于在不对语言进行重大更改或与 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)

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

于 2010-05-05T13:16:51.797 回答
1

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

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

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

于 2010-05-05T09:36:29.470 回答
1

这是我最喜欢的:在你delete使用它(不仅是取消引用,而且还有 castin 等)完成非空指针之后是 UB(参见这个问题)

你如何遇到UB:

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

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

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

于 2010-05-05T10:27:58.363 回答
0

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

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

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

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

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

于 2010-05-05T09:32:10.243 回答