An unknown blog

KaTeX 炸了不想修。

0%

警惕 C++ 的未定义行为

若非明确指明,本文所探讨的标准为 C++14。

引入

阅读下面的一段代码,判断输出。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
int i = 1;
int arr[10];
int main()
{
while (i < 10)
arr[++i] = arr[i - 1] + 1;
for (i = 1; i < 10; i++) {
std::cout << arr[i] << ' ';
}
}

A. 0 1 2 3 4 5 6 7 8
B. 0 1 1 2 2 3 3 4 4

在 g++ (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0 上,答案是 B。
在 Dev-Cpp 5.11 (TDM-GCC 4.9.2) 上,答案是 A。

为什么会出现这种情况呢?
注意到 Ubuntu 环境下的编译输出:

1
std.cpp:7:7: warning: operation on ‘i’ may be undefined [-Wsequence-point]

在变量 i 上的操作可能是未定义的。

我们的一位同学就是因为这个“undefined behavior (UB)”导致一份代码浪费了一个下午的时间来调试。

两边编译器的实现是不一样的:
Ubuntu:

1
2
3
LET TMP_VAR <- arr[i - 1] + 1
i <- i + 1
arr[i] <- TMP_VAR

Windows:

1
2
3
i <- i + 1
LET TMP_VAR <- arr[i - 1] + 1
arr[i] <- TMP_VAR

建议在 OI 赛场上,所有代码都放在考场提供的 NOI Linux 上进行编译,并务必添加如下参数:

1
-std=c++14 -O2 -Wall

常见未定义行为

数组越界访问

如下代码:

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;
char s[11];

int main()
{
cin >> s + 1;
cout << s + 1;
}

在部分编译器上,即使输入的字符串长度大于 $10$,但是输出的字符串就是原串;但是在另一部分编译器上,输出字符串只能输出⑨个字符。更可怕的是,编译器没有对此报错。

在 C++20 标准中,部分编译器已经开始对此报错。新标准抛弃了 cinchar* 的支持,但是保留了对 char[] 的支持。(cout 并没有放弃支持)

1
2
3
4
5
6
7
src.cpp: 在函数‘int main()’中:
src.cpp:7:9: 错误:no match for ‘operator>>’ (operand types are ‘std::istream’ {aka ‘std::basic_istream<char>’} and ‘char*’)
7 | cin >> s + 1;
| ~~~ ^~ ~~~~~
| | |
| | char*
| std::istream {aka std::basic_istream<char>}

我记得之前洛谷上有个讨论就是关于这个的 但是我找不到了

不确定的运算顺序

文章开头的代码就属于这种类型,只不过藏得深了一些。

求值任何表达式的任何部分,包括求值函数参数的顺序都未指明(除了下列的一些例外)。编译器能以任何顺序求值任何操作数和其他子表达式,并且可以在再次求值同一表达式时选择另一顺序。

C++ 中无从左到右或从右到左求值的概念。这不会与运算符的从左到右及从右到左结合性混淆:表达式 a() + b() + c() 由于 operator+ 的从左到右结合性被分析成 (a() + b()) + c(),但在运行时可以首先、最后或者在 a()b() 之间对 c() 求值。

  1. 如果某个内存位置上的一项副作用相对于同一个内存位置上的另一副作用是无顺序的,那么它的行为未定义。
    1
    2
    3
    4
    5
    i = ++i + 2;       // 具有良好定义
    i = i++ + 2; // C++17 前行为未定义
    f(i = -2, i = -2); // C++17 前行为未定义
    f(++i, ++i); // C++17 前行为未定义,C++17 起未指明
    i = ++i + i++; // 行为未定义
  2. 如果某个内存位置上的副作用相对于使用在同一个内存位置中的任何对象的值的值计算是无顺序的,那么它的行为未定义。
    1
    2
    3
    cout << i << i++; // C++17 前行为未定义
    a[i] = i++; // C++17 前行为未定义
    n = ++i + i; // 行为未定义

参见 求值顺序 - cppreference.com

有符号整数自然溢出

记得之前看过一个笑话。

判断真假:$\exists x\in \Z,x > x + 1$

  • 数竞生:假命题
  • 信竞生:当 $x =2147483647$ 时,$x+1==-2147483648$,是真命题。

但是,这个信竞生不知道的是,编译器不这么认为。

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;
int main()
{
int a;
cin >> a;
if (a > a + 114514) cout << "Meow?";
else cout << "Meow!";
}

输入:2147483647
输出:Meow!

编译器认为 $a$ 和 $a+114514$ 都应该在 int 范围内,因此 a > a + 114514 恒为假,所以把 "Meow?"一行给优化掉了。

但是无符号整数自然溢出不是未定义行为。如果 a 声明为无符号整数,程序执行符合预期。
(这也就是写字符串哈希的时候使用 ull 自然溢出的原因)

未初始化的标量

1
2
3
4
5
6
7
#include <iostream>
using namespace std;
int main()
{
bool a;
cout << a;
}
1
2
3
4
ub.cpp: In function ‘int main()’:
ub.cpp:6:13: warning: ‘a’ is used uninitialized in this function [-Wuninitialized]
6 | cout << a;
| ^

输出 0/1 都是可能(且合法)的。

总结

一个符合标准的实现可以在假定未定义行为永远不发生(除了显式使用不严格遵守标准的扩展)的基础上进行优化,可能导致原本存在未定义行为(例如有符号数溢出)的程序经过优化后显示出更加明显的错误(例如死循环)。因此,这种未定义行为一般应被视为bug。

“未定义行为 - 维基百科,自由的百科全书,” 维基百科, Apr. 1, 2023. (访问时间 Apr. 1, 2023).

只能尽量避免这种阴间情况的发生了吧。给建议的话,减少自以为是的压行

版权信息 / Copyrights

头图:Pixiv PID 105783009 使用未经授权 侵权联系我删除

Header image of this article: Pixiv PID 105783009; Unauthorized use, infringement contact me to delete.

Link: https://www.pixiv.net/artworks/105783009