关于我面试时被问到了一个C++ Undefined Behavior之后我指出这是UB对面觉得没问题这件事
关于我面试时被问到了一个C++ Undefined Behavior之后我指出这是UB对面觉得没问题这件事
今天一大早爬起来面试啊就和面试官有一搭没一搭聊着, 聊到 C++ 之后面试官问了这么个问题: 以下这段代码有没有问题?
struct Alice {
void appears_to_work() {
cout << "yeah" << endl;
}
};
int main() {
Alice* a = nullptr;
a->appears_to_work();
}
我说这个嘛, 这个跑起来多半没问题, 毕竟:
struct Alice {
// 这么个东西在一般的编译器的实现看来...
void func() { /* ... */ }
};
// 多半和这么个东西大差不差
void func(Alice* this) { /* ... */ }
那下面那个形式里只要没使用 this
也就是 Alice
的数据成员 (包括这个函数是虚函数的情景), 只论这个函数的话多半是跑得通的, 你甚至可以把 this
打出来然后欣赏它在 terminal 里圆润的样子... 但是! 还是不能这么写, 因为我没记错的话通过悬垂指针访问对象的任意非静态东西的行为是 UB.
然后在一分钟的短暂辩论之后我放弃说服面试官这个写法不行了, 毕竟我是在面试, 不是在当语言律师. 但面试结束之后我就还是有点不爽啊: 要是我能构造出一个直观展现出这个 UB 让逻辑上"正确"的程序 crash 掉的例子, 那将是绝杀; 可惜面试的时候构造不得. 所以在那之后我就捣鼓出了这么个例子来证明这个确实是 UB:
#include <iostream>
using namespace std;
struct Alice {
int i;
void __attribute__((noinline)) appears_to_work() {
cout << "yeah" << endl; // 这行没啥必要
}
void out_i() { // 其实直接把函数体写在下面的 `if` 里也行
cout << "out_i" << endl; // 这行其实也没啥必要
i = 1;
}
};
extern Alice* some;
int main() {
Alice* a = some; // 1
a->appears_to_work(); // 2
if (a != nullptr) { // 3
a->out_i(); // 4
}
}
Alice* some = nullptr;
如果按照面试官的说法, 这个程序应该正常执行: 1
的这个赋值固然是没有问题的, 将 a
的值设为 nullptr
; 2
的这个调用按照面试官的思路应该正常打出一行 yeah
之后就无事发生; 3
的 if
判断 a
实际上是空的, 然后4
根本不会被执行. 完美的推测, 那么实际上呢?
噔 噔 咚 (喜
召唤古神! 这个程序 segfault 了! 这个程序在 x64 clang 17.0.1
/gcc 13.2
上开 -O3 时都会炸, 而在这两个编译器开 -O0 以及许多其它编译器上表现是"正常"的. 这是怎么一回事呢? 相信大家也很好奇这是怎么一回事, 关于 C++ UB 是怎么一回事, 接下来小编就带领大家了解这究竟是怎么一回事吧!
直面古神: .sc 1d10=10
disclaimer: 以下的东西由一个陷入暂时疯狂的可怜人写出, 既不保证准确性也不保证正确性, 永远不要觉得自己理解了古神
首先, 关于通过 nullptr
访问非静态成员这件事, 以下给出了为什么它看起来没问题以及为什么这实际上是 UB 的解答:
然后, 在走近科学之前, 大家先跟着我念我也忘了是从哪听来的这么一句话:
编译器能够任意地假设 UB 行为不会发生
这某种意义上是 UB and optimization 的一个 (可能不对也不稳定的从几个 UB 例子里拟合出来的) 推论: 正确的 C++ 代码不含有任何 UB
+ 编译器总是假断你的代码是正确的
-> 编译器认为你总是确保了会触发 UB 的代码分支总是不会被访问
, 而编译器便有可能基于这一信息来完成对代码的进一步优化.
也就是说 (ん?), 我们可以利用这一点 (.意志 1d100=100), 来构造出一个例子, 使得编译器"认为"一个实际上为空的指针非空 (.sc 1d100=100), 从而绕过一个原本应该正常防止通过空指针访问成员的 if
语句以达到 crash 的目的 (.智力 1d100=100), 以此来达到演示 UB 是怎么 crash 掉系统的目的 (永久疯狂!).
下面解释具体的构造技巧, 注意这不代表按着这些技巧来就一定能构造出拿 UB 骗过编译器优化的办法来:
第 8 行的
__attribute__((noinline))
是用来强制这一函数不 inline, 从而保证第 22 行的调用一定会成为一个对实际方法入口的调用的. 它的用处在于如果 inline 了, 编译器会发现它没做任何和Alice
的成员相关的事情, 从而也没有"证明"a
非空; 我们需要这个方法调用本身来确保这一点. 在实践中, 一个足够大的函数有相当可能性本来就不会被编译器 inline.第 14 行的赋值是 segfault 的起爆器.
第 18 行的
extern
用来干扰编译器的常量折叠, 从而防止编译器发现在 23 行的if
处a
必定是nullptr
而抹掉整个if
块. 在实践中对象的指针本来就可能产生于十万八千里外的某个翻译单元, 编译器很难捕捉到这么长程的关系; 况且可能对象指针是通过函数边界进来的, 编译器没法在一开始对它作任何假定.
I mean, 嗯, 我觉着在工程里这么写也迟早会出问题, 不单纯是我搞出来的这个用来找茬的例子. 如果今天面试我的那位看到了本文, 建议回去检查一下自己手上的代码以免出大问题, 然后请我吃饭或 (且) 给我发个 offer 什么的.
转载自:https://zhuanlan.zhihu.com/p/665536071
原作者:伊格娜
转载已获得原作者本人许可