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


148

GCC 6具有新的优化程序功能:假定该this值始终不为null并基于此进行优化。

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

变更文档显然将此称为危险,因为它破坏了数量惊人的频繁使用的代码。

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


21
@Ben希望您能以一种很好的方式表达自己的意思。带有UB的代码应重写为不调用UB。就这么简单。哎呀,经常有一些常见问题告诉您如何实现。因此,恕我直言,这不是真正的问题。都好。
恢复莫妮卡

49
看到人们捍卫在代码中取消引用空指针,我感到很惊讶。简直太神奇了。
SergeyA 2016年

19
@Ben,探索未定义行为是很长时间以来非常有效的优化策略。我喜欢它,因为我喜欢使我的代码运行更快的优化。
SergeyA 2016年

17
我同意SergeyA。整个过程开始是因为人们似乎对this作为隐式参数传递的事实不屑一顾,因此他们开始像使用显式参数一样开始使用它。不是。当取消引用此null时,您正在调用UB,就像您取消引用任何其他null指针一样。这就是全部。如果要传递nullptrs,请使用显式参数 DUH 。它不会变慢,也不会笨拙,而且具有这种API的代码无论如何都深入内部,因此范围非常有限。我想故事的结局。
恢复莫妮卡

41
感谢GCC打破不良代码的循环->低效的编译器以支持不良代码->更多不良代码->更无效的编译-> ...
MM

Answers:


87

我猜想有一个问题需要回答,为什么好心的人会首先写支票。

最常见的情况是,如果您的类属于自然发生的递归调用的一部分。

如果你有:

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并编译。甚至有明确的示例表明,与thisnull进行比较是有意义的,并且原始的Cfront编译器也利用了这一点。因此,从C背景出发,检查的明显选择是:

if(this == nullptr) return;      

注意:Bjarne Stroustrup甚至提到这里的规则this多年来已发生变化

这在许多编译器上已经工作了很多年。发生标准化时,情况发生了变化。最近,编译器开始利用调用成员函数(其中的thisbe 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;
        ...
    }

前两个选项似乎都没有吸引力,尽管代码可以解决它,但是他们编写了不好的代码,this == nullptr而不是使用适当的修复程序。

我猜想这就是其中一些代码库演变为this == nullptr在其中进行检查的方式。


6
1 == 0未定义的行为怎么可能?很简单false
Johannes Schaub-litb

11
支票本身不是不确定的行为。它总是总是错误的,因此被编译器消除了。
SergeyA 2016年

15
Hmm .. this == nullptr成语是未定义的行为,因为在此之前,您已经在nullptr对象上调用了成员函数,该对象是未定义的。而且编译器可以自由省略检查
jtlim

6
@Joshua,第一个标准发布于1998年。在此之前发生的一切都是每个实现都想要的。黑暗时代。
SergeyA 2016年

26
嘿,哇,我不敢相信有人写过依赖于调用实例函数的代码... 没有实例。我本能地使用标记为“在调用traverse_in_order之前进行所有检查”的摘录,甚至this都不会考虑是否可以为空。我想这可能是在存在SO的时代中学习C ++的好处,因为它可以使我的大脑深陷UB的危险,并阻止我这样做。
underscore_d

65

这样做是因为“实用”代码被破坏,并且涉及到不确定的行为。this除了作为微优化(通常是一个过早的优化)之外,没有理由使用null 。

这是一种危险的做法,因为由于类层次结构的遍历而导致的指针调整可能会将null this变为非null。因此,至少其方法应该使用null的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);
  }
};

如果您有一个空树(例如,根为nullptr),则此解决方案仍然通过使用nullptr调用traverse_in_order来依赖未定义的行为。

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

这里的论点似乎归结为某种原因,需要在可以从空实例指针调用的对象上编写非静态方法。没有这样的需要。在C ++世界中,使用C语言编写对象的方法仍然更好,因为它至少可以是类型安全的。基本上,null this是这样一种微优化,具有如此狭窄的使用范围,不允许恕我直言是完美的。任何公共API都不应依赖null this


18
@Ben,写此代码的人首先是错误的。有趣的是,您正在命名诸如MFC,Qt和Chromium之类的破烂项目。和他们在一起很好。
SergeyA 2016年

19
@Ben,我所熟知的Google糟糕的编码风格。尽管许多人认为Google代码是最光辉的例子,但Google代码(至少是公开可用的)常常写得不好。也许这将使他们重新审视自己的编码样式(以及使用它们时的指导原则)。
SergeyA 2016年

18
@Ben没有人使用gcc 6编译的Chromium来追溯替换这些设备上的Chromium。在使用gcc 6和其他现代编译器编译Chromium之前,需要先对其进行修复。这也不是一项艰巨的任务。这些this支票是由各种静态代码分析器挑选出来的,因此,好像没有人必须手动将它们全部消灭一样。该补丁可能是几百行琐碎的更改。
恢复莫妮卡

8
@Ben实际上,空this引用是立即崩溃。即使没有人关心在代码上运行静态分析器,也将很快发现这些问题。C / C ++遵循“仅为使用的功能付费”的口号。如果要进行检查,则必须明确地进行检查,这意味着在this为时已晚时不要在上进行检查,因为编译器假定this其不为null。否则,它必须进行检查this,对于99.9999%的代码,这样的检查是浪费时间。
恢复莫妮卡

10
我对任何认为该标准已被破坏的人的建议:使用其他语言。不乏没有不确定行为的类C ++语言。
MM

35

变更文档显然将此称为危险,因为它破坏了数量惊人的频繁使用的代码。

该文档并不认为它很危险。它也没有声称它破坏了数量惊人的代码。它只是指出了一些流行的代码库,据称该代码库依赖于这种未定义的行为,并且除非更改,否则将使用该变通方法,除非使用了解决方法选项。

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

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

粗心或不了解信息的程序员是否有特定的模式依赖于这种特定的未定义行为?

我不知道它是否广泛使用模式,但是不知情的程序员可能会认为这样做可以解决程序崩溃的问题:

if (this)
    member_variable = 42;

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

我敢肯定,如果程序员没有足够的信息,他们将能够提出依赖此UB的更高级(反)模式。

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

我可以。


11
“ *如果实际的c ++代码依赖未定义的行为,那么对该未定义行为的更改可能会破坏它。这就是为什么要避免使用UB的原因” this * 1000
underscore_d

if(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere(); 例如,调试器无法轻松告诉您的事件序列的漂亮易读日志。现在就进行调试吧,不用花大量时间在没有编写的代码中突然出现随机空值的情况下,就花大量时间检查所有内容,然后在创建C ++之后制定有关UB的规则。它曾经是有效的。
斯蒂芬·霍肯赫尔

@StephaneHockenhull这就是-fsanitize=null目的。
eerorika

@ user2079303问题:这是否会使生产代码减慢到您无法在运行时离开检票的位置,从而使公司损失了很多钱?那会增加尺寸而不适合闪光灯吗?这对包括Atmel在内的所有目标平台都有效吗?可以-fsanitize=null使用SPI将错误记​​录到引脚#5、6、10、11的SD / MMC卡上吗?那不是一个通用的解决方案。有人认为访问空对象违反了面向对象的原理,但是某些OOP语言具有可以对其进行操作的空对象,因此这不是OOP的通用规则。1/2
斯蒂芬·霍根赫尔

1
...与此类文件匹配的正则表达式?说,例如,如果两次访问一个左值,则编译器可以合并访问,除非它们之间的代码执行某些特定操作中的任何一个都比尝试定义允许代码访问存储的确切情况容易得多。
超级猫

25

某些已损坏的“实用”(拼写“越野车”的有趣方法)代码如下所示:

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可以被内联,因此在实践中,指针并不需要在所有被访问。因此,对于某些编译器而言,它似乎可以正常工作:没有用于取消引用null p->valid()的段错误,为false,因此代码调用do_something_else(p),它检查null指针,因此什么也不做。没有观察到崩溃或意外行为。

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

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

在此示例中,错误位于中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实际上可能是原本是在单独的函数中的代码段,但现在已合并为一段代码,并且编译器能够在更多地方应用其指针为非空的知识。


遇到这样的用法时,有可能使GCC 6仪器输出编译时警告this吗?
jotik '16


3
@jotik,^^^ TC所说的。可能会这样,但是您将始终收到有关所有代码的警告。值范围传播是最常见的优化之一,它影响几乎所有地方的所有代码。优化器只看到可以简化的代码。他们看不到“一个笨蛋编写的一段代码,如果他们的笨拙的UB得到优化,它会受到警告”。对于编译器来说,要区分“程序员想要优化的冗余检查”和“程序员认为会帮助但冗余的冗余检查”之间的区别并不容易。
Jonathan Wakely

1
如果您想检测代码以给出各种类型的UB的运行时错误,包括的无效使用this,请使用-fsanitize=undefined
Jonathan Wakely


-25

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不为null,则使用它包含的句柄,否则使用默认句柄。这被封装在GetHandle函数中。

诀窍是调用非虚函数实际上并没有使用this指针,因此不会发生访问冲突。

我还是不明白

存在很多这样写的代码。如果有人简单地重新编译它,而不更改任何行,DoThing(NULL)则对您的每次调用都是一个崩溃的错误-如果您幸运的话。

如果您不走运,则对崩溃的错误的调用将成为远程执行漏洞。

这甚至可以自动发生。您有一个自动构建系统,对吗?将其升级到最新的编译器是没有害处的,对吗?但现在不是-如果您的编译器是GCC,则不是。

好,告诉他们!

他们被告知了。他们这样做是在充分了解后果的情况下。

但为什么?

谁能说?也许:

  • 他们认为C ++语言的理想纯度胜于实际代码
  • 他们认为应该因不遵守标准而受到惩罚
  • 他们不了解世界的现实
  • 他们正在……故意引入错误。也许是外国政府。你住在哪里?所有政府对世界大多数国家都是外国政府,大多数政府对世界某些国家怀有敌意。

也许还有其他东西。谁能说?


32
不同意答案的每一行。对于严格的别名优化也发表了相同的评论,但希望这些评论现在被驳回。解决方案是教育开发人员,而不是根据不良的开发习惯阻止优化。
SergeyA 2016年

30
我确实按照您说的去读了整件事,的确确实让我哭了,但主要是因为Felix的愚蠢,我认为这不是您想要传达的东西……
Mike Vine

33
为无用的咆哮而投票。“他们……故意引入错误。也许是为了外国政府。” 真?这不是/ r / conspiracy。
isanae '16

31
体面的程序员一遍又一遍地重复该咒语,不会调用未定义的行为,但是这些怪癖还是继续这样做了。看看发生了什么。我一点也不同情。这就是开发人员的错,就这么简单。他们需要承担责任。还记得吗 个人的责任?人们依靠您的口头禅“但是实际上呢!” 恰恰是这种情况如何产生的。避免这样的废话恰恰是为什么首先存在标准的原因。按照标准进行编码,您就不会有问题。期。
Lightness Races in Orbit

18
“用较新版本的编译器简单地重新编译以前可以正常工作的安全代码可能会引入安全漏洞”,这种情况经常发生。除非您要强制要求一个编译器的一个版本是唯一可以在永恒的余生中使用的编译器。您还记得何时只能使用精确的gcc 2.7.2.1编译linux内核吗?gcc项目甚至因为人们厌倦了废话而分叉了。过去了很长时间。
MM
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.