为什么增强的 GCC 6 优化器会破坏实用的 C++ 代码?

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

GCC 6 has a new optimizer feature:假设this始终不为空,并以此为基础进行优化。

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.

更改文档明确指出这是危险的,因为它破坏了数量惊人的常用代码。

为什么这个新假设会破坏实际的 C++ 代码?是否存在粗心或不知情的程序员依赖这种特定未定义行为的特定模式?我无法想象有人写 if (this == NULL) 因为那太不自然了。

它这样做是因为 "practical" 代码被破坏并涉及未定义的行为。没有理由使用 null this,除了作为微优化,通常是非常不成熟的优化。

这是一种危险的做法,因为 adjustment of pointers due to class hierarchy traversal can turn a null this into a non-null one. So, at the very least, the class whose methods are supposed to work with a null this must be a final class with no base class: it can't derive from anything, and it can't be derived from. We're quickly departing from practical to ugly-hack-land

实际上,代码不一定要丑陋:

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);
  }
};

If you had an empty tree (eg. root is nullptr), this solution is still relying on undefined behavior by calling traverse_in_order with a nullptr.

如果树是空的,a.k.a。 null Node* root,您不应该在其上调用任何非静态方法。时期。拥有通过显式参数获取实例指针的类 C 树代码非常好。

这里的争论似乎归结为需要在某种程度上需要在可以从空实例指针调用的对象上编写非静态方法。没有这个必要。编写此类代码的 C-with-objects 方法在 C++ 世界中仍然更好,因为它至少可以是类型安全的。基本上,null this 是一种微观优化,使用范围如此狭窄,恕我直言,禁止它是完全可以的。没有 public API 应该依赖于一个 null this.

我猜想首先要回答的问题是为什么好心人会写支票。

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

如果你有:

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 的规则多年来发生了变化 here

这在许多编译器上工作了很多年。标准化发生后,情况发生了变化。最近,编译器开始利用调用成员函数的优势,其中 thisnullptr 是未定义的行为,这意味着此条件始终为 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();

  • 我相信 easiest/neatest 以符合标准的方式修复此特定示例的方法是实际使用哨兵节点而不是 nullptr

    // static class, or global variable
    Node sentinel;
    
    void Node::traverse_in_order() {
        if(this == &sentinel) return;
        ...
    }
    

前两个选项似乎都没有吸引力,虽然代码可以逃脱它,但他们用 this == nullptr 编写了错误代码,而不是使用适当的修复程序。

我猜这就是其中一些代码库如何演变为在其中进行 this == nullptr 检查的原因。

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?

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

Are there particular patterns where careless or uninformed programmers rely on this particular undefined behavior?

我不知道它是否广泛传播 anti-pattern,但不知情的程序员可能认为他们可以通过以下方式修复程序崩溃:

if (this)
    member_variable = 42;

当实际错误在其他地方取消引用空指针时。

我敢肯定,如果程序员不够了解,他们将能够想出更多依赖于此 UB 的高级(反)模式。

I cannot imagine anyone writing if (this == NULL) because that is so unnatural.

我可以。

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++ 语言的理想纯度,而不是实际代码
  • 他们认为不遵守标准的人应该受到惩罚
  • 他们不了解世界的真相
  • 他们是……故意引入错误。也许是为了外国政府。你住在哪里?所有政府对世界上大多数人来说都是陌生的,而且大多数人都对世界上的某些人怀有敌意。

或者可能是别的什么。谁能说?

一些 "practical"(有趣的拼写方式 "buggy")损坏的代码如下所示:

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

并且它忘记了 p->bar() 有时 returns 一个空指针这一事实,这意味着取消引用它以调用 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*) 时,会 "conceptual" 解引用指针以调用 p->DummyImpl::valid(),但实际上该成员函数只是 returns false 而无需访问 *thisreturn false 可以内联,因此实际上根本不需要访问指针。因此,对于某些编译器,它似乎工作正常:没有用于取消引用 null 的段错误,p->valid() 为 false,因此代码调用 do_something_else(p),它检查空指针,因此什么也不做。未观察到崩溃或意外行为。

在 GCC 6 中,您仍然可以调用 p->valid(),但是编译器现在从该表达式推断出 p 必须是非空的(否则 p->valid() 将是未定义的行为) 并记下该信息。优化器使用推断的信息,因此如果对 do_something_else(p) 的调用被内联,则 if (p) 检查现在被认为是多余的,因为编译器记住它不是空的,因此将代码内联到:

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);
}

要记住的重要一点是,大多数像这样的优化并不是编译器说 "ah, the programmer tested this pointer against null, I will remove it just to be annoying" 的情况。发生的情况是,各种 运行 的常规优化(如内联和值范围传播)结合起来使这些检查变得多余,因为它们是在较早的检查或取消引用之后进行的。如果编译器知道指针在函数中的 A 点是非空的,并且该指针在同一函数中后面的点 B 之前没有更改,那么它就知道它在 B 点也是非空的。当发生内联时A 点和 B 点实际上可能是原本在不同函数中的代码片段,但现在合并为一段代码,并且编译器能够在更多地方应用其指针非空的知识。这是一个基本但非常重要的优化,如果编译器不这样做,日常代码会相当慢,人们会抱怨不必要的分支重复地重新测试相同的条件。