C++ 中的空指针:你能做什么和不能做什么

一些软件工程师可能已经厌倦了这个话题,但在这里我们选择了 7 个示例并尝试使用标准(撰写本文时的最新草案)来解释他们的行为:

struct A {  
int data_mem;  
void non_static_mem_fn() {}  
static void static_mem_fn() {}  
};

void foo(int) {}

A* p{nullptr};

/*1*/ *p;  
/*2*/ foo((*p, 5));  
/*3*/ A a{*p};  
/*4*/ p->data_mem;  
/*5*/ int b{p->data_mem};  
/*6*/ p->non_static_mem_fn();  
/*7*/ p->static_mem_fn();

一个明显但重要的一点是,使用空指针初始化的p不能指向有效对象,因为它“可与对象指针的所有其他值区分开来”(conv.ptr#1)。

示例 1

*p;

这是一个表达式语句,其中 *p 是一个废弃值表达式,但仍需要对其进行评估(stmt.expr#1)。根据定义 ( expr.unary.op#1 ),一元运算符 * “执行间接”,结果是“引用表达式指向的对象或函数的左值”。很清楚语义是什么,但不清楚是否存在对象必须存在的前提条件。甚至一次也没有提到空指针。

可以尝试从它执行间接这一事实得出结论,因为basic.stc#4说

“通过无效的指针值间接。. . 有未定义的行为”。

但是,该确切段落包含无效指针值的定义并引用basic.compound#3.4,其中将空指针值和无效指针值列为不同的指针值。

dcl.ref#5中还有一条注释说

“创建此类引用的唯一方法是将其绑定到通过空指针间接获得的“对象”,这会导致未定义的行为,”

但尚不清楚最后一个条款指的是哪一部分。如果它是“绑定它”,那么绑定到一个不存在的对象是未定义的行为,这符合该段落的规范文本。

由于标准为解释留下了空间,而不是在这个特定主题上明确,让我们转向核心语言问题列表,其中核心工作组详细阐述了标准的措辞等。我们的主题有一个专门的问题,CWG 达成了非正式共识(这就是“起草”状态的定义方式)

 p = 0; *p; 本质上不是错误。左值到右值的转换会给它带来不确定的行为。”

如果“非正式共识”听起来不够好,还有一个专门针对示例 7 的问题,CWG 表示出于这个确切原因应该允许这样做。

我将在接下来的内容中考虑到这一共识。如果未来的标准禁止像 C 那样通过空指针进行间接寻址(N2176、6.5.3.2和脚注 104),那么所有示例都将被呈现为包含未定义的行为。

示例 2

foo((*p, 5));

为了调用foo() ,需要初始化参数,这会导致对运算符逗号的评估。它的操作数是从左到右计算的,除了最右边的之外,它们都是丢弃值表达式(expr.comma#1)。所以这个例子也是格式良好的。

示例 3

A a{*p};

将选择隐式复制构造函数来初始化a,并且需要使用有效对象初始化const A&才能调用它,否则行为未定义(dcl.ref#5)。但是,在我们的案例中没有这样的对象。

示例 4

p->data_mem;

此表达式语句的表达式将转换为(*(p)).data_mem per expr.ref#2,它指定“第一个表达式指定的对象的相应成员子对象”(expr.ref#6.2)。再次不清楚是否存在一个对象必须存在的先决条件。在basic.lookup.qual#1中看到“引用”和“指定”可以互换使用,这使得它更加类似于示例 1。我会说这个例子是结构良好的,但是一些编译器不同意。有关详细信息,请参阅本文末尾的“使用常量表达式检查”部分。

例 5

int b{p->data_mem};

继续前面的示例,我们将尝试使用表达式的结果来初始化int,而不是丢弃它。它需要转换为纯右值,因为该类别的表达式会初始化对象(basic.lval#1.2)。由于目标类型是 int,因此将访问表达式的结果(conv.lval#3.4),这在我们的例子中会导致未定义的行为,因为basic.lval#11中的条件都不满足。

示例 6

p->non_static_mem_fn();

class.mfct.non-static#1读到

“可以为其类类型的对象或从其类类型派生的类的对象调用非静态成员函数,”

其中“可能”是指许可,而不是可能性(ISO 指令第 2 部分)。所以行为是不确定的,因为没有对象。

例 7

p->static_mem_fn();

正如我们在示例 1 的描述中提到的,CWG 说这个示例是有效代码。唯一要补充的是,即使不需要其结果(脚注 59),也会通过 -> 左侧的表达式进行间接寻址。

检查常量表达式

由于常量表达式不能依赖未定义的行为(expr.const#5),我们可以询问编译器对我们示例的意见。尽管他们的诊断并不理想,但至少有时他们是正确的。我们稍微编辑了我们的示例以使其适合常量表达式评估,将它们提供给三个流行的编译器,并注释掉他们认为不好的示例,因为 GCC 和 MSVC 的诊断消息对这些特定示例有很多不足之处。测试本身可以在Godbolt上找到,我们的结果总结如下表所示。

#片段期待海合会 10.1铿锵声 10MSVC 19.24
1*p;++++
2foo((*p, 5));++++
3一个{*p};    
4p->data_mem;++  
5int b{p->data_mem};    
6p->non_static_mem_fn(); + +
7p->static_mem_fn();++++

结果使我们对示例 6 的结论产生了一些怀疑,对示例 4 的结论更加怀疑。但有趣的是,我们所有人都对关键示例 1 持有相同的看法。

感谢您与我们一起跟随 C++ 中的空指针的冒险!:-) 通常,我们共享从当前固件开发项目中提取的代码片段,但这次我们对纯粹的“哲学”问题感兴趣,因此综合了这些示例。