ChatGPT解决这个技术问题 Extra ChatGPT

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

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

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

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

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

@Ben 希望你的意思是好的。应重写带有 UB 的代码以不调用 UB。就这么简单。哎呀,经常有常见问题解答告诉您如何实现它。所以,恕我直言,这不是一个真正的问题。都好。
我很惊讶地看到人们在代码中为取消引用空指针辩护。简直太神奇了。
@Ben,很长一段时间以来,探索未定义的行为一直是非常有效的优化策略。我喜欢它,因为我喜欢让我的代码运行得更快的优化。
我同意谢尔盖的观点。整个骚动的开始是因为人们似乎对 this 作为隐式参数传递这一事实感到担忧,所以他们开始使用它,就好像它是一个显式参数一样。它不是。当您取消引用 null this 时,您调用 UB 就像您取消引用任何其他空指针一样。这就是它的全部。如果你想传递 nullptrs,使用显式参数,DUH。它不会更慢,也不会更笨重,而且具有此类 API 的代码无论如何都深入内部,因此范围非常有限。我想故事的结尾。
感谢 GCC 打破坏代码循环 -> 低效编译器支持坏代码 -> 更多坏代码 -> 更低效编译 -> ...

α
αλεχολυτ

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

最常见的情况可能是如果您有一个属于自然发生的递归调用的类。

如果你有:

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(); 之前进行所有检查过程(); if(right) right->traverse_in_order();这意味着还要检查每个呼叫站点是否可以有一个空根。

不要使用成员函数 这意味着您正在编写旧的 C 风格代码(可能作为静态方法),并使用对象作为参数显式调用它。例如。你又开始写 Node::traverse_in_order(node);而不是 node->traverse_in_order();在呼叫站点。

我相信以符合标准的方式修复此特定示例的最简单/最简洁的方法是实际使用哨兵节点而不是 nullptr。 // 静态类,或者全局变量 Node sentinel;无效节点::traverse_in_order() { if(this == &sentinel) return; ... }

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

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


1 == 0 怎么可能是未定义的行为?它只是false
检查本身不是未定义的行为。它总是错误的,因此被编译器消除。
嗯.. this == nullptr 习惯用法是未定义的行为,因为您在此之前已在 nullptr 对象上调用了成员函数,这是未定义的。并且编译器可以随意省略检查
@Joshua,第一个标准于 1998 年发布。在此之前发生的任何事情都是每个实现想要的。黑暗时代。
嘿,哇,我不敢相信有人写过依赖于调用实例函数的代码...没有实例。我会本能地使用标记为“在调用 traverse_in_order 之前执行所有检查”的摘录,甚至没有考虑 this 可以为空。我想这也许是在一个存在 SO 的时代学习 C++ 的好处,它可以在我的大脑中根深蒂固 UB 的危险,并劝阻我不要做这种奇怪的黑客行为。
C
Community

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

这是一种危险的做法,因为 adjustment of pointers due to class hierarchy traversal 可以将 null this 变成非 null 的。因此,至少,其方法应该与 null this 一起使用的类必须是没有基类的最终类:它不能派生自任何东西,也不能派生自任何东西。我们很快就从实际转向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);
  }
};

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

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

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


@Ben,编写此代码的人首先是错误的。有趣的是,您将诸如 MFC、Qt 和 Chromium 之类的严重损坏的项目命名为。很好摆脱他们。
@Ben,谷歌糟糕的编码风格对我来说是众所周知的。谷歌代码(至少公开可用)通常写得很糟糕,尽管很多人认为谷歌代码是一个光辉的例子。可能这会让他们重新审视他们的编码风格(以及他们使用时的指导方针)。
@Ben 没有人用使用 gcc 6 编译的 Chromium 追溯替换这些设备上的 Chromium。在使用 gcc 6 和其他现代编译器编译 Chromium 之前,需要修复它。这也不是一项艰巨的任务。 this 检查是由各种静态代码分析器挑选出来的,所以没有人必须手动将它们全部找出来。该补丁可能是几百行微不足道的更改。
@Ben 实际上,空 this 取消引用会立即崩溃。即使没有人关心对代码运行静态分析器,这些问题也会很快被发现。 C/C++ 遵循“只为使用的功能付费”的口号。如果您想要检查,则必须明确说明它们,这意味着不要在 this 上进行检查,因为为时已晚,因为编译器假定 this 不为空。否则它必须检查 this,对于 99.9999% 的代码,这样的检查是浪费时间。
我对任何认为标准被打破的人的建议是:使用不同的语言。不乏没有未定义行为可能性的类 C++ 语言。
e
eerorika

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

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

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

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

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

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

if (this)
    member_variable = 42;

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

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

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

我可以。


“如果实际的 c++ 代码依赖于未定义的行为,那么对未定义行为的更改可能会破坏它。这就是要避免 UB 的原因” this * 1000
if(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere(); 例如,调试器无法轻易告诉您的一系列事件的易读日志。现在享受调试的乐趣,而无需花费数小时在大型数据集中突然出现随机空值时到处进行检查,在您尚未编写的代码中......并且关于此的 UB 规则是在创建 C++ 之后制定的。它曾经是有效的。
@StephaneHockenhull 这就是 -fsanitize=null 的用途。
@user2079303 问题:这是否会减慢生产代码的速度,以至于您在运行时无法离开签入,从而使公司损失大量资金?这会增加尺寸并且不适合闪存吗?这适用于包括 Atmel 在内的所有目标平台吗? -fsanitize=null 可以使用 SPI 将错误记录到引脚 #5、6、10、11 上的 SD/MMC 卡吗?这不是一个通用的解决方案。有人认为访问空对象违反了面向对象的原则,但某些 OOP 语言具有可以操作的空对象,因此这不是 OOP 的通用规则。 1/2
...匹配此类文件的正则表达式?比如说,如果一个左值被访问两次,编译器可能会合并这些访问,除非它们之间的代码执行几个特定的事情中的任何一个,这比尝试定义允许代码访问存储的精确情况要容易得多。
J
Jonathan Wakely

一些被破坏的“实用”(拼写“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 而无需访问 *thisreturn false 可以内联,因此实际上根本不需要访问指针。因此,对于某些编译器,它似乎可以正常工作:取消引用 null 没有段错误,p->valid() 为假,因此代码调用 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);
  }
}

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

在此示例中,错误在 func 中,它应该首先检查 null (或者调用者不应该使用 null 调用它):

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

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


是否可以检测 GCC 6 在遇到 this 的此类用法时输出编译时警告?
@jotik,^^^ TC 所说的。这是可能的,但您会一直收到所有代码的警告。值范围传播是最常见的优化之一,它影响几乎所有代码,无处不在。优化器只看到可以简化的代码。他们没有看到“由一个白痴编写的一段代码,如果他们的愚蠢的 UB 被优化掉,想要得到警告”。编译器很难区分“程序员想要优化的冗余检查”和“程序员认为有帮助但多余的冗余检查”之间的区别。
如果您想检测您的代码以针对各种类型的 UB 提供 runtime 错误,包括对 this 的无效使用,那么只需使用 -fsanitize=undefined
B
Ben

C++ 标准在重要方面被破坏了。不幸的是,GCC 开发人员并没有保护用户免受这些问题的影响,而是选择使用未定义的行为作为实施边际优化的借口,即使已经向他们清楚地解释了它的危害有多大。

这里有一个比我更聪明的人详细解释。 (他说的是C,但那里的情况是一样的)。

https://groups.google.com/forum/m/#!msg/boring-crypto/48qa1kWignU/o8GGp2K1DAAJ

为什么有害?

简单地使用更新版本的编译器重新编译以前工作的安全代码可能会引入安全漏洞。虽然可以使用标志禁用新行为,但显然现有的 makefile 没有设置该标志。并且由于没有产生警告,对于开发人员来说,之前合理的行为已经改变并不明显。

在此示例中,开发人员使用 assert 包含了整数溢出检查,如果提供了无效长度,它将终止程序。 GCC 团队基于未定义整数溢出而取消了检查,因此可以取消检查。这导致该代码库的真实实例在问题得到修复后重新变得易受攻击。

https://gcc.gnu.org/bugzilla/show_bug.cgi?id=30475

阅读全文。足以让你流泪。

好的,但是这个呢?

很久以前,有一个相当普遍的成语是这样的:

 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++ 语言的理想纯度而不是实际代码

他们认为人们应该因不遵守标准而受到惩罚

他们不了解世界的真相

他们是……故意引入错误。也许是外国政府。你住在哪里?所有政府对世界上大多数国家都是陌生的,而且大多数政府对世界上的某些国家怀有敌意。

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


不同意答案的每一行。对严格的别名优化也做了同样的评论,现在希望这些评论被驳回。解决方案是教育开发人员,而不是阻止基于不良开发习惯的优化。
我确实像你说的那样去读了整本书,确实我哭了,但主要是因为菲利克斯的愚蠢,我认为这不是你想要表达的......
对无用的咆哮投了反对票。 “他们......故意引入错误。也许是外国政府。”真的吗?这不是/r/阴谋。
体面的程序员一遍又一遍地重复着不调用未定义行为的口头禅,但这些不法分子仍然继续前进并做到了。看看发生了什么。我一点同情心都没有。这是开发商的错,就这么简单。他们需要承担责任。还记得吗?个人的责任?人们依赖你的口头禅“但在实践中呢!”正是这种情况最初是如何产生的。避免这样的废话正是标准首先存在的原因。代码符合标准,你不会有问题。时期。
“使用更新版本的编译器简单地重新编译以前工作的安全代码可能会引入安全漏洞”——这种情况总是会发生。除非你想强制一个编译器的一个版本是唯一允许使用的编译器。你还记得 linux 内核只能用 gcc 2.7.2.1 编译吗? gcc 项目甚至因为人们厌倦了废话而被分叉。过了很长时间才过去。