未定义行为
来自cppreference.com
若违反语言的某些规则,则令整个程序失去意义。
解释
C++ 标准为不被归入下列分类之一的每个程序都精确定义了其可观察行为:
- 非良构 - 程序拥有语法错误或可诊断的语义错误。
- 遵从标准的 C++ 编译器必须为此给出诊断,即使它定义了为这种代码赋予了含义的语言扩展(例如用可变长度数组)也应如此。
- 标准文本用 shall(应当)、 shall not(不应当)和 ill-formed(非良构)给出了这些要求。
- 执行这种程序的行为未定义。
- 由实现定义的行为 - 程序的行为随实现而变动,遵从标准的实现必须为每个这样的行为的效果提供文档。
- 例如 std::size_t 的类型或每个字节的位数,或者 std::bad_alloc::what 的文本。
- 由实现定义的行为的一个子集是本地环境特定行为,它取决于实现所提供的本地环境。
- 未指明的行为 - 程序的行为随实现而变动,且遵从标准的实现不需要为每个行为的效果提供文档。
|
(C++26 起) |
- 未定义行为 - 对程序的行为无任何限制。
未定义行为与优化
由于正确的 C++ 程序不存在未定义的行为,因此在启用优化后编译实际存在未定义行为的程序时,编译器可能会产生意想不到的结果。
例如,
有符号溢出
int foo(int x) { return x+1 > x; // 要么为 true,要么因为有符号溢出导致未定义行为 }
可能编译为(演示)
foo(int): mov eax, 1 ret
访问越界
int table[4] = {}; bool exists_in_table(int v) { // 可能在头 4 次迭代中返回 true,或者因为边界外访问导致未定义行为 for (int i = 0; i <= 4; i++) if (table[i] == v) return true; return false; }
可能编译为(演示)
exists_in_table(int): mov eax, 1 ret
未初始化的标量
std::size_t f(int x) { std::size_t a; if(x) // x 为零时会有未定义行为 a = 42; return a; }
可能编译为(演示)
f(int): mov eax, 42 ret
以下给出的输出曾在旧版本 gcc 上观察到
运行此代码
可能的输出:
p is true p is false
非法标量
int f() { bool b = true; unsigned char* p = reinterpret_cast<unsigned char*>(&b); *p = 10; // 从 b 读取现在是 UB return b == 0; }
可能编译为(演示)
f(): mov eax, 11 ret
空指针解引用
以下示例演示读取解引用空指针的结果。
int foo(int* p) { int x = *p; if (!p) return x; // 要么如上产生未定义行为,要么不可能采用此分支 else return 0; } int bar() { int* p = nullptr; return *p; // 无条件 UB }
可能编译为(演示)
foo(int*): xor eax, eax ret bar(): ret
访问已传递给 std::realloc 的指针
选择 clang 以观察所示输出
运行此代码
#include <cstdlib> #include <iostream> int main() { int* p = (int*)std::malloc(sizeof(int)); int* q = (int*)std::realloc(p, sizeof(int)); *p = 1; // 未定义行为:访问已传递给 realloc 的指针 *q = 2; if (p == q) // 未定义行为:访问已传递给 realloc 的指针 std::cout << *p << *q << '\n'; }
可能的输出:
12
无副作用的非平凡 (C++26 起)无限循环
作为 C++ 向前进展保证的一部分,如果平凡的无限循环以外的 (C++26 起)没有可观察行为的循环不会终止,那么它的行为未定义。编译器可以移除这种循环。
选择 clang 或最新版 gcc 以观察所示输出
运行此代码
#include <iostream> bool fermat() { const int max_value = 1000; // 无副作用的非平凡无限循环是未定义行为 for (int a = 1, b = 1, c = 1; true; ) { if (((a * a * a) == ((b * b * b) + (c * c * c)))) return true; // 证伪 :() a++; if (a > max_value) { a = 1; b++; } if (b > max_value) { b = 1; c++; } if (c > max_value) c = 1; } return false; // 未能证伪 } int main() { std::cout << "费马大定理"; fermat() ? std::cout << "被证伪!\n" : std::cout << "未被证伪。\n"; }
可能的输出:
费马大定理被证伪!
平凡的无限循环不是未定义行为。 |
(C++26 起) |
带诊断消息的非良构
要注意的是,允许编译器以赋予非良构程序一定含义的方式来扩展语言。这种情况下 C++ 标准所规定的仅是一条诊断消息(编译器警告),除非程序是“非良构但无需诊断的”。
例如,除非通过 --pedantic-errors
禁用了语言扩展,GCC 都会编译以下示例代码而只给出一条警告,即使它在 C++ 标准中是作为“错误”的示例给出的(另请参见 GCC Bugzilla #55783)
运行此代码
#include <iostream> // 示例代码,请勿使用常量 double a{1.0}; // C++23 标准, §9.4.5 List-initialization [dcl.init.list], 示例 #6: struct S { // 无初始化式列表构造函数 S(int, double, double); // #1 S(); // #2 // ... }; S s1 = {1, 2, 3.0}; // OK,调用 #1 S s2{a, 2, 3}; // 错误:窄化 S s3{}; // OK,调用 #2 // — end example] S::S(int, double, double) {} S::S() {} int main() { std::cout << "已通过所有检查。\n"; }
可能的输出:
main.cpp:17:6: error: type 'double' cannot be narrowed to 'int' in initializer ⮠ list [-Wc++11-narrowing] S s2{a, 2, 3}; // 错误:窄化 ^ main.cpp:17:6: note: insert an explicit cast to silence this issue S s2{a, 2, 3}; // 错误:窄化 ^ static_cast<int>( ) 1 error generated.
引用
延伸内容 |
---|
|
参阅
[[assume(表达式)]] (C++23)
|
指示 表达式 在给定的位置永远为 true (属性指示符) |
[[indeterminate]] (C++26)
|
指示对象在未初始化时具有不确定值 (属性指示符) |
(C++23) |
标记执行的不可抵达点 (函数) |
外部链接
1. | The LLVM Project Blog: 每个 C 程序员都应该知道的关于未定义行为的事 #1/3 |
2. | The LLVM Project Blog: 每个 C 程序员都应该知道的关于未定义行为的事 #2/3 |
3. | The LLVM Project Blog: 每个 C 程序员都应该知道的关于未定义行为的事 #3/3 |
4. | 未定义行为能导致时间旅行(在所有事项中,时间旅行可是最惊人的) |
5. | 了解 C/C++ 中的整数溢出 |
6. | 空指针的趣事,第一部分(Linux 2.6.30 中空指针解引用所致的未定义行为所引发的局部滥用) |
7. | 未定义行为与费马大定理 |