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)
,因为那太不自然了。
this
作为隐式参数传递这一事实感到担忧,所以他们开始使用它,就好像它是一个显式参数一样。它不是。当您取消引用 null this 时,您调用 UB 就像您取消引用任何其他空指针一样。这就是它的全部。如果你想传递 nullptrs,使用显式参数,DUH。它不会更慢,也不会更笨重,而且具有此类 API 的代码无论如何都深入内部,因此范围非常有限。我想故事的结尾。
我想需要回答的问题是为什么好心的人会首先写支票。
最常见的情况可能是如果您有一个属于自然发生的递归调用的类。
如果你有:
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
这在许多编译器上工作了很多年。当标准化发生时,情况发生了变化。最近,编译器开始利用调用成员函数的优势,其中 this
为 nullptr
是未定义的行为,这意味着此条件始终为 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
检查的方式。
这样做是因为“实用”代码被破坏并且一开始就涉及未定义的行为。没有理由使用 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
。
this
检查是由各种静态代码分析器挑选出来的,所以没有人必须手动将它们全部找出来。该补丁可能是几百行微不足道的更改。
this
取消引用会立即崩溃。即使没有人关心对代码运行静态分析器,这些问题也会很快被发现。 C/C++ 遵循“只为使用的功能付费”的口号。如果您想要检查,则必须明确说明它们,这意味着不要在 this
上进行检查,因为为时已晚,因为编译器假定 this
不为空。否则它必须检查 this
,对于 99.9999% 的代码,这样的检查是浪费时间。
更改文档明确指出这是危险的,因为它破坏了数量惊人的常用代码。
该文件并未将其称为危险。它也没有声称它破坏了数量惊人的代码。它只是指出了一些流行的代码库,它声称已知这些代码库依赖于这种未定义的行为,并且会由于更改而中断,除非使用解决方法选项。
为什么这个新假设会破坏实际的 C++ 代码?
如果实际的 c++ 代码依赖于未定义的行为,那么对该未定义行为的更改可能会破坏它。这就是为什么要避免使用 UB,即使依赖它的程序似乎按预期工作。
是否存在粗心或不知情的程序员依赖这种特定未定义行为的特定模式?
我不知道它是否是广泛传播的反模式,但一个不知情的程序员可能会认为他们可以通过以下方式修复他们的程序崩溃:
if (this)
member_variable = 42;
当实际的错误是在其他地方取消引用空指针时。
我敢肯定,如果程序员不够了解,他们将能够提出依赖于这个 UB 的更高级(反)模式。
我无法想象有人会写 if (this == NULL),因为那太不自然了。
我可以。
if(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere();
例如,调试器无法轻易告诉您的一系列事件的易读日志。现在享受调试的乐趣,而无需花费数小时在大型数据集中突然出现随机空值时到处进行检查,在您尚未编写的代码中......并且关于此的 UB 规则是在创建 C++ 之后制定的。它曾经是有效的。
-fsanitize=null
的用途。
-fsanitize=null
可以使用 SPI 将错误记录到引脚 #5、6、10、11 上的 SD/MMC 卡吗?这不是一个通用的解决方案。有人认为访问空对象违反了面向对象的原则,但某些 OOP 语言具有可以操作的空对象,因此这不是 OOP 的通用规则。 1/2
一些被破坏的“实用”(拼写“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
可以内联,因此实际上根本不需要访问指针。因此,对于某些编译器,它似乎可以正常工作:取消引用 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 实际上可能是最初位于不同函数中的代码段,但现在组合成一段代码,并且编译器能够在更多地方应用其指针非空的知识。这是一个基本但非常重要的优化,如果编译器不这样做,日常代码会相当慢,人们会抱怨不必要的分支来重复重新测试相同的条件。
this
的此类用法时输出编译时警告?
this
的无效使用,那么只需使用 -fsanitize=undefined
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++ 语言的理想纯度而不是实际代码
他们认为人们应该因不遵守标准而受到惩罚
他们不了解世界的真相
他们是……故意引入错误。也许是外国政府。你住在哪里?所有政府对世界上大多数国家都是陌生的,而且大多数政府对世界上的某些国家怀有敌意。
或者也许是别的东西。谁能说?
1 == 0
怎么可能是未定义的行为?它只是false
。this == nullptr
习惯用法是未定义的行为,因为您在此之前已在 nullptr 对象上调用了成员函数,这是未定义的。并且编译器可以随意省略检查this
可以为空。我想这也许是在一个存在 SO 的时代学习 C++ 的好处,它可以在我的大脑中根深蒂固 UB 的危险,并劝阻我不要做这种奇怪的黑客行为。