为什么增强的GCC 6优化器会破坏实用的C++代码
Why does the enhanced GCC 6 optimizer break practical C++ code?
GCC 6 有一个新的优化器功能:它假设this
总是不为空,并基于此进行优化。
值范围传播现在假定C++成员函数的 this 指针为非 null。这消除了常见的空指针检查,但也破坏了一些不符合代码库(如Qt-5,Chromium,KDevelop)。作为临时解决方法,可以使用 -fno-delete-null-指针检查。可以使用 -fsanitize=undefined 来识别错误的代码。
更改文档明确指出这是危险的,因为它破坏了数量惊人的常用代码。
为什么这个新假设会破坏实际的C++代码?是否有特定的模式,粗心或不知情的程序员依赖于这种特定的未定义行为?我无法想象有人会写if (this == NULL)
因为这太不自然了。
回答的问题是什么,为什么善意的人会首先写支票。
最常见的情况可能是,如果你有一个类是自然发生的递归调用的一部分。
如果您有:
struct Node
{
Node* left;
Node* right;
};
在 C 中,你可以这样写:
void traverse_in_order(Node* n) {
if(!n) return;
traverse_in_order(n->left);
process(n);
traverse_in_order(n->right);
}
在C++中,将其作为成员函数是很好的:
void Node::traverse_in_order() {
// <--- What check should be put here?
left->traverse_in_order();
process();
right->traverse_in_order();
}
在C++的早期(标准化之前),强调成员函数是this
参数隐含的函数的语法糖。代码是用C++编写的,转换为等效的C并编译。甚至有明确的例子表明this
与 null 进行比较是有意义的,最初的 Cfront 编译器也利用了这一点。因此,来自C背景,检查的明显选择是:
if(this == nullptr) return;
注意:Bjarne Stroustrup甚至提到,多年来this
的规则已经发生了变化
多年来,这在许多编译器上都有效。当标准化发生时,情况发生了变化。最近,编译器开始利用调用成员函数的优势,其中this
nullptr
是未定义的行为,这意味着此条件始终false
,并且编译器可以自由省略它。
这意味着要遍历此树,您需要:
在呼叫
traverse_in_order
之前执行所有检查void Node::traverse_in_order() { if(left) left->traverse_in_order(); process(); if(right) right->traverse_in_order(); }
这意味着还要在每个呼叫站点检查是否可以具有空根。
不使用成员函数
这意味着您正在编写旧的 C 样式代码(可能作为静态方法),并使用对象作为参数显式调用它。 例如,您又回到了编写
Node::traverse_in_order(node);
而不是在调用站点node->traverse_in_order();
。我相信以符合标准的方式修复此特定示例的最简单/最整洁的方法是实际使用哨兵节点而不是
nullptr
。// static class, or global variable Node sentinel; void Node::traverse_in_order() { if(this == &sentinel) return; ... }
前两个选项似乎都没有那么吸引人,虽然代码可以侥幸逃脱,但他们用this == nullptr
编写了糟糕的代码,而不是使用适当的修复程序。
我猜这就是其中一些代码库演变为在其中具有this == nullptr
检查的方式。
它这样做是因为"实用"代码被破坏并且一开始就涉及未定义的行为。没有理由使用空this
,除了作为微优化,通常是一个非常不成熟的优化。
这是一种危险的做法,因为由于类层次结构遍历而调整指针可能会将空this
变为非空。因此,至少,其方法应该使用 null this
的类必须是没有基类的最终类:它不能从任何东西派生,也不能派生自。我们正迅速从实用转向丑陋的黑客领域。
实际上,代码不一定是丑陋的:
struct Node
{
Node* left;
Node* right;
void process();
void traverse_in_order() {
traverse_in_order_impl(this);
}
private:
static void traverse_in_order_impl(Node * n)
if (!n) return;
traverse_in_order_impl(n->left);
n->process();
traverse_in_order_impl(n->right);
}
};
如果你有一个空树(例如根是nullptr),这个解决方案仍然依赖于使用nullptr调用traverse_in_order的未定义行为。
如果树是空的,又称空Node* root
,则不应在其上调用任何非静态方法。时期。拥有类似 C 的树代码通过显式参数获取实例指针是完全可以的。
这里的论点似乎归结为需要以某种方式在可以从空实例指针调用的对象上编写非静态方法。没有这样的必要。在C++世界中,编写此类代码的 C 与对象方式仍然更好,因为它至少可以是类型安全的。基本上,空this
是如此微优化,使用范围如此狭窄,恕我直言,不允许它是完全可以的。任何公共 API 都不应依赖于空this
。
更改文档明确指出这是危险的,因为它破坏了数量惊人的常用代码。
该文件没有称其为危险。它也没有声称它破坏了惊人的代码数量。它只是指出了一些流行的代码库,它声称已知这些代码库依赖于这种未定义的行为,并且除非使用解决方法选项,否则会因更改而中断。
为什么这个新假设会破坏实用的C++代码?
如果实用的 C++ 代码依赖于未定义的行为,那么对该未定义行为的更改可能会破坏它。这就是为什么要避免UB的原因,即使依赖它的程序似乎按预期工作。
是否有特定的模式,粗心或不知情的程序员依赖于这种特定的未定义行为?
我不知道这是否是广泛传播的反模式,但一个不知情的程序员可能会认为他们可以通过执行以下操作来修复他们的程序免于崩溃:
if (this)
member_variable = 42;
当实际错误在其他地方取消引用空指针时。
我敢肯定,如果程序员不够了解,他们将能够提出依赖于此UB的更高级(反)模式。
我无法想象有人会写
if (this == NULL)
因为这太不自然了。
我可以。
一些被破坏的"实用"(拼写"buggy"的有趣方式)代码看起来像这样:
void foo(X* p) {
p->bar()->baz();
}
它忘记了解释p->bar()
有时会返回一个 null 指针的事实,这意味着取消引用它以调用 baz()
是未定义的。
并非所有损坏的代码都包含显式if (this == nullptr)
或if (!p) return;
检查。有些情况只是不访问任何成员变量的函数,因此似乎工作正常。例如:
struct DummyImpl {
bool valid() const { return false; }
int m_data;
};
struct RealImpl {
bool valid() const { return m_valid; }
bool m_valid;
int m_data;
};
template<typename T>
void do_something_else(T* p) {
if (p) {
use(p->m_data);
}
}
template<typename T>
void func(T* p) {
if (p->valid())
do_something(p);
else
do_something_else(p);
}
在此代码中,当您使用空指针调用func<DummyImpl*>(DummyImpl*)
时,会"概念上"取消引用指针以调用p->DummyImpl::valid()
,但实际上该成员函数只是返回false
而不访问*this
。该return false
可以内联,因此实际上根本不需要访问指针。因此,对于某些编译器,它似乎工作正常:取消引用 null 没有段错误,p->valid()
是假的,因此代码调用 do_something_else(p)
,它会检查空指针,因此什么也不做。未观察到崩溃或意外行为。
在GCC 6中,您仍然可以调用p->valid()
,但是编译器现在从该表达式推断p
必须是非null的(否则p->valid()
将是未定义的行为),并记下该信息。优化程序使用该推断的信息,以便在内联对do_something_else(p)
的调用时,if (p)
检查现在被视为冗余,因为编译器会记住它不为 null,因此将代码内联到:
template<typename T>
void func(T* p) {
if (p->valid())
do_something(p);
else {
// inlined body of do_something_else(p) with value propagation
// optimization performed to remove null check.
use(p->m_data);
}
}
现在,这确实取消了对空指针的引用,因此以前似乎有效的代码停止工作。
在此示例中,错误在 func
中,它应该首先检查 null(或者调用者永远不应该使用 null 调用它):
template<typename T>
void func(T* p) {
if (p && p->valid())
do_something(p);
else
do_something_else(p);
}
要记住的重要一点是,大多数这样的优化都不是编译器说"啊,程序员针对 null 测试了这个指针,我会删除它只是为了烦人"。发生的情况是,各种普通优化(如内联和值范围传播)相结合,使这些检查变得多余,因为它们是在较早的检查或取消引用之后出现的。如果编译器知道指针在函数中的点 A 处为非 null,并且指针未在同一函数的后面点 B 之前更改,则它知道它在 B 处也是非 null。当内联发生时,点 A 和 B 实际上可能是最初位于单独函数中的代码段,但现在组合成一段代码,并且编译器能够在更多位置应用其指针非 null 的知识。这是一个基本但非常重要的优化,如果编译器不这样做,日常代码会相当慢,人们会抱怨不必要的分支来重复重新测试相同的条件。
C++标准在重要方面被打破。不幸的是,GCC 开发人员没有保护用户免受这些问题的影响,而是选择使用未定义的行为作为实施边际优化的借口,即使已经向他们清楚地解释了它的危害性。
在这里,一个比我更聪明的人非常详细地解释。(他说的是C,但那里的情况是一样的)。
- https://groups.google.com/forum/m/#!msg/boring-crypto/48qa1kWignU/o8GGp2K1DAAJ
为什么有害?
简单地使用较新版本的编译器重新编译以前工作的安全代码可能会引入安全漏洞。虽然可以使用标志禁用新行为,但显然,现有生成文件没有设置该标志。由于没有产生警告,因此开发人员并不明显地表明先前合理的行为已经改变。
在此示例中,开发人员使用 assert
包括整数溢出检查,如果提供的长度无效,它将终止程序。GCC 团队删除了检查,理由是整数溢出未定义,因此可以删除检查。这导致在问题修复后,此代码库的真实野外实例被重新变得易受攻击。
- https://gcc.gnu.org/bugzilla/show_bug.cgi?id=30475
阅读整件事。足以让你哭泣。
好的,但是这个呢?
很久以前,有一个相当常见的成语是这样的:
OPAQUEHANDLE ObjectType::GetHandle(){
if(this==NULL)return DEFAULTHANDLE;
return mHandle;
}
void DoThing(ObjectType* pObj){
osfunction(pObj->GetHandle(), "BLAH");
}
所以习语是:如果pObj
不为 null,则使用它包含的句柄,否则使用默认句柄。这封装在 GetHandle
函数中。
诀窍在于调用非虚拟函数实际上并没有使用任何this
指针,因此没有访问冲突。
我还是不明白
存在很多这样编写的代码。如果有人只是简单地重新编译它,而不更改一行,那么每次调用DoThing(NULL)
都是一个崩溃的错误 - 如果你幸运的话。
如果幸运的话,对崩溃错误的调用会成为远程执行漏洞。
这甚至可以自动发生。你有一个自动化的构建系统,对吧?将其升级到最新的编译器是无害的,对吧?但现在不是 - 如果你的编译器是 GCC 就不是了。
好的,所以告诉他们!
他们被告知。他们是在充分了解后果的情况下这样做的。
但。。。为什么?
谁能说?也许:
- 他们重视C++语言的理想纯度,而不是实际代码
- 他们认为人们应该因为不遵守标准而受到惩罚
- 他们对世界的现实一无所知
- 他们是...故意引入错误。也许对于外国政府来说。你住在哪里?所有政府对世界大部分地区都是陌生的,大多数政府对世界某些国家怀有敌意。
或者也许是别的什么。谁能说?
- C++我的数学有什么问题,为什么我的代码不能正确循环
- 代码在main()中运行,但在函数中出现错误
- 在VS代码中交叉编译Windows与Linux上的MinGW的SDL程序
- 编译包含字符串的代码时遇到问题
- 我在c++代码中生成了一个运行时#3异常
- 如何在linux终端中同时编译和运行c++代码
- 为cl.exe(Visual Studio代码)指定命令行C++版本
- 在Linux for Windows上编译C++代码时出错
- 我的字符计数代码计算错误.为什么
- 孤立代码块在结构中引发异常
- 在编译C++代码(具有dlib和opencv)到WASM时面临问题
- 为什么我的C#代码在调用回C++COM直到Task时会暂停.等待/线程.加入
- 处理小于cpu数据总线的数据类型.(c++转换为机器代码)
- 此代码是否违反一个定义规则
- 为什么我的代码在输出中增加了93天
- 我的简单if-else语句是如何无法访问的代码
- 使用动态分配的数组会导致代码分析发出虚假的C6386缓冲区溢出警告
- 为什么在这个代码结束循环中没有得到结束
- 在c代码之间共享数据的最佳方式
- 这个指针和内存代码打印是什么?我不知道是打印垃圾还是如何打印我需要的值