第23章 指针字面量nullptr(C++11)

在C++标准中有一条特殊的规则,即0既是一个整型常量,又是一个空指针常量。0作为空指针常量还能隐式地转换为各种指针类型。比如我们在初始化变量的时候经常看到的代码:

char *p = NULL;
int x = 0;

这里的NULL是一个宏,在C++11标准之前其本质就是0

#ifndef NULL
    #ifdef __cplusplus
        #define NULL 0
    #else
        #define NULL ((void *)0)
    #endif
#endif

在上面的代码中,C++将NULL定义为0,而C语言将NULL定义为(void *)0。之所以有所区别,是因为C++和C的标准定义不同,C++标准中定义空指针常量是评估为0的整数类型的常量表达式右值,而C标准中定义0为整型常量或者类型为void *的空指针常量。

使用0代表不同类型的特殊规则给C++带来了二义性,对C++的学习和使用造成了不小的麻烦,下面是C++标准文档的两个例子:

// 例子1
void f(int)
{
  std::cout << "int" << std::endl;
}

void f(char *)
{
  std::cout << "char *" << std::endl;
}

f(NULL);
f(reinterpret_cast<char *>(NULL));

在上面这段代码中f(NULL)函数调用的是f(int)函数,因为NULL会被优先解析为整数类型。没有办法让编译器自动识别传入NULL的意图,除非使用类型转换,将NULL转换到char*f(reinterpret_cast<char *>(NULL))可以正确地调用f(char *)函数。注意,上面的代码可以在MSVC中编译执行。在GCC中,我们会得到一个NULL有二义性的错误提示。

下面这个例子看起来就更加奇怪了:

// 例子2
std::string s1(false);
std::string s2(true);

以上代码可以用MSVC编译,其中s1可以成功编译,但是s2则会编译失败。原因是false被隐式转换为0,而0又能作为空指针常量转换为const char * const,所以s1可以编译成功,true则没有这样的待遇。在GCC中,编译器对这种代码也进行了特殊处理,如果用C++11(-std=c++11)及其之后的标准来编译,则两条代码均会报错。但是如果用C++03以及之前的标准来编译,则虽然第一句代码能编译通过,但会给出警告信息,第二句代码依然编译失败。

鉴于0作为空指针常量的种种劣势,C++标准委员会在C++11中添加关键字nullptr表示空指针的字面量,它是一个std::nullptr_t类型的纯右值。nullptr的用途非常单纯,就是用来指示空指针,它不允许运用在算术表达式中或者与非指针类型进行比较(除了空指针常量0)。它还可以隐式转换为各种指针类型,但是无法隐式转换到非指针类型。注意,0依然保留着可以代表整数和空指针常量的特殊能力,保留这一点是为了让C++11标准兼容以前的C++代码。所以,下面给出的例子都能够顺利地通过编译:

char* ch = nullptr;
char* ch2 = 0;
assert(ch == 0);
assert(ch == nullptr); 
assert(!ch);
assert(ch2 == nullptr);
assert(nullptr == 0);

将指针变量初始化为0或者nullptr的效果是一样的,在初始化以后它们也能够与0或者nullptr进行比较。从最后一句代码看出nullptr也可以和0直接比较,返回值为true。虽然nullptr可以和0进行比较,但这并不代表它的类型为整型,同时它也不能隐式转换为整型:

int n1 = nullptr;
char* ch1 = true ? 0 : nullptr;
int n2 = true ? nullptr : nullptr;
int n3 = true ? 0 : nullptr;

以上代码的第一句和第三句操作都是将一个std::nullptr_t类型赋值到int类型变量。由于这个转换并不能自动进行,因此会产生编译错误。而第二句和第四句中,因为条件表达式的 :前后类型不一致,而且无法简单扩展类型,所以同样会产生编译错误。请注意,上面代码中的第二句在MSVC中是可以编译通过的。

进一步来看nullptr的类型std::nullptr_t,它并不是一个关键字,而是使用decltypenullptr的类型定义在代码中,C++标准规定该类型的长度和void *相同:

namespace std
{
  using nullptr_t = decltype(nullptr);
  // 等价于
  typedef decltype(nullptr) nullptr_t;
}

static_assert(sizeof(std::nullptr_t) == sizeof(void *));

我们还可以使用std::nullptr_t去创建自己的nullptr,并且有与nullptr相同的功能:

std::nullptr_t null1, null2;

char* ch = null1;
char* ch2 = null2;
assert(ch == 0);
assert(ch == nullptr); 
assert(ch == null2);
assert(null1 == null2);
assert(nullptr == null1);

不过话说回来,虽然这段代码中null1null2nullptr的能力相同,但是它们还是有很大区别的。首先,nullptr是关键字,而其他两个是声明的变量。其次,nullptr是一个纯右值,而其他两个是左值:

std::nullptr_t null1, null2;
std::cout << "&null1 = " << &null1 << std::endl;  // null1和null2是左值,可
                                                  // 以成功获取对象指针,
std::cout << "&null2 = " << &null2 << std::endl;  // 并且指针指向的内存地址不同

上面这段代码对null1null2做了取地址的操作,并且返回不同的内存地址,证明它们都是左值。但是这个操作用在nullptr上肯定会产生编译错误:

std::cout << "&nullptr = " << &nullptr << std::endl;  // 编译失败,取地址操作
                                                      // 需要一个左值

nullptr是一个纯右值,对nullptr进行取地址操作就如同对常数取地址一样,这显然是错误的。讨论过nullptr的特性以后,我们再来看一看重载函数的例子:

void f(int)
{
  std::cout << "int" << std::endl;
}

void f(char *)
{
  std::cout << "char *" << std::endl;
}

f(nullptr);

以上代码的f(nullptr)会调用f(char *),因为nullptr可以隐式转换为指针类型,而无法隐式转换为整型,所以编译器会找到形参为指针的函数版本。不过,如果这份代码中出现多个形参是指针的函数,则使用nullptr也会产生二义性,因为nullptr可以隐式转换为任何指针类型,所以编译器无法决定应该调用哪个形参为指针的函数。

使用nullptr的另一个好处是,我们可以为函数模板或者类设计一些空指针类型的特化版本。在C++11以前这是不可能实现的,因为0的推导类型是int而不是空指针类型。现在我们可以利用nullptr的类型为std::nullptr_t写出下面的代码:

#include <iostream>

template<class T>
struct widget
{
  widget()
  {
       std::cout << "template" << std::endl;
  }
};

template<>
struct widget<std::nullptr_t>
{
  widget()
  {
       std::cout << "nullptr" << std::endl;
  }
};

template<class T>
widget<T>* make_widget(T)
{
  return new widget<T>();
}

int main()
{
  auto w1 = make_widget(0);
  auto w2 = make_widget(nullptr);
}

nullptr的出现消除了使用0带来的二义性,与此同时其类型和含义也更加明确。含义明确的好处是,C++标准可以加入一系列明确的规则去限制nullptr的使用,这让程序员能更快地发现编程时的错误。所以建议读者在编译器支持的情况下,总是优先使用nullptr而非0