某些已损坏的“实用”(拼写“越野车”的有趣方法)代码如下所示:
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实际上可能是原本是在单独的函数中的代码段,但现在已合并为一段代码,并且编译器能够在更多地方应用其指针为非空的知识。