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

boo*_*ife 148 c++ gcc compiler-optimization undefined-behavior

GCC 6具有一个新的优化器功能:它假定this始终不为null并基于此进行优化.

值范围传播现在假定C++成员函数的this指针是非null.这消除了常见的空指针检查,但也打破了一些不符合规范的代码库(例如Qt-5,Chromium,KDevelop).作为临时解决方法,可以使用-fno-delete-null-pointer-checks.使用-fsanitize = undefined可以识别错误的代码.

更改文档清楚地将其称为危险,因为它打破了大量频繁使用的代码.

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

jtl*_*lim 87

我想这个问题需要回答,为什么善意的人会先写支票.

最常见的情况可能是,如果您的类是自然发生的递归调用的一部分.

如果你有:

struct Node
{
    Node* left;
    Node* right;
};
Run Code Online (Sandbox Code Playgroud)

在C中,你可以写:

void traverse_in_order(Node* n) {
    if(!n) return;
    traverse_in_order(n->left);
    process(n);
    traverse_in_order(n->right);
}
Run Code Online (Sandbox Code Playgroud)

在C++中,很高兴使它成为一个成员函数:

void Node::traverse_in_order() {
    // <--- What check should be put here?
    left->traverse_in_order();
    process();
    right->traverse_in_order();
}
Run Code Online (Sandbox Code Playgroud)

在C++的早期阶段(标准化之前),强调的是成员函数是this参数隐含的函数的语法糖.代码是用C++编写的,转换为等效的C并编译.甚至有一些明确的例子,比较thisnull是有意义的,原始的Cfront编译器也利用了这一点.所以来自C背景,检查的明显选择是:

if(this == nullptr) return;      
Run Code Online (Sandbox Code Playgroud)

注: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();
    }
    
    Run Code Online (Sandbox Code Playgroud)

    这意味着如果您可以拥有空根,也可以在每个调用站点进行检查.

  • 不要使用成员函数

    这意味着您正在编写旧的C样式代码(可能作为静态方法),并将该对象显式地作为参数调用.例如.你回到了写作Node::traverse_in_order(node);而不是node->traverse_in_order();在通话网站.

  • 我认为以符合标准的方式修复这个特定示例的最简单/最好的方法是实际使用sentinel节点而不是nullptr.

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

前两个选项中的任何一个看起来都不具吸引力,虽然代码可以侥幸逃脱,但是他们编写了错误的代码,this == nullptr而不是使用正确的修复程序.

我猜这些代码库中的一些是如何演变为对this == nullptr它们进行检查的.

  • 嘿,哇,我简直不敢相信有人编写的代码依赖于调用实例函数......没有实例_.我本能地使用标记为"在调用traverse_in_order之前执行所有检查"的摘录,甚至没有考虑过"这个"是否可以为空.我想也许这就是学习C++的好处,在这个时代,SO存在以巩固UB在我脑中的危险,并阻止我做这样奇怪的黑客攻击. (26认同)
  • 嗯...`this == nullptr`idiom是未定义的行为,因为你之前在nullptr对象上调用了一个成员函数,这是未定义的.编译器可以省略检查 (15认同)
  • 检查本身不是未定义的行为.它总是错误的,因此被编译器消除了. (11认同)
  • `1 == 0`怎么可能是未定义的行为?它只是'假'. (6认同)
  • @Joshua,第一个标准于1998年出版.无论之前发生了什么,无论每个实现都需要.黑暗时代. (6认同)
  • @jtlim:_"我的意思是介绍该行现在将使整个函数未定义的行为"_它始终如此.整个程序有不确定的行为.它总是有.我认为你误解了"未定义的行为"意味着什么! (6认同)
  • 检查零比检查`==&sentinel`更有效.在x86上,它会略微增加代码大小以使用`cmp r64,imm32`而不是`test r64,r64`,但是在许多其他ISA(如ARM或MIPS)上,你必须将32位常量加载到一个寄存器然后与它进行比较.因此需要额外的几个指令并使用寄存器. (3认同)
  • 事实上,这曾经被定义过.所有20世纪90年代的手册都说非虚拟方法调用会正确成功,你可以把if(this == NULL)放在里面. (2认同)
  • 此外,在Linux共享库中,获取"&sentinel"可能需要来自GOT的负载,因为它甚至不是链接时间常量.(如果sentinel是`static`,它可以使用x86-64 RIP相对LEA.)无论如何,这显然比`nullptr`更糟糕. (2认同)

Rei*_*ica 65

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

这是一种危险的做法,因为由于类层次结构遍历而调整指针可以将null this转换为非null值.所以,在最起码,他们的方法都应该有一个空的上班族this必须是没有基类final类:它不能从任何派生,它不能从派生.我们正在迅速从实用到丑陋的土地上离开.

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

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);
  }
};
Run Code Online (Sandbox Code Playgroud)

如果你有一个空的树(如根nullptr),该解决方案仍是通过调用traverse_in_order与nullptr依赖未定义行为.

如果树是空的,也就是null Node* root,则不应该在其上调用任何非静态方法.期.拥有类似C的树代码可以通过显式参数获取实例指针.

这里的论点似乎归结为某种程度上需要在可以从空实例指针调用的对象上编写非静态方法.没有这样的需要.在C++世界中,编写此类代码的C-with-objects方式仍然更好,因为它至少可以是类型安全的.基本上,null this是这样的微优化,具有如此狭窄的使用领域,不允许它是恕我直言完全没错.没有公共API应该依赖于null this.

  • @Ben,Google中可怕的编码风格对我来说是众所周知的.谷歌代码(至少是公开的)通常编写得很糟糕,尽管有多人认为谷歌代码是一个光辉的榜样.可能这将使他们重新审视他们的编码风格(以及他们在其上的指导方针). (19认同)
  • @Ben,编写此代码的人首先是错误的.很有趣的是,你正在命名像MFC,Qt和Chromium这样严重破坏的项目.与他们好好解决. (18认同)
  • @Ben没有人在使用gcc 6编译的Chromium上追溯替换这些设备上的Chromium.在使用gcc 6和其他现代编译器编译Chromium之前,需要修复它.这也不是一项艰巨的任务; 各种静态代码分析器都会选择`this`检查,因此并不是说有人必须手动将它们全部捕获.补丁可能是几百行的微不足道的变化. (18认同)
  • 我对任何认为标准被破坏的人的建议:使用不同的语言.不缺少类似C++的语言,不存在未定义行为的可能性. (10认同)
  • @Ben实际上,null`this`取消引用是一个瞬间崩溃.即使没有人关心在代码上运行静态分析器,也会很快发现这些问题.C/C++遵循"仅为您使用的功能付费"的口头禅.如果你想要检查,你必须明确它们,这意味着不要在`this`上执行它们,因为它太晚了,因为编译器认为`this`不是null.否则它必须检查`this`,并且对于99.9999%的代码,这样的检查是浪费时间. (8认同)
  • @Ben null`his`不适用于没有虚方法的简单的单深度单继承.这是糟糕的代码,就是这样.如果你希望它工作,你需要一个静态方法,它接受实例指针,检查它为null,并适当地进行.这使你的意图明确,这就是标准的目标,这是一件好事.标准强迫你展示你的手,它不会使你无法使用这项技术. (7认同)
  • 然后你必须在调用``traverse_in_order``之前检查根,这不会使这个解决方案无效,因为对函数的调用不包含在答案中. (6认同)
  • @Ben你必须更慢地向我解释为什么禁止这个== nullptr的标准被打破,而不是那个有这个== nullptr的代码(无论多么普遍).有很多代码被证明容易受到缓冲区溢出的影响,但是这种糟糕的编程习惯无处不在(例如,在SQL Server中)这一事实并不能使标准应该被重写以允许. (6认同)
  • 很好地摆脱了Chromium?哪个是十亿Android设备?不太可能.更有可能的是,这会引入以前不存在于数十亿Android设备中的漏洞....现有的代码库太大,无法"修复代码"成为解决方案.解决方案是*修复标准*.标准被打破了.依靠破碎的标准作为打破以前工作代码的借口只是破坏. (3认同)
  • 你和我一样知道还有很多其他的代码库会被破坏,它们没有像Chromium那样得到同样的关注.它们只需使用构建机器上的任何编译器进行重新编译,为下一个版本做好准备.**标准被打破.**标准需要固定以强调安全性,大多数UB应该是IB. (3认同)
  • @supercat不,我的意思是`Foo ::*member`(参见http://en.cppreference.com/w/cpp/language/pointer上的"数据成员指南"部分) (3认同)
  • @supercat一个字符串可以工作,通过将对象的字段清零来获得一个空字符串,这可以没有空指针.如果一个人不关心小字符串优化,那么当然可以让字符串实例成为指针的大小,并且内部*是*指向PIMPL的指针.既然你真的不应该以任何其他方式使用字符串,而是作为值,字符串内部的魔法是无关紧要的,它们可以简单地是PIMPL指针.当然,您不需要在用户代码中指向它们.`sizeof(std :: string)== sizeof(void*)`是好的,iff slow-ish IRL. (2认同)

eer*_*ika 35

更改文档清楚地将其称为危险,因为它打破了大量频繁使用的代码.

该文件并未将其称为危险品.它也没有声称它打破了惊人数量的代码.它只是指出一些流行的代码库,它声称已知它依赖于这种未定义的行为,并且除非使用了变通方法选项,否则会因更改而中断.

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

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

是否存在粗心或不知情的程序员依赖于这种特定的未定义行为的特定模式?

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

if (this)
    member_variable = 42;
Run Code Online (Sandbox Code Playgroud)

当实际的bug在其他地方取消引用空指针时.

我敢肯定,如果程序员知之甚少,他们将能够提出依赖于这个UB的更高级(反)模式.

我无法想象有人写作,if (this == NULL)因为那是不自然的.

我可以.

  • "如果实际的c ++代码依赖于未定义的行为,那么对未定义行为的更改可能会破坏它.这就是为什么要避免使用UB"这*1000 (11认同)

Jon*_*ely 25

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

void foo(X* p) {
  p->bar()->baz();
}
Run Code Online (Sandbox Code Playgroud)

并且它忘记了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);
}
Run Code Online (Sandbox Code Playgroud)

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);
  }
}
Run Code Online (Sandbox Code Playgroud)

这现在确实取消引用空指针,因此以前似乎工作的代码停止工作.

在这个例子中,错误是在func,它应该首先检查null(或者调用者永远不应该用null调用它):

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

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

  • @jotik类似于["警告:在3个内联级别之后(可能跨链接时间优化的文件),一些常见的子表达式消除,在将这个东西从循环中提升并证明这13个指针不是别名之后,我们发现了你是\ [比较`this`到null \]"](http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_21.html)的情况? (5认同)
  • @jotik,^^^ TC说什么.这是可能的,但你会得到警告**所有代码,所有的时间**.值范围传播是最常见的优化之一,几乎影响所有代码,无处不在.优化器只看到代码,可以简化.他们没有看到"一个白痴写的代码,如果他们愚蠢的UB得到优化就会被警告".编译器不容易区分"程序员想要优化的冗余检查"和"程序员认为有用的冗余检查,但是多余"之间的区别. (3认同)
  • @jotik [已经有了警告.](http://stackoverflow.com/questions/36893251/why-does-the-enhanced-gcc-6-optimizer-break-practical-c-code/36916651#comment61383137_36894819) (3认同)