07:在创建对象时注意区分()和{}
自C++11以来,指定初始化值的的方式包括使用小括号,等号,以及大括号:
int x(0); // initializer is in parentheses int y = 0; // initializer follows "=" int z{ 0 }; // initializer is in braces int z = { 0 }; // initializer uses "=" and braces
C++将后两种使用大括号的两种方式视为相同的方式。
C++11之前,单纯的直接初始化和复制初始化方式,在某些情况下无法进行想要的初始化。比如没有办法在初始化STL容器时直接指定所持有的值。为了解决这个问题,C++11引入了统一初始化:单一的,至少从概念上可以用于一切场合,表达一切意思的初始化。它的基础就是大括号形式。使用统一初始化,可以指定容器的初始内容:
std::vector<int> v1{1,2,3}; // v1's initial content is 1, 2, 3 std::vector<int> v2 = {1,2,3}; // v2's initial content is 1, 2, 3
使用统一初始化,也可以用来为类的非静态成员指定默认初始化值,这是在C++11中新加入的能力,也可以使用等号的初始化语法,但是不能使用小括号:
class Widget { … private: int w = { 1 }; // fine, w's default value is 1 int x{ 2 }; // fine, x's default value is 2 int y = 3; // also fine int z(4); // error! };
不可复制的对象,比如std::atomic类型的对象,可以使用大括号和小括号进行初始化,不能使用等号进行初始化,因为复制构造函数是不可访问的:
std::atomic<int> ai1{ 0 }; // fine std::atomic<int> ai2 = { 0 }; // fine std::atomic<int> ai3(0); // fine std::atomic<int> ai4 = 0; // error!
实际上,ai1和ai2它们的初始化方式是等价的,都是调用相应的构造函数。ai2不是调用复制构造函数。
统一初始化还有一些新特性,它禁止内建类型之间进行隐式窄化类型转换(narrowing conversions):
double x, y, z; int sum1{ x + y + z }; // error! sum of doubles may not be expressible as int int sum2 = { x + y + z }; // error! sum of doubles may not be expressible as int
采用小括号和等号进行初始化,不会进行窄化类型转换检查,因为那样的话就会破坏太多遗留代码:
int sum2(x + y + z); // okay (value of expression truncated to an int) int sum3 = x + y + z; // ditto
统一初始化的另一个特性是:它对于C++最令人苦恼的解析语法免疫。在C++中,任何能够解析为声明的,都要解析为声明。如下面的代码:
Widget w1(10); // call Widget ctor with argument 10 Widget w2(); // most vexing parse! declares a function named w2 that returns a Widget!
w1以10为参数调用构造函数,而w2的本意是调用Widget的默认构造函数,但是这条语句却被编译器理解为声明了一个函数,函数名为w2,返回一个Widget对象。
由于函数声明不能使用大括号,因此,使用大括号可以实现上面的意图:
Widget w3{}; // calls Widget ctor with no args
统一初始化并非没有缺陷,使用统一初始化有时会出现意外行为,这是因为统一初始化,std::initializer_list以及构造函数重载决议之间的纠结关系造成的。比如之前就提到过,auto和统一初始化一起出现时,推导的类型是std::initializer_list。
如果所有的构造函数中,形参中没有std::initializer_list类型,那么小括号和大括号的形式是没有区别的:
class Widget { public: Widget(int i, bool b) { std::cout << "Widget(int i, bool b)" << std::endl; } Widget(int i, double d) { std::cout << "Widget(int i, double d)" << std::endl; } }; Widget w1(10, true); // Widget(int i, bool b) Widget w2{10, true}; // Widget(int i, bool b) Widget w22 = {10, true}; // Widget(int i, bool b) Widget w3(10, 5.0); // Widget(int i, double d) Widget w4{10, 5.0}; // Widget(int i, double d) Widget w42 = {10, 5.0}; // Widget(int i, double d)
如果某些构造函数中有std::initializer_list类型的形参,则使用统一初始化的调用语句,只要有任何可能,编译器就会优先选择带有std::initializer_list的那个构造函数:
class Widget { public: Widget(int i, bool b) { std::cout << "Widget(int i, bool b)" << std::endl; } Widget(int i, double d) { std::cout << "Widget(int i, double d)" << std::endl; } Widget(std::initializer_list<long double> il) { std::cout << "Widget(std::initializer_list<long double> il)" << std::endl; } }; Widget w1(10, true); // Widget(int i, bool b) Widget w2{10, true}; // Widget(std::initializer_list<long double> il) Widget w22 = {10, true}; // Widget(std::initializer_list<long double> il) Widget w3(10, 5.0); // Widget(int i, double d) Widget w4{10, 5.0}; // Widget(std::initializer_list<long double> il) Widget w42 = {10, 5.0}; // Widget(std::initializer_list<long double> il)
即使是平常会执行复制或移动构造函数的情况,也可能被带有std::initializer_list形参的构造函数所劫持:
class Widget { public: Widget(int i, bool b); // as before Widget(int i, double d); // as before Widget(std::initializer_list<long double> il); // as before operator float() const; // convert to float }; Widget w5(w4); // calls copy ctor Widget w6{w4}; // calls std::initializer_list ctor(w4 converts to float, and float // converts to long double) Widget w7(std::move(w4)); // calls move ctor Widget w8{std::move(w4)}; // calls std::initializer_list ctor(for same reason as w6)
编译器想要把统一初始化物匹配到带有std::initializer_list形参的构造函数的决心如此强烈,以至于当此构造函数无法调用时,这种决心还是会占上风:
class Widget { public: Widget(int i, bool b); Widget(int i, double d); Widget(std::initializer_list<bool> il); … }; Widget w{10, 5.0}; // error! requires narrowing conversions
上面的代码,编译器会忽略前两个构造函数(其中第二个构造函数的形参和实参是精确匹配的),而是尝试第三个带有std::initializer_list的构造函数,而此函数需要把int和double强制转化为bool,这两种转化都是窄化的,因此这会造成编译错误。
只有在找不到任何方法把统一初始化物的实参转换为std::initializer_list的情况下,编译器才会选择其他的构造函数:
class Widget { public: Widget(int i, bool b); Widget(int i, double d); Widget(std::initializer_list<std::string> il); … }; Widget w1(10, true); // Widget(int i, bool b) Widget w2{10, true}; // Widget(int i, bool b) Widget w3(10, 5.0); // Widget(int i, double d) Widget w4{10, 5.0}; // Widget(int i, double d)
还有一种情况需要考虑,假设你使用”Widget w{}”构造对象,而Widget既支持默认构造函数,又支持带有std::initializer_list类型形参的构造函数,这种情况下,C++规定应该执行默认构造函数,此时空大括号表示没有实参,而不是空std::initializer_list:
class Widget { public: Widget(); Widget(std::initializer_list<int> il); }; Widget w1; // calls default ctor Widget w2{}; // also calls default ctor
如果你的确想要调用那个带有std::initializer_list的构造函数,并传入一个空的std::initializer_list的话,可以这样:
Widget w4({}); // calls std::initializer_list ctor with empty list Widget w5{{}}; // ditto
std::vector的众多构造函数中,有一个具有两个形参的构造函数,第一个形参指定了容器的初始尺寸,第二个指定了初始化时所有元素的初始值;还有一个带有std::initializer_list的构造函数。这种情况下,当构造元素类型为数值类型的std::vector时,传递2个实参时,使用小括号还是大括号会有本质区别:
std::vector<int> v1(10, 20); // 创建了包含10个元素的vector, 所有元素初始化值为20 std::vector<int> v2{10, 20}; // 创建了包含2个元素的vector,元素分别为10和20
作为类的设计者,需要注意,最好把构造函数设计为无论使用小括号还是大括号,都不会影响调用哪个构造函数才好,从这个角度看,std::vector的接口设计是失败的,应该避免同类行为;
如果你有一个类本来所有构造函数都没有std::initializer_list的形参,当添加一个带有std::initializer_list的构造函数时,可能在客户代码中,使用大括号初始化的的地方,重新决议到新的构造函数上了。
如果你是一个开发类客户代码,则创建对象时,最好在选择其中一种风格作为默认选用,只有在必要时才使用另一种初始化风格。究竟选用哪一种更改是没有定论的,可以任选一种病坚持下去。
08:优先使用nullptr,而非0或NULL
字面常量0的类型是int,如果在只能使用指针的语境中使用了0,则C++会勉强将其解释为空指针,但0的类型是int这是一个不争的事实。这个结论对于NULL而言也类似,标准允许NULL实现为int以外(如long)的整型,因此,0和NULL都不是指针类型。
基于上面的事实,在C++98中,如果函数在指针和整型之间发生了重载,则当向重载函数传入0或NULL时,可能从来不会调用到接收指针类型的那个重载函数:
void f(int); // three overloads of f void f(bool); void f(void*); f(0); // calls f(int), not f(void*) f(NULL); // might not compile, but typically calls f(int). Never calls f(void*)
如果NULL被定义为0L,那么这个调用就有歧义,因为从long到int,从long到bool,还有从0L到void*的类型转换被视为是同样好的。而这里调用的本意却是调用f时传入空指针,因此对于C++98而言,指导原则是不要在指针类型和整型之间做重载,这个原则在C++11中依然成立,因为即使有了nullptr,还是有很多人使用0和NULL。
nullptr的优势在于它不是整型,当然它也不是指针类型,但可以把它当做任意类型的指针。它实际是std::nullptr_t类型的右值,类型std::nullptr_t可以隐式转换为所有的指针类型。因此调用f时,如果传入了nullptr,则会调用f(void*)。
这不是nullptr的唯一有点,它还可以增加代码的清晰度,比如:
auto result = findRecord( /* arguments */ ); if (result == 0) { … }
如果不知道或者不容易得出findRecord的返回值类型,则result是指针类型还是整型就不清楚了,但是如果改成这样:
auto result = findRecord( /* arguments */ ); if (result == nullptr) { … }
这种情况下,result一定是指针类型。
nullptr在模板中使用也更亮眼,比如下面的代码:
int f1(std::shared_ptr<Widget> spw); double f2(std::unique_ptr<Widget> upw); bool f3(Widget* pw); std::mutex f1m, f2m, f3m; using MuxGuard = std::lock_guard<std::mutex>; … { MuxGuard g(f1m); auto result = f1(0); } … { MuxGuard g(f2m); auto result = f2(NULL); } … { MuxGuard g(f3m); auto result = f3(nullptr); }
上面的代码,调用f1和f2时分别使用了0和NULL,这是没有问题的,但是上面的代码结构是类似的,可以使用模板避免重复代码:
template<typename FuncType, typename MuxType, typename PtrType> auto lockAndCall(FuncType func, MuxType& mutex, PtrType ptr) -> decltype(func(ptr)) { MuxGuard g(mutex); return func(ptr); } auto result1 = lockAndCall(f1, f1m, 0); // error! auto result2 = lockAndCall(f2, f2m, NULL); // error! auto result3 = lockAndCall(f3, f3m, nullptr); // fine
使用模板的情况下,在使用0和NULL就会发生编译错误,传参0或NULL时,ptr推导为int,而使用ptr调用func时,func期望的参数为std::shared_ptr<Widget>或std::unique_ptr<Widget>,这就发生了错误。而使用nullptr就不会发生这种问题,ptr推导为std::nullptr_t,当ptr调用f3时,从std::nullptr_t到Widget*存在隐式类型转换,因为std::nullptr_t可以隐式转换为所有指针类型。因此可以编译通过。
以上,就是优先使用nullptr,而非0或NULL的理由。
使用nullptr需要注意一点,如果若干重载函数接收不同类型的指针,则重载函数中,需要有一个函数接收std::nullptr_t类型,否则这些重载函数可能无法接收空指针:
void f(int* pi) { std::cout << "Pointer to integer overload "; } void f(double* pd) { std::cout << "Pointer to double overload "; } void f(std::nullptr_t nullp) { std::cout << "null pointer overload "; } int* pi; double* pd; f(pi); //Pointer to integer overload f(pd); //Pointer to double overload f(nullptr); // would be ambiguous without void f(nullptr_t) // f(0); // ambiguous call: all three functions are candidates // f(NULL); // ambiguous if NULL is an integral null pointer constant // (as is the case in most implementations)
09:优先使用别名声明,而非typedef
C++11中引入了别名声明(alias declaration),它提供类似于typedef的功能:
typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS; using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;
别名声明相比于typedef,有以下几个优点:在处理涉及函数指针的类型时,别名声明比typedef更好理解一些:
typedef void (*FP)(int, const std::string&); using FP = void (*)(int, const std::string&);
别名声明还有一个压倒性的优势,就是别名声明可以模板化(别名模板alias template),而typedef不具备这种特性。比如,要定义一个链表类型的同义词,该链表使用自定义分配器MyAlloc,使用别名声明:
// MyAllocList<T> is synonym for std::list<T, MyAlloc<T>> template<typename T> using MyAllocList = std::list<T, MyAlloc<T>>; MyAllocList<Widget> lw; // client code
如果使用typedef,则需要自己定义一个模板:
// MyAllocList<T>::type is synonym for std::list<T, MyAlloc<T>> template<typename T> struct MyAllocList { typedef std::list<T, MyAlloc<T>> type; }; MyAllocList<Widget>::type lw; // client code
如果想在模板中使用MyAllocList<Widget>::type,则必须使用typename:
// Widget<T> contains a MyAllocList<T> as a data member template<typename T> class Widget { private: typename MyAllocList<T>::type list; };
而如果使用别名声明,则不需要typename:
template<typename T> using MyAllocList = std::list<T, MyAlloc<T>>; template<typename T> class Widget { private: MyAllocList<T> list; };
某些情况下,可能需要去除T的所有const和引用饰词,比如将const std::string&变换为std::string;又或者需要为一个类型加上const或将其变为左值引用形式,比如将Widget变换为const Widget或Widget&。在C++11中,以类型特征(type traits)的形式提供了这些变换的工具,类型特征在头文件<type_traits>中定义的一整套模板。比如:
std::remove_const<T>::type // yields T from const T std::remove_reference<T>::type // yields T from T& and T&& std::add_lvalue_reference<T>::type // yields T& from T
这些变换都是以::type为结尾,这是因为在C++11中,类型特征是用嵌套在模板化的struct里的typedef来实现的。因此,如果需要在模板内部使用它们,必须加上typename。到了C++14中,为C++11中的所有类型变换都加上了对应的别名模板:每个C++11中的std::transformation<T>::type,对应于C++14中的std::transformation_t。比如:
std::remove_const<T>::type // C++11: const T → T std::remove_const_t<T> // C++14 equivalent std::remove_reference<T>::type // C++11: T&/T&& → T std::remove_reference_t<T> // C++14 equivalent std::add_lvalue_reference<T>::type // C++11: T → T& std::add_lvalue_reference_t<T> // C++14 equivalent
当然C++11中那种形式在C++14中依然可以使用,但是明显使用别名模板更优雅,实际上它的实现也不难:
template <class T> using remove_const_t = typename remove_const<T>::type; template <class T> using remove_reference_t = typename remove_reference<T>::type; template <class T> using add_lvalue_reference_t = typename add_lvalue_reference<T>::type;
10:优先使用限定作用域的枚举,而不是无限定作用域的枚举
一般而言,在大括号中声明的变量名,其可见性会限制在括号内的作用域中。但是这对于 C++98 风格的枚举元素而言并不成立。枚举元素和包含它的枚举类型同属一个作用域空间,这意味着在这个作用域中不能再有相同名字的定义:
enum Color { black, white, red}; // black, white, red 和 Color 同属一个定义域 auto white = false; // 错误!因为 white在这个定义域已经被声明过
这种类型的枚举类型称为无限定作用域的(unscoped)的枚举类型。在 C++11 中,增加了有限定作用域的枚举类型(scoped enums),这种枚举类型不会造成名字泄露:
enum class Color { black, white, red }; // black, white, red are scoped to Color auto white = false; // fine, no other "white" in scope Color c = white; // error! no enumerator named "white" is in this scope Color c = Color::white; // fine auto c = Color::white; // also fine
限定作用域的枚举是通过"enum class"来声明的,因此有时也被称作枚举类(enum class)。
除了不会造成名字泄漏之外,限定作用域的枚举类型还有一个优势:它们的枚举元素是更强类型的。无限定作用域的枚举类型会将枚举元素隐式转换为整数类型。因此像下面这种语义上完全荒诞的情况是合法的:
enum Color { black, white, red }; std::vector<std::size_t> primeFactors(std::size_t x); Color c = red; if (c < 14.5 ){ // 将Color和double类型比较! auto factors = primeFactors(c); }
而限定作用域的枚举类型,不存在从枚举元素到其他类型的隐式转换:
enum class Color { black, white, red }; Color c = Color::red; // error: no match for ‘operator<’ (operand types are ‘main(int, char**)::Color’ and ‘double’) if (c < 14.5) { auto factors = primeFactors(c); ... }
如果确实想将Color类型转换为其他类型,使用强制类型转换即可:
if(static_cast<double>(c) < 14.5) { auto factors = primeFactors(static_cast<std::size_t(c)); ... }
乍看之下,限定作用域的枚举类型还有个优点,就是可以提前声明:
enum Color; // 出错! enum class Color; // 没有问题
这实际上是一种误导,在 C++11 中,无限定作用域的枚举类型也可以提前声明,但是需要一点额外的工作。
在C++中,枚举类型都会由编译器选择一个整数类型作为其底层类型。对于Color这样的无限定作用域的枚举而言,编译器会选择char作为其底层类型,因为只有三个值需要表示。而有的枚举类型的取值范围就大得多:
enum Status { good = 0, failed = 1, incomplete = 100, corrupt = 200, indeterminate = 0xFFFFFFFF };
这种情况下,编译器一定会选择一个取值范围比char大的整数类型来表示Status的取值。为了更高效的利用内存,编译器通常会为枚举类型选择足够表示枚举元素取值的最小底层类型。在某些情况下,编译器会用空间换时间,这种情况下它可能不会选择只具备最小尺寸的类型。为了使这种设计可以实现,C++98仅仅支持枚举类型的定义(所有枚举元素被列出来),而不支持枚举类型的声明。这样可以保证在枚举类型被用到之前,编译器已经给每个枚举类型选择了底层类型。
但是没有前置声明的能力会造成一些弊端,最重要的就是会增加编译依赖性。比如上面的枚举类型Status可能会在整个系统中都被用到,因此其定义被包含在系统每部分都依赖的一个头文件当中。此时如果一个新的状态需要被引入:
enum Status { good = 0, failed = 1, incomplete = 100, corrupt = 200, audited = 500, //new element indeterminate = 0xFFFFFFFF };
有可能导致整个系统的代码需要被重新编译。因此C++11为枚举类型提供了前置声明的能力,使得这种弊端被消除了。例如:
enum class Status; // 前置声明 void continueProcessing(Status s); // 使用前置声明的枚举体
若头文件包含了这些声明,则在Status的定义被修改时,包含这个声明的头文件就不需要重新编译。
但是如果编译器需要在枚举类型使用之前就知道它的大小,为什么C++11中的枚举类型就可以进行前置声明,而C++98就不行呢?原因是简单的,限定作用域的枚举类型的底层类型是已知的,默认是int,如果默认的类型不适合,还可重载它;无限定作用域的枚举类型,也可以指定它的底层类型:
enum class Status; // 底层类型是int enum class Status: std::uint32_t; // Status底层类型是std::uint32_t(来自<cstdint>)
可以为无限定作用域的枚举类型指定底层类型,这样做了之后,无限定范围的枚举类型也可以前置声明了:
enum Color: std::uint8_t; // 底层类型是std::uint8_t
底层类型的指定也可以放在枚举定义处:
enum class Status: std::uint32_t { good = 0, failed = 1, incomplete = 100, corrupt = 200, audited = 500, indeterminate = 0xFFFFFFFF };
在一种特殊情况下,无限定作用域的枚举要比限定作用域的枚举更具优势,就是在需要使用C++11中的std::tuple类型的各个域时:
using UserInfo = std::tuple<std::string, // 姓名 std::string, // 电子邮件 std::size_t> ; // 影响力 UserInfo uInfo; auto val = std::get<1>(uInfo); // 得到第一个域的值
使用std::tuple时,不太希望去翻看其定义了解tuple每个域的意义,此时可以使用枚举类型,把名字和域的编号联系在一起:
enum UserInfoFields {uiName, uiEmail, uiReputation }; UserInfo uInfo; auto val = std::get<uiEmail>(uInfo); // 得到电子邮件域的值
上面代码使用了无限定作用域的枚举,它利用了无限定作用域UserInfoFields的隐式转换能力,将枚举元素隐式转换到std::get()所要求的std::size_t类型。
如果使用限定作用域的枚举,则代码就十分冗余:
enum class UserInfoFields { uiName, uiEmail, uiReputaion }; UserInfo uInfo; auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);
如果不想这么啰嗦,就得写一个函数,以枚举元素为形参,返回其对应的std::size_t类型的值。但是std::get是一个模板,需要传入其中的值是一个模板参数,因此这个函数必须在编译阶段就确定它的结果,也就是说它必须是一个constexpr函数。
实际上,它还必须是一个constexpr函数模板,因为它应该对任何类型的枚举元素有效。编写函数模板实现这种泛化,那返回值也需要泛化才行。这样就不是返回std::size_t,而需要返回枚举类型的底层类型(调用std::get时,将底层类型隐式转换为std::size_t)。这个底层类型可以通过std::underlying_type类型特征取得:
template<typename E> constexpr typename std::underlying_type<E>::type toUType(E enumerator) noexcept { return static_cast<typename std::underlying_type<E>::type>(enumerator); }
C++14中,可以将std::underlying_type<E>::type替代为std::underlying_type_t;可以使用auto 返回值:
template<typename E> // C++14 constexpr auto toUType(E enumerator) noexcept { return static_cast<std::underlying_type_t<E>>(enumerator); }
无论哪种形式,toUType允许我们像下面一样访问一个元组的某个域:
auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);
这样依然比使用无限定作用域的枚举类型要复杂,但它可以避免命名空间污染和不易引起注意的枚举元素的隐式类型转换。
11:优先使用已删除函数,而非private未定义函数
在C++98中,为了阻止某些特殊函数(比如复制构造函数、复制赋值运算符等)被使用,采取的做法通常是将其声明为private且不去定义它们。比如:
template <class charT, class traits = char_traits<charT> > class basic_ios : public ios_base { public: … private: basic_ios(const basic_ios& ); // not defined basic_ios& operator=(const basic_ios&); // not defined };
将函数声明为private,阻止了客户端代码调用这些函数;而不去定义它们,使得友元或成员函数调用它们时,发生链接错误。
在C++11中,有更好的途径达到相同的效果:使用”=delete”将函数标识为已删除函数(deleted function),比如:
template <class charT, class traits = char_traits<charT> > class basic_ios : public ios_base { public: … basic_ios(const basic_ios& ) = delete; basic_ios& operator=(const basic_ios&) = delete; };
已删除函数无法通过任何方法使用,所以即使成员和友元函数调用它们,就会发生编译错误,而不是像C++98中那样,直到链接阶段才报错。
习惯上将已删除函数声明为public,而非private。这是因为C++先校验可访问性,后校验删除状态。如果将已删除函数声明为private,客户端代码调用它们时,编译器只会抱怨该函数为private,而使客户端忽略了真正的原因。
已删除函数还有一个重要的优点在于,任何函数都能成为已删除函数。比如下面的函数:
bool isLucky(int number);
在实际调用时,所有能够隐式转换为int的类型调用isLucky,都可以编译成功:
if (isLucky('a')) … if (isLucky(true)) … if (isLucky(3.5)) …
虽然能编译通过,但是语义上可能是无意义的。因此,为了保证参数必须为int,可以这样对函数进行重载:
bool isLucky(int number); // original function bool isLucky(char) = delete; // reject chars bool isLucky(bool) = delete; // reject bools bool isLucky(double) = delete; // reject doubles and floats
这种情况下,尽管已删除函数是不可使用的,但是它们依然参与重载决议,因此,下面的代码就无法编译了:
if (isLucky('a')) … // error! call to deleted function if (isLucky(true)) … // error! call to deleted function if (isLucky(3.5f)) … // error! call to deleted function
已删除函数还有一个优点,它可以阻止特定的模板具现。比如下面的模板:
template<typename T>
void processPointer(T* ptr);
如果需要阻止使用void*和char*来具现processPointer,则可以这样:
template<> void processPointer<void>(void*) = delete; template<> void processPointer<char>(char*) = delete;
这种情况下,使用void*和char*调用processPointer都将是非法的。
更进一步的,对于类内部的函数模板,如果你想通过private在阻止某些类型的具现,这是办不到的,因为你不能给予成员函数模板的某个特化以不同于主模板的访问级别:
class Widget { public: … template<typename T> void processPointer(T* ptr) { … } private: template<> // error! void processPointer<void>(void*); };
问题在于模板特化必须在名字空间作用域,而非类作用域内。如果使用已删除函数则不存在这种问题,一来他们根本不需要不同的访问级别,二来成员函数模板也可以在类外(即名字空间作用域)被删除:
class Widget { public: … template<typename T> void processPointer(T* ptr) { … } … }; template<> // still public but deleted void Widget::processPointer<void>(void*) = delete;
实际上,C++98中的private未定义函数,就是作为已删除函数的一种模拟而已。所以有了已删除函数,就不需要时private未定义函数了。
12:为意在改写的函数添加override声明
在基类中定义虚函数,其目的就是为了在派生类中重写该函数。也就是所谓override,而为了能正确的override,需要满足下面的条件:
基类中的函数必须是虚函数;
基类和派生类中的函数名必须完全相同(析构函数除外);
基类和派生类中的函数形参类型必须完全相同;
基类和派生类中的函数常量性必须完全相同;
基类和派生类中的函数返回值和异常规格必须兼容;
基类和派生类中的函数引用饰词(reference qualifier)必须完全相同;这是C++11新引入的条件。函数引用饰词是C++11引入的新特性,是为了限制成员函数用于左值还是右值,带有引用饰词的函数不一定是虚函数。比如:
class Widget { public: … void doWork() &; // *this是左值时调用 void doWork() &&; // *this是右值时调用 }; Widget makeWidget(); // factory function (returns rvalue) Widget w; // normal object (an lvalue) w.doWork(); // Widget::doWork & makeWidget().doWork(); // Widget::doWork &&
后面会讨论有关引用饰词的问题。这里只需要记住,如果基类中的虚函数带有引用饰词,则派生类中的改写版本必须带有完全相同的引用饰词。
override有这么多的条件,意味着小的错误就会造成override失败,而这种失败的代码也能够运行的,但是表达的意思却不是我们的初衷。比如下面的代码,所有的函数都破坏了某个override条件:
class Base { public: virtual void mf1() const; virtual void mf2(int x); virtual void mf3() &; void mf4() const; }; class Derived: public Base { public: virtual void mf1(); virtual void mf2(unsigned int x); virtual void mf3() &&; void mf4() const; };
override这么容易出错,因此C++11中引入了”override”关键字,用于显示表明派生类中的函数是为了改写基类版本:
class Derived: public Base { public: virtual void mf1() override; virtual void mf2(unsigned int x) override; virtual void mf3() && override; virtual void mf4() const override; };
这样声明之后,编译器就会检查所有与改写相关的问题,从而导致不符合override条件时报错,如:error: ‘virtual void Derived::mf1()’ marked ‘override’, but does not override
使用override声明,还可以在你打算更改基类虚函数的签名时,明确的提示出这种更改所带来的影响,只要派生类中所有虚函数都增加了override声明,改变基类虚函数的声明时,编译器就会报错。
“override”关键字是C++11新引入的语境关键字(contextual keyword)之一(另一个是”final”),所谓语境关键字,就是仅在特定语境下保留该关键字。针对”override”而言,它仅出现在成员函数声明的末尾时才有保留意义。因此,如果一些遗留代码中已经使用了override这个单词的话,不必为了升级到C++11而改名:
class Warning { // potential legacy class from C++98 public: … void override(); // legal in both C++98 and C++11 };
下面继续讨论引用饰词的内容。如果编写两个重载函数,一个仅接受左值实参,另一个仅接受右值实参:
void doSomething(Widget& w); // accepts only lvalue Widgets void doSomething(Widget&& w); // accepts only rvalue Widgets
成员函数引用饰词的作用,就是针对*this,在其为左值时调用左值版本,为右值是调用右值版本。带引用饰词的成员函数的需求并不常见,下面是一个例子:
class Widget { public: using DataType = std::vector<double>; DataType& data() { return values; } … private: DataType values; }; Widget w; auto vals1 = w.data(); // copy w.values into vals1 Widget makeWidget(); auto vals2 = makeWidget().data(); // copy values inside the Widget into vals2
Widget::data的返回值类型是一个左值引用,因此,初始化vals1时,是以w.values为基础进行复制构造;而对于vals2,是以makeWidget函数返回的临时对象中的values进行复制构造,因为是个临时对象,更好的做法实际上是移动而非复制,因此,可以让data函数在右值上调用时,返回结果为右值,此时就可以使用引用饰词对data函数进行重载:
class Widget { public: using DataType = std::vector<double>; DataType& data() & // for lvalue Widgets, return lvalue { return values; } DataType data() && // for rvalue Widgets, return rvalue { return std::move(values); } private: DataType values; };
这里data函数的返回类型是不同的,左值引用的版本,返回的是左值引用;而右值引用的版本,返回的是一个临时对象。使用这个定义,初始化vals2时,采用的就是移动构造函数。
13:优先选用const_iterator,而非iterator
const_iterator是STL中相当于指向const对象的指针等价物,它们指向不能被修改的值。只要有可能,就应该使用const,因此,只要需要一个迭代器,而其指向的内容没有修改的必要,就应该使用const_iterator。
这一点对于C++98和C++11都成立,但是C++98中的const_iterator比较难用。比如下面的代码:
std::vector<int> values; std::vector<int>::iterator it = std::find(values.begin(),values.end(), 1983); values.insert(it, 1998);
这里实际上应该使用const_iterator,因为没有任何地方修改了iterator所指向的内容。但是一旦要改成const_iterator,则需要费很大功夫,改完的代码也未必正确:
typedef std::vector<int>::iterator IterT; typedef std::vector<int>::const_iterator ConstIterT; std::vector<int> values; ConstIterT ci = std::find(static_cast<ConstIterT>(values.begin()), static_cast<ConstIterT>(values.end()), 1983); values.insert(static_cast<IterT>(ci), 1998); // may not compile; see below
在C++98中,为了使std::find返回const_iterator,其参数必须是const_iterator,因为values是non-const对象,因此其begin和end返回的是iterator,所以这里采用强制类型转换,将iterator转换为const_iterator(也可以将values绑定到const引用上,在该引用上执行begin和end)。从std::find得到const_iterator之后,调用insert时,因insert只接受iterator不接受const_iterator,因此,这里有需要强制转换会iterator。
然而,这段代码可能最终无法编译,因为从const_iterator到iterator不存在可移植的类型转换,这种转换即使在C++11中也有一样的限制。所以,const_iterator在C++98中如此难用,很少有人愿意招惹这个麻烦。
到了C++11,这中现象彻底改观了,获取和使用const_iterator都非常容易,容器成员cbegin和cend都返回const_iterator,即使对于non-const容器也一样;而且STL成员函数若要使用指示位置的迭代器(比如用于插入和删除),也要求必须使用const_iterator类型。所以,上面的代码,在C++11中可以改为这样:
std::vector<int> values; auto it = std::find(values.cbegin(),values.cend(), 1983); values.insert(it, 1998);
只有一种情形,C++11对于const_iterator的支持还不够成分,就是在编写通用化的库代码的时候。这是因为某些容器或类似容器的数据结构,没有提供成员函数版本的begin和end,而是以非成员函数的形式提供begin和end函数(还有cbegin,cend和rbegin等)。内建数组就属于这种情况,因此,通用化的库代码只能使用非成员函数,而不能假定其提供成员函数版本。比如,上面的代码写成通用的模板形式是这样的:
template<typename C, typename V> void findAndInsert(C& container, const V& targetVal, const V& insertVal) { using std::cbegin; using std::cend; auto it = std::find(cbegin(container), cend(container), targetVal); container.insert(it, insertVal); }
这个代码在C++14上可以正常运行,但是在C++11中,只提供了非成员函数版本的begin和end函数,而没有cbegin,cend,rbegin,rend,crbegin和crend。所以C++14纠正了这个错误。如果只能使用C++11,则需要自己实现非成员函数的cbegin等函数,下面是一个简单实现:
template <class C> auto cbegin(const C& container)->decltype(std::begin(container)) { return std::begin(container); }
cbegin的形参container是const引用,用其调用std::begin时,传入const容器会产生const_iterator。
总之,本条款的要点是鼓励你只要在能使用const_iterator的场合下就去使用它,而它的原动力(只要有可能,就应该使用const)是在C++98中就有的了,但是C++98中的const_iterator实在太难用,到了C++11中,它变得好用起来,而C++14则是吧C++11中残留的一些死角清理完了。
14:只要函数不会抛出异常,就为其加上noexcept声明
在《More Effective C++》的条款14中曾经提到过,要谨慎使用异常说明符。而到了C++11中,逐渐达成了一个共识:关于函数抛出异常这件事,真正重要的是它是否会抛出异常。这就是C++11异常说明符noexcept出现的原因,同时也就把C++98中的异常说明符替换掉了(C++98中的异常说明符仍然可用,但是已经标注为废弃特性了)。
在C++11中,使用无条件的noexcept表示函数不会抛出异常。函数是否需要加上noexcept声明,事关接口设计。客户代码关心函数是否具有该声明,它可以影响客户端代码的异常安全性和运行效率。如果明知道函数不会抛出异常,而没有加上noexcept声明,这就是接口规格缺陷。
为不会抛出异常的函数增加noexcept声明,也可以让编译器生成更好的目标代码。对比C++98和C++11的异常说明:
int f(int x) throw(); // no exceptions from f: C++98 style int f(int x) noexcept; // no exceptions from f: C++11 style
在C++98中,一旦f抛出异常,则调用栈会展开,然后程序终止。而在C++11中,程序终止之前,调用栈只是有可能展开,这种情况下,优化器就无需进行栈展开,也无需保证函数中的对象以构造顺序的逆序完成析构。而对于C++98中,throw()声明的函数就没有这种优化灵活性,没有异常说明符的函数也一样,因此总结如下就是:
RetType function(params) noexcept; // 最优 RetType function(params) throw(); // 优化不够 RetType function(params); // 优化不够
对于某些函数而言,是否声明为noexcept具有更大的影响。比如有下面的代码:
std::vector<Widget> vw;
Widget w;
vw.push_back(w);
这段代码在C++98下运行良好,移植到C++11后,你希望利用C++11中的移动语义来提高效率,这就需要Widget提供移动操作。std::vector的push_back函数,当std::vector的空间不足时,push_back内部会申请更大的内存空间,并将原有元素转移到新内存空间中。在C++98中,转移操作是通过复制元素实现的,这就保证push_back可以提供强烈的异常安全保证,因为即使复制过程中发生了异常,std::vector原有空间还在。而到了C++11中,一个很自然的优化措施就是使用移动代替复制,然而,移动操作却有可能打破原有的异常安全保证,如果n个元素已经移动成功,而第n+1个元素移动时发生了异常,push_back操作无法继续运行,然而原有的n个元素已经移动到新内存中了,std::vector的内容已经发生了变化,即使将这n个元素在移动回原内存,也无法保证此时的移动操作不会发生异常。
因此,C++11中的push_back实现,只有在确知移动操作不会抛出异常的时候,才将复制操作替换为移动操作。它采用的策略也就是所谓的“move if you can, but copy if you must”,而push_back不是唯一采取该策略的函数,C++98中其他提供强烈异常安全保证的函数,比如std::vector::reserve,std::deque::insert等等,也采用同样的策略。然而这些函数如何知道移动操作是否会抛出异常呢,很简单,查看移动操作是否声明为noexcept即可(C++11中引入了noexcept操作符,用以在编译期判断表达式是否会抛出异常。noexcept(expression)表达式返回true,表示expression会抛出异常,返回false表示不会抛出异常)。
swap函数是需要noexcept声明的另一个例子。该函数是众多STL算法的核心组件,它的广泛应用意味着针对其实施noexcept声明可以带来性能的提升。然而,标准库中的swap是否是noexcept的,取决于用户定义的swap是否是noexcept的。比如,标准库为数组和std::pair声明的swap函数如下:
template <class T, size_t N> void swap(T (&a)[N], T (&b)[N]) noexcept(noexcept(swap(*a, *b))); template <class T1, class T2> struct pair { … void swap(pair& p) noexcept(noexcept(swap(first, p.first)) && noexcept(swap(second, p.second))); };
实际上,C++11引入的noexcept说明符有两种形式:
noexcept
noexcept(expression)
第一种形式等价于noexcept(true)。而这里的swap声明中使用的是第二种形式的noexcept。比如针对两个Widget类型对象的数组而言,其swap为noexcept的前提是,noexcept(swap(Widget1, Widget2))表达式结果为真,也就是Widget的swap操作为noexcept的,因此,Widget的swap函数就决定了Widget对象数组的swap是否为noexcept;类似的,两个含所有Widget的std::pair的swap是否为noexcept,也取决于Widget的swap是否为noexcept。
虽然声明noexcept可以带来诸多好处,然而,noexcept声明是函数接口的组成部分,所以应该在能保证函数实现长期具有noexcept性质的前提下,才将其声明为noexcept。如果先前为函数加上了noexcept声明,后续又拿掉了(相当于修改了函数接口),这就有破坏客户代码的风险。
事实上,大多数函数都是异常中立的(exception-neutral),这种函数本身不抛出异常,但其调用的函数可能会抛出。而有些函数,比如移动操作和swap,声明为noexcept会带来很好的收益,因此只要有可能就应该将其声明为noexcept。
在C++98中,允许内存释放函数(operator delete)和析构函数抛出异常被认为是一种差劲的变成风格,而到了C++11中,这种规则升级成了语言规则。默认情况下,内存释放函数和析构函数(无论用户定义的,还是编译器自动生成的)都隐式地具备noexcept性质,因而也就无需再加noexcept声明了。析构函数不具备的noexcept性质的特殊情况,发生在其所在类有数据成员(包括继承而来的,或者是数据成员中包含的数据成员),显示的将其析构函数声明为noexcept(false)。这样的析构函数很少见。
最后,下面的代码:
void setup(); // functions defined elsewhere void cleanup(); void doWork() noexcept { setup(); … cleanup(); }
doWork带有noexcept声明,尽管它调用了不带noexcept声明的setup和cleanup。这是可以通过编译的。有可能setup和cleanup在文档中说明了它们不会抛出异常,或者它们来自于C编写的库。
15:只要有可能使用constexpr,就使用它
constexpr对象,具备const属性,而且其值在编译阶段就可知。在编译阶段就可知的值可以用于特殊场合,比如std::array的大小,整型模板实参,枚举元素的值等:
int sz; // non-constexpr variable constexpr auto arraySize1 = sz; // error! sz's value not known at compilation std::array<int, sz> data1; // error! same problem constexpr auto arraySize2 = 10; // fine, 10 is a compile-time constant std::array<int, arraySize2> data2; // fine, arraySize2 is constexpr
const并未提供constexpr同样的保证,因为const对象不一定是编译期可知的:
int sz; // as before const auto arraySize = sz; // fine, arraySize is const copy of sz std::array<int, arraySize> data; // error! arraySize's value not known at compilation
总之,所有constexpr对象都是const对象,但是并非所有的const对象都是constexpr队形。
constexpr函数可以用于要求编译期常量的语境中,这种语境中,若传给一个constexpr函数的实参是编译期已知的,则函数的返回值结果也是编译期可知的;如果任一实参不是编译期已知的,则这种语境下会发生编译错误;
调用constexpr时,如果传入的值是编译期未知的,则该函数的使用方式和普通函数一样,是在运行期进行结果的计算。
比如我们要自己实现pow函数,需要使其计算结果可用于指定std::array的尺寸:
constexpr // pow's a constexpr func int pow(int base, int exp) noexcept { … // impl is below } constexpr auto numConds = 5; std::array<int, pow(3, numConds)> results;
pow声明为constexpr并不表明pow要返回一个const值,它表明如果base和exp是编译期常量,则pow的返回结果也可以当做编译器常量使用;而如果base和exp中有一个不是编译期常量,则pow的返回结果将在运行期计算。因此,pow可以用于需要编译期常量的语境中,比如指定std::array大小这样,同样它也可以用于执行期语境,如:
auto base = readFromDB("base"); // get these values at runtime auto exp = readFromDB("exponent"); auto baseToExp = pow(base, exp); // call pow function at runtime
由于constexpr函数在传入编译期常量时要返回编译期结果,它们的实现就必须加以限制。在C++11中,constexpr函数只能包含单条return语句。这种限制下,可以使用条件运算符来表示if-else;可以使用递归代替循环,比如pow就可以这样实现:
constexpr int pow(int base, int exp) noexcept { return (exp == 0 ? 1 : base * pow(base, exp - 1)); }
而在C++14中,这种限制大大放宽,可以这样实现了:
constexpr int pow(int base, int exp) noexcept { // C++14 auto result = 1; for (int i = 0; i < exp; ++i) result *= base; return result; }
constexpr函数只能传入和返回字面类型(literal type),这样的类型可以持有编译期就能决定的值。在C++11中,除了void之外的所有内建类型都符合这个条件;而用户自定义类型也可以是字面类型,因为它的构造函数和其他成员函数也可以是constexpr函数:
class Point { public: constexpr Point(double xVal = 0, double yVal = 0) noexcept : x(xVal), y(yVal) {} constexpr double xValue() const noexcept { return x; } constexpr double yValue() const noexcept { return y; } void setX(double newX) noexcept { x = newX; } void setY(double newY) noexcept { y = newY; } private: double x, y; };
Point的构造函数可以声明为constexpr函数,因为只要传入的参数是编译期可知的,则构造出来的Point对象其数据成员的值也是编译期可知的,因此该Point对象也就是constexpr的:
constexpr Point p1(9.4, 27.7); // fine, "runs" constexpr ctor during compilation constexpr Point p2(28.8, 5.3); // also fine
类似的,xValue和yValue也可以是constexpr的,因为他们如果是通过编译期已知其值的Point对象来调用,也就是一个constexpr Point对象来调用的话,数据成员x和y的值也是编译期可知的。这种情况下,可以写出这样的constexpr函数,他调用xValue或yValue,并使用它们的结果来初始化constexpr对象:
constexpr Point midpoint(const Point& p1, const Point& p2) noexcept { return { (p1.xValue() + p2.xValue()) / 2, (p1.yValue() + p2.yValue()) / 2 }; } constexpr auto mid = midpoint(p1, p2); // 使用constexpr函数结果来初始化constexpr对象
对象mid尽管其初始化过程涉及到了构造函数,xValue和yValue,却仍然是constexpr对象。
在C++11中,有两个限制条件阻止了Point的setX和setY声明为constexpr。第一,它们修改了数据成员,在C++11中,constexpr成员函数都隐式的声明为const了(隐式的增加了成员函数的const饰词,即不能修改对象);第二,它们返回void类型,而在C++11中,void不是个字面类型。
到了C++14,这两个限制都被解除了,所以在C++14中,连setX和setY也可以是constexpr了:
class Point { public: … constexpr void setX(double newX) noexcept // C++14 { x = newX; } constexpr void setY(double newY) noexcept // C++14 { y = newY; } … };
从而可以写出下面的代码:
constexpr Point reflection(const Point& p) noexcept { // (C++14) Point result; // create non-const Point result.setX(-p.xValue()); result.setY(-p.yValue()); return result; // return copy of it } constexpr Point p1(9.4, 27.7); // as above constexpr Point p2(28.8, 5.3); constexpr auto mid = midpoint(p1, p2); constexpr auto reflectedMid = reflection(mid); // reflectedMid的值是编译期可知的
注意,constexpr声明的函数还有其他一些限制,而且这些限制随着新标准的出现也在不断的发生变化,因此,具体使用时可以查阅相关手册。
最后,constexpr是对象和函数接口的组成部分,这意味着:只要需要一个常量表达式的语境都可以使用它。一旦把对象或函数声明为constexpr了,客户就可以将其用于这种语境。万一后来你修改了代码,移除了constexpr属性(针对constexpr函数,不显示去除constexpr声明,只是增加修改语句,就有可能去除其constexpr属性,比如增加了一条为调试而写的IO语句),就有可能导致客户代码无法编译。“只要有可能使用constexpr,就使用它”中,“只要有可能”实际上需要一个长期承诺。
16:保证const函数的线程安全性
class Polynomial { public: using RootsType = std::vector<double>; RootsType roots() const { if (!rootsAreValid) { … rootsAreValid = true; } return rootVals; } private: mutable bool rootsAreValid{ false }; mutable RootsType rootVals{}; }; Polynomial p; /*----- Thread 1 ----- */ /*------- Thread 2 ------- */ auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();
上面的代码中,两个线程同时调用同一个Polynomial对象p的roots函数,尽管roots是一个const成员函数,意味着它代表的是一个读操作。但是因为成员rootsAreValid和rootVals是mutable的,roots函数还是修改这两个成员,因此,roots函数并不是线程安全的。解决办法就是使用锁:
class Polynomial { public: using RootsType = std::vector<double>; RootsType roots() const { std::lock_guard<std::mutex> g(m); // lock mutex if (!rootsAreValid) { ... rootsAreValid = true; } return rootVals; } // unlock mutex private: mutable std::mutex m; mutable bool rootsAreValid{ false }; mutable RootsType rootVals{}; };
之所以要把std::mutex_m声明为mutable,是因为加锁和解锁都不是const成员函数所能执行的。
需要注意的是,std::mutex是个move-only类型,因此,将m加入到Polynomial的副作用就是使Polynomial无法复制,只能移动。
对于某些场景下,引入互斥量是杀鸡用牛刀之举。比如要计算一个成员函数被调用的次数,可以使用std::atomic类型的计数器,std::atomic能确保其他线程可以以不可分割的方式观察到其操作。std::atomic通常要比互斥锁有更低的成本。下面是使用std::atomic的例子:
class Point { // 2D point public: … double distanceFromOrigin() const noexcept { ++callCount; // atomic increment return std::sqrt((x * x) + (y * y)); } private: mutable std::atomic<unsigned> callCount{ 0 }; double x, y; };
与std::mutex一样,std::atomic也是move-only类型,callCount的引入也会是的Point成为move-only类型。
某些情况下,过度依赖std::atomic也会造成问题:
class Widget { public: int magicValue() const { if (cacheValid) return cachedValue; else { auto val1 = expensiveComputation1(); auto val2 = expensiveComputation2(); cachedValue = val1 + val2; // uh oh, part 1 cacheValid = true; // uh oh, part 2 return cachedValue; } } private: mutable std::atomic<bool> cacheValid{ false }; mutable std::atomic<int> cachedValue; };
上面的代码,如果两个线程同时调用同一个Widget对象的magicValue函数,依然会有问题。这是因为:对于单个要求同步的变量或内存区域,使用std::atomic就足够了,如果有两个或更多变量或内存区域需要作为一个整体进行操作,就必须使用互斥锁。因此,正确的代码应该是这样的:
class Widget { public: int magicValue() const { std::lock_guard<std::mutex> guard(m); // lock m if (cacheValid) return cachedValue; else { auto val1 = expensiveComputation1(); auto val2 = expensiveComputation2(); cachedValue = val1 + val2; cacheValid = true; return cachedValue; } } // unlock m private: mutable std::mutex m; mutable int cachedValue; // no longer atomic mutable bool cacheValid{ false }; // no longer atomic };
17:理解特殊成员函数的生成机制
特殊成员函数是指C++会自动生成的成员函数。C++98中有4种特殊成员函数:默认构造函数,析构函数,复制构造函数,复制赋值运算符。这些函数只有在需要时才会生成,也就是某些代码中使用了它们,而在类中并未显示声明的情况下才会生成。生成的特殊成员函数都是public且inline的,且都是非虚函数,除非基类中的析构函数是虚函数,这种情况下,编译器为派生类生成的析构函数也是虚函数。
C++11中增加了2个新特殊成员函数:移动构造函数和移动赋值运算符:
class Widget { public: Widget(Widget&& rhs); // move constructor Widget& operator=(Widget&& rhs); // move assignment operator };
新加入的移动操作也仅在需要时才生成,生成的移动构造函数用形参rhs的各个非静态成员对本类的对应成员进行移动构造,同时还会移动构造它的基类部分;生成的移动赋值操作符同样也是用形参rhs的各个非静态成员对本类对应的成员进行移动赋值,同时还会移动赋值它的基类部分。
这里所谓的移动操作并不一定真的是移动,实际上是一种移动请求,对于那些不可移动的类型(没有提供移动操作的支持,大多数C++98的遗留类型都是这样),将通过其复制操作实现“移动”。对每个成员进行移动,实际上就是对每个成员调用std::move函数,将其返回值用于函数重载决议,最终决定是执行移动还是复制。
类似于复制操作,如果明确声明了移动操作,移动操作也不会自动生成。然而,移动操作生成的条件与复制操作又有所不同。
两个复制操作是相互独立的,声明了其中一个,并不会阻止编译器生成另一个。然而两个移动操作并不相互独立,声明了其中一个,就会阻止编译器生成另一个。这种机制的理由在于:如果声明了移动构造函数,你实际上是在说,移动构造的实现方式与编译器默认的按成员移动的移动构造函数多少有所不同,这也就意味着按成员的移动赋值运算符也极有可能有所不同,同样的理由也适用于移动赋值阻止移动构造的情况。
更进一步,一旦显示声明了复制操作,这个类也不会生成移动操作了。依据在于,声明复制操作(复制构造或复制赋值)的行为表明对象的常规按成员复制的方式对于该类并不适用,编译器因此判定,按成员移动也不适用于移动操作。
反之亦然,一旦声明了移动操作(移动构造或移动赋值),则编译器就会将复制操作声明已删除的(=delete):‘constexpr Widget::Widget(const Widget&)’ is implicitly declared as deleted because ‘Widget’ declares a move constructor or move assignment operator。
之前有一个指导原则叫大三律(Rule of Three),也就是声明了复制构造函数、复制赋值操作符或析构函数中的任何一个,就得声明剩下的两个。大三律在C++11中依然成立,结合声明了复制操作就会阻止隐式生成移动操作的事实,因此C++11中有这样的规定,只要用户声明了析构函数,就不会生成移动操作。所以,移动操作自动生成的条件为下面三个条件同时成立:未声明任何移动操作;未声明任何复制操作;未声明析构函数。
总有一天,这样的机制也会延伸到复制操作。因为C++11已经将在已声明析构函数或复制操作的情况下自动生成复制操作的行为视为一种废弃行为。因此,如果你有一些代码在已经存在任一复制操作或析构函数的情况下,依然依赖复制操作自动生成的话,就应该考虑升级你的代码,以消除这种依赖。如果编译器生成的函数确实有着正确的行为,则在C++11中可以通过”=default”来显式的表达这种想法:
class Widget { public: ~Widget(); // 用户自己声明了析构函数 Widget(const Widget&) = default; // 默认生成的复制构造函数是OK的 Widget& operator=(const Widget&) = default; // 默认生成的复制赋值操作符是OK的 };
注意,”=default”只能用在特殊成员函数上,用于其他成员函数或非成员函数上时会报编译错误。”=default”就是强制编译器生成某个特殊成员函数,比如即使用户已经显示声明了复制构造函数Widget(Widget&),依然可以再声明”Widget(const Widget&) = default;”,也就是让编译器生成参数为const的复制构造函数,这样Widget就有了两个复制构造函数,这里的”=default”,实际上起到了代替编译器生成特殊成员函数的函数体的作用,如果某个特殊成员函数已经声明成了=default,又在类中定义了相同签名的函数,这就是重复定义了。
这种手法对于多态基类很有用,所谓多态基类,就是定义接口的类。多态基类往往会有虚析构函数,否则某些行为(通过基类指针来对派生类对象执行delete等)会有未定义行为,然而除非一个类继承而来的析构函数就是virtual的,唯一拥有virtual析构函数的方式就是显式将其声明为virtual。通常情况下,虚析构函数的默认实现就是正确的,那么”=default”就可以很好的表达这一点。然而,如果用户声明的析构函数,就会阻止生成移动操作,如果确实需要支持移动操作,则使用”=default”会再次给予编译器生成移动操作的机会;显示声明移动操作会废除复制操作,如果也需要复制操作,也需要使用”=default”来实现:
class Base { public: virtual ~Base() = default; // 将析构函数声明为virtual Base(Base&&) = default; // 支持移动操作 Base& operator=(Base&&) = default; Base(const Base&) = default; // 支持复制操作 Base& operator=(const Base&) = default; };
事实上,即使编译器能够为类生成复制和移动操作,这些生成的函数也符合需求,则使用”=default”也是一种好的策略,这样可以清晰的表明你的意图,避免一些微妙的错误。比如某个类先前没有声明任何特殊成员函数,编译器将在需要这些函数的时候自动生成它们。但是过了一段时间,你需要在对象的默认构造函数和析构中都加上日志,这个时候就需要显示的声明默认构造函数和虚构函数。但是根据新规则,显示的析构函数会阻止移动操作的生成,对复制操作倒是没有影响。这种情况下,这段代码依然能编译成功,运行成功,甚至连需要移动操作的地方也看似正常,然而这个类确实不能在移动了,移动操作实际上是通过复制完成的,这就有可能比原来的真正移动操作慢上若干个数量级。这种情况下,如果复制和移动操作都已经显示的用=default来定义了,这个问题就不会出现。
总之在C++11中,特殊成员函数的生成规则如下:
默认构造函数:与C++98中相同,仅当类中不包含任何用户声明的构造函数时才生成;
析构函数:与C++98基本相同,唯一的区别在于析构函数默认是noexcept(条款14)的。仅当基类中的析构函数为virtual的,派生类的析构函数才是virtual的;
复制构造函数:运行期行为与C++98相同,都是按成员进行复制构造。仅当类中不包含用户声明的复制构造函数时才生成;如果该类中声明了移动操作,则复制构造函数将被删除;在已经存在复制赋值操作符或析构函数的情况下,仍然生成复制构造函数已经成了废弃行为;
复制赋值运算符:运行期行为与C++98相同,都是按成员进行复制赋值。仅当类中不包含用户声明的复制赋值运算符时才生成;如果该类中声明了移动操作,则复制赋值运算符将被删除;在已经存在复制构造函数或析构函数的情况下,仍然生成复制赋值运算符已经成了废弃行为;
移动构造函数和移动赋值运算符:按成员进行移动操作;仅当类中不包含用户声明的复制操作、移动操作和析构函数时才生成。
最后,上面的机制没有提到成员函数模板会阻止编译器生成任何特殊成员函数,比如:
class Widget { // construct Widget from anything template<typename T> Widget(const T& rhs); // assign Widget from anything template<typename T> Widget& operator=(const T& rhs); };
编译器始终会生成Widget的复制和移动操作,即使模板的具现结果生成了复制构造函数或复制赋值运算符的签名(T的类型为Widget)。