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

149

GCC 6有一项新的优化器特性:它假设this总是非空并基于此进行优化。

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

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

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


22
希望你的意思是积极的。带有未定义行为的代码应该被重写,以避免触发未定义行为,这很简单。事实上,通常都有常见问题解答告诉您如何做到这一点。因此,在我看来,这不是一个真正的问题。一切都好。 - Kuba hasn't forgotten Monica
52
看到人们在代码中为解除空指针引用辩护,我感到很惊讶。真是太神奇了。 - SergeyA
19
@Ben,利用未定义行为一直是一种非常有效的优化策略。我喜欢它,因为我喜欢让我的代码运行更快的优化。 - SergeyA
17
我同意SergeyA的观点。整个争论起因在于人们似乎过分关注this作为隐式参数的事实,因此他们开始像使用显式参数一样使用它。但实际上并不是这样。当你对一个空指针的this进行解引用时,你将会像对其他任何空指针一样触发未定义行为。就是这样简单。如果想要传递nullptr,请使用显式参数,傻瓜。它不会更慢,不会更笨重,并且具有此类API的代码深入内部,因此范围非常有限。故事结束。 - Kuba hasn't forgotten Monica
43
为GCC点赞,打破了糟糕代码的循环 -> 低效编译器支持糟糕代码 -> 更多糟糕代码 -> 更低效的编译 -> ... - M.M
显示剩余39条评论
5个回答

90

我想需要回答的问题是,为什么有善意的人一开始就会写支票。

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

如果您有:

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为空指针是未定义行为,这意味着这个条件总是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 检查的原因。


6
为什么 1 == 0 可能是未定义行为?它只是简单地被判定为 false - Johannes Schaub - litb
11
这个检查本身不是未定义行为。它只是始终为假,因此被编译器消除了。 - SergeyA
15
这个this == nullptr惯用语是未定义行为,因为在此之前你已经调用了一个空指针对象的成员函数,这是未定义的。而编译器有权省略检查。 - jtlim
6
@Joshua,第一个标准是在1998年发布的。在此之前发生了什么取决于每个实现者自己的想法。那是黑暗的岁月。 - SergeyA
27
哇,我简直不敢相信有人会编写依赖于调用实例函数却没有实例的代码。我本能地会使用被标记为“在调用traverse_in_order之前进行所有检查”的摘录,甚至不会考虑this可能为空。我想这可能是在SO存在的年代学习C++的好处之一,它巩固了UB的危害并阻止我像这样做奇怪的技巧。 - underscore_d
显示剩余15条评论

67
它这样做是因为“实际”的代码一开始就存在问题,包含未定义的行为。除了作为微小优化(通常是非常过早的优化)之外,没有理由使用空的this
这是一种危险的做法,因为由于类层次遍历而调整指针可能会将空的this转换为非空的。因此,至少应该将方法应该与空的this一起工作的类设置为最终类,没有基类:它不能派生任何东西,也不能被派生。我们很快就会从实际到丑陋的hack地带
从实用角度来看,代码不必很丑陋:
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来进行未定义行为。
如果树为空,即null Node* root,您不应该在其上调用任何非静态方法。 没有例外。 在C样式的树代码中直接通过显式参数获取实例指针是完全可以的。
这里的论点似乎归结为需要在对象上编写可从null实例指针调用的非静态方法。 没有这样的需求。 在C ++世界中,以C带对象的方式编写此类代码仍然更好,因为它至少可以是类型安全的。 基本上,null this是一种微观优化,具有如此狭窄的用途范围,禁止使用它在我看来是完全可以的。 任何公共API都不应依赖于null this。

18
@Ben,写这段代码的人一开始就错了。有趣的是你把像MFC、Qt和Chromium这样严重破损的项目命名。祝它们好运。 - SergeyA
19
@Ben,我对谷歌的可怕编码风格非常了解。尽管有多个人认为谷歌代码是光辉的例子,但谷歌的代码(至少是公开的)经常写得很糟糕。也许这会促使他们重新审视他们的编码风格(并顺便检查一下他们的指南)。 - SergeyA
19
@Ben 这些设备上没有人会将已安装的Chromium浏览器迁移至使用gcc 6编译的版本。在Chromium将被编译为gcc 6和其他现代编译器之前,它需要进行修复。修复也不是一项巨大的任务;各种静态代码分析器可以找出this检查问题,因此不需要人工逐个搜索。这个补丁可能只需要几百行简单的更改。 - Kuba hasn't forgotten Monica
9
在实践中,空的 this 解引用会导致立即崩溃。即使没有人运行静态分析器检查代码,这些问题也很快就会被发现。C/C++ 遵循“只为所需功能付费”的信条。如果您想要检查,必须明确指定,这意味着不能在 this 上进行检查,因为编译器假定 this 不为 null,当时已经太晚了。否则,它将不得不检查 this,而对于99.9999%的代码来说,这样的检查是浪费时间。 - Kuba hasn't forgotten Monica
11
我对于任何认为C++标准有问题的人的建议是:使用其他语言。现在有很多类似于C++的编程语言,它们没有未定义行为的可能性。 - M.M
显示剩余22条评论

36

更改文档明确指出这是危险的,因为它会破坏相当多经常使用的代码。

这份文档并没有称之为危险。也没有声称它会破坏“相当多的代码”。它只是指出了一些广泛使用的代码库,据称已知依赖于该未定义行为,并且如果不使用解决方法选项,则会因更改而中断。

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

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

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

我不知道这是否是一种普遍的反模式,但一个无知的程序员可能会认为他们可以通过以下方式修复程序不崩溃:

if (this)
    member_variable = 42;

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

我相信,如果程序员不够了解情况,他们将能够提出更高级(反)模式,这些模式依赖于这种未定义行为。

我无法想象任何人会编写if (this == NULL),因为那是非常不自然的。

但我可以。


11
如果实用的C++代码依赖于未定义行为,那么对该未定义行为的更改可能会导致代码出错。这就是为什么应该避免未定义行为的原因。 - underscore_d
如果(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere();例如,一个漂亮易读的事件序列日志,调试器无法轻松告诉您。现在尝试调试这个问题吧,而不是花费数小时在大型数据集中随机出现的空值和您没有编写的代码上放置检查。而且,关于这一点的UB规则是在C++创建后才制定的。它曾经是有效的。 - Stephane Hockenhull
@user2079303 问题:这会不会减慢生产代码的速度,以至于您在运行时无法离开检查,从而给公司造成大量损失?这会增加大小而不适合Flash吗?这能在所有目标平台上使用,包括Atmel吗?-fsanitize=null是否可将错误记录到SD / MMC卡上,使用SPI针脚#5,6,10,11?但这不是一种通用解决方案。一些人认为,访问空对象违反了面向对象的原则,然而一些OOP语言有一个可以操作的空对象,因此这不是OOP的通用规则。1/2 - Stephane Hockenhull
@StephaneHockenhull:C和C++都需要识别一类编译器,其中对PODS的操作行为是基于位和字节的动作定义的,但即使这些编译器明确允许执行特定类型的优化,即使它们会改变程序行为。以这种方式定义事物将允许比现有标准设计更简单的规则。用一个粗略的类比来说,哪个更容易:描述所有不包含字符“bob”的文件集,除非作为单词“bobbin”的一部分,或者产生... - supercat
1
有一个匹配这些文件的正则表达式吗?比起试图定义允许代码访问存储的精确情况,说例如如果一个lvalue被访问两次,编译器可以合并访问除非它们之间的代码执行了几个特定的操作会更容易些。 - supercat
显示剩余3条评论

25

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

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而不访问*this。这个return false可以被内联,因此在实践中指针根本不需要被访问。因此,在某些编译器中,它似乎正常工作:对空指针进行解引用不会导致段错误,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);
}

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

4
@jotik,^^^就是T.C说的那样。这是可能的,但您将始终收到警告对于所有代码而言。值范围传播是最常见的优化之一,几乎影响到任何地方的所有代码。优化器只看到可以简化的代码,它们不知道“某个白痴编写的代码,他想要在其愚蠢的未定义行为被优化掉时得到警告”。编译器很难区分“程序员想要进行优化的冗余检查”和“程序员认为会有帮助但实际上是多余的冗余检查”。 - Jonathan Wakely
1
如果你想对代码进行 运行时 错误检测,包括对this的无效使用等各种类型的UB,则只需使用 -fsanitize=undefined - Jonathan Wakely
@T.C. 我明白你所描述的为什么是一个坏主意,那也不是我想问的。我指的是程序员编写可疑代码的直接情况,如 if(!this)(this == 0) 等。是否有可能让编译器(GCC 6)在遇到这样的代码时发出警告?这可能有助于修复有缺陷的代码的 努力 - jotik
3
已经有一个警告了。 - T.C.
显示剩余2条评论

-24

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)都会导致崩溃 - 如果你幸运的话。

如果你不幸运,崩溃漏洞就会变成远程执行漏洞。

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

好的,告诉他们!

他们已经被告知了。他们完全知道后果。

但是...为什么?

谁能说呢?也许:

  • 他们珍视 C++ 语言的纯洁性胜过实际的代码
  • 他们认为人们应该因为不遵循标准而受到惩罚
  • 他们对现实世界没有了解
  • 他们有意引入错误。也许是为了一个外国政府。你住在哪里?所有政府对大多数世界都是外国的,而且大多数对某些世界是敌对的。

或者也许是其他什么。谁能说呢?


33
我不同意答案中的每一句话。类似的评论也曾出现在强制类型别名优化方面,但这些评论已经被驳回了。解决方法是教育开发人员,而不是基于不良开发习惯阻止优化。 - SergeyA
32
我按照你说的去阅读了整件事,确实让我哭泣,但主要是因为费利克斯的愚蠢,我认为这可能不是你想传达的重点。 - Mike Vine
33
因为无用的牢骚被踩了赞。"他们......故意引入漏洞。也许是为了外国政府。" 真的吗?这不是 /r/conspiracy(阴谋论)啊。 - isanae
32
称职的程序员一遍又一遍地重申“不要调用未定义的行为”,然而这些笨蛋还是这么做了。看看发生了什么。我完全没有同情心。这是开发人员的错,就这么简单。他们需要承担责任。还记得个人责任吗?人们依赖你的口号“但在实践中怎么办!”正是导致这种情况发生的原因。避免这种无聊的事情正是标准存在的原因。按照标准编写代码,就不会有问题。结尾。 - Lightness Races in Orbit
18
简单地用新版本编译先前有效且安全的代码,可能会引入安全漏洞 - 这总是会发生的。除非您希望强制规定某个编译器版本是将来唯一允许使用的编译器版本。您还记得Linux内核只能使用gcc 2.7.2.1精确编译的情况吗?GCC项目甚至被分叉了,因为人们已经受够了这些胡言乱语。过了很长时间才跨越这个障碍。 - M.M
显示剩余32条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接