《现代C++语言核心特性解析》笔记(二)

十一、非受限联合类型(C++11)

1. 联合类型在C++中的局限性

在编程的问题中,用尽量少的内存做尽可能多的事情一直都是一个重要的课题。C++中的联合类型(union)可以说是节约内存的一个典型代表。因为在联合类型中多个对象可以共享一片内存,相应的这片内存也只能由一个对象使用,例如:

#include <iostream>

union U
{
	int x1;
	float x2;
};

int main()
{
	U u;
	u.x1 = 5;
	std::cout << u.x1 << std::endl;
	std::cout << u.x2 << std::endl;

	u.x2 = 5.0;
	std::cout << u.x1 << std::endl;
	std::cout << u.x2 << std::endl;
}

在上面的代码中联合类型U里的成员变量x1x2共享同一片内存,所以修改x1的值,x2的值也会发生相应的变化,反之亦然。

不过需要注意的是,虽然x1x2共享同一片内存,但是由于CPU对不同类型内存的理解存在区别,因此即使内存相同也不能随意使用联合类型的成员变量,而是应该使用之前初始化过的变量。

像这样多个对象共用一片内存的情况在内存紧缺时是非常实用的。不过令人遗憾的是,过去的联合类型在C++中的使用并不广泛,因为C++中的大多数对象不能成为联合类型的成员。过去的C++标准规定,联合类型的成员变量的类型不能是一个非平凡类型,也就是说它的成员类型不能有自定义构造函数,比如:

union U
{
    int x1;
    float x2;
    std::string x3;
};

int main()
{   
    U u; // 编译报错
    u.x3 ="aa";
    std::cout << u.x3 << std::endl;
}

上面的代码是无法通过编译的,因为x3存在自定义的构造函数,所以它是一个非平凡类型。但事实上,面向对象的编程中一个好的类应该隐藏内部的细节,这就要求构造函数足够强大并正确地初始化对象的内部数据结构,而编译器提供的构造函数往往不具备这样的能力,于是大多数情况下,我们会为自己的类添加一个好用的构造函数,但是这种良好的设计却造成了这个类型无法在联合类型中使用。

基于这些问题,C++委员会在新的提案当中多次强调“我们没有任何理由限制联合类型使用的类型”。在这份提案中有一段话非常好地阐述了C++的设计理念,同时也批判了联合类型的限制对这种理念的背叛,这段话是这样说的:

当面对一个可能被滥用的功能时,语言的设计者往往有两条路可走,一是为了语言的安全性禁止此功能,另外则是为了语言的能力和灵活性允许这个功能,C++的设计者一般会采用后者。但是联合类型的设计却与这一理念背道而驰。这种限制完全没有必要,去除它可以让联合类型更加实用。

回味这段话,C++的设计确实一直遵从这样的理念,我们熟悉的指针就是一个典型的代表!

2. 使用非受限联合类型

为了让联合类型更加实用,在C++11标准中解除了大部分限制,联合类型的成员可以是除了引用类型外的所有类型。不过这样的修改引入了另外一个问题,如何精确初始化联合类型成员对象。这一点在过去的联合类型中不是一个问题,因为对于平凡类型,编译器只需要对成员对象都执行编译器提供的默认构造即可,虽然从同一内存多次初始化的角度来说这是不正确的,但是从结果上看没有任何问题。现在情况发生了变化,由于允许非平凡类型的存在,对所有成员一一进行默认构造明显是不可取的,因此我们需要有选择地初始化成员对象。实际上,让编译器去选择初始化本身也是不合适的,这个事情应该交给程序员来做。基于这些考虑,在C++11中如果有联合类型中存在非平凡类型,那么这个联合类型的特殊成员函数将被隐式删除,也就是说我们必须自己至少提供联合类型的构造和析构函数,比如:

#include <iostream>
#include <string>
#include <vector>

union U
{
	U() {}		// 存在非平凡类型成员,必须提供构造函数
	~U() {}		// 存在非平凡类型成员,必须提供析构函数
	int x1;
	float x2;
	std::string x3;
	std::vector<int> x4;
};

int main()
{
	U u;
	u.x3 = "hello world";
	std::cout << u.x3 << std::endl;
}

在上面的代码中,由于x3x4的类型std::stringstd::vector是非平凡类型,因此U必须提供构造和析构函数。虽然这里提供的构造和析构函数什么也没有做,但是代码依然可以成功编译。不过请注意,能够编译通过并不代表没有问题,实际上这段代码会运行出错,因为非平凡类型x3并没有被构造,所以在赋值操作的时候必然会出错。现在修改一下代码:

#include <iostream>
#include <string>
#include <vector>

union U
{
	U() : x3() {}
	~U() { x3.~basic_string(); }
	int x1;
	float x2;
	std::string x3;
	std::vector<int> x4;
};

int main()
{
	U u;
	u.x3 = "hello world";
	std::cout << u.x3;
}

在上面的代码中,我们对联合类型U的构造和析构函数进行了修改。其中在构造函数中添加了初始化列表来构造x3,在析构函数中手动调用了x3的析构函数。前者很容易理解,而后者需要注意,联合类型在析构的时候编译器并不知道当前激活的是哪个成员,所以无法自动调用成员的析构函数,必须由程序员编写代码完成这部分工作。现在联合类型U的成员对象x3可以正常工作了,但是这种解决方案依然存在问题,因为在编写联合类型构造函数的时候无法确保哪个成员真正被使用。具体来说,如果在main函数内使用U的成员x4,由于x4并没有经过初始化,因此会导致程序出错:

#include <iostream>
#include <string>
#include <vector>

union U
{
	U() : x3() {}
	~U() { x3.~basic_string(); }
	int x1;
	float x2;
	std::string x3;
	std::vector<int> x4;
};

int main()
{
	U u;
	u.x4.push_back(58);
}

基于这些考虑,我还是比较推荐让联合类型的构造和析构函数为空,也就是什么也不做,并且将其成员的构造和析构函数放在需要使用联合类型的地方。让我们继续修改上面的代码:

#include <iostream>
#include <string>
#include <vector>

union U
{
	U() {}
	~U() {}
	int x1;
	float x2;
	std::string x3;
	std::vector<int> x4;
};

int main()
{
	U u;
	new(&u.x3) std::string("hello world");
	std::cout << u.x3 << std::endl;
	u.x3.~basic_string();

	new(&u.x4) std::vector<int>;
	u.x4.push_back(58);
	std::cout << u.x4[0] << std::endl;
	u.x4.~vector();
}

请注意,上面的代码用了placement new的技巧来初始化构造x3x4对象,在使用完对象后手动调用对象的析构函数。通过这样的方法保证了联合类型使用的灵活性和正确性。

最后简单介绍一下非受限联合类型对静态成员变量的支持。联合类型的静态成员不属于联合类型的任何对象,所以并不是对象构造时被定义的,不能在联合类型内部初始化。实际上这一点和类的静态成员变量是一样的,当然了,它的初始化方法也和类的静态成员变量相同:

#include <iostream>
union U
{
	static int x1;
};

int U::x1 = 42;

int main()
{
	std::cout << U::x1 << std::endl;
}

总结

在C++中联合类型因为其实用性过低一直以来都是一个很少被提及的类型,尤其是现在对于动则16GB内存的PC来说,内存似乎已经不是人们关注的最重要的问题了。但是,我认为这次关于联合类型的修改的意义是非凡的,因为这个修改表达了C++对其设计理念的坚持,这种态度难能可贵。除此之外,现代PC的大内存并不能代表所有的机器环境,在一些生产环境中依旧需要能节省内存的程序。诚然,非受限联合类型在使用上有一些烦琐复杂,但作为C++程序员,合理利用内存也应该是一种理所当然的自我要求。如果开发环境支持C++17标准,则大部分情况下我们可以使用std::variant来代替联合体。

十二、委托构造函数(C++11)

1. 冗余的构造函数

一个类有多个不同的构造函数在C++中是很常见的,例如:

class X
{
public:
	X() : a_(0), b_(0.) { CommonInit(); }
	X(int a) : a_(a), b_(0.) { CommonInit(); }
	X(double b) : a_(0), b_(b) { CommonInit(); }
	X(int a, double b) : a_(a), b_(b) { CommonInit(); }
private:
	void CommonInit() {}
	int a_;
	double b_;
};

虽然这段代码在语法上没有任何问题,但是构造函数包含了太多重复代码,这使代码的维护变得困难。首先,类X需要在每个构造函数的初始化列表中初始化构造所有的成员变量,这段代码只有两个数据成员,而在现实代码编写中常常会有更多的数据成员或者更多的构造函数,那么在初始化列表中会有更多的重复内容,非常不利于代码的维护。其次,在构造函数主体中也有相同的情况,一旦类的构造过程需要依赖某个函数,那么所有构造函数的主体就需要调用这个函数,在例子中这个函数就是CommonInit

也许有读者会提出将数据成员的初始化放到CommonInit函数里,从而减轻初始化列表代码冗余的问题,例如:

class X1
{
public:
	X1() { CommonInit(0, 0.); }
	X1(int a) { CommonInit(a, 0.); }
	X1(double b) { CommonInit(0, b); }
	X1(int a, double b) { CommonInit(a, b); }
private:
	void CommonInit(int a, double b)
	{
		a_ = a;
		b_ = b;
	}
	int a_;
	double b_;
};

以上代码在编译和运行上都没有问题,因为类X1的成员变量都是基本类型,所以在构造函数主体进行赋值也不会有什么问题。但是,如果成员函数中包含复杂的对象,那么就可能引发不确定问题,最好的情况是只影响类的构造效率,例如:

class X2
{
public:
	X2() { CommonInit(0, 0.); }
	X2(int a) { CommonInit(a, 0.); }
	X2(double b) { CommonInit(0, b); }
	X2(int a, double b) { CommonInit(a, b); }
private:
	void CommonInit(int a, double b)
	{
		a_ = a;
		b_ = b;
		c_ = "hello world";
	}
	int a_;
	double b_;
	std::string c_;
};

在上面的代码中,std::string类型的对象c_看似是在CommonInit函数中初始化为"hello world",但实际上它并不是一个初始化过程,而是一个赋值过程。因为对象的初始化过程早在构造函数主体执行之前,也就是初始化列表阶段就已经执行了。所以这里的c_对象进行了两次操作,一次为初始化,另一次才是赋值为"hello world",很明显这样对程序造成了不必要的性能损失。另外,有些情况是不能使用函数主体对成员对象进行赋值的,比如禁用了赋值运算符的数据成员。

当然读者还可能会提出通过为构造函数提供默认参数的方法来解决代码冗余的问题,例如:

class X3
{
public:
	X3(double b) : a_(0), b_(b) { CommonInit(); }
	X3(int a = 0, double b = 0.) : a_(a), b_(b) { CommonInit(); }
private:
	void CommonInit() {}
	int a_;
	double b_;
};

这种做法的作用非常有限,可以看到上面这段代码,虽然通过默认参数的方式优化了两个构造函数,但是对于X3(double b)这个构造函数依然需要在初始化列表中重复初始化成员变量。另外,使用默认参数稍不注意就会引发二义性的问题,例如:

class X4
{
public:
	X4(int c) : a_(0), b_(0.), c_(c) { CommonInit(); }
	X4(double b) : a_(0), b_(b), c_(0) { CommonInit(); }
	X4(int a = 0, double b = 0., int c = 0) : a_(a), b_(b), c_(c) { CommonInit(); }
private:
	void CommonInit() {}
	int a_;
	double b_;
	int c_;
};

int main()
{
	X4 x4(1); // 编译报错
}

以上代码无法通过编译,因为当main函数对x4进行构造时,编译器不知道应该调用X4(int c)还是X4(int a = 0, double b = 0.,int c = 0)。所以让构造函数使用默认参数也不是一个好的解决方案。

现在读者可以看出其中的问题了,过去C++没有提供一种复用同类型构造函数的方法,也就是说无法让一个构造函数将初始化的一部分工作委托给同类型的另外一个构造函数。这种功能的缺失就造成了程序员不得不编写重复烦琐代码的困境,更进一步来说它也造成了代码维护性下降。比如,如果想在类X中增加一个数据成员d_,那么就必须在4个构造函数的初始化列表中初始化成员变量d_,修改和删除也一样。

2. 委托构造函数

为了合理复用构造函数来减少代码冗余,C++11标准支持了委托构造函数:某个类型的一个构造函数可以委托同类型的另一个构造函数对对象进行初始化。为了描述方便我们称前者为委托构造函数,后者为代理构造函数(英文直译为目标构造函数)。委托构造函数会将控制权交给代理构造函数,在代理构造函数执行完之后,再执行委托构造函数的主体。委托构造函数的语法非常简单,只需要在委托构造函数的初始化列表中调用代理构造函数即可,例如:

class X
{
public:
	X() : X(0, 0.) {}
	X(int a) : X(a, 0.) {}
	X(double b) : X(0, b) {}
	X(int a, double b) : a_(a), b_(b) { CommonInit(); }
private:
	void CommonInit() {}
	int a_;
	double b_;
};

可以看到X(), X(int a), X(double b)分别作为委托构造函数将控制权交给了代理构造函数X(int a, double b)。它们的执行顺序是先执行代理构造函数的初始化列表,接着执行代理构造函数的主体(也就是CommonInit函数),最后执行委托构造函数的主体,在这个例子中委托构造函数的主体都为空。

委托构造函数的语法很简单,不过想合理使用它还需注意以下5点。

  1. 每个构造函数都可以委托另一个构造函数为代理。也就是说,可能存在一个构造函数,它既是委托构造函数也是代理构造函数,例如:
class X
{
public:
	X() : X(0) {}
	X(int a) : X(a, 0.) {}
	X(double b) : X(0, b) {}
	X(int a, double b) : a_(a), b_(b) { CommonInit(); }
private:
	void CommonInit() {}
	int a_;
	double b_;
};

在上面的代码中构造函数X(int a),它既是一个委托构造函数,也是X()的代理构造函数。另外,除了自定义构造函数以外,我们还能让特殊构造函数也成为委托构造函数,例如:

class X
{
public:
	X() : X(0) {}
	X(int a) : X(a, 0.) {}
	X(double b) : X(0, b) {}
	X(int a, double b) : a_(a), b_(b) { CommonInit(); }
	X(const X &other) : X(other.a_, other.b_) {}    // 委托拷贝构造函数
private:
	void CommonInit() {}
	int a_;
	double b_;
};

以上代码增加了一个复制构造函数X(const X &other),并且把复制构造函数的控制权委托给了X(int a, double b),而其自身主体不需要执行。

2.不要递归循环委托! 这一点非常重要,因为循环委托不会被编译器报错,随之而来的是程序运行时发生未定义行为,最常见的结果是程序因栈内存用尽而崩溃:

class X
{
public:
	X() : X(0) {}
	X(int a) : X(a, 0.) {}
	X(double b) : X(0, b) {}
	X(int a, double b) : X() { CommonInit(); } // 这里循环了

private:
	void CommonInit() {}
	int a_;
	double b_;
};

上面代码中的3个构造函数形成了一个循环递归委托,X()委托到X(int a)X(int a)委托到X(int a, double b),最后X(int a, double b)又委托到X()。请读者务必注意不要编写出这样的循环递归委托代码,因为我目前实验的编译器,默认情况下除了CLang会给出错误提示,MSVC和GCC都不会发出任何警告。这里也建议读者在使用委托构造函数时,通常只指定一个代理构造函数即可,其他的构造函数都委托到这个代理构造函数,尽量不要形成链式委托,避免出现循环递归委托。

  1. 如果一个构造函数为委托构造函数,那么其初始化列表里就不能对数据成员和基类进行初始化:
class X
{
public:
	X() : a_(0), b_(0) { CommonInit(); }
	X(int a) : X(), a_(a) {}		// 编译错误,委托构造函数不能在初始化列表初始化成员变量
	X(double b) : X(), b_(b) {}	    // 编译错误,委托构造函数不能在初始化列表初始化成员变量

private:
	void CommonInit() {}
	int a_;
	double b_;
};

在上面的代码中X(int a)X(double b)都委托了X()作为代理构造函数,但是它们又打算初始化自己所需的成员变量,这样就导致了编译错误。其实这个错误很容易理解,因为根据C++标准规定,一旦类型有一个构造函数完成执行,那么就会认为其构造的对象已经构造完成。将这个规则放在这里来看,委托构造函数将控制权交给代理构造函数,代理构造函数执行完成以后,编译器认为对象已经构造成功,再次执行初始化列表必然会导致不可预知的问题,所以C++标准禁止了这样的语法。

注:CLion 中会提示 An initializer for a delegating constructor must appear alone

  1. 委托构造函数的执行顺序是先执行代理构造函数的初始化列表,然后执行代理构造函数的主体,最后执行委托构造函数的主体,例如:
#include <iostream>

class X
{
public:
	X() : X(0) { InitStep3(); }
	X(int a) : X(a, 0.) { InitStep2(); }
	X(double b) : X(0, b) {}
	X(int a, double b) : a_(a), b_(b) { InitStep1(); }
private:
	void InitStep1() { std::cout << "InitStep1()" << std::endl; }
	void InitStep2() { std::cout << "InitStep2()" << std::endl; }
	void InitStep3() { std::cout << "InitStep3()" << std::endl; }
	int a_;
	double b_;
};

int main()
{
	X x;
}

编译执行以上代码,输出结果如下:

InitStep1()
InitStep2()
InitStep3()
  1. 如果在代理构造函数执行完成后,委托构造函数主体抛出了异常,则自动调用该类型的析构函数。这一条规则看起来有些奇怪,因为通常在没有完成构造函数的情况下,也就是说构造函数发生异常,对象类型的析构函数是不会被调用的。而这里的情况正好是一种中间状态,是否应该调用析构函数看似存在争议,其实不然,因为C++标准规定(规则3也提到过),一旦类型有一个构造函数完成执行,那么就会认为其构造的对象已经构造完成,所以发生异常后需要调用析构函数,来看一看具体的例子:
#include <iostream>

class X
{
public:
	X() : X(0, 0.) { throw 1; }
	X(int a) : X(a, 0.) {}
	X(double b) : X(0, b) {}
	X(int a, double b) : a_(a), b_(b) { CommonInit(); }
	~X() { std::cout << "~X()" << std::endl; }
private:
	void CommonInit() {}
	int a_;
	double b_;
};

int main()
{
	try {
		X x;
	}
	catch (...) {
	}
}

上面的代码中,构造函数X()委托构造函数X(int a, double b)对对象进行初始化,在代理构造函数初始化完成后,在X()主体内抛出了一个异常。这个异常会被main函数的try catch捕获,并且调用X的析构函数析构对象。读者不妨自己编译运行代码,并观察运行结果。

补充:Java 中的构造函数也是可以互相调用的,而 Kotlin 中的构造函数分成了主次构造函数,并且次构造函数必须调用主构造函数。

// Java 中的构造函数互相调用
public class X {
	private int a; 
	private double b; 
	public X(int a) { 
		this(a, 0.0); // 通过 this 互相调用
	}
	public X(double b) { 
		this(0, b); // 通过 this 互相调用
	}
	public X(int a, double b) { 
		this.a = a;
		this.b = b; 
	}
}
// Kotlin 中的构造函数互相调用
class X(val a: Int, val b: Double) { // 主构造函数
	constructor(a: Int) : this(a,0.0) // 副构造函数
	constructor(b: Double) : this(0, b) // 副构造函数
}

3. 委托模板构造函数

委托模板构造函数是指一个构造函数将控制权委托到同类型的一个模板构造函数,简单地说,就是代理构造函数是一个函数模板。这样做的意义在于泛化了构造函数,减少冗余的代码的产生。将代理构造函数编写成函数模板往往会获得很好的效果,让我们看一看例子:

#include <vector>
#include <list>
#include <deque>

class X {
	template<class T> X(T first, T last) : l_(first, last) { }
	std::list<int> l_;
public:
	X(std::vector<short>&);
	X(std::deque<int>&);
};
X::X(std::vector<short>& v) : X(v.begin(), v.end()) { }
X::X(std::deque<int>& v) : X(v.begin(), v.end()) { }

int main()
{
	std::vector<short> a{ 1,2,3,4,5 };
	std::deque<int> b{ 1,2,3,4,5 };
	X x1(a);
	X x2(b);
}

在上面的代码中,template<class T> X(T first, T last) 是一个代理模板构造函数,X(std::vector<short>&)X(std::deque<int>&) 将控制权委托给了它。这样一来,我们就无须编写 std::vector<short>std::deque<int> 版本的代理构造函数。后续增加委托构造函数也不需要修改代理构造函数,只需要保证参数类型支持迭代器就行了。

4. 捕获委托构造函数的异常

当使用 Function-try-block 去捕获委托构造函数异常时,其过程和捕获初始化列表异常如出一辙。如果一个异常在代理构造函数的初始化列表或者主体中被抛出,那么委托构造函数的主体将不再被执行,与之相对的,控制权会交到异常捕获的catch代码块中:

#include <iostream>

class X
{
public:
	X() try : X(0) {}
	catch (int e)
	{
		std::cout << "catch: " << e << std::endl;
		throw 3;
	}
	X(int a) try : X(a, 0.) {}
	catch (int e)
	{
		std::cout << "catch: " << e << std::endl;
		throw 2;
	}
	X(double b) : X(0, b) {}
	X(int a, double b) : a_(a), b_(b) { throw 1; }
private:
	int a_;
	double b_;
};

int main()
{
	try {
		X x;
	}
	catch (int e) {
		std::cout << "catch: " << e << std::endl;
	}
}

编译运行以上代码,输出结果如下:

catch: 1
catch: 2
catch: 3

由于这段代码是一个链式委托构造,X() 委托到 X(int a)X(int a) 委托到 X(int a, double b)。因此在 X(int a, double b) 发生异常的时候,会以相反的顺序抛出异常。

补充: Function-try-block 指的是下面这样的写法,即在函数后面加上 try 关键字来捕获函数体中抛出的异常

int get_number(int x) try {
 	return x;
} catch(...) { 
	return x; 
}

5. 委托参数较少的构造函数

看了以上各种示例代码,读者是否发现一个特点:将参数较少的构造函数委托给参数较多的构造函数。通常情况下我们建议这么做,因为这样做的自由度更高。但是,并不是完全否定从参数较多的构造函数委托参数较少的构造函数的意义。这种情况通常发生在构造函数的参数必须在函数体中使用的场景。以std::fstream作为例子:

basic_fstream();
explicit basic_fstream(const char* s, ios_base::openmode mode);

basic_fstream 的这两个构造函数,由于 basic_fstream(const char * s, ios_base::openmode mode) 需要在构造函数体内执行具体打开文件的操作,所以它完全可以委托 basic_fstream() 来完成一些最基础的初始化工作,最后执行到自己的主体时再打开文件:

basic_fstream::basic_fstream(const char* s, ios_base::openmode mode) : basic_fstream()
{
	if (open(s, mode) == 0)
		setstate(failbit);
}

总结

为了解决构造函数冗余的问题,C++委员会想了很多办法,本章介绍的委托构造函数就是其中之一,也是最重要的方法。通过委托构造函数,我们可以有效地减少构造函数重复初始化数据成员的问题,将初始化工作统一地交给某个构造函数来完成。这样在需要增减和修改数据成员的时候就只需要修改代理构造函数即可。不止如此,委托构造函数甚至支持通过模板来进一步简化编写多余构造函数的工作,可以说该特性对于复杂类结构是非常高效且实用的。

十三、using Base::Base 继承构造函数(C++11)

1. 继承关系中构造函数的困局

相信读者在编程经历中一定遇到过下面的问题,假设现在有一个类Base 提供了很多不同的构造函数。某一天,你发现 Base 无法满足未来业务需求,需要把 Base 作为基类派生出一个新类 Derived 并且对某些函数进行改造以满足未来新的业务需求,比如下面的代码:

class Base {
public:
	Base() : x_(0), y_(0.) {};
	Base(int x, double y) : x_(x), y_(y) {}
	Base(int x) : x_(x), y_(0.) {}
	Base(double y) : x_(0), y_(y) {}
	void SomeFunc() {}
private:
	int x_;
	double y_;
};


class Derived : public Base {
public:
	Derived() {};
	Derived(int x, double y) : Base(x, y) {}
	Derived(int x) : Base(x) {}
	Derived(double y) : Base(y) {}
	void SomeFunc() {}
};

基类 BaseSomeFunc 无法满足当前的业务需求,于是在其派生类 Derived 中重写了这个函数,但令人头痛的是,面对 Base 中大量的构造函数,我们不得不在 Derived 中定义同样多的构造函数,目的仅仅是转发构造参数,因为派生类本身并没有需要初始化的数据成员。单纯地转发构造函数不仅会导致代码的冗余,而且大量重复的代码也会让程序更容易出错。实际上,这个工作完全可以让编译器自动完成,因为它实在太简单了,让编译器代劳不仅消除了代码冗余而且意图上也更加明确。

2. 使用继承构造函数

我们都知道C++中可以使用using关键字将基类的函数引入派生类,比如:

class Base {
public:
	void foo(int) {}
};

class Derived : public Base {
public:
	using Base::foo;
	void foo(char*) {}
};

int main()
{
	Derived d;
	d.foo(5);
}

C++11的继承构造函数正是利用了这一点,将using关键字的能力进行了扩展,使其能够引入基类的构造函数:

class Base {
public:
	Base() : x_(0), y_(0.) {};
	Base(int x, double y) : x_(x), y_(y) {}
	Base(int x) : x_(x), y_(0.) {}
	Base(double y) : x_(0), y_(y) {}
private:
	int x_;
	double y_;
};


class Derived : public Base {
public:
	using Base::Base;
};

在上面的代码中,派生类 Derived 使用 using Base::Base 让编译器为自己生成转发到基类的构造函数,从结果上看这种实现方式和前面人工编写代码转发构造函数没有什么区别,但是在过程上代码变得更加简洁易于维护了。

使用继承构造函数虽然很方便,但是还有6条规则需要注意。

  1. 派生类是隐式继承基类的构造函数,所以只有在程序中使用了这些构造函数,编译器才会为派生类生成继承构造函数的代码。
  2. 派生类不会继承基类的默认构造函数和复制构造函数。这一点乍看有些奇怪,但仔细想想也是顺理成章的。因为在C++语法规则中,执行派生类默认构造函数之前一定会先执行基类的构造函数。同样的,在执行复制构造函数之前也一定会先执行基类的复制构造函数。所以继承基类的默认构造函数和默认复制构造函数的做法是多余的,这里不会这么做。
  3. 继承构造函数不会影响派生类默认构造函数的隐式声明,也就是说对于继承基类构造函数的派生类编译器依然会为其自动生成默认构造函数的代码
  4. 在派生类中声明签名相同的构造函数会禁止继承相应的构造函数。这一条规则不太好理解,让我们结合代码来看一看:
#include <iostream>

class Base {
public:
	Base() : x_(0), y_(0.) {};
	Base(int x, double y) : x_(x), y_(y) {}
	Base(int x) : x_(x), y_(0.) { std::cout << "Base(int x)" << std::endl; }
	Base(double y) : x_(0), y_(y) { std::cout << "Base(double y)" << std::endl; }
private:
	int x_;
	double y_;
};

class Derived : public Base {
public:
	using Base::Base;
	Derived(int x) { std::cout << "Derived(int x)" << std::endl; }
};

int main()
{
	Derived d(5);
	Derived d1(5.5);
}

输出:

Derived(int x)
Base(double y)

在上面的代码中,派生类 Derived 使用 using Base::Base 继承了基类的构造函数,但是由于 Derived 定义了构造函数 Derived(int x),该函数的签名与基类的构造函数 Base(int x) 相同,因此这个构造函数的继承被禁止了,Derived d(5) 会调用派生类的构造函数并且输出 "Derived(int x)"。另外,这个禁止动作并不会影响到其他签名的构造函数,Derived d1(5.5) 依然可以成功地使用基类的构造函数进行构造初始化。

  1. 派生类继承多个签名相同的构造函数会导致编译失败:
#include <iostream>

class Base1 {
public:
	Base1(int) { std::cout << "Base1(int x)" << std::endl; };
};

class Base2 {
public:
	Base2(int) { std::cout << "Base2(int x)" << std::endl; };
};

class Derived : public Base1, Base2 {
public:
	using Base1::Base1;
	using Base2::Base2;
};

int main()
{
	Derived d(5); // 编译报错,存在二义性
}

在上面的代码中,Derived 继承了两个类 Base1Base2,并且继承了它们的构造函数。但是由于这两个类的构造函数 Base1(int)Base2(int) 拥有相同的签名,导致编译器在构造对象的时候不知道应该使用哪一个基类的构造函数,因此在编译时给出一个二义性错误。

  1. 继承构造函数的基类构造函数不能为私有:
class Base {
	Base(int) {}
public:
	Base(double) {}
};

class Derived : public Base {
public:
	using Base::Base;
};

int main()
{
	Derived d(5.5);
	Derived d1(5); // 编译报错
}

在上面的代码中,Derived d1(5) 无法通过编译,因为它对应的基类构造函数 Base(int) 是一个私有函数,Derived d(5.5) 则没有这个问题。

最后再介绍一个有趣的问题,在早期的C++11编译器中,继承构造函数会把基类构造函数注入派生类,于是导致了这样一个问题:

#include <iostream>

struct Base {
	Base() = default;
	template<typename T> Base(T, typename T::type = 0)
	{
		std::cout << "Base(T, typename T::type)" << std::endl;
	}
	Base(int) { std::cout << "Base(int)" << std::endl; }
};

struct Derived : Base {
	using Base::Base;
	Derived(int) { std::cout << "Derived(int)" << std::endl; }
};

int main()
{
	Derived d(42L);
}

上面这段代码用早期的编译器(比如 GCC 6.4)编译运行的输出结果是 "Base(int)",而用新的 GCC 编译运行的输出结果是 "Derived(int)"。在老的版本中,template<typename T> Base(T, typename T::type = 0) 被注入派生类中,形成了这样两个构造函数:

template<typename T> Derived(T);
template<typename T> Derived(T, typename T::type);

这是因为继承基类构造函数时,不会继承默认参数,而是在派生类中注入带有各种参数数量的构造函数的重载集合。于是,编译器理所当然地选择推导 Derived(T)Derived(long) 作为构造函数。在构造基类时,由于 Base(long, typename long::type = 0) 显然是一个非法的声明,因此编译器选择使用 Base(int) 作为基类的构造函数。最终结果就是我们看到的输出了 “Base(int)”。而在新版本中继承构造函数不会注入派生类,所以不存在这个问题,编译器会直接使用派生类的 Derived(int) 构造函数构造对象。

总结

本章介绍了继承构造函数特性,与委托构造函数特性委托本类中的构造函数不同,该特性用于有继承关系的派生类中,让派生类能够直截了当地使用基类构造函数,而不需要为每个派生类的构造函数反复编写继承的基类构造函数。至此,所有简化构造函数、消除构造函数的代码冗余的特性均已介绍完毕,它们分别是非静态数据成员默认初始化、委托构造函数以及本章介绍的继承构造函数。

十四、enum class 强枚举类型(C++11 C++17 C++20)

1. 枚举类型的弊端

C++之父本贾尼·斯特劳斯特卢普曾经在他的The Design And Evolution Of C++一书中写道“C enumerations constitute a curiously halfbaked concept.”。翻译过来就是“C语言的枚举类型构成了一个奇怪且半生不熟的概念”,可见这位C++之父对于enum类型的现状是不满意的,主要原因是enum类型破坏了C++的类型安全。大多数情况下,我们说C++是一门类型安全的强类型语言,但是枚举类型在一定程度上却是一个例外,具体来说有以下几个方面的原因。

首先,虽然枚举类型存在一定的安全检查功能,一个枚举类型不允许分配到另外一种枚举类型,而且整型也无法隐式转换成枚举类型。但是枚举类型却可以隐式转换为整型,因为C++标准文档提到“枚举类型可以采用整型提升的方法转换成整型”。请看下面的代码示例:

enum School {
  principal,
  teacher,
  student
};

enum Company {
  chairman,
  manager,
  employee
};

int main()
{
  School x = student;
  Company y = manager;
  bool b = student >= manager;	// 不同类型之间的比较操作
  b = x < employee;
  int y = student;				// 隐式转换为int
}

在上面的代码中,两个不同类型的枚举标识符 studentmanager 可以进行比较,这在 C++ 语言的其他类型中是很少看到的。这种比较合法的原因是枚举类型先被隐式转换为整型,然后才进行比较。同样的问题也出现在 student 直接赋值到 int 类型变量上的情况中。另外,下面的代码会触发 C++ 对枚举的检查,它们是无法编译通过的:

School x = chairman; // 类型不匹配,无法通过编译
Company y = student; // 类型不匹配,无法通过编译
x = 1; // 整型无法隐式转换到枚举类型

然后是枚举类型的作用域问题,枚举类型会把其内部的枚举标识符导出到枚举被定义的作用域。也是就说,我们使用枚举标识符的时候,可以跳过对于枚举类型的描述:

School x = student;
Company y = manager;

无论是初始化 x,还是初始化 y,我们都没有对 studentmanager 的枚举类型进行描述。因为它们已经跳出了 SchoolCompany。在我们看到的第一个例子中,这没有什么问题,两种类型相安无事。但是如果遇到下面的这种情况就会让人头痛了:

enum HighSchool {
	student,
	teacher,
	principal
};
enum University {
	student,
	professor,
	principal
};

HighSchoolUniversity 都有 studentprincipal,而枚举类型又会将其枚举标识符导出到定义它们的作用域,这样就会发生重复定义,无法通过编译。解决此类问题的一个办法是使用命名空间,例如:

enum HighSchool {
	student,
	teacher,
	principal
};
namespace AcademicInstitution
{
	enum University {
		student,
		professor,
		principal
	};
}

这样一来, University 的枚举标识符就会被导出到 AcademicInstitution 的作用域,和 HighSchool 的全局作用域区分开来。

对于上面两个问题,有一个比较好但并不完美的解决方案,代码如下:

#include <iostream>

class AuthorityType {
 enum InternalType
 {
     ITBan,
     ITGuest,
     ITMember,
     ITAdmin,
     ITSystem,
 };

 InternalType self_;

public:
 AuthorityType(InternalType self) : self_(self) {}

 bool operator < (const AuthorityType &other) const
 {
     return self_ < other.self_;
 }

 bool operator > (const AuthorityType &other) const
 {
     return self_ > other.self_;
 }

 bool operator <= (const AuthorityType &other) const
 {
     return self_ <= other.self_;
 }

 bool operator >= (const AuthorityType &other) const
 {
     return self_ >= other.self_;
 }

 bool operator == (const AuthorityType &other) const
 {
     return self_ == other.self_;
 }

 bool operator != (const AuthorityType &other) const
 {
     return self_ != other.self_;
 }

 const static AuthorityType System, Admin, Member, Guest, Ban;
};

#define DEFINE_AuthorityType(x) const AuthorityType \
 AuthorityType::x(AuthorityType::IT ## x)
DEFINE_AuthorityType(System);
DEFINE_AuthorityType(Admin);
DEFINE_AuthorityType(Member);
DEFINE_AuthorityType(Guest);
DEFINE_AuthorityType(Ban);

int main()
{
 bool b = AuthorityType::System > AuthorityType::Admin;
 std::cout << std::boolalpha << b << std::endl;
}

让我们先看一看以上代码的优点。

将枚举类型变量封装成类私有数据成员,保证无法被外界访问。访问枚举类型的数据成员必须通过对应的常量静态对象。另外,根据 C++ 标准的约束,访问静态对象必须指明对象所属类型。也就是说,如果我们想访问 ITSystem 这个枚举标识符,就必须访问常量静态对象 System,而访问 System 对象,就必须说明其所属类型,这使我们需要将代码写成 AuthorityType::System 才能编译通过。

由于我们实现了比较运算符,因此可以对枚举类型进行比较。但是比较运算符函数只接受同类型的参数,所以只允许相同类型进行比较。

当然很明显,这样做也有缺点。

  • 最大的缺点是实现起来要多敲很多代码。
  • 枚举类型本身是一个POD类型,而我们实现的类破坏了这种特
    性。

还有一个严重的问题是,无法指定枚举类型的底层类型。因此,不同的编译器对于相同枚举类型可能会有不同的底层类型,甚至有无符号也会不同。来看下面这段代码:

#include <iostream>

enum E {
 e1 = 1,
 e2 = 2,
 e3 = 0xfffffff0
};

int main()
{
 bool b = e1 < e3;
 std::cout << std::boolalpha << b << std::endl;
}

读者可以思考一下,上面这段代码的输出结果是什么?答案是不同的编译器会得到不同的结果。在 GCC 中,结果返回 true,我们可以认为 E 的底层类型为 unsigned int。如果输出 e3,会发现其值为 4294967280。但是在 MSVC 中结果输出为 false,很明显在编译器内部将 E 定义为了 int 类型,输出 e3 的结果为 -16。这种编译器上的区别会使在编写跨平台程序时出现重大问题。

虽然说了这么多枚举类型存在的问题,但是我这里想强调一个观点,如果代码中有需要表达枚举语义的地方,还是应该使用枚举类型。原因就是在第一个问题中讨论的,枚举类型还是有一定的类型检查能力。我们应该避免使用宏和const int的方法去实现枚举,因为其缺点更加严重。

值得一提的是,枚举类型缺乏类型检查的问题倒是成就了一种特殊用法。如果读者了解模板元编程,那么肯定见过一种被称为enum hack的枚举类型的用法。简单来说就是利用枚举值在编译期就能确定下来的特性,让编译器帮助我们完成一些计算:

#include <iostream>
template<int a, int b>
struct add {
    enum {
        result = a + b
    };
};

int main()
{
    std::cout << add<5, 8>::result << std::endl;
}

用GCC查看其GIMPLE的中间代码:

main ()
{
  int D.39267;
  _1 = std::basic_ostream<char>::operator<< (&cout, 13);
  std::basic_ostream<char>::operator<< (_1, endl);
  D.39267 = 0;
  return D.39267;
}

可以看到add<5, 8>::result在编译器编译代码的时候就已经计算出来了,运行时直接使用<<运算符输出结果13

2. 使用强枚举类型

由于枚举类型确实存在一些类型安全的问题,因此C++标准委员会在C++11标准中对其做出了重大升级,增加了强枚举类型。另外,为了保证老代码的兼容性,也保留了枚举类型之前的特性。

强枚举类型具备以下3个新特性:

  1. 枚举标识符属于强枚举类型的作用域。
  2. 枚举标识符不会隐式转换为整型。
  3. 能指定强枚举类型的底层类型,底层类型默认为int类型。

定义强枚举类型的方法非常简单,只需要在枚举定义的 enum 关键字之后加上 class 关键字就可以了。下面将 HighSchoolUniversity 改写为强枚举类型:

#include <iostream>

enum class HighSchool {
    student,
    teacher,
    principal
};

enum class University {
    student,
    professor,
    principal
};

int main()
{
    HighSchool x = HighSchool::student;
    University y = University::student;
    bool b = x < HighSchool::headmaster; // 编译失败,找不到 headmaster
    std::cout << std::boolalpha << b << std::endl;
}

观察上面的代码可以发现,首先,在不使用命名空间的情况下,两个有着相同枚举标识符的强枚举类型可以在一个作用域内共存。这符合强枚举类型的第一个特性,其枚举标识符属于强枚举类型的作用域,无法从外部直接访问它们,所以在访问时必须加上枚举类型名,否则会编译失败,如 HighSchool::student
其次,相同枚举类型的枚举标识符可以进行比较,但是不同枚举类型就无法比较其枚举标识符了,因为它们失去了隐式转换为整型的能力,这一点符合强枚举类型的第二个特性:

HighSchool x = student; // 编译失败,找不到student的定义
bool b = University::student < HighSchool::student;// 编译失败,比较的类型不同
int y = University::student; // 编译失败,无法隐式转换为int类型

有了这两个特性的支持,强枚举类型就可以完美替代14.1节中实现的AuthorityType类,强枚举类型不仅实现起来非常简洁,而且还是POD类型。

对于强枚举类型的第三个特性,我们可以在定义类型的时候使用:符号来指明其底层类型。利用它可以消除不同编译器带来的歧义:

#include <iostream>

enum class E : unsigned int {
    e1 = 1,
    e2 = 2,
    e3 = 0xfffffff0
};

int main()
{
    bool b = E::e1 < E::e3;
    std::cout << std::boolalpha << b << std::endl;
}

上面这段代码明确指明了枚举类型 E 的底层类型是无符号整型,这样一来无论使用 GCC 还是 MSVC,最后返回的结果都是 true。如果这里不指定具体的底层类型,编译器会使用 int 类型。但 GCC 和 MSVC 的行为又出现了一些区别:MSVC 会编译成功, e3 被编译为一个负值;而 GCC 则会报错,因为 0xfffffff0 超过了 int 能表达的最大正整数范围。

在C++11标准中,我们除了能指定强枚举类型的底层类型,还可以指定枚举类型的底层类型,例如:

#include <iostream>

enum E : unsigned int {
    e1 = 1,
    e2 = 2,
    e3 = 0xfffffff0
};

int main()
{
    bool b = e1 < e3;
    std::cout << std::boolalpha << b << std::endl;
}

另外,虽然我们多次强调了强枚举类型的枚举标识符是无法隐式转换为整型的,但还是可以通过static_cast对其进行强制类型转换,但我建议不要这样做。最后说一点,强枚举类型不允许匿名,我们必须给定一个类型名,否则无法通过编译。

3. 列表初始化有底层类型枚举对象

从C++17标准开始,对有底层类型的枚举类型对象可以直接使用列表初始化。这条规则适用于所有的强枚举类型,因为它们都有默认的底层类型int,而枚举类型就必须显式地指定底层类型才能使用该特性:

enum class Color {
	Red,
	Green,
	Blue
};
int main()
{
	Color c{ 5 };		// 编译成功
	Color c1 = 5;		// 编译失败
	Color c2 = { 5 };	// 编译失败
	Color c3(5);		// 编译失败
}

在上面的代码中,c 可以在 C++17 环境下成功编译运行,因为 Color 有默认底层类型 int,所以能够通过列表初始化对象,但是 c1c2c3 就没有那么幸运了,它们的初始化方法都是非法的。同样的道理,下面的代码能编译通过:

enum class Color1 : char {};
enum Color2 : short {};

int main()
{
	Color1 c{ 7 };
	Color2 c1{ 11 };
	Color2 c2 = Color2{ 5 };
}

请注意,虽然 Color2 c2 = Color2{ 5 }Color c2 = { 5 } 在代码上有些类似,但是其含义是完全不同的。对于 Color2 c2 = Color2{ 5 } 来说,代码先通过列表初始化了一个临时对象,然后再赋值到 c2,而 Color c2 = { 5 } 则没有这个过程。另外,没有指定底层类型的枚举类型是无法使用列表初始化的,比如:

enum Color3 {};

int main()
{
	Color3 c{ 7 };
}

以上代码一定会编译报错,因为无论是C++17还是在此之前的标准,Color3都没有底层类型。同所有的列表初始化一样,它禁止缩窄转换,所以下面的代码也是不允许的:

enum class Color1 : char {};

int main()
{
	Color1 c{ 7.11 };
}

到此为止,读者应该都会有这样一个疑问,C++11标准中对强枚举类型初始化做了严格限制,目的就是防止枚举类型的滥用。可是C++17又打破了这种严格的限制,我们似乎看不出这样做的好处。实际上,让有底层类型的枚举类型支持列表初始化的确有一个十分合理的动机。

现在假设一个场景,我们需要一个新整数类型,该类型必须严格区别于其他整型,也就是说不能够和其他整型做隐式转换,显然使用typedef的方法是不行的。另外,虽然通过定义一个类的方法可以到达这个目的,但是这个方法需要编写大量的代码来重载运算符,也不是一个理想的方案。所以,C++的专家把目光投向了有底层类型的枚举类型,其特性几乎完美地符合以上要求,除了初始化整型值的时候需要用到强制类型转换。于是,C++17为有底层类型的枚举类型放宽了初始化的限制,让其支持列表初始化:

#include <iostream>
enum class Index : int {};

int main()
{
	Index a{ 5 };
	Index b{ 10 };
	// a = 12; // 编译失败
	// int c = b; // 编译失败
	std::cout << "a < b is " << std::boolalpha << (a < b) << std::endl;
}

在上面的代码中,定义了 Index 的底层类型为 int,所以可以使用列表初始化 ab,由于 ab 的枚举类型相同,因此所有 a < b 的用法也是合法的。但是 a = 12int c = b 无法成功编译,因为强枚举类型是无法与整型隐式相互转换的

最后提示一点,在C++17的标准库中新引入的std::byte类型就是用这种方法定义的。

4. 使用 using 打开强枚举类型

C++20标准扩展了using功能,它可以打开强枚举类型的命名空间。在一些情况下,这样做会让代码更加简洁易读,例如:

enum class Color {
	Red,
	Green,
	Blue
};

const char* ColorToString(Color c)
{
	switch (c)
	{
		case Color::Red: return "Red";
		case Color::Green: return "Green";
		case Color::Blue: return "Blue";
		default:
			return "none";
	}
}

在上面的代码中,函数 ColorToString 中需要不断使用 Color:: 来指定枚举标识符,这显然会让代码变得冗余。通过 using 我们可以简化这部分代码:

const char* ColorToString(Color c)
{
	switch (c)
	{
		using enum Color;
		case Red: return "Red";
		case Green: return "Green";
		case Blue: return "Blue";
		default:
			return "none";
	}
}

以上代码使用 using enum Color;Color 中的枚举标识符引入 switch-case 作用域。请注意,switch-case 作用域之外依然需要使用 Color:: 来指定枚举标识符。除了引入整个枚举标识符之外,using 还可以指定引入的标识符,例如:

const char* ColorToString(Color c)
{
	switch (c)
	{
		using Color::Red;
		case Red: return "Red";
		case Color::Green: return "Green";
		case Color::Blue: return "Blue";
		default:
			return "none";
	}
}

十五、扩展的聚合类型(C++17 C++20)

1. 聚合类型的新定义

C++17标准对聚合类型的定义做出了大幅修改,即从基类公开且非虚继承的类也可能是一个聚合。同时聚合类型还需要满足常规条件。

  1. 没有用户提供的构造函数。
  2. 没有私有和受保护的非静态数据成员。
  3. 没有虚函数

在新的扩展中,如果类存在继承关系,则额外满足以下条件。

  1. 必须是公开的基类,不能是私有或者受保护的基类。
  2. 必须是非虚继承。

请注意,这里并没有讨论基类是否需要是聚合类型,也就是说基类是否是聚合类型与派生类是否为聚合类型没有关系,只要满足上述5个条件,派生类就是聚合类型。在标准库<type_traits>中提供了一个聚合类型的甄别办法is_aggregate,它可以帮助我们判断目标类型是否为聚合类型:

#include <iostream>
#include <string>

class MyString : public std::string {};

int main()
{
	std::cout << "std::is_aggregate_v<std::string> = "
		<< std::is_aggregate_v<std::string> << std::endl;
	std::cout << "std::is_aggregate_v<MyString> = "
		<< std::is_aggregate_v<MyString> << std::endl;
}

std::is_aggregate_v<std::string> = 0
std::is_aggregate_v<MyString> = 1

在上面的代码中,先通过 std::is_aggregate_v 判断 std::string 是否为聚合类型,根据我们对 std::string 的了解,它存在用户提供的构造函数,所以一定是非聚合类型。然后判断类 MyString 是否为聚合类型,虽然该类继承了 std::string,但因为它是公开继承且是非虚继承,另外,在类中不存在用户提供的构造函数、虚函数以及私有或者受保护的数据成员,所以 MyString 应该是聚合类型。

2. 聚合类型的初始化

由于聚合类型定义的扩展,聚合对象的初始化方法也发生了变化。过去要想初始化派生类的基类,需要在派生类中提供构造函数,例如:

#include <iostream>
#include <string>

class MyStringWithIndex : public std::string {
public:
	MyStringWithIndex(const std::string& str, int idx) : std::string(str), index_(idx) {}
	int index_ = 0;
};

std::ostream& operator << (std::ostream &o, const MyStringWithIndex& s)
{
	o << s.index_ << ":" << s.c_str();
	return o;
}

int main()
{
	MyStringWithIndex s("hello world", 11);
	std::cout << s << std::endl;
}

在上面的代码中,为了初始化基类我们不得不为 MyStringWithIndex 提供一个构造函数,用构造函数的初始化列表来初始化 std::string。现在,由于聚合类型的扩展,这个过程得到了简化。需要做的修改只有两点,第一是删除派生类中用户提供的构造函数,第二是直接初始化:

#include <iostream>
#include <string>

class MyStringWithIndex : public std::string {
public:
	int index_ = 0;
};

std::ostream& operator << (std::ostream &o, const MyStringWithIndex& s)
{
	o << s.index_ << ":" << s.c_str();
	return o;
}

int main()
{
	MyStringWithIndex s{ {"hello world"}, 11 };
	std::cout << s << std::endl;
}

删除派生类中用户提供的构造函数是为了让 MyStringWithIndex 成为一个C++17标准的聚合类型,而作为聚合类型直接使用大括号初始化即可。MyStringWithIndex s{ {"hello world"}, 11} 是典型的初始化基类聚合类型的方法。其中 {"hello world"} 用于基类的初始化,11 用于 index_ 的初始化。这里的规则总是假设基类是一种在所有数据成员之前声明的特殊成员。所以实际上, {"hello world"} 的大括号也可以省略,直接使用 MyStringWithIndex s{ "hello world", 11} 也是可行的。

MyStringWithIndex s{ "hello world", 11 };

另外,如果派生类存在多个基类,那么其初始化的顺序与继承的顺序相同。

#include <iostream>
#include <string>

class Count {
public:
	int Get() { return count_++; }
	int count_ = 0;
};

class MyStringWithIndex : public std::string, public Count {
public:
	int index_ = 0;
};

std::ostream& operator << (std::ostream &o, MyStringWithIndex& s)
{
	o << s.index_ << ":" << s.Get() << ":" << s.c_str();
	return o;
}

int main()
{
	MyStringWithIndex s{ "hello world", 7, 11 };
	std::cout << s << std::endl;
	std::cout << s << std::endl;
}

3. 扩展聚合类型的兼容问题

虽然扩展的聚合类型给我们提供了一些方便,但同时也带来了一个兼容老代码的问题,请考虑以下代码:

#include <iostream>
#include <string>

class BaseData {
	int data_;
public:
	int Get() { return data_; }
protected:
	BaseData() : data_(11) {}
};

class DerivedData : public BaseData {
public:
};

int main()
{
	DerivedData d{};
	std::cout << d.Get() << std::endl;
}

以上代码使用C++11或者C++14标准可以编译成功,而使用C++17标准编译则会出现错误,主要原因就是聚合类型的定义发生了变化。在C++17之前,类 DerivedData 不是一个聚合类型,所以 DerivedData d{} 会调用编译器提供的默认构造函数。调用 DerivedData 默认构造函数的同时还会调用 BaseData 的构造函数。虽然这里 BaseData 声明的是受保护的构造函数,但是这并不妨碍派生类调用它。从C++17开始情况发生了变化,类 DerivedData 变成了一个聚合类型,以至于 DerivedData d{} 也跟着变成聚合类型的初始化,因为基类 BaseData 中的构造函数是受保护的关系,它不允许在聚合类型初始化中被调用,所以编译器无奈之下给出了一个编译错误。如果读者在更新开发环境到C++17标准的时候遇到了这样的问题,只需要为派生类提供一个默认构造函数即可

class DerivedData : public BaseData {
public:
    DerivedData() {}
};

4. 禁止聚合类型使用用户声明的构造函数

在前面我们提到没有用户提供的构造函数是聚合类型的条件之一,但是请注意,用户提供的构造函数和用户声明的构造函数是有区别的,比如:

#include <iostream>
struct X {
	X() = default;
};

struct Y {
	Y() = delete;
};

int main() {
	std::cout << std::boolalpha 
		<< "std::is_aggregate_v<X> : " << std::is_aggregate_v<X> << std::endl
		<< "std::is_aggregate_v<Y> : " << std::is_aggregate_v<Y> << std::endl;
}

用C++17标准编译运行以上代码会输出:

std::is_aggregate_v<X> : true
std::is_aggregate_v<Y> : true

由此可见,虽然类XY都有用户声明的构造函数,但是它们依旧是聚合类型。不过这就引出了一个问题,让我们将目光放在结构体Y上,因为它的默认构造函数被显式地删除了,所以该类型应该无法实例化对象,例如:

Y y1; // 编译失败,使用了删除函数

但是作为聚合类型,我们却可以通过聚合初始化的方式将其实例化:

Y y2{}; // 编译成功

编译成功的这个结果显然不是类型Y的设计者想看到的,而且这个问题很容易在真实的开发过程中被忽略,从而导致意想不到的结果。

除了删除默认构造函数,将其列入私有访问中也会有同样的问题,比如:

struct Y {
private:
	Y() = default;
};

Y y1; // 编译失败,构造函数为私有访问
y y2{}; // 编译成功

请注意,这里 Y() = default; 中的 = default 不能省略,否则 Y 会被识别为一个非聚合类型。

为了避免以上问题的出现,在C++17标准中可以使用 explicit 说明符或者将 = default 声明到结构体外,例如:

struct X {
	explicit X() = default;
};
struct Y {
	Y();
};
Y::Y() = default;

这样一来,结构体X和Y被转变为非聚合类型,也就无法使用聚合初始化了。不过即使这样,还是没有解决相同类型不同实例化方式表现不一致的尴尬问题,所以在C++20标准中禁止聚合类型使用用户声明的构造函数,这种处理方式让所有的情况保持一致,是最为简单明确的方法。同样是本节中的第一段代码示例,用C++20环境编译的输出结果如下:

std::is_aggregate_v<X> : false
std::is_aggregate_v<Y> : false

值得注意的是,这个规则的修改会改变一些旧代码的意义,比如我们经常用到的禁止复制构造的方法:

struct X {
	std::string s;
	std::vector<int> v;
	X() = default;
	X(const X&) = delete;
	X(X&&) = default;
};

上面这段代码中结构体X在C++17标准中是聚合类型,所以可以使用聚合类型初始化对象。但是升级编译环境到C++20标准会使X转变为非聚合对象,从而造成无法通过编译的问题。一个可行的解决方案是,不要直接使用= delete;来删除复制构造函数,而是通过加入或者继承一个不可复制构造的类型来实现类型的不可复制,例如:

struct X {
	std::string s;
	std::vector<int> v;
	[[no_unique_address]] NonCopyable nc;
};

// 或者
struct X : NonCopyable {
	std::string s;
	std::vector<int> v;
};

这种做法能让代码看起来更加简洁,所以我们往往会被推荐这样做。

5. 使用带小括号的列表初始化聚合类型对象

通过15.2节,我们知道对于一个聚合类型可以使用带大括号的列表对其进行初始化,例如:

struct X {
	int i;
	float f;
};
X x{ 11, 7.0f };

如果将上面初始化代码中的大括号修改为小括号,C++17标准的编译器会给出无法匹配到对应构造函数X::X(int, float)的错误,这说明小括号会尝试调用其构造函数。这一点在C++20标准中做出了修改,它规定对于聚合类型对象的初始化可以用小括号列表来完成,其最终结果与大括号列表相同。所以以上代码可以修改为:

X x( 11, 7.0f );

另外,前面的章节曾提到过带大括号的列表初始化是不支持缩窄转换的,但是带小括号的列表初始化却是支持缩窄转换的,比如:

struct X {
	int i;
	short f;
};
X x1{ 11, 7.0 }; // 编译失败,7.0 从 double 转换到 short 是缩窄转换
X x2( 11, 7.0 ); // 编译成功

需要注意的是,到目前为止该特性只在GCC中得到支持,而CLang和MSVC都还没有支持该特性。

总结

虽然本章的内容不多且较为容易理解,但它却是一个比较重要的章节。因为扩展的聚合类型改版了原本聚合类型的定义,这就导致了一些兼容性问题,这种情况在C++新特性中并不多见。如果不能牢固地掌握新定义的知识点,很容易导致代码无法通过编译,更严重的可能是导致代码运行出现逻辑错误,类似这种Bug又往往难以定位,所以对于扩展的聚合类型我们尤其需要重视起来。

十六、override 和 final 说明符(C++11)

1. 重写、重载和隐藏

重写(override)、重载(overload)和隐藏(overwrite)在C++中是3个完全不同的概念,但是在平时的工作交流中,我发现有很多C++程序员对它们的概念模糊不清,经常误用或者混用这3个概念,所
以在说明override说明符之前,我们先梳理一下三者的区别。

  1. 重写(override 的意思更接近覆盖,在C++中是指派生类覆盖了基类的虚函数,这里的覆盖必须满足有相同的函数签名和返回类型,也就是说有相同的函数名、形参列表以及返回类型。
  2. 重载(overload,它通常是指在同一个类中有两个或者两个以上函数,它们的函数名相同,但是函数签名不同,也就是说有不同的形参。这种情况在类的构造函数中最容易看到,为了让类更方便使用,我们经常会重载多个构造函数。
  3. 隐藏(overwrite 的概念也十分容易与上面的概念混淆。隐藏是指基类成员函数,无论它是否为虚函数,当派生类出现同名函数时,如果派生类函数签名不同于基类函数,则基类函数会被隐藏。如果派生类函数签名与基类函数相同,则需要确定基类函数是否为虚函数,如果是虚函数,则这里的概念就是重写;否则基类函数也会被隐藏。另外,如果还想使用基类函数,可以使用using关键字将其引入派生类。

2. 重写引发的问题

在编码过程中,重写虚函数很容易出现错误,原因是C++语法对重写的要求很高,稍不注意就会无法重写基类虚函数。更糟糕的是,即使我们写错了代码,编译器也可能不会提示任何错误信息,直到程序编译成功后,运行测试才会发现其中的逻辑问题,例如:

class Base {
public:
	virtual void some_func() {}
	virtual void foo(int x) {}
	virtual void bar() const {}
	void baz() {}
};

class Derived : public Base {
public:
	virtual void sone_func() {}
	virtual void foo(int &x) {}
	virtual void bar() {}
	virtual void baz() {}
};

以上代码可以编译成功,但是派生类Derived的4个函数都没有触发重写操作。第一个派生类虚函数sone_func的函数名与基类虚函数some_func不同,所以它不是重写。第二个派生类虚函数foo(int &x)的形参列表与基类虚函数foo(int x)不同,所以同样不是重写。第三个派生类虚函数bar()相对于基类虚函数少了常量属性,所以不是重写。最后的基类成员函数baz根本不是虚函数,所以派生类的baz函数也不是重写。

3. 使用 override 说明符

可以看到重写如此容易出错,光靠人力排查避免出错是很困难的,尤其当类的继承关系非常复杂的时候。所以C++11标准提供了一个非常实用的override说明符,这个说明符必须放到虚函数的尾部,它明确告诉编译器这个虚函数需要覆盖基类的虚函数,一旦编译器发现该虚函数不符合重写规则,就会给出错误提示:

class Base {
public:
	virtual void some_func() {}
	virtual void foo(int x) {}
	virtual void bar() const {}
	void baz() {}
};

class Derived : public Base {
public:
	virtual void sone_func() override {}
	virtual void foo(int &x) override {}
	virtual void bar() override {}
	virtual void baz() override {}
};

上面这段代码示例针对前面一节中的示例在派生类虚函数尾部都加上了override说明符,编译后编译器给出了4条错误信息,明确指出这4个函数都无法重写。如此一来,我们可以轻松地找到代码中的错误,而不必等到运行时再慢慢调试排查。override说明符不仅为派生类的编写者提供了方便,对于基类编写者同样也有帮助。假设某个基类需要修改虚函数的形参以确保满足新需求,那么在override的帮助下,基类编写者可以轻松地发现修改基类虚函数的代价。如果没有override说明符,则修改基类虚函数将面临很大的风险,因为编译器不会给出错误提示,我们只能靠测试来检查问题所在。

4. 使用 final 说明符

在C++中,我们可以为基类声明纯虚函数来迫使派生类继承并且重写这个纯虚函数。但是一直以来,C++标准并没有提供一种方法来阻止派生类去继承基类的虚函数。C++11标准引入final说明符解决了上述问题,它告诉编译器该虚函数不能被派生类重写。final说明符用法和override说明符相同,需要声明在虚函数的尾部。

class Base {
public:
	virtual void foo(int x) {}

};

class Derived : public Base {
public:
	void foo(int x) final {};
};

class Derived2 : public Derived {
public:
	void foo(int x) {};
};

在上面的代码中,因为基类Derived的虚函数foo声明为final,所以派生类Derived2重写foo函数的时候编译器会给出错误提示。请注意finaloverride说明符的一点区别,final说明符可以修饰最底层基类的虚函数而override则不行,所以在这个例子中final可以声明基类Base的虚函数foo,只不过我们通常不会这样做。

有时候,overridefinal 会同时出现。这种情况通常是由中间派生类继承基类后,希望后续其他派生类不能修改本类虚函数的行为而产生的,举个例子:

class Base {
public:
	virtual void log(const char *) const {...}
	virtual void foo(int x) {}

};

class BaseWithFileLog : public Base {
public:
	virtual void log(const char *) const override final {...}
};

class Derived : public BaseWithFileLog {
public:
	void foo(int x) {};
};

在上面这段代码中,基类 Base 有一个虚函数 log,它将日志打印到标准输出。为了更好地保存日志,我们创建了一个派生类 BaseWithFileLog,它重写了 log 函数,将日志写入文件。为了确保重写不会出现错误,并且后续的继承者不能修改日志的行为,我们为 log 函数添加了 overridefinal 说明符。这样,后续的派生类 Derived 只能重写虚函数 foo,而无法修改日志函数,确保了日志行为的一致性。

最后要说明的是,final 说明符不仅能声明虚函数,还可以声明类。如果在类定义时使用 final 声明了类,那么这个类将不能被其他类继承作为基类,例如:

class Base final {
public:
	virtual void foo(int x) {}

};

class Derived : public Base {
public:
	void foo(int x) {};
};

在上面的代码中,由于 Base 被声明为 final,因此 Derived 继承 Base 会在编译时出错。

5. override 和 final 说明符的特别之处

为了和过去的 C++ 代码保持兼容,增加保留的关键字需要十分谨慎。因为一旦增加了某个关键字,过去的代码就可能面临大量的修改。所以在 C++11 标准中,overridefinal 并没有被作为保留的关键字,其中 override 只有在虚函数尾部才有意义,而 final 只有在虚函数尾部以及类声明的时候才有意义,因此以下代码仍然可以编译通过:

class X {
public:
	void override() {}
	void final() {}
};

不过,为了避免不必要的麻烦,建议读者不要将它们作为标识符来使用。

总结

本章介绍了overridefinal说明符,虽然它们的语法十分简单,但是却非常实用。尤其是override说明符,它指明类的成员函数必须是一个重写函数,要求编译器检查派生类中的虚函数确实重写了基类中的函数,否则就会引发一个编译错误。通常来说,我们应该用override说明有重写意图的虚函数,以免由于粗心大意造成不必要的错误。

十七、基于范围的 for 循环(C++11 C++17 C++20)

1. 烦琐的容器遍历

通常遍历一个容器里的所有元素会用到for循环和迭代器,在大多数情况下我们并不关心迭代器本身,而且在循环中使用迭代器的模式往往十分固定——获取开始的迭代器、不断更新当前迭代器、将当前迭代器与结束的迭代器作比较以及解引用当前迭代器获取我们真正关心的元素:

std::map<int, std::string> index_map{ {1, "hello"}, {2, "world"}, {3, "!"} };
std::map<int, std::string>::iterator it = index_map.begin();
for (; it != index_map.end(); ++it) {
	std::cout << "key=" << (*it).first << ", value=" << (*it).second << std::endl;
}

从上面的代码可以看到,为了输出index_map中的内容不得不编写很多关于迭代器的代码,但迭代器本身并不是业务逻辑所关心的部分。对于这个问题的一个可行的解决方案是使用标准库提供的 std::for_each 函数,使用该函数只需要提供容器开始和结束的迭代器以及执行函数或者仿函数即可,例如:

std::map<int, std::string> index_map{ {1, "hello"}, {2, "world"}, {3, "!"} };

void print(std::map<int, std::string>::const_reference e)
{
	std::cout << "key=" << e.first << ", value=" << e.second << std::endl;
}

std::for_each(index_map.begin(), index_map.end(), print);

相对于上一段代码,这段代码使用std::for_each遍历容器比直接使用迭代器的方法要简洁许多。实际上单纯的迭代器遍历操作完全可以交给编译器来完成,这样能让程序员专注于业务代码而非迭代器的循环。

2. 基于范围的 for 循环语法

C++11标准引入了基于范围的for循环特性,该特性隐藏了迭代器的初始化和更新过程,让程序员只需要关心遍历对象本身,其语法也比传统for循环简洁很多:

for ( range_declaration : range_expression ) loop_statement

基于范围的for循环不需要初始化语句、条件表达式以及更新表达式,取而代之的是一个范围声明和一个范围表达式。其中范围声明是一个变量的声明,其类型是范围表达式中元素的类型或者元素类型的引用。而范围表达式可以是数组或对象,对象必须满足以下2个条件中的任意一个。

  1. 对象类型定义了beginend成员函数。
  2. 定义了以对象类型为参数的beginend普通函数。
#include <iostream>
#include <string>
#include <map>

std::map<int, std::string> index_map{ {1, "hello"}, {2, "world"}, {3, "!"} };
int int_array[] = { 0, 1, 2, 3, 4, 5 };

int main()
{
	for (const auto &e : index_map) {
		std::cout << "key=" << e.first << ", value=" << e.second << std::endl;
	}
	
	for (auto e : int_array) {
		std::cout << e << std::endl;
	}
}

以上代码通过基于范围的for循环遍历数组和标准库的map对象。其中 const auto &eauto e 是范围声明,而 index_mapint_array 是范围表达式。为了让范围声明更加简洁,推荐使用 auto 占位符。当然,这里使用 std::map<int, std::string>::value_typeint 来替换 auto 也是可以的。值得注意的是,代码使用了两种形式的范围声明,前者是容器或者数组中元素的引用,而后者是容器或者数组中元素的值。一般来说,我们希望对于复杂的对象使用引用,而对于基础类型使用值,因为这样能够减少内存的复制。如果不会在循环过程中修改引用对象,那么推荐在范围声明中加上 const 限定符以帮助编译器生成更加高效的代码:

#include <vector>
#include <iostream>

struct X
{
	X() { std::cout << "default ctor" << std::endl; }
	X(const X& other) {
		std::cout << "copy ctor" << std::endl;
	}
};

int main()
{
	std::vector<X> x(10);
	std::cout << "for (auto n : x)" << std::endl;
	for (auto n : x) {
	}
	std::cout << "for (const auto &n : x)" << std::endl;
	for (const auto &n : x) {
	}
}

输出:

default ctor
default ctor
default ctor
default ctor
default ctor
default ctor
default ctor
default ctor
default ctor
default ctor
for (auto n : x)
copy ctor
copy ctor
copy ctor
copy ctor
copy ctor
copy ctor
copy ctor
copy ctor
copy ctor
copy ctor
for (const auto &n : x)

编译运行上面这段代码会发现 for(auto n : x) 的循环调用10次复制构造函数,如果类X的数据量比较大且容器里的元素很多,那么这种复制的代价是无法接受的。而 for(const auto &n : x) 则解决了这个问题,整个循环过程没有任何的数据复制。

3. begin 和 end 函数不必返回相同类型

在C++11标准中基于范围的for循环相当于以下伪代码:

{
	auto && __range = range_expression;
	for (auto __begin = begin_expr, __end = end_expr; __begin != __end; ++__begin) {
		range_declaration = *__begin;
		loop_statement
	}
}

其中 begin_exprend_expr 可能是 __range.begin()__range.end(),或者是 begin(__range)end(__range)。当然,如果 __range 是一个数组指针,那么还可能是 __range__range+__count(其中 __count 是数组元素个数)。这段伪代码有一个特点,它要求 begin_exprend_expr 返回的必须是同类型的对象。但实际上这种约束完全没有必要,只要 __begin != __end 能返回一个有效的布尔值即可,所以C++17标准对基于范围的for循环的实现进行了改进,伪代码如下:

{
	auto && __range = range_expression;
	auto __begin = begin_expr;
	auto __end = end_expr;
	for (; __begin != __end; ++__begin) {
		range_declaration = *__begin;
		loop_statement
	}
}

可以看到,以上伪代码将__begin__end分离到两条不同的语句,不再要求它们是相同类型。

4. 临时范围表达式的陷阱

读者是否注意到了,无论是C++11还是C++17标准,基于范围的for循环伪代码都是由以下这句代码开始的:

auto && __range = range_expression;

理解了右值引用的读者应该敏锐地发现了这里存在的陷阱 auto &&。对于这个赋值表达式来说,如果 range_expression 是一个纯右值,那么右值引用会扩展其生命周期,保证其整个for循环过程中访问的安全性。但如果 range_expression 是一个泛左值,那结果可就不确定了,参考以下代码:

class T {
	std::vector<int> data_;
public:
	std::vector<int>& items() { return data_; }
	// …
};

T foo()
{
	T t;
	return t;
}

for (auto& x : foo().items()) {} // 未定义行为

请注意,这里的for循环会引发一个未定义的行为,因为 foo().items() 返回的是一个泛左值类型 std::vector<int>&,于是右值引用无法扩展其生命周期,导致for循环访问无效对象并造成未定义行为。对于这种情况请读者务必小心谨慎,将数据复制出来是一种解决方法:

T thing = foo();
for (auto & x :thing.items()) {}

在C++20标准中,基于范围的for循环增加了对初始化语句的支持,所以在C++20的环境下我们可以将上面的代码简化为:

for (T thing = foo(); auto & x :thing.items()) {}

5. 实现一个支持基于范围的 for 循环的类

前面用大量篇幅介绍了使用基于范围的for循环遍历数组和标准容器的方法,实际上我们还可以让自定义类型支持基于范围的for循环。 要完成这样的类型必须先实现一个类似标准库中的迭代器。

  1. 该类型必须有一组和其类型相关的 beginend 函数,它们可以是类型的成员函数,也可以是独立函数。
  2. beginend 函数需要返回一组类似迭代器的对象,并且这组对象必须支持 operator *operator !=operator ++ 运算符函数。

请注意,这里的 operator ++ 应该是一个前缀版本,它需要通过声明一个不带形参的 operator ++ 运算符函数来完成。 下面是一个完整的例子:

#include <iostream>

class IntIter {
public:
	IntIter(int *p) : p_(p) {}
	bool operator!=(const IntIter& other)
	{
		return (p_ != other.p_);
	}

	const IntIter& operator++()
	{
		p_++;
		return *this;
	}

	int operator*() const
	{
		return *p_;
	}
private:
	int *p_;
};

template<unsigned int fix_size>
class FixIntVector {
public:
	FixIntVector(std::initializer_list<int> init_list)
	{
		int *cur = data_;
		for (auto e : init_list) {
			*cur = e;
			cur++;
		}
	}

	IntIter begin()
	{
		return IntIter(data_);
	}

	IntIter end()
	{
		return IntIter(data_ + fix_size);
	}
private:
	int data_[fix_size]{0};
};

int main()
{
	FixIntVector<10> fix_int_vector {1, 3, 5, 7, 9};
	for (auto e : fix_int_vector)
	{
		std::cout << e << std::endl;
	}
}

在上面的代码中,FixIntVector 是存储 int 类型数组的类模板,类 IntIterFixIntVector 的迭代器。在 FixIntVector 中实现了成员函数 beginend,它们返回了一组迭代器,分别表示数组的开始和结束位置。类 IntIter 本身实现了 operator *operator !=operator ++ 运算符函数,其中 operator * 用于编译器生成解引用代码,operator != 用于生成循环条件代码,而前缀版本的 operator ++ 用于更新迭代器。

请注意,这里使用成员函数的方式实现了 beginend,但有时候需要遍历的容器可能是第三方提供的代码。这种情况下我们可以实现一组独立版本的 beginend 函数,这样做的优点是能在不修改第三方代码的情况下支持基于范围的 for 循环。

总结

基于范围的for循环很好地解决了遍历容器过于烦琐的问题,它自动生成迭代器的遍历代码并将其隐藏于后台。强烈建议读者使用基于范围的for循环来处理单纯遍历容器的操作。当然,使用时需注意临时范围表达式结果的生命周期问题。另外,对于在遍历容器过程中需要修改容器的需求,还是需要使用迭代器来处理。

十八、支持初始化语句的 if 和 switch(C++17)

1. 支持初始化语句的 if

在C++17标准中,if控制结构可以在执行条件语句之前先执行一个初始化语句。语法如下:

if (init; condition) {}

其中 init 是初始化语句,condition 是条件语句,它们之间使用分号分隔。允许初始化语句的 if 结构让以下代码成为可能:

#include <iostream>
bool foo()
{
	return true;
}
int main()
{
	if (bool b = foo(); b) {
		std::cout << std::boolalpha << "good! foo()=" << b << std::endl;
	}
}

在上面的代码中,bool b = foo() 是一个初始化语句,在初始化语句中声明的变量 b 能够在 if 的作用域继续使用。事实上,该变量的生命周期会一直伴随整个 if 结构,包括 else ifelse 部分。

if 初始化语句中声明的变量拥有和整个 if 结构一样长的声明周期,所以前面的代码可以等价于:

#include <iostream>
bool foo()
{
	return true;
}
int main()
{
	{
		bool b = foo();
		if (b) {
			std::cout << std::boolalpha << "good! foo()=" << b << std::endl;
		}
	}
}

当然,我们还可以在if结构中添加else部分:

if (bool b = foo(); b) {
	std::cout << std::boolalpha << "good! foo()=" << b << std::endl;
} else {
	std::cout << std::boolalpha << "bad! foo()=" << b << std::endl;
}

if 结构中引入 else if 后,情况会稍微变得复杂一点,因为在 else if 条件语句之前也可以使用初始化语句:

#include <iostream>
bool foo()
{
	return false;
}
bool bar()
{
	return true;
}
int main()
{
	if (bool b = foo(); b) {
		std::cout << std::boolalpha << "foo()=" << b << std::endl;
	}
	else if (bool b1 = bar(); b1) {
		std::cout << std::boolalpha << "foo()=" << b << ", bar()=" << b1 << std::endl;
	}
}

在上面的代码中,ifelse if 都有初始化语句,它们分别初始化变量 bb1 并且在各自条件成立的作用域内执行了日志输出。值得注意的是,bb1 的生命周期并不相同。其中变量 b 的生命周期会贯穿整个 if 结构(包括 else if),可以看到在 else if 中也能引用变量 b。但是 b1 则不同,它的生命周期只存在于 else if 以及后续存在的 else ifelse 语句,而无法在之前的 if 中使用,等价于:

{
	bool b = foo();
	if (b) {
		std::cout << std::boolalpha << "foo()=" << b << std::endl;
	}
	else {
		bool b1 = bar();
		if (b1) {
			std::cout << std::boolalpha << "foo()=" << b << ", bar()=" << b1 << std::endl;
		}
	}
}

因为if初始化语句声明的变量会贯穿整个if结构,所以我们可以利用该特性对整个if结构加锁,例如:

#include <mutex>

std::mutex mx;
bool shared_flag = true;

int main()
{
	if (std::lock_guard<std::mutex> lock(mx); shared_flag) { 
		shared_flag = false;
	}
}

继续扩展思路,从本质上来说初始化语句就是在执行条件判断之前先执行了一个语句,并且语句中声明的变量将拥有与 if 结构相同的生命周期。所以我们在代码中没有必要一定在初始化语句中初始化判断条件的变量,如 if(std::lock_guard<std::mutex> lock(mx); shared_flag),初始化语句并没有初始化条件判断的变量 shared_flag。类似的例子还有:

#include <cstdio>
#include <string>

int main()
{
	std::string str;
	if (char buf[10]{0}; std::fgets(buf, 10, stdin)) {
		str += buf; 
	}
}

在上面的代码中,if 的初始化语句只声明了一个数组 buf 并将 buf 作为实参传入 std::fgets 函数,而真正做条件判断的是 std::fgets 函数返回值。

2. 支持初始化语句的 switch

if 控制结构一样,switch 在通过条件判断确定执行的代码分支之前也可以接受一个初始化语句。不同的是,switch 结构不存在 elseelse if 的情况,所以语法更加简单。这里以 std::condition_variable 为例,其成员函数 wait_for 需要一个 std::unique_lock<std::mutex>& 类型的实参,于是在 switch 的初始化语句中可以构造一个 std::unique_lock<std::mutex> 类型的对象。

#include <condition_variable>
#include <chrono>
using namespace std::chrono_literals;

std::condition_variable cv;
std::mutex cv_m;

int main()
{
	switch (std::unique_lock<std::mutex> lk(cv_m); cv.wait_for(lk, 100ms))
	{
        case std::cv_status::timeout:
            break;
        case std::cv_status::no_timeout:
		    break;
	}
}

switch 初始化语句声明的变量的生命周期会贯穿整个 switch 结构,这一点和 if 也相同,所以变量 lk 能够引用到任何一个 case 的分支中。

总结

读者应该已经注意到,所谓带初始化语句的 ifswitch 的新特性只不过是一颗语法糖而已,其带来的功能可以轻易地用等价代码代替,但是 C++ 委员会还是决定将该特性引入 C++17 标准。其中的一个原因是该特性并非是全新的语法,在 for 循环中已经存在类似的语法了,而且新增语法也不会增加语法的复杂度,所以无论是学习成本还是使用成本都是很低的。另外,使用该特性的等价代码并非是一种好的解决方案,因为增加大量的大括号和缩进并不利于代码的阅读和维护;而如果不增加大括号和缩进又会导致初始化代码声明的变量入侵 ifswitch 以外的作用域,如此一来在代码整理和重构的时候可能会出现问题。因此将初始化语句和条件语句写在一行确实有助于代码阅读和整理,与此同时也能减少无谓的大括号和缩进,增加代码的可读性和可维护性。

十九、static_assert 声明

1. 运行时断言

在静态断言出现之前,我们使用的是运行时断言,只有程序运行起来之后才有可能触发它。通常情况下,运行时断言只会在 Debug 模式下使用,因为断言的行为比较粗暴,它会直接显示错误信息并终止程序。在 Release 版本中,我们通常会忽略断言(头文件 cassert 已经通过宏 NDEBUGDebugRelease 版本做了区分处理,我们可以直接使用 assert)。还有一点需要注意,断言不能代替程序中的错误检查,它只应该出现在需要表达式返回 true 的位置,例如算术表达式的除数不能为 0,分配内存的大小必须大于 0 等。相反,如果表达式中涉及外部输入,则不应该依赖断言,例如客户输入、服务端返回等。

void* resize_buffer(void* buffer, int new_size)
{
	assert(buffer != nullptr); // OK,用 assert 检查函数参数
	assert(new_size > 0);
	assert(new_size <= MAX_BUFFER_SIZE);}
bool get_user_input(char c)
{
	assert(c == '\0x0d'); // 不合适,assert不应该用于检查外部输入}

在上面这段代码中,我们对函数 resize_buffer 的形参 buffernew_size 进行了断言。显然,作为一个重新分配内存的函数,这两个参数必须是合法的。建议一个断言处理一个判别式,这样一来,当断言发生的时候能迅速定位到问题所在。如果写成 assert((buffer != nullptr) && (new_size > 0) && (new_size <= MAX_BUFFER_SIZE)),则当断言发生的时候,我们还是无法马上确定问题。而函数 get_user_input 就不应该使用断言检查参数了,因为用户输入的字符可能是各种各样的。

2 静态断言的需求

虽然运行时断言可以满足一部分需求,但是它有一个缺点就是必须让程序运行到断言代码的位置才会触发断言。如果想在模板实例化的时候对模板实参进行约束,这种断言是无法办到的。我们需要一个能在编译阶段就给出断言的方法。可惜在C++11标准之前,没有一个标准方法来达到这个目的,我们需要利用其他特性来模拟。下面给出几个可行的方案:

#define STATIC_ASSERT_CONCAT_IMP(x, y) x ## y
#define STATIC_ASSERT_CONCAT(x, y) \
    STATIC_ASSERT_CONCAT_IMP(x, y)

// 方案1
#define STATIC_ASSERT(expr)                 \
    do {                                    \
        char STATIC_ASSERT_CONCAT(          \
            static_assert_var, __COUNTER__) \
            [(expr) != 0 ? 1 : -1];         \
    } while (0)


template<bool>
struct static_assert_st;
template<>
struct static_assert_st<true> {};

// 方案2
#define STATIC_ASSERT2(expr)    \
    static_assert_st<(expr) != 0>()

// 方案3
#define STATIC_ASSERT3(expr)        \
    static_assert_st<(expr) != 0>   \
    STATIC_ASSERT_CONCAT(           \
    static_assert_var, __COUNTER__)

以上代码的方案1,利用的技巧是数组的大小不能为负值,当 expr 表达式返回结果为 false 的时候,条件表达式求值为 -1,这样就导致数组大小为 -1,自然就会引发编译失败。方案2和方案3则是利用了C++模板特化的特性,当模板实参为 true 的时候,编译器能找到特化版本的定义。但当模板参数为 false 的时候,编译器无法找到相应的特化定义,从而编译失败。方案2和方案3的区别在于,方案2会构造临时对象,这让它无法出现在类和结构体的定义当中。而方案3则声明了一个变量,可以出现在结构体和类的定义中,但是它最大的问题是会改变结构体和类的内存布局。总而言之,虽然我们可以在一定程度上模拟静态断言,但是这些方案并不完美。

3. 静态断言

static_assert声明是C++11标准引入的特性,用于在程序编译阶段评估常量表达式并对返回false的表达式断言,我们称这种断言为静态断言。它基本上满足我们对静态断言的要求。

  1. 所有处理必须在编译期间执行,不允许有空间或时间上的运行时成本。
  2. 它必须具有简单的语法。
  3. 断言失败可以显示丰富的错误诊断信息。
  4. 它可以在命名空间、类或代码块内使用。
  5. 失败的断言会在编译阶段报错。

C++11标准规定,使用static_assert需要传入两个实参:常量表达式诊断消息字符串。请注意,第一个实参必须是常量表达式,因为编译器无法计算运行时才能确定结果的表达式:

#include <type_traits>

class A {
};

class B : public A {
};

class C {
};

template<class T>
class E {
	static_assert(std::is_base_of<A, T>::value, "T is not base of A");
};

int main(int argc, char *argv[])
{
 	E<C> x;							// 使用正确,但由于 A 不是 C 的基类,会触发失败断言
    E<B> y;                         // 使用正确,A 是 B 的基类,不会触发失败断言
 	static_assert(sizeof(int) < 4);	// 使用正确,但表达式返回 false,会触发失败断言
    static_assert(sizeof(int) >= 4, "sizeof(int) >= 4"); // 使用正确,表达式返回真,不会触发失败断言
    static_assert(argc > 0, "argc > 0"); // 使用错误,argc > 0 不是常量表达式
}

在上面的代码中,argc > 0 依赖于用户输入的参数,显然不是一个常量表达式。在这种情况下,编译器会报错,符合上面的第5条要求。类模板 Estatic_assert 的使用是正确的,根据第1条和第4条要求,static_assert 可以在类定义里使用并且不会改变类的内部状态。只不过在实例化类模板 E<C> 的时候,因为 A 不是 C 的基类,所以会触发静态断言,导致编译中断。

4. 单参数 static_assert

不知道读者是否和我有同样的想法,在大多数情况下使用static_assert的时候输入的诊断信息字符串就是常量表达式本身,所以让常量表达式作为诊断信息字符串参数的默认值是非常理想的。为了达到这个目的,我们可以定义一个宏:

#define LAZY_STATIC_ASSERT(B) static_assert(B, #B)

可能是该需求比较普遍的原因,2014年2月C++标准委员会就提出升级static_ assert的想法,希望让其支持单参数版本,即常量表达式,而断言输出的诊断信息为常量表达式本身。这个观点提出后得到了大多数人的认同,但是由于2014年2月C++14标准已经发布了,因此该特性不得不顺延到C++17标准中。在支持C++17标准的环境中,我们可以忽略第二个参数:

#include <type_traits>

class A {
};

class B : public A {
};

class C {
};

template<class T>
class E {
    static_assert(std::is_base_of<A, T>::value);
};

int main(int argc, char *argv[])
{
    E<C> x; // 使用正确,但由于A不是C的基类,会触发失败断言
    static_assert(sizeof(int) < 4); // 使用正确,但表达式返回false,会触发失败断言
}

不过在GCC上,即使指定使用C++11标准,GCC依然支持单参数的 static_assert。MSVC则不同,要使用单参数的 static_assert 需要指定C++17标准。

总结

静态断言并不是一个新鲜的概念,早在C++11标准出现之前,boost、loki等代码库就已经采用很多变通的办法实现了静态断言的部分功能。之所以这些代码库都会实现静态断言,主要是因为该特性可以将错误排查的工作前置到编译时,这对于程序员来说是非常友好的。C++11以及后来的C++17标准引入的static_assert完美地满足了静态断言的各种需求,当断言表达式是常量表达式的时候,我们应该优先使用static_assert静态断言。

二十、结构化绑定(C++17 C++20)(解构语法)

1. 使用结构化绑定

熟悉Python的读者应该知道,Python函数可以有多个返回值,例如:

def return_multiple_values():
	return 11, 7
x, y = return_multiple_values()

在上面的代码中,函数 return_multiple_values 返回的是一个元组(tuple) (11, 7),在函数返回后,元组中的元素值被自动分配到了 xy 上。回过头来看C++,我们惊喜地发现在C++11标准中也引入了元组的概念,通过元组,C++也能返回多个值,但使用方法不如Python那么简洁:

#include <iostream>
#include <tuple>

std::tuple<int, int> return_multiple_values()
{
	return std::make_tuple(11, 7);
}

int main()
{
	int x = 0, y = 0;
	std::tie(x, y) = return_multiple_values();
	std::cout << "x=" << x << " y=" << y << std::endl;
}

可以看到,这段代码和Python完成了同样的工作,但代码却要繁琐许多。其中一个原因是C++11必须指定 return_multiple_values 函数的返回值类型。另外,在调用 return_multiple_values 函数之前还需要声明变量 xy,并且使用函数模板 std::tiexy 通过引用绑定到 std::tuple<int&, int&> 上。对于第一个问题,我们可以使用 C++14 中 auto 的新特性来简化返回类型的声明。

auto return_multiple_values()
{
	return std::make_tuple(11, 7);
}

重点来了,要想解决第二个问题就必须使用C++17标准中新引入的特性——结构化绑定。所谓结构化绑定是指将一个或者多个名称绑定到初始化对象中的一个或者多个子对象(或者元素)上,相当于给初始化对象的子对象(或者元素)起了别名,请注意别名不同于引用,这一点会在后面详细介绍。首先让我们看一看结构化绑定是如何化腐朽为神奇的:

#include <iostream>
#include <tuple>

auto return_multiple_values()
{
	return std::make_tuple(11, 7);
}

int main()
{
	auto[x, y] = return_multiple_values();
	std::cout << "x=" << x << " y=" << y << std::endl;
}

在上面这段代码中,auto[x, y] = return_multiple_values() 是一个典型的结构化绑定声明。其中 auto 是类型占位符,[x, y] 是绑定标识符列表,其中 xy 是用于绑定的名称。绑定的目标是函数 return_multiple_values() 返回结果副本的子对象或者元素。用支持C++17标准的编译器编译运行这段代码会正确地输出:

x=11 y=7

这种语法其实就是解构赋值的语法,在在很多其他的语言中也有类似的语法,如 JavaScript ES6 、Kotlin 等

请注意,结构化绑定的目标不必是一个函数的返回结果,实际上等号的右边可以是任意一个合理的表达式,比如:

#include <iostream>
#include <string>

struct BindTest {
	int a = 42;
	std::string b = "hello structured binding";
};

int main()
{
	BindTest bt;
	auto[x, y] = bt;
	std::cout << "x=" << x << " y=" << y << std::endl;
}

编译运行这段代码的输出如下:

x=42 y=hello structured binding

可以看到结构化绑定能够直接绑定到结构体上。将其运用到基于范围的for循环中会有更好的效果:

#include <iostream>
#include <string>
#include <vector>

struct BindTest {
	int a = 42;
	std::string b = "hello structured binding";
};

int main()
{
	std::vector<BindTest> bt{ {11, "hello"},  {7, "c++"},  {42, "world"} };
	for (const auto& [x, y] : bt) {
		std::cout << "x=" << x << " y=" << y << std::endl;
	}
}

请注意以上代码的for循环部分。在这个基于范围的for循环中,通过结构化绑定直接将 xy 绑定到向量 bt 中的结构体子对象上,省去了通过向量的元素访问成员变量 ab 的步骤。

2. 深入理解结构化绑定

在阅读了前面的内容之后,读者是否有这样的理解。

  1. 结构化绑定的目标就是等号右边的对象。
  2. 所谓的别名就是对等号右边对象的子对象或者元素的引用。

如果确实是这么理解的,请忘掉它们,因为上面的理解是错误的

真实的情况是,在结构化绑定中编译器会根据限定符生成一个等号右边对象的匿名副本,而绑定的对象正是这个副本而非原对象本身。另外,这里的别名真的是单纯的别名,别名的类型和绑定目标对象的子对象类型相同,而引用类型本身就是一种和非引用类型不同的类型。在初步了解了结构和绑定的“真相”之后,现在我将使用伪代码进一步说明它是如何工作起来的。对于结构化绑定代码:

BindTest bt;
const auto [x, y] = bt;

编译器为其生成的代码大概是这样的:

BindTest bt;
const auto _anonymous = bt;
aliasname x = _anonymous.a
aliasname y = _anonymous.b

在上面的伪代码中,_anonymous 是编译器生成的匿名对象,可以注意到 const auto [x, y] = btauto 的限定符会直接应用到匿名对象 _anonymous 上。也就是说, _anonymousconst 还是 volatile 完全依赖 auto 的限定符。另外,在伪代码中 xy 的声明用了一个不存在的关键字 aliasname 来表达它们不是 _anonymous 成员的引用而是 _anonymous 成员的别名,也就是说 xy 的类型分别为 const intconst std::string,而不是 const int&const std::string&。为了证明以上两点,读者可以尝试编译运行下面这段代码:

#include <iostream>
#include <string>

struct BindTest {
	int a = 42;
	std::string b = "hello structured binding";
};

int main()
{
	BindTest bt;
	const auto[x, y] = bt;

	std::cout << "&bt.a=" << &bt.a << " &x=" << &x << std::endl;
	std::cout << "&bt.b=" << &bt.b << " &y=" << &y << std::endl;
	std::cout << "std::is_same_v<const int, decltype(x)>=" 
		<< std::is_same_v<const int, decltype(x)> << std::endl;
	std::cout << "std::is_same_v<const std::string, decltype(y)>=" 
		<< std::is_same_v<const std::string, decltype(y)> << std::endl;
}

/*

int main()
{
	BindTest bt;
	auto&[x, y] = bt;

	std::cout << "&bt.a=" << &bt.a << " &x=" << &x << std::endl;
	std::cout << "&bt.b=" << &bt.b << " &y=" << &y << std::endl;
	
	x = 11;
	std::cout << "bt.a=" << bt.a << std::endl;
	bt.b = "hi structured binding";
	std::cout << "y=" << y << std::endl;
}

*/

编译运行的结果如下:

&bt.a=0x651a7ffd70 &x=0x651a7ffd40
&bt.b=0x651a7ffd78 &y=0x651a7ffd48
std::is_same_v<const int, decltype(x)>=1
std::is_same_v<const std::string, decltype(y)>=1

正如上文中描述的那样,别名 x 并不是 bt.a,因为它们的内存地址不同。另外, xy 的类型分别与 const intconst std::string 相同也证明了它们是别名而不是引用的事实。由此可见,如果在上面这段代码中试图使用 xy 去修改 bt 的数据成员是无法成功的,因为一方面 xy 都是常量类型;另一方面即使 xy 是非常量类型,改变的 xy 只会影响匿名对象而非 bt 本身。当然了,了解了结构化绑定的原理之后,写一个能改变 bt 成员变量的结构化绑定代码就很简单了:

int main()
{
	BindTest bt;
	auto&[x, y] = bt;

	std::cout << "&bt.a=" << &bt.a << " &x=" << &x << std::endl;
	std::cout << "&bt.b=" << &bt.b << " &y=" << &y << std::endl;
	
	x = 11;
	std::cout << "bt.a=" << bt.a << std::endl;
	bt.b = "hi structured binding";
	std::cout << "y=" << y << std::endl;
}

输出:

&bt.a=0xcf0e3ffdb0 &x=0xcf0e3ffdb0
&bt.b=0xcf0e3ffdb8 &y=0xcf0e3ffdb8
bt.a=11
y=hi structured binding

虽然只是将 const auto 修改为 auto&,但是已经能达到让 bt 数据成员和 xy 相互修改的目的了:

BindTest bt;
auto &_anonymous = bt;
aliasname x = _anonymous.a
aliasname y = _anonymous.b

关于引用有趣的一点是,如果结构化绑定声明为 const auto& [x, y] = bt,那么 x = 11 会编译失败,因为 x 绑定的对象是一个常量引用,而 bt.b = "hi structured binding" 却能成功修改 y 的值,因为 bt 本身不存在常量问题。

请注意,使用结构化绑定无法忽略对象的子对象或者元素:

auto t = std::make_tuple(42, "hello world");
auto [x] = t;

以上代码是无法通过编译的,必须有两个别名分别对应 bt 的成员变量 ab。熟悉 C++11 的读者可能会提出仿照 std::tie 使用 std::ignore 的方案:

auto t = std::make_tuple(42, "hello world");
int x = 0, y = 0;
std::tie(x, std::ignore) = t;
std::tie(y, std::ignore) = t;

虽然这个方案对于std::tie是有效的,但是结构化绑定的别名还有一个限制:无法在同一个作用域中重复使用。这一点和变量声明是一样的,比如:

auto t = std::make_tuple(42, "hello world");
auto[x, ignore] = t;
auto[y, ignore] = t; // 编译错误,ignore无法重复声明

3. 结构化绑定的3种类型

结构化绑定可以作用于3种类型,包括原生数组、结构体和类对象、元组和类元组的对象,接下来将一一介绍。

1. 绑定到原生数组

我们在上面的示例代码中并没有见到过这种类型,它是3种情况中最简单的一种。绑定到原生数组即将标识符列表中的别名一一绑定到原生数组对应的元素上。所需条件仅仅是要求别名的数量与数组元素的个数一致,比如:

#include <iostream>

int main()
{
	int a[3]{ 1, 3, 5 };
	auto[x, y, z] = a;
	std::cout << "[x, y, z]=[" 
		<< x << ", " 
		<< y << ", " 
		<< z << "]" << std::endl;
}

以上代码很好理解,别名 xyz 分别绑定到 a[0]a[1]a[2] 所对应的匿名对象上。另外,绑定到原生数组需要小心数组的退化,因为在绑定的过程中编译器必须知道原生数组的元素个数,一旦数组退化为指针,就将失去这个属性。

2. 绑定到结构体和类对象

将标识符列表中的别名分别绑定到结构体和类的非静态成员变量上,这一点在之前的例子中已经见到了。但是我们之前没有提过关于这种绑定的限制条件,实际上这种情况的限制条件要比原生数组复杂得多。

  • 首先,类或者结构体中的非静态数据成员个数必须和标识符列表中的别名的个数相同
  • 其次,这些数据成员必须是公有的C++20标准修改了此项规则,详情见20.5节);
  • 这些数据成员必须是在同一个类或者基类中
  • 最后,绑定的类和结构体中不能存在匿名联合体
class BindTest {
	int a = 42;		// 私有成员变量
public:
	double b = 11.7;
};

int main()
{
	BindTest bt;
	auto[x, y] = bt;
}

以上代码会编译错误,因为BindTest成员变量a是私有的,违反了绑定结构体的限制条件:

class BindBase1 {
public:
	int a = 42;
	double b = 11.7;
};

class BindTest1 : public BindBase1 {};

class BindBase2 {};

class BindTest2 : public BindBase2 {
public:
	int a = 42;
	double b = 11.7;
};

class BindBase3 {
public:
	int a = 42;
};

class BindTest3 : public BindBase3 {
public:
	double b = 11.7;
};

int main()
{
	BindTest1 bt1;
	BindTest2 bt2;
	BindTest3 bt3;
	auto[x1, y1] = bt1;	// 编译成功
	auto[x2, y2] = bt2;	// 编译成功
	auto[x3, y3] = bt3;	// 编译错误
}

在上面这段代码中,auto[x1, y1] = bt1auto[x2, y2] = bt2 可以顺利地编译,因为类 BindTest1BindTest2 的非静态数据成员要么全部在派生类中定义,要么全部在基类中定义。 BindTest3 却不同,其中成员变量 a 的定义在基类,成员变量 b 的定义在派生类,这一点违反了绑定结构体的限制条件,所以 auto[x3, y3] = bt3 会导致编译错误。最后需要注意的是,类和结构体中不能出现匿名的联合体,而对于命名的联合体则没有限制。

3. 绑定到元组和类元组的对象

绑定到元组就是将标识符列表中的别名分别绑定到元组对象的各个元素。绑定到类元组又是什么意思呢?要解释这个概念就要从绑定的限制条件讲起。实际上,绑定元组和类元组有一系列抽象的条件:对于元组或者类元组类型T:

  1. 需要满足 std::tuple_size<T>::value 是一个符合语法的表达式,并且该表达式获得的整数值与标识符列表中的别名个数相同。
  2. 类型 T 还需要保证 std::tuple_element<i, T>::type 也是一个符合语法的表达式,其中 i 是小于 std::tuple_size<T>::value 的整数,表达式代表了类型 T 中第 i 个元素的类型。
  3. 类型 T 必须存在合法的成员函数模板 get<i>() 或者函数模板 get<i>(t),其中 i 是小于 std::tuple_size<T>::value 的整数, t 是类型 T 的实例,get<i>()get<i>(t) 返回的是实例 t 中第 i 个元素的值。

理解上述条件会发现,它们其实比较抽象。这些条件并没有明确规定结构化绑定的类型一定是元组,任何具有上述条件特征的类型都可以成为绑定的目标。另外,获取这些条件特征的代价也并不高,只需要为目标类型提供 std::tuple_sizestd::tuple_element 以及 get 的特化或者偏特化版本即可。实际上,标准库中除了元组本身毫无疑问地能够作为绑定目标以外,std::pairstd::array 也能作为结构化绑定的目标,其原因就是它们是满足上述条件的类元组。说到这里,就不得不进一步讨论 std::pair 了,因为它对结构化绑定的支持给我们带来了一个不错的惊喜:

#include <iostream>
#include <string>
#include <map>

int main()
{
	std::map<int, std::string> id2str{ {1, "hello"}, {3, "Structured"}, {5, "bindings"} };

	for (const auto& elem : id2str) {
		std::cout << "id=" << elem.first << ", str=" << elem.second << std::endl;
	}
}

上面这段代码是一个基于范围的for循环遍历 std::map 的例子,其中 elemstd::pair<const int, std::string> 类型,要在循环体中输出 key 和 value 的值就需要访问成员变量 firstsecond。这个例子中使用基于范围的for循环已经比使用迭代器遍历 std::map 简单了很多,但是加入结构化绑定后代码将被进一步简化。我们可以将 std::pair 的成员变量 firstsecond 绑定到别名以保证代码阅读起来更加清晰:

int main()
{
	std::map<int, std::string> id2str{ {1, "hello"}, {3, "Structured"}, {5, "bindings"} };

    for (const auto&[id, str] : id2str) {
        std::cout << "id=" << id<< ", str=" << str << std::endl;
    }
}

4. 实现一个类元组类型

我们已经知道了通过满足类元组的限制条件让任何类型支持结构化绑定的方法,现在是时候实践一下了。以上一节中提到的BindTest3为例,我们知道由于它的数据成员分散在派生类和基类之中,因此无法使用结构化绑定。下面将通过让其满足类元组的条件,从而达到支持结构化绑定的目的:

#include <iostream>
#include <tuple>

class BindBase3 {
public:
	int a = 42;
};

class BindTest3 : public BindBase3 {
public:
	double b = 11.7;
};

namespace std {
	template<>
	struct tuple_size<BindTest3> {
		static constexpr size_t value = 2;
	};

	template<>
	struct tuple_element<0, BindTest3> {
		using type = int;
	};

	template<>
	struct tuple_element<1, BindTest3> {
		using type = double;
	};
}


template<std::size_t Idx>
auto& get(BindTest3 &bt) = delete;

template<>
auto& get<0>(BindTest3 &bt) { return bt.a; }

template<>
auto& get<1>(BindTest3 &bt) { return bt.b;}

int main()
{
	BindTest3 bt3;
	auto& [x3, y3] = bt3;
	x3 = 78;
	std::cout << bt3.a << std::endl;
}

在上面这段代码中,我们为BindTest3实现了3种特性以满足类元组的限制条件。

首先实现的是:

template<>
struct tuple_size<BindTest3> {
	static constexpr size_t value = 2;
};

它的作用是告诉编译器将要绑定的子对象和元素的个数,这里通过特化让tuple_size<BindTest3>::value的值为2,也就是存在两个子对象。

然后需要明确的是每个子对象和元素的类型:

template<>
struct tuple_element<0, BindTest3> {
	using type = int;
};

template<>
struct tuple_element<1, BindTest3> {
	using type = double;
};

这里同样通过特化的方法指定了两个子对象的具体类型。

最后需要实现的是 get 函数,注意,get 函数的实现有两种方式,一种需要给 BindTest3 添加成员函数;另一种则不需要,我们通常会选择不破坏原有代码的方案,所以这里先展示后者:

template<std::size_t Idx>
auto& get(BindTest3 &bt) = delete;

template<>
auto& get<0>(BindTest3 &bt) { return bt.a; }

template<>
auto& get<1>(BindTest3 &bt) { return bt.b;}

可以看到函数模板 get 也特化出了两个函数实例,它们分别返回 bt.abt.b 的引用。之所以这里需要返回引用,是因为我希望结构化绑定的别名能够修改 BindTest3 的实例,如果需要的是一个只读的结构化绑定,则这里可以不必返回引用。最后 template<std::size_t Idx> auto& get(BindTest3 &bt) = delete 可以明确地告知编译器不要生成除了特化版本以外的函数实例以防止 get 函数模板被滥用。

正如上文强调的,我不推荐实现成员函数版本的get函数,因为这需要修改原有的代码。但是当我们重新编写一个类,并且希望它支持结构化绑定的时候,也不妨尝试实现几个get成员函数:

#include <iostream>
#include <tuple>

class BindBase3 {
public:
	int a = 42;
};

class BindTest3 : public BindBase3 {
public:
	double b = 11.7;
	template<std::size_t Idx> auto& get() = delete;

};

template<> auto& BindTest3::get<0>() { return a; }
template<> auto& BindTest3::get<1>() { return b; }

namespace std {
	template<>
	struct tuple_size<BindTest3> {
		static constexpr size_t value = 2;
	};

	template<>
	struct tuple_element<0, BindTest3> {
		using type = int;
	};

	template<>
	struct tuple_element<1, BindTest3> {
		using type = double;
	};
}

int main()
{
	BindTest3 bt3;
	auto& [x3, y3] = bt3;
	x3 = 78;
	std::cout << bt3.a << std::endl;
}

这段代码和第一份实现代码基本相同,我们只需要把精力集中到get成员函数的部分:

class BindTest3 : public BindBase3 {
public:
	double b = 11.7;
	template<std::size_t Idx> auto& get() = delete;
};
template<> auto& BindTest3::get<0>() { return a; }
template<> auto& BindTest3::get<1>() { return b; }

这段代码中 get 成员函数的优势显而易见,成员函数不需要传递任何参数。另外,特化版本的函数 get<0>get<1> 可以直接返回 ab,这显得格外简洁。读者不妨自己编译运行一下这两段代码,其输出结果应该都是 78,修改 bt.a 成功。

5. 绑定的访问权限问题

前面提到过,当在结构体或者类中使用结构化绑定的时候,需要有公开的访问权限,否则会导致编译失败。这条限制乍看是合理的,但是仔细想来却引入了一个相同条件下代码表现不一致的问题:

struct A {
    friend void foo();
private:
    int i;
};
void foo() {
    A a{};
    auto x = a.i; // 编译成功
    auto [y] = a; // 编译失败
}
class C {
	int i;
	void foo(const C& other) {
		auto [x] = other; // 编译失败
	}
};

在上面这段代码中,foo 是结构体 A 的友元函数,它可以访问 A 的私有成员 i。但是,结构化绑定却失败了,这就明显不合理了。同样的问题还有访问自身成员的时候:

为了解决这类问题,C++20标准规定结构化绑定的限制不再强调必须为公开数据成员,编译器会根据当前操作的上下文来判断是否允许结构化绑定。幸运的是,虽然标准是2018年提出修改的,但在我实验的3种编译器上,无论是C++17还是C++20标准,以上代码都可以顺利地通过编译。

在 CLion 中上面两段代码都是可以正常编译成功的。

总结

本章介绍的结构化绑定是新特性中比较有趣的一个,使用该特性可以直接绑定数据对象的内部成员,函数返回多个值就是其中一个应用。另外,自定义支持结构化绑定的类型也并不困难,代码库作者不妨为库中的类型添加类元组方法,让它们支持结构化绑定。

二十一、noexcept 关键字(C++11 C++17 C++20)

1. 使用 noexcept 代替 throw

异常处理是C++语言的重要特性。在C++11标准之前,我们可以使用 throw (optional_type_list) 声明函数是否抛出异常,并描述函数抛出的异常类型。理论上,运行时必须检查函数发出的任何异常是否确实存在于 optional_type_list 中,或者是否从该列表中的某个类型派生。如果不是,则会调用处理程序 std::unexpected。但实际上,由于这个检查实现比较复杂,因此并不是所有编译器都会遵从这个规范。此外,大多数程序员似乎并不喜欢 throw(optional_type_list) 这种声明抛出异常的方式,因为在他们看来抛出异常的类型并不是他们关心的事情,他们只需要关心函数是否会抛出异常,即是否使用了 throw() 来声明函数。

使用throw声明函数是否抛出异常一直没有什么问题,直到C++11标准引入了移动构造函数。移动构造函数中包含着一个严重的异常陷阱。

当我们想将一个容器的元素移动到另外一个新的容器中时。在C++11之前,由于没有移动语义,我们只能将原始容器的数据复制到新容器中。如果在数据复制的过程中复制构造函数发生了异常,那么我们可以丢弃新的容器,保留原始的容器。在这个环境中,原始容器的内容不会有任何变化。

但是有了移动语义,原始容器的数据会逐一地移动到新容器中,如果数据移动的途中发生异常,那么原始容器也将无法继续使用,因为已经有一部分数据移动到新的容器中。这里读者可能会有疑问,如果发生异常就做一个反向移动操作,恢复原始容器的内容不就可以了吗?实际上,这样做并不可靠,因为我们无法保证恢复的过程中不会抛出异常。

这里的问题是,throw 并不能根据容器中移动的元素是否会抛出异常来确定移动构造函数是否允许抛出异常。针对这样的问题,C++标准委员会提出了 noexcept 说明符。

noexcept 是一个与异常相关的关键字,它既是一个说明符,也是一个运算符。作为说明符,它能够用来说明函数是否会抛出异常,例如:

struct X {
	int f() const noexcept
	{
		return 58;
	}
	void g() noexcept {}
};
int foo() noexcept
{
	return 42;
}

以上代码非常简单,用 noexcept 声明了函数 foo 以及 X 的成员函数 fg。指示编译器这几个函数是不会抛出异常的,编译器可以根据声明优化代码。请注意,noexcept 只是告诉编译器不会抛出异常,但函数不一定真的不会抛出异常。这相当于对编译器的一种承诺,当我们在声明了 noexcept 的函数中抛出异常时,程序会调用 std::terminate 去结束程序的生命周期。

另外,noexcept 还能接受一个返回布尔的常量表达式,当表达式评估为 true 的时候,其行为和不带参数一样,表示函数不会抛出异常。反之,当表达式评估为 false 的时候,则表示该函数有可能会抛出异常。这个特性广泛应用于模板当中,例如:

template <class T>
T copy(const T & o) noexcept {}

以上代码想实现一个复制函数,并且希望使用 noexcept 优化不抛出异常时的代码。但问题是如果 T 是一个复杂类型,那么调用其复制构造函数是有可能发生异常的。直接声明 noexcept 会导致当函数遇到异常的时候程序被终止,而不给我们处理异常的机会。我们希望只有在 T 是一个基础类型时复制函数才会被声明为 noexcept,因为基础类型的复制是不会发生异常的。这时就需要用到带参数的 noexcept 了:

template <class T>
T copy(const T &o) noexcept(std::is_fundamental<T>::value) {}

上面这段代码通过 std::is_fundamental 来判断 T 是否为基础类型,如果 T 是基础类型,则复制函数被声明为 noexcept(true),即不会抛出异常。反之,函数被声明为 noexcept(false),表示函数有可能抛出异常。请注意,由于 noexcept 对表达式的评估是在编译阶段执行的,因此表达式必须是一个常量表达式。

实际上,这段代码并不是最好的解决方案,因为我还希望在类型 T 的复制构造函数保证不抛出异常的情况下都使用 noexcept 声明。基于这点考虑,C++标准委员会又赋予了 noexcept 作为运算符的特性。noexcept 运算符接受表达式参数并返回 truefalse。因为该过程是在编译阶段进行,所以表达式本身并不会被执行。而表达式的结果取决于编译器是否在表达式中找到潜在异常:

#include <iostream>
int foo() noexcept
{
    return 42;
}

int foo1()
{
    return 42;
}

int foo2() throw()
{
    return 42;
}

int main()
{
    std::cout << std::boolalpha;
    std::cout << "noexcept(foo())  = " << noexcept(foo()) << std::endl;
    std::cout << "noexcept(foo1()) = " << noexcept(foo1()) << std::endl;
    std::cout << "noexcept(foo2()) = " << noexcept(foo2()) << std::endl;
}

上面这段代码的运行结果如下:

noexcept(foo())  = true
noexcept(foo1()) = false
noexcept(foo2()) = true

noexcept运算符能够准确地判断函数是否有声明不会抛出异常。有了这个工具,我们可以进一步优化复制函数模板:

template <class T>
T copy(const T &o) noexcept(noexcept(T(o))) {}

这段代码看起来有些奇怪,因为函数声明中连续出现了两个 noexcept 关键字,只不过两个关键字发挥了不同的作用。其中第二个关键字是运算符,它判断 T(o) 是否有可能抛出异常。而第一个 noexcept 关键字则是说明符,它接受第二个运算符的返回值,以此决定 T 类型的复制函数是否声明为不抛出异常。

2. 用 noexcept 来解决移动构造问题

上文曾提到过,异常的存在对容器数据的移动构造构成了威胁,因为我们无法保证在移动构造的时候不抛出异常。现在 noexcept 运算符可以判断目标类型的移动构造函数是否有可能抛出异常。如果没有抛出异常的可能,那么函数可以选择进行移动操作;否则将使用传统的复制操作。

下面,我们就来实现一个使用移动语义的容器经常用到的工具函数swap

template<class T>
void swap(T& a, T& b) noexcept(noexcept(T(std::move(a))) && noexcept(a.operator=(std::move(b))))
{
	T tmp(std::move(a));
	a = std::move(b);
	b = std::move(tmp);
}

上面这段代码只做了两件事情:第一,检查类型 T 的移动构造函数和移动赋值函数是否都不会抛出异常;第二,通过移动构造函数和移动赋值函数移动对象 ab。在这个函数中使用 noexcept 的好处在于,它让编译器可以根据类型移动函数是否抛出异常来选择不同的优化策略。但是这个函数并没有解决上文容器移动的问题。

继续改进swap函数:

template<class T>
void swap(T& a, T& b) noexcept(noexcept(T(std::move(a))) && noexcept(a.operator=(std::move(b))))
{
	static_assert(noexcept(T(std::move(a))) && noexcept(a.operator=(std::move(b))));
	T tmp(std::move(a));
	a = std::move(b);
	b = std::move(tmp);
}

改进版的 swap 在函数内部使用 static_assert 对类型 T 的移动构造函数和移动赋值函数进行检查,如果其中任何一个抛出异常,那么函数会编译失败。使用这种方法可以迫使类型 T 实现不抛出异常的移动构造函数和移动赋值函数。但是这种实现方式过于强制,我们希望在不满足移动要求的时候,有选择地使用复制方法完成移动操作。

最终版swap函数:

#include <iostream>
#include <type_traits
struct X {
    X() {}
    X(X&&) noexcept {}
    X(const X&) {}
    X operator= (X&&) noexcept { return *this; }
    X operator= (const X&) { return *this; }
};

struct X1 {
    X1() {}
    X1(X1&&) {}
    X1(const X1&) {}
    X1 operator= (X1&&) { return *this; }
    X1 operator= (const X1&) { return *this; }
};

template<typename T>
void swap_impl(T& a, T& b, std::integral_constant<bool, true>) noexcept
{
    T tmp(std::move(a));
    a = std::move(b);
    b = std::move(tmp);
}

template<typename T>
void swap_impl(T& a, T& b, std::integral_constant<bool, false>)
{
    T tmp(a);
    a = b;
    b = tmp;
}

template<typename T>
void swap(T& a, T& b)
noexcept(noexcept(swap_impl(a, b, std::integral_constant<bool, noexcept(T(std::move(a)))
    && noexcept(a.operator=(std::move(b)))>())))
{
    swap_impl(a, b, std::integral_constant<bool, noexcept(T(std::move(a)))
        && noexcept(a.operator=(std::move(b)))>());
}

int main()
{
    X x1, x2;
    swap(x1, x2);

    X1 x3, x4;
    swap(x3, x4);
}

以上代码实现了两个版本的 swap_impl,它们的形参列表的前两个形参是相同的,只有第三个形参类型不同。第三个形参为 std::integral_constant<bool, true> 的函数会使用移动的方法交换数据,而第三个参数为 std::integral_constant<bool, false> 的函数则会使用复制的方法来交换数据。swap 函数会调用 swap_impl,并且以移动构造函数和移动赋值函数是否会抛出异常为模板实参来实例化 swap_impl 的第三个参数。这样,不抛出异常的类型会实例化一个类型为 std::integral_constant<bool, true> 的对象,并调用使用移动方法的 swap_impl;反之则调用使用复制方法的 swap_impl

请注意这段代码中,我为了更多地展示 noexcept 的用法将代码写得有些复杂。实际上,noexcept(T(std::move(a))) && noexcept(a.operator=(std::move(b))) 这段代码完全可以使用 std::is_nothrow_move_constructible<T>::value && std::is_nothrow_move_assignable<T>::value 来代替。

3. noexcept 和 throw()

在了解了 noexcept 以后,现在是时候对比一下 noexceptthrow() 两种方法了。请注意,这两种指明不抛出异常的方法在外在行为上是一样的。如果用 noexcept 运算符去探测 noexceptthrow() 声明的函数,会返回相同的结果。

但实际上在C++11标准中,它们在实现上确实是有一些差异的。

  • 如果一个函数在声明了 noexcept 的基础上抛出了异常,那么程序将不需要展开堆栈,并且它可以随时停止展开。另外,它不会调用 std::unexpected,而是调用 std::terminate 结束程序
  • throw() 则需要展开堆栈,并调用 std::unexpected

这些差异让使用 noexcept 的程序拥有更高的性能。在C++17标准中,throw() 成为 noexcept 的一个别名,也就是说 throw()noexcept 拥有了同样的行为和实现。另外,在C++17标准中只有 throw() 被保留了下来,其他用 throw 声明函数抛出异常的方法都被移除了。在C++20中,throw() 也被标准移除了,使用 throw 声明函数异常的方法正式退出了历史舞台。

4. 默认使用 noexcept 的函数

C++11标准规定下面几种函数会默认带有noexcept声明。

  1. 默认构造函数、默认复制构造函数、默认赋值函数、默认移动构造函数和默认移动赋值函数。有一个额外要求,对应的函数在类型的基类和成员中也具有noexcept声明,否则其对应函数将不再默认带有noexcept声明。另外,自定义实现的函数默认也不会带有noexcept声明:
#include <iostream>

struct X {
};

#define PRINT_NOEXCEPT(x)    \
    std::cout << #x << " = " << x << std::endl

int main()
{
    X x;
    std::cout << std::boolalpha;
    PRINT_NOEXCEPT(noexcept(X()));
    PRINT_NOEXCEPT(noexcept(X(x)));
    PRINT_NOEXCEPT(noexcept(X(std::move(x))));
    PRINT_NOEXCEPT(noexcept(x.operator=(x)));
    PRINT_NOEXCEPT(noexcept(x.operator=(std::move(x))));
}

以上代码的运行输出结果如下:

noexcept(X()) = true
noexcept(X(x)) = true
noexcept(X(std::move(x))) = true
noexcept(x.operator=(x)) = true
noexcept(x.operator=(std::move(x))) = true

可以看到编译器默认实现的这些函数都是带有 noexcept 声明的。如果我们在类型 X 中加入某个成员变量 M,情况会根据 M 的具体实现发生变化:

#include <iostream>

struct M {
    M() {}
    M(const M&) {}
    M(M&&) noexcept {}
    M operator= (const M&) noexcept { return *this; }
    M operator= (M&&) { return *this; }
};

struct X {
    M m;
};

#define PRINT_NOEXCEPT(x)    \
    std::cout << #x << " = " << x << std::endl

int main()
{
    X x;
    std::cout << std::boolalpha;
    PRINT_NOEXCEPT(noexcept(X()));
    PRINT_NOEXCEPT(noexcept(X(x)));
    PRINT_NOEXCEPT(noexcept(X(std::move(x))));
    PRINT_NOEXCEPT(noexcept(x.operator=(x)));
    PRINT_NOEXCEPT(noexcept(x.operator=(std::move(x))));
}

这时的结果如下:

noexcept(X()) = false
noexcept(X(x)) = false
noexcept(X(std::move(x))) = true
noexcept(x.operator=(x)) = true
noexcept(x.operator=(std::move(x))) = false

以上代码表明,如果成员 m 的类型 M 自定义实现了默认函数,并且部分函数没有声明为 noexcept,那么 X 对应的默认函数也会丢失 noexcept 声明。例如,M(){} 没有使用 noexcept 声明,导致 noexcept(X()) 返回 false,而 M(M&&) noexcept{} 使用了 noexcept 声明,所以 noexcept(x.operator=(x)) 返回 true

  1. 类型的析构函数以及 delete 运算符默认带有 noexcept 声明,请注意即使自定义实现的析构函数也会默认带有 noexcept 声明,除非类型本身或者其基类和成员明确使用 noexcept(false) 声明析构函数,以上也同样适用于 delete 运算符
#include <iostream>

struct M {
    ~M() noexcept(false) {}
};

struct X {
};

struct X1 {
    ~X1() {}
};

struct X2 {
    ~X2() noexcept(false) {}
};

struct X3 {
    M m;
};

#define PRINT_NOEXCEPT(x)    \
    std::cout << #x << " = " << x << std::endl

int main()
{
    X *x = new X;
    X1 *x1 = new X1;
    X2 *x2 = new X2;
    X3 *x3 = new X3;
    std::cout << std::boolalpha;
    PRINT_NOEXCEPT(noexcept(x->~X()));
    PRINT_NOEXCEPT(noexcept(x1->~X1()));
    PRINT_NOEXCEPT(noexcept(x2->~X2()));
    PRINT_NOEXCEPT(noexcept(x3->~X3()));
    PRINT_NOEXCEPT(noexcept(delete x));
    PRINT_NOEXCEPT(noexcept(delete x1));
    PRINT_NOEXCEPT(noexcept(delete x2));
    PRINT_NOEXCEPT(noexcept(delete x3));
}

以上代码的运行输出结果如下:

noexcept(x->~X()) = true
noexcept(x1->~X1()) = true
noexcept(x2->~X2()) = false
noexcept(x3->~X3()) = false
noexcept(delete x) = true
noexcept(delete x1) = true
noexcept(delete x2) = false
noexcept(delete x3) = false

可以看出 noexcept 运算符对于析构函数和 delete 运算符有着同样的结果。自定义析构函数 ~X1() 依然会带有 noexcept 的声明,除非如同 ~X2() 显示的声明 noexcept(false)X3 有一个成员变量 m,其类型 M 的析构函数被声明为 noexcept(false),这使 X3 的析构函数也被声明为 noexcept(false)

5. 使用 noexcept 的时机

什么时候使用 noexcept 是一个关乎接口设计的问题。原因是一旦我们用 noexcept 声明了函数接口,就需要确保以后修改代码也不会抛出异常,不会有理由让我们删除 noexcept 声明。这是一种协议,试想一下,如果客户看到我们给出的接口使用了 noexcept 声明,他会自然而然地认为“哦好的,这个函数不会抛出异常,我不用为它添加额外的处理代码了”。如果某天,我们迫于业务需求撕毁了协议,并在某种情况下抛出异常,这对客户来说是很大的打击。因为编译器是不会提示客户,让他在代码中添加异常处理的。所以对于大多数函数和接口,我们应该保持函数的异常中立。

那么哪些函数可以使用 noexcept 声明呢?这里总结了两种情况。

  1. 一定不会出现异常的函数。通常情况下,这种函数非常简短,例如求一个整数的绝对值、对基本类型的初始化等。
  2. 当我们的目标是提供不会失败或者不会抛出异常的函数时可以使用 noexcept 声明。对于保证不会失败的函数,例如内存释放函数,一旦出现异常,相对于捕获和处理异常,终止程序是一种更好的选择。这也是 delete 会默认带有 noexcept 声明的原因。另外,对于保证不会抛出异常的函数而言,即使有错误发生,函数也更倾向用返回错误码的方式而不是抛出异常。

除了上述两种理由,我认为保持函数的异常中立是一个明智的选择,因为将函数从没有noexcept声明修改为带noexcept声明并不会付出额外代价,而反过来的代价有可能是很大的。

6. 将异常规范作为类型的一部分

在C++17标准之前,异常规范没有作为类型系统的一部分,所以下面的代码在编译阶段不会出现问题:

void(*fp)() noexcept = nullptr;
void foo() {}

int main()
{
	fp = &foo;
}

在上面的代码中,fp 是一个指向确保不抛出异常的函数的指针,而函数 foo 则没有不抛出异常的保证。在C++17之前,它们的类型是相同的,也就是说 std::is_same<decltype(fp), decltype(&foo)>::value 返回的结果为 true。显然,这种宽松的规则会带来一些问题,例如一个会抛出异常的函数通过一个保证不抛出异常的函数指针进行调用,结果该函数确实抛出了异常,正常流程本应该是由程序捕获异常并进行下一步处理,但是由于函数指针保证不会抛出异常,因此程序直接调用 std::terminate 函数中止了程序:

#include <iostream>
#include <string>

void(*fp)() noexcept = nullptr;
void foo() 
{
	throw(5);
}

int main()
{
	fp = &foo;
	try {
		fp();
	}
	catch (int e)
	{
		std::cout << e << std::endl;
	}
}

以上代码预期中的运行结果应该是输出数字5。但是由于函数指针的使用不当,导致程序意外中止并且只留下了一句:“terminate calledafter throwing an instance of ‘int’”。

为了解决此类问题,C++17标准将异常规范引入了类型系统。这样一来,fp = &foo 就无法通过编译了,因为 fp&foo 变成了不同的类型,std::is_same<decltype(fp), decltype(&foo)>::value 会返回 false。值得注意的是,

虽然类型系统引入异常规范导致 noexcept 声明的函数指针无法接受没有 noexcept 声明的函数,但是反过来却是被允许的,比如:

void(*fp)() = nullptr;
void foo() noexcept {}

int main()
{
	fp = &foo;
}

这里的原因很容易理解,一方面这个设定可以保证现有代码的兼容性,旧代码不会因为没有声明 noexcept 的函数指针而编译报错。另一方面,在语义上也是可以接受的,因为函数指针既没有保证会抛出异常,也没有保证不会抛出异常,所以接受一个保证不会抛出异常的函数也合情合理。同样,虚函数的重写也遵守这个规则,例如:

class Base {
public:
	virtual void foo() noexcept {}
};
class Derived : public Base {
public:
	void foo() override {};
};

以上代码无法编译成功,因为派生类试图用没有声明 noexcept 的虚函数重写基类中声明 noexcept 的虚函数,这是不允许的。但反过来是可以通过编译的:

class Base {
public:
	virtual void foo() {}
};
class Derived : public Base {
public:
	void foo() noexcept override {};
};

最后需要注意的是模板带来的兼容性问题,在标准文档中给出了这样一个例子:

void g1() noexcept {}
void g2() {}
template<class T> void f(T *, T *) {}

int main()
{
    f(g1, g2);
}

在C++17中,g1g2 已经是不同类型的函数,编译器无法推导出同一个模板参数,导致编译失败。为了让这段编译成功,需要简单修改一下函数模板:

void g1() noexcept {}
void g2() {}
//template<class T> void f(T *, T *) {}
template<class T1, class T2> void f(T1 *, T2 *) {}

int main()
{
    f(g1, g2);
}

总结

异常规范是C++的语言功能特性之一,从C++11开始到C++17之前 C++ 同时有两种异常规范,本章介绍的 noexcept 就是C++11新引入的一种,旧的动态异常则从C++17开始被废弃。相对于旧异常规范,新规范更加高效并且更加适合新增的C++特性,本章提到的对于移动构造函数的应用就是新规范的用法之一。另外值得注意的是,noexcept 不仅是说明符同时也是运算符它既能规定函数是否抛出异常也能获取到函数是否抛出异常,这一点让程序员有办法更为灵活地控制异常。最后,在函数类型中纳入异常规范可以完善C++的类型系统。

二十二、using 类型别名和别名模板(C++11 C++14)

1. 型别名

在C++的程序中,我们经常会看到特别长的类型名,比如 std::map<int, std::string>::const_iterator。为了让代码看起来更加简洁,往往会使用 typedef 为较长的类型名定义一个别名,例如:

typedef std::map<int, std::string>::const_iterator map_const_iter;
map_const_iter iter;

C++11标准提供了一个新的定义类型别名的方法,该方法使用using关键字,具体语法如下:

using identifier = type-id

其中 identifier 是类型的别名标识符, type-id 是已有的类型名。相对于 typedef,我更喜欢 using 的语法,因为它很像是一个赋值表达式,只不过它所“赋值”的是一个类型。这种表达式在定义函数指针类型的别名时显得格外清晰:

typedef void(*func1)(int, int);
using func2 = void(*)(int, int);

可以看到,使用 typedef 定义函数类型别名和定义其他类型别名是有所区别的,而使用 using 则不存在这种区别,这让使用 using 定义别名变得更加统一清晰。如果一定要找出 typedef 在定义类型别名上的一点优势,那应该只有对C语言的支持了。

2. 别名模板

前面我们已经了解到使用 using 定义别名的基本用法,但是显然 C++ 委员会不会因为这点内容就添加一个新的关键字。事实上 using 还承担着一个更加重要的特性——别名模板。所谓别名模板本质上也应该是一种模板,它的实例化过程是用自己的模板参数替换原始模板的模板参数,并实例化原始模板。定义别名模板的语法和定义类型别名并没有太大差异,只是多了模板形参列表:

template < template-parameter-list >
using identifier = type-id;

其中 template-parameter-list 是模板的形参列表,而 identifiertype-id 是别名类模板型名和原始类模板型名。下面来看一个例子:

#include <map>
#include <string>

template<class T>
using int_map = std::map<int, T>;

int main()
{
	int_map<std::string> int2string;
	int2string[11] = "7";
}

在上面的代码中,int_map 是一个别名模板,它有一个模板形参。当 int_map 发生实例化的时候,模板的实参 std::string 会替换 std::map<int, T> 中的 T,所以真正实例化的类型是 std::map<int, std::string>。通过这种方式,我们可以在模板形参比较多的时候简化模板形参。

看到这里,有模板元编程经验的读者可能会提出typedef其实也能做到相同的事情。没错,我们是可以用typedef来改写上面的代码:

#include <map>
#include <string>

template<class T>
struct int_map {
	typedef std::map<int, T> type;
};

int main()
{
	int_map<std::string>::type int2string;
	int2string[11] = "7";
}

以上代码使用 typedef 和类型嵌套的方案也能达到同样的目的。不过很明显这种方案要复杂不少,不仅要定义一个 int_map 的结构体类型,还需要在类型里使用 typedef 来定义目标类型,最后必须使用 int_map<std::string>::type 来声明变量。除此之外,如果遇上了待决的类型,还需要在变量声明前加上 typename 关键字:

template<class T>
struct int_map {
    typedef std::map<int, T> type;
};
template<class T>
struct X {
    typename int_map<T>::type int2other; // 必须带有 typename 关键字,否则编译错误
};

在上面这段代码中,类模板 X 没有确定模板形参 T 的类型,所以 int_map<T>::type 是一个未决类型,也就是说 int_map<T>::type 既有可能是一个类型,也有可能是一个静态成员变量,编译器是无法处理这种情况的。这里的 typename 关键字告诉编译器应该将 int_map<T>::type 作为类型来处理。而别名模板不会有 ::type 的困扰,当然也不会有这样的问题了:

template<class T>
using int_map = std::map<int, T>;

template<class T>
struct X {
    int_map<T> int2other; // 编译成功,别名模板不会有任何问题
};

值得一提的是,虽然别名模板有很多 typedef 不具备的优势,但是 C++11 标准库中的模板元编程函数都还是使用的 typedef 和类型嵌套的方案,例如:

template<bool, typename _Tp = void>
struct enable_if { };

template<typename _Tp>
struct enable_if<true, _Tp>
{ typedef _Tp type; };

不过这种情况在 C++14 中得到了改善,在 C++14 标准库中模板元编程函数已经有了别名模板的版本。当然,为了保证与老代码的兼容性,typedef 的方案依然存在。别名模板的模板元编程函数使用 _t 作为其名称的后缀以示区分:

template<bool _Cond, typename _Tp = void>
using enable_if_t = typename enable_if<_Cond, _Tp>::type;

总结

本章介绍了使用 using 定义类型别名的方法,可以说这种新方法更符合C++的语法习惯。除此之外,使用 using 还可以定义别名模板,相对于内嵌类型实现类似别名模板的方案,该方法更加简单直接。建议读者在编译环境允许的情况下尝试使用 using 来定义别名。

二十三、指针字面量 nullptr(C++11)

1. 零值整数字面量

在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 以及之前的标准来编译,则虽然第一句代码能编译通过,但会给出警告信息,第二句代码依然编译失败。

2. nullptr 关键字

鉴于 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);

将指针变量初始化为 0nullptr 的效果是一样的,初始化后它们也能够与 0nullptr 进行比较。从最后一句代码看出 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);
}

输出:

template
nullptr

总结

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

川峰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值