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

GCC 6有一个新的优化器function :它假定this总是不为空,并基于this优化。

值范围传播现在假定C ++成员函数的这个指针是非空的。 这消除了常见的空指针检查, 但也打破了一些不合格的代码库(如Qt-5,Chromium,KDevelop) 。 作为一个临时的work-around -fno-delete-null-pointer-checks可以被使用。 错误的代码可以通过使用-fsanitize = undefined来识别。

更改文件清楚地表明这是危险的,因为它打破了令人吃惊的常用代码量。

为什么这个新的假设会打破实用的C ++代码呢? 有没有特定的模式,不小心或不知情的程序员依赖这种特定的未定义的行为? 我无法想象任何人if (this == NULL)因为这是不自然的写作。

我想这个问题需要回答,为什么善意的人会把支票写在第一位。

最常见的情况是,如果你有一个类是自然发生的recursion调用的一部分。

如果你有:

 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 ++早期(标准化之前),强调了成员函数是一个函数的语法糖,其中这个参数是隐含的。 代码是用C ++编写的,转换成等效的C并编译。 甚至还有一些明确的例子,将它与null进行比较是有意义的,原来的Cfront编译器也利用了这一点。 所以来自C背景的人,明显的select是:

 if(this == nullptr) return; 

注意:Bjarne Stroustrup甚至提到这个规则在this已经改变了

这在许多编译器上工作了很多年。 当标准化发生时,这个变化了。 最近,编译器开始利用调用成员函数的地方, 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(); 在呼叫现场。

  • 我相信以符合标准的方式修复这个特定示例的最简单/最新的方法是实际使用一个前哨节点而不是nullptr。

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

前两个选项似乎没有吸引力,而代码可以逃脱它,他们用this == nullptr写入不好的代码,而不是使用适当的修复。

我猜这就是这些代码库的一些发展,有this == nullptr检查他们。

编辑:增加了一个合适的解决scheme,因为人们似乎想提供一个这个答案,这个答案就是为什么人们写了nullptr检查的例子。

编辑5月9日:添加链接到Bjarne Stroustroup对这个变化的评论

它是这样做的,因为“实际”的代码被打破,并涉及未定义的行为开始。 没有理由使用null,除了作为一个微优化,通常是一个非常早熟的。

这是一个危险的做法,因为由类层次遍历引起的指针的调整可以将null变为非null。 所以,至less,其方法应该使用null的类必须是没有基类的最终类:它不能从任何东西派生,也不能从派生。 我们正在迅速离开实际到丑陋的土地 。

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

 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),这个解决scheme仍然依靠未定义的行为,通过调用带有nullptr的traverse_in_order。

如果树是空的,也就是一个空的Node* root ,你不应该调用它的任何非静态方法。 期。 具有类C树形结构的代码通过一个显式的参数来获取一个实例指针是完全没问题的。

这里的参数似乎归结为某种程度上需要在可以从空实例指针调用的对象上编写非静态方法。 没有这样的需要。 编写这样的代码的C与对象的方式在C ++世界中仍然更好,因为它至less可以是types安全的。 基本上, this是一个微观优化,如此狭窄的使用领域,恕我直言,恕我直言,完美的罚款。 没有公共的API应该依赖于null。

更改文件清楚地表明这是危险的,因为它打破了令人吃惊的常用代码量。

该文件并没有说它是危险的。 它也没有声称它打破了令人惊讶的数量的代码 。 它只是指出了一些stream行的代码库,它声称已知依赖这种未定义的行为,并会因为更改而中断,除非使用了解决方法选项。

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

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

有没有特定的模式,不小心或不知情的程序员依赖这种特定的未定义的行为?

我不知道是否广泛传播模式,但一个不知情的程序员可能会认为,他们可以通过做的崩溃来修复他们的程序:

 if (this) member_variable = 42; 

当实际的错误是解引用其他地方的空指针。

我相信,如果程序员不够了解,他们将能够提出更多依赖于这个UB的高级(反)模式。

我无法想象任何人if (this == NULL)因为这是不自然的写作。

我可以。

一些被打破的“实用”(有趣的拼写“buggy”)代码看起来像这样:

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

它忘记说明p->bar()有时返回一个空指针的事实,这意味着取消引用它来调用baz()是未定义的。

不是所有被破坏的代码都包含显式if (this == nullptr)if (!p) return; 检查。 有些情况是简单的函数,没有访问任何成员variables,所以似乎工作正常。 例如:

 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可以内联,所以实际上指针不需要被访问。 因此,对于一些编译器来说,它看起来工作正常:没有段引用dereferencing null, p->valid()是false,所以代码调用do_something_else(p) ,它检查空指针,所以什么都不做。 没有观察到崩溃或意外的行为。

使用GCC 6你仍然可以调用p->valid() ,但是编译器现在从这个expression式推断出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); } 

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

C ++标准在重要方面被打破。 不幸的是,GCC开发者并没有保护用户免受这些问题的困扰,而是select使用未定义的行为作为实现边际优化的借口,即使已经向他们清楚地解释了它们的有害程度。

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

为什么它有害?

使用较新版本的编译器重新编译以前工作的安全代码可能会引入安全漏洞 。 虽然可以用标志禁用新的行为,但显然,现有的makefile没有设置该标志。 而且由于没有警告产生,开发者并不清楚以前的合理行为是否已经改变。

在这个例子中,开发人员已经包含了一个整数溢出检查,使用assert ,如果提供了一个无效的长度将终止程序。 GCC团队根据整数溢出未定义的原则取消了检查,因此可以删除检查。 这导致这个代码库的实际情况在问题得到解决之后被重新修复。

阅读整个事情。 这足以让你哭泣。

好的,但是这个呢?

回头的时候,有一个相当普遍的成语是这样的:

  OPAQUEHANDLE ObjectType::GetHandle(){ if(this==NULL)return DEFAULTHANDLE; return mHandle; } void DoThing(ObjectType* pObj){ osfunction(pObj->GetHandle(), "BLAH"); } 

所以成语是:如果pObj不为null,则使用它包含的句柄,否则使用默认句柄。 这被封装在GetHandle函数中。

诀窍是调用一个非虚函数实际上并没有使用this指针,所以没有访问冲突。

我还是不明白

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

如果你不幸,调用崩溃的错误成为远程执行漏洞。

这甚至可以自动发生。 你有一个自动化的构build系统,对吧? 升级到最新的编译器是无害的,对吧? 但现在不是 – 如果你的编译器是GCC的话。

好的,告诉他们!

他们被告知。 他们完全了解后果。

但为什么?

谁能说? 也许:

  • 他们重视C ++语言在实际代码中的理想纯度
  • 他们认为应该惩罚不遵守标准的人
  • 他们不了解世界的现实
  • 他们是…故意引入错误。 也许是为了一个外国政府。 你住在哪里? 所有的政府对世界上大多数国家都是外来的,而且大多数国家对世界上的一些国家都是敌视的。

或者也许别的东西。 谁能说?