Cpp 未定义行为
概述
C/C++存在很多的未定义行为,如果程序中使用了未定义行为,那么得到的结果是不可知的,编译器给出任何反馈都是符合语法标准的,因为未定义行为导致的BUG是难以察觉的。
未定义行为可能会导致编译报错,也可能导致运行出错等,还可能无事发生,具体结果可能与平台/编译器有关,不过在大部分情况下不同编译器会得到类似的结果。
未定义行为的存在是有客观原因的,一方面语法标准无法穷尽所有的可能情况;另一方面有些可能非法的行为(例如下标越界)在编译期难以直接检测,如果在运行期进行检测(例如检查下标越界),又会牺牲很多的运行效率。
如果我们在不经意间使用了未定义行为,那么即使是相同的代码,在C++的编译器不同等级的优化措施下,也可能得到完全不同的结果,例如:
- Debug模式正常,Release模式异常
- 例如产生随机的结果,通常的原因是使用没有正确初始化的变量,在Debug模式下被编译器初始化为0,但是Release模式下直接使用了内存中的随机值;
- 其它未定义行为,在Release模式下经过编译器优化中,产生不合理的结果。
- Debug模式异常,Release模式正常
- 代码中的未定义行为在Debug模式下会正常暴露出来,但是在Release模式下由于编译器优化正好消去了对应的未定义行为。
还有一个与未定义行为略微不同的概念:实现定义行为,它的含义是在语法标准中没有精确的行为,只是一个模糊的约束,编译器有一定的实现自由度。
例如语法要求int
至少两个字节,但是目前各种平台上的int
都是两个字节;例如语法要求long
的范围至少是int
,在某些平台上long
是两个字节,在某些平台上long
却是四个字节。
常见未定义行为
常见的未定义行为包括:
- 数组下标以及指针访问越界;
- 空指针解引用;
- 访问未初始化的变量;(虽然某些变量的内存会自动初始化为0,但是建议始终进行初始化)
- 整数除以0,这通常会导致程序崩溃;(浮点数除以0会得到INF,并不会导致程序崩溃)
- 有符号整数运算的溢出;(无符号整数运算会自动取模,这是语法规定的,不是未定义行为)
- 非void函数没有提供返回值,调用方拿到的返回值是不确定的;(main函数除外)
按位移动在很多情况下也是未定义行为:
- 例如移动位数为负数或者超过合理范围;
- 例如对有符号数的右移,尤其是负数的右移,到底是补0还是补1?
运算顺序未定义
有一些老教材喜欢考究的运算顺序,严格来说也是未定义行为,例如
1
2
3
4
5int i = 1;
i = i++; // UB
int a = 1;
int b = (a++) + (++a); // UB
在调用时,对于同一个函数的多个参数的求值顺序也是未定义行为,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void func(int a, int b) {
std::cout << "a = " << a << " b = " << b << std::endl;
}
int main() {
int s = 10;
func(s++, s);
s = 10;
func(s, s++);
s = 10;
func(++s, s);
s = 10;
func(s, ++s);
}
目前各种编译器的实现都会从右到左求值,实际运行结果为
1
2
3
4a = 10 b = 10
a = 11 b = 10
a = 11 b = 10
a = 11 b = 11
无副作用的死循环优化
未定义行为在编译器优化之后可能会产生违反直觉的结果,因为实际情况可能和编译器优化时的假设相违背。
例如下面这份穷举验证费马大定理的代码 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
bool try_format_theorem() {
constexpr uint64_t N = 10000000;
uint64_t a = 1;
uint64_t b = 1;
uint64_t c = 1;
while (true) {
if (a * a * a + b * b * b == c * c * c) { return true; }
++c;
if (c > N) {
c = 1;
++b;
}
if (b > N) {
b = 1;
++a;
}
if (a > N) { a = 1; }
}
return false;
}
int main() {
if (try_format_theorem()) { std::cout << "Found\n"; }
else { std::cout << "Not found\n"; }
return 0;
}
这段代码在经过不同模式的编译之后,程序运行效果是不一样的:
- Debug模式:程序会陷入死循环;(这是合理的结果,因为代码中的
while
循环只会在找到时返回,而费马大定理告诉我们是找不到的) - Release模式:程序直接输出
Found
并结束。
为什么Release模式下得到了异常的结果?因为我们的代码中的while
循环是无副作用的死循环,编译器在优化时的逻辑为:
- 总是假定代码中的死循环是可以顺利终止的,而死循环中没有跳出语句,只有一个返回语句,因此循环外面的
return false;
语句是不可能达到的; - 由于在死循环中只有一个退出语句,只会返回
true
,但是具体在a,b,c
取值多少时返回,反而是不重要的,不会对外部产生任何影响;
因此,编译器采用的优化措施就是:直接让整个函数立刻返回true
。
这里的问题核心是对无副作用的死循环的优化,在代码中将其破环就可以避免这种错误结果,例如:
- 第一种办法是消除死循环,在
a>N
时直接break
跳出循环或者直接返回false
; - 第二种办法是让循环有意义,例如在找到成功的
a,b,c
时将其打包返回,这样编译器就不敢进行优化,必须一个个进行遍历计算。
还有一种办法是关闭优化,当然这并不是说完全使用Debug模式,而是在Release模式下我们也可以手动关闭对代码片段的优化,例如MSVC编译器支持如下的标记
1
2
3
// some code here
int与string直接相加
在C++中,int
与字符串类型std::string
的直接相加操作虽然可能不会报错,但是结果是不符合直觉的:不会进行字符串拼接,而是执行指针偏移。
例如下面的代码 1
2
3
4
5
6
7
8
9
10
11
12
int main() {
int n = 100;
std::string s;
s = n + "_" + std::string{"Hello, World!"};
std::cout << s;
return 0;
}
在各种编译环境下的运行结果为 1
2
3
4
5
6
7
8(gcc)
sic_string::_M_replaceHello, World!
(clang)
invalid argumentHello, World!
(MSVC)
ity\VC\Tools\MSVC\14.39.33519\include\charconvHello, World!
为了说明这里的n
是偏移量,我们将n
改为102,得到的结果变成
1
2
3
4
5
6
7
8(gcc)
c_string::_M_replaceHello, World!
(clang)
valid argumentHello, World!
(MSVC)
y\VC\Tools\MSVC\14.39.33519\include\charconvHello, World!