漫谈C++——用了这么久,你真的懂nullptr吗

C++学习笔记

Posted by Felix Zhang on September 3, 2020

用了这么久,你真的懂nullptr吗?

你是否在混着用NULLnullptr? 你是否分不清这二者到底有什么区别? 滥用NULL到底会带来什么不可思议的错误?

今天,我们就来讨论讨论nullptr这个空指针的救世主。在C++11之前,几乎所有人写代码时对NULL又爱又恨,爱是因为他确实能够起到空指针的作用,恨是刚躲过了这个坑,又遇见了那个妖,魑魅魍魉怎么它就这么多。还好,C++11给我们带来了希望的曙光——nulltpr

什么是NULL

这个问题想要回答好还真是不容易。从定义上来看,NULL是一个宏,它被定义为0(也就是int型的零),或者0Llong型的零),或者其他__零指针常数__;但是一般来说我们还是把它当作整形来看待。零指针常数之所以得名,是因为他们能够被转化为空指针。在C中,NULL也可以被理解为(void*)0,因为void类型的指针都可以被转化为其他任何指针类型。详情请看文章。。。

虽然NULL很明显是为了指针而被制造出来的,但是如果你把它当作参数传到函数中去,它就会“原形毕露”——编译器会把它当作一个int类型,或一个long类型,而不是一个指针类型。比如说下边的例子:

class Girlfriend {/* ... */};

void kissGirlfriend(Girlfriend* gf);
void kissGirlfriend(int gfId);

int main() {
	// 参数匹配后,其实调用的是第二个函数,你的第零号女朋友莫名其妙被亲了一下
	kissGirlfriend(NULL);
}

从上边代码来看,我们很明显是想传一个空指针进去,让程序调用第一个重载函数,但很可惜的是,这种情况下我们唯一能确定的事情就是我们的预想肯定不会发生——可能有两个结果。

  • 如果NULL被定义为0int零),那么编译器会自然而然调用第二个重载函数,毕竟这是一个完美参数匹配。
  • 如果NULL被定义为0L或者其他形式的整型零,编译器会报错,告诉你有一个指向不明的函数调用,——因为0L可以被等同地转化为空指针或int,由此就产生了二义性。

让我们看看这个问题怎么解决。第一个尝试:我们可以试着让一个enum来代替那个函数的int,然后去消除二义性。同时让我们给参数一个名字,让代码看起来指向性也更明确一些:

class Girlfriend {/* ... */};

enum GirlfriendId {/* ... */};
void kissGirlfriend(Girlfriend* gf);
void kissGirlfriend(GirlfriendId gfId);

int main() {
	auto noGf = NULL;
	kissGirlfriend(noGF);	//还是ERROR
}

在这里,noGf不是指针,是一个正儿八经的整形变量,另一方面,从整型零到空指针的转化只能发生在零指针常数上。所以编译器很无奈的告诉我们,他不知道该把noGf转化为一个GirlfriendId还是Girlfriend*

NULL的弊端

上边两个例子都向我们展示了问题所在:NULL终究只是一个宏。它是一个整型,它不是指针,所以一旦涉及类型转换就会有风险。因而随之带来的问题是,我们没有办法在不显示声明指针类型的情况下定义一个空指针。

nullptr

自从C++11以来,有一个小特性完美的解决了这个问题——nullptr。作为一个字面常量和一个零指针常数,它可以被隐式转换为任何指针类型。我们只需稍作修改上边的例子,你就会得到:

void kissGirlfriend(GirlFriend* gf);
void kissGirlfriend(int gfId);

int main() {
	kissGirlfriend(nullptr);	//调用第一个重载函数
}

这时编译器就会乖乖听话了:因为nullptr是没办法转换成int的,它会隐式转化为一个空的Girlfriend*从而正确的函数会被调用。下边这个例子对nullptr的类型进行说明:

class Girlfriend {/* ... */};

enum GirlfriendId {/* ... */};
void kissGirlfriend(Girlfriend* gf);
void kissGirlfriend(GirlfriendId gfId);

int main() {
	auto noGf = nullptr;
	kissGirlfriend(noGF);	//OK
}

nullptr有它自己的类型,std::nullptr_t,他可以隐式转换为任何指针类型。所以上例中noGf现在有了nullptr_t类型并且可以转换为Girlfriend*,而不是GirlfriendId。所以,无论在哪里,都请使用nullptr,而不是NULL0或者其他零指针常数。

nullptr和智能指针

智能指针并不是真正的指针,他们是类。所以上边所有的隐式转化对诸如shared_ptr<T>等智能指针都不会奏效。但很幸运的是,对于nullptr自己的类型,智能指针类都重载了针对这种类型的构造器,所以下边的代码其实是合法的:

shared_ptr<Girlfriend> gfPtr = nullptr;
unique_ptr<Boyfriend> bfPtr = nullptr;

需要注意的是,除了从auto_ptrunique_ptr的转换,这是唯一一种智能指针的隐式构造函数。因此,当一个函数的参数类型是智能指针类型时,理论上你也可以直接传一个nullptr进去,而不必画蛇添足地再额外用nullptr创建一个智能指针出来:

void doSomething(unique_ptr<Object> objectPtr);

int main() {
	doSomething(nullptr);	//OK
}

向后兼容

更妙的是,C++11标准规定了任何零指针常量都可以隐式转化为nullptr,这让我们能够有效地兼容使用NULL的老代码。也就是说,如果你将nullptrnullptr_t引入老代码,代码将会很好地兼容以前的逻辑。尤其是当我们想向03版及之前的代码中引入智能指针时,我们更能体会到这种兼容特性的妙处:

//C++03 version:
void doSomething(Object* objectPtr) {
  //...
  delete objectPtr;
}

int main() {
  doSomething(NULL);
}

我们通过把原始的指针用unique_ptr替换来替换时,如果某处忘记了把NULL替换成nullptr怎么办?别担心,这样不会引起错误:

//引入 unique_ptr
void doSomething(unique_ptr<Object> objectPtr) {
	//...
}

int main() {
	doSomething(NULL);	// OK
}

原因就在于,NULL是一个零指针常数,可以隐式转换为nullptr,而nullptr又可以调用unique_ptr<T>的隐式构造器,通过两次隐式转换,我们就可以完美完成上边的调用。

结语

nullptr是一个有用的小特性,它能让你的代码更安全,也更readable。还有什么理由不用它呢?