概述

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
5
int 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
#include <iostream>

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
4
a = 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
#include <iostream>

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
#pragma optimize("", off)
// some code here
#pragma optimize("", on)

int与string直接相加

在C++中,int与字符串类型std::string的直接相加操作虽然可能不会报错,但是结果是不符合直觉的:不会进行字符串拼接,而是执行指针偏移。

例如下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <string>

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!