Why does the enhanced GCC 6 optimizer break practical C++ code?

GCC 6 has a new optimizer feature: It assumes that this is always not null and optimizes based on that.

Value range propagation now assumes that the this pointer of C++ member functions is non-null. This eliminates common null pointer checks but also breaks some non-conforming code-bases (such as Qt-5, Chromium, KDevelop). As a temporary work-around -fno-delete-null-pointer-checks can be used. Wrong code can be identified by using -fsanitize=undefined.

The change document clearly calls this out as dangerous because it breaks a surprising amount of frequently used code.

Why would this new assumption break practical C++ code? Are there particular patterns where careless or uninformed programmers rely on this particular undefined behavior? I cannot imagine anyone writing if (this == NULL) because that is so unnatural.

9987 次浏览

它之所以这样做,是因为“实用”准则已经被破坏,而且从一开始就牵涉到未定义行为。没有理由使用一个空 this,除了作为一个微观优化,通常是一个非常不成熟的。

这是一种危险的做法,因为 由于类层次结构遍历而调整指针可以把一个空 this变成一个非空 this。因此,至少,假定其方法与空 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);
}
};

如果有一个空树(例如 root 是 nullptr) ,这个解决方案仍然依赖于未定义行为,方法是使用 nullptr 调用 traverse _ in _ order。

如果树是空的,也就是空的 Node* root,那么您不应该在它上面调用任何非静态方法。就这样。使用类似 C 语言的树代码,通过显式参数获取实例指针是完全可行的。

这里的参数似乎可以归结为需要在对象上编写非静态方法,这些方法可以从空实例指针调用。没这个必要。在 C + + 世界中,使用 C-with-object 编写此类代码的方式仍然要好得多,因为它至少可以实现类型安全。基本上,零 this是一个如此微观的优化,使用领域如此狭窄,以至于不允许它是完全没有问题的。任何公共 API 都不应该依赖于空 this

我想这个问题需要回答为什么善意的人会在第一时间开出支票。

最常见的情况可能是,您有一个类是自然发生的递归调用的一部分。

如果你有:

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;

注意: 比雅尼·斯特劳斯特鲁普甚至提到,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;
    ...
    }
    

Neither of the first two options seem that appealing, and while code could get away with it, they wrote bad code with this == nullptr instead of using a proper fix.

I'm guessing that's how some of these code bases evolved to have this == nullptr checks in them.

修改文档明确指出这是危险的,因为它破坏了大量经常使用的代码。

文件上没说这很危险。它也没有声称它打破了 数量惊人的代码。它只是简单地指出了一些流行的代码基,它声称已知这些代码基依赖于这种未定义的行为,并且如果不使用变通方法选项,就会由于更改而中断。

为什么这个新的假设会打破实际的 C + + 代码?

如果 很实际 c + + 代码依赖于未定义的行为,那么对该未定义行为的更改可能会破坏它。这就是为什么要避免使用 UB,即使依赖它的程序看起来像预期的那样工作。

有没有特定的模式,粗心或无知的程序员依赖于这种特定的未定义行为?

我不知道它是否是广泛传播的 模式,但是一个不知情的程序员可能认为他们可以通过以下方法修复程序:

if (this)
member_variable = 42;

当实际的 bug 在其他地方解除空指针的引用时。

我相信,如果程序员没有足够的知识,他们将能够提出更高级(反)的模式,依赖于这个 UB。

我无法想象有人写 if (this == NULL),因为这太不自然了。

我可以。

C + + 标准在一些重要方面被打破了。不幸的是,GCC 开发人员没有保护用户免受这些问题的影响,而是选择使用未定义的行为作为实现边际优化的借口,即使已经向他们清楚地解释了这种行为的危害有多大。

这里有一个比我更聪明的人详细地解释。(他说的是 C,但情况是一样的)。

为什么有害?

简单地重新编译以前工作的安全代码,使用新版本的编译器可能会导致安全漏洞 。虽然可以通过标志禁用新行为,但现有的 makefile 显然没有设置这个标志。而且由于没有产生任何警告,开发人员并不清楚以前合理的行为已经发生了变化。

在本例中,开发人员使用 assert包含了一个整数溢出检查,如果提供了无效的长度,它将终止程序。GCC 团队删除了检查,因为整数溢出没有定义,因此可以删除检查。这导致这个代码库的实际野生实例在问题得到解决之后重新变得易受攻击。

看完整本书,足以让你哭泣。

好吧,那这个呢?

很久以前,有一个相当常见的成语是这样的:

 OPAQUEHANDLE ObjectType::GetHandle(){
if(this==NULL)return DEFAULTHANDLE;
return mHandle;


}


void DoThing(ObjectType* pObj){
osfunction(pObj->GetHandle(), "BLAH");
}

因此,习惯用法是: 如果 pObj不为空,则使用它包含的句柄,否则使用默认句柄。这封装在 GetHandle函数中。

技巧在于,调用非虚函数实际上不会使用 this指针,因此不存在访问冲突。

我还是不明白

存在许多这样编写的代码。如果有人只是简单地重新编译它,而不改变一行,那么每次调用 DoThing(NULL)都是一个崩溃的错误——如果你足够幸运的话。

如果运气不好,对崩溃 bug 的调用就会成为远程执行漏洞。

这甚至可以自动发生。你们有自动生成系统,对吧?将它升级到最新的编译器是无害的,对吗?但是现在它不是——如果您的编译器是 GCC 就不是。

好吧,那就告诉他们!

他们已经被告知了,他们这么做的时候完全知道后果。

但是... 为什么?

谁知道呢? 也许:

  • 与实际代码相比,他们更看重 C + + 语言的理想纯度
  • 他们认为人们应该因为不遵守标准而受到惩罚
  • 他们不了解世界的现实
  • 他们... 故意引入虫子。也许是为了外国政府。你住在哪里?所有国家的政府对世界上大多数国家来说都是陌生的,而且大多数国家对世界上的某些国家怀有敌意。

或者别的什么,谁知道呢?

一些被破解的“实用”(拼写“ bug”的滑稽方式)代码看起来是这样的:

void foo(X* p) {
p->bar()->baz();
}

而且它忘记了这样一个事实,即 p->bar()有时返回一个空指针,这意味着解除对它的引用以调用 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而不访问 *thisreturn false可以是内联的,因此在实践中根本不需要访问指针。因此,对于某些编译器,它似乎工作正常: 没有用于解引用 null 的 segfault,p->valid()为 false,所以代码调用 do_something_else(p),它检查 null 指针,因此什么也不做。未观察到崩溃或意外行为。

在 GCC 6中,仍然可以调用 p->valid(),但是编译器现在从这个表达式推断出 p必须是非空的(否则 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);
}
}

现在这确实解引用了一个空指针,因此以前看起来可以工作的代码停止工作。

在这个例子中,bug 在 func中,它应该首先检查 null (或者调用方不应该使用 null 调用它) :

template<typename T>
void func(T* p) {
if (p && p->valid())
do_something(p);
else
do_something_else(p);
}

需要记住的一点是,大多数像这样的优化并不是编译器说“啊,程序员测试这个指针是否为 null,我将删除它只是为了让它更烦人”的情况。会发生的情况是,内联和值范围传播等各种常规优化结合在一起,使这些检查变得多余,因为它们是在更早的检查或取消引用之后发生的。如果编译器知道一个指针在函数的点 A 处是非空的,并且该指针在同一个函数的后一个点 B 之前没有更改,那么它就知道它在 B 处也是非空的。当内联发生时,点 A 和点 B 可能实际上是一些代码片段,这些代码片段最初在单独的函数中,但现在被组合成一段代码,编译器能够应用它的知识,指针在更多的地方是非空的。这是一个基本的,但非常重要的优化,如果编译器不这样做,每天的代码会相当慢,人们会抱怨不必要的分支重复测试相同的条件。