【Effective Modern C++】第3章 转向现代C++
文章目录
- 【Effective Modern C++】第3章 转向现代C++
条款7:在创建对象时注意区分()和{}
C++11 引入了统一初始化:单一的、至少从概念上可以用于一切场合、表达一切意思的初始化。
-
括号初始化让你可以表达以前表达不出的东西。使用花括号,创建并指定一个容器的初始元素变得很容易:
std::vector<int> v{ 1, 3, 5 }; //v初始内容为1,3,5
-
大括号初始化也能被用于为非静态数据成员指定默认初始值
class Widget{ … private: int x{ 0 }; //没问题,x初始值为0 int y = 0; //也可以 int z(0); //错误! }
-
另一方面,不可拷贝的对象(例如`std::atomic)可以使用花括号初始化或者圆括号初始化,但是不能使用"="初始化:
std::atomic<int> ai1{ 0 }; //没问题 std::atomic<int> ai2(0); //没问题 std::atomic<int> ai3 = 0; //错误!
这么一来,在C++中这三种方式都被看做是初始化表达式,但是只有花括号任何地方都能被使用。
大括号初始化有一项新特性,就是它禁用内建型别之间进行隐式窄化型别转换,否则代码不能通过编译。使用圆括号和"="的初始化不检查是否转换为变窄转换,因为由于历史遗留问题它们必须要兼容老旧代码:
double x, y, z;
...
int sum1{ x + y + z }; //错误!double的和可能不能表示为int
int sum2(x + y +z); //可以(表达式的值被截为int)
int sum3 = x + y + z; //同上
-
另一个值得注意的特性是括号表达式对于C++最令人头疼的解析问题有天生的免疫性。C++规定任何可以被解析为一个声明的东西必须被解析为声明。这个规则的副作用是让很多程序员备受折磨:他们可能想创建一个使用默认构造函数构造的对象,却不小心变成了函数声明。问题的根源是如果你调用带参构造函数,你可以这样做:
Widget w1(10); //使用实参10调用Widget的一个构造函数
但是如果你尝试使用相似的语法调用
Widget
无参构造函数,它就会变成函数声明:Widget w2(); //最令人头疼的解析!声明一个函数w2,返回Widget
由于函数声明中形参列表不能带花括号,所以使用花括号初始化表明你想调用默认构造函数构造对象就没有问题:
Widget w3{}; //调用没有参数的构造函数构造对象
-
关于括号初始化还有很多要说的。它的语法能用于各种不同的上下文,它防止了隐式的变窄转换,而且对于C++最令人头疼的解析也天生免疫。既然好到这个程度那为什么这个条款不叫“优先考虑括号初始化语法”呢?
括号初始化的缺点是有时它有一些令人惊讶的行为。这些行为使得括号初始化、
std::initializer_list
和构造函数参与重载决议时本来就不清不楚的暧昧关系进一步混乱。把它们放到一起会让看起来应该左转的代码右转。class Widget { public: Widget(int i, bool b); //同上 Widget(int i, double d); //同上 Widget(std::initializer_list<long double> il); //新添加的 … };
然而,如果有一个或者多个构造函数的声明包含一个
std::initializer_list
形参,那么使用括号初始化语法的调用更倾向于选择带std::initializer_list
的那个构造函数。如果编译器遇到一个括号初始化并且有一个带std::initializer_list的构造函数,那么它一定会选择该构造函数。w2
和w4
将会使用新添加的构造函数,即使另一个非std::initializer_list
构造函数和实参更匹配:Widget w1(10, true); //使用圆括号初始化,同之前一样 //调用第一个构造函数 Widget w2{10, true}; //使用花括号初始化,但是现在 //调用带std::initializer_list的构造函数 //(10 和 true 转化为long double) Widget w3(10, 5.0); //使用圆括号初始化,同之前一样 //调用第二个构造函数 Widget w4{10, 5.0}; //使用花括号初始化,但是现在 //调用带std::initializer_list的构造函数 //(10 和 5.0 转化为long double)
编译器一遇到括号初始化就选择带
std::initializer_list
的构造函数的决心是如此强烈,以至于就算带std::initializer_list
的构造函数不能被调用,它也会硬选。不过,语言规定,空的花括号意味着没有实参,不是一个空的
std::initializer_list
,故最终会调用默认构造。比如日常使用的
std::vector
有一个非std::initializer_list
构造函数允许你去指定容器的初始大小,以及使用一个值填满你的容器。但它也有一个std::initializer_list
构造函数允许你使用花括号里面的值初始化容器。如果你创建一个数值类型的std::vector
(比如std::vector<int>
),然后你传递两个实参,把这两个实参放到圆括号和放到花括号中有天壤之别:std::vector<int> v1(10, 20); //使用非std::initializer_list构造函数 //创建一个包含10个元素的std::vector, //所有的元素的值都是20 std::vector<int> v2{10, 20}; //使用std::initializer_list构造函数 //创建包含两个元素的std::vector, //元素的值为10和20
从以上讨论中我们得出两个重要结论。第一,作为一个类库作者,你需要意识到如果一堆重载的构造函数中有一个或者多个含有
std::initializer_list
形参,用户代码如果使用了括号初始化,可能只会看到你std::initializer_list
版本的重载的构造函数。因此,你最好把你的构造函数设计为不管用户是使用圆括号还是使用花括号进行初始化都不会有什么影响。换句话说,了解了std::vector
设计缺点后,你以后设计类的时候应该避免诸如此类的问题。所以如果你要加入std::initializer_list
构造函数,请三思而后行。第二,作为一个类库使用者,你必须认真的在花括号和圆括号之间选择一个来创建对象。大多数开发者都使用其中一种作为默认情况,只有当他们不能使用这种的时候才会考虑另一种。默认使用花括号初始化的开发者主要被适用面广、禁止变窄转换、免疫C++最令人头疼的解析这些优点所吸引。这些开发者知道在一些情况下(比如给定一个容器大小和一个初始值创建
std::vector
)要使用圆括号。默认使用圆括号初始化的开发者主要被C++98语法一致性、避免std::initializer_list
自动类型推导、避免不会不经意间调用std::initializer_list
构造函数这些优点所吸引。这些开发者也承认有时候只能使用花括号(比如创建一个包含着特定值的容器)。关于花括号初始化和圆括号初始化哪种更好大家没有达成一致,所以我的建议是选择一种并坚持使用它。如果你是一个模板的作者,花括号和圆括号创建对象就更麻烦了。通常不能知晓哪个会被使用。举个例子,假如你想创建一个接受任意数量的参数来创建的对象。使用可变参数模板(variadic template)可以非常简单的解决:
template<typename T, //要创建的对象类型 typename... Ts> //要使用的实参的类型 void doSomeWork(Ts&&... params) { create local T object from params... … }
在现实中我们有两种方式实现这个伪代码(关于
std::forward
请参见条款25):T localObject(std::forward<Ts>(params)...); //使用圆括号 T localObject{std::forward<Ts>(params)...}; //使用花括号
考虑这样的调用代码:
std::vector<int> v; … doSomeWork<std::vector<int>>(10, 20);
如果
doSomeWork
创建localObject
时使用的是圆括号,std::vector
就会包含10个元素。如果doSomeWork
创建localObject
时使用的是花括号,std::vector
就会包含2个元素。哪个是正确的?doSomeWork
的作者不知道,只有调用者知道。这正是标准库函数std::make_unique
和std::make_shared
面对的问题。它们的解决方案是使用圆括号,并被记录在文档中作为接口的一部分。(注:更灵活的设计——允许调用者决定从模板来的函数应该使用圆括号还是花括号——是有可能的。详情参见Andrzej’s C++ blog在2013年6月5日的文章,“Intuitive interface — Part I.”)
要点速记
- 花括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性
- 在构造函数重载决议中,编译器会尽最大努力将括号初始化与
std::initializer_list
参数匹配,即便其他构造函数看起来是更好的选择 - 对于数值类型的
std::vector
来说使用花括号初始化和圆括号初始化会造成巨大的不同 - 在模板类选择使用圆括号初始化或使用花括号初始化创建对象是一个挑战。
条款8:优先选用nullptr,而非0或NULL
-
0和NULL都不具备指针型别,故在函数调用时会发生和想要表达的本来意思不同的调用。
nullptr
的优点是它不是整型。老实说它也不是一个指针类型,但是你可以把它认为是所有类型的指针。nullptr
的真正类型是std::nullptr_t
,在一个完美的循环定义以后,std::nullptr_t
又被定义为nullptr
。std::nullptr_t
可以隐式转换为指向任何内置类型的指针,这也是为什么nullptr
表现得像所有类型的指针。void f(int); //三个f的重载函数 void f(bool); void f(void*); f(0); //调用f(int)而不是f(void*) f(NULL); //可能不会被编译,一般来说调用f(int), //绝对不会调用f(void*) f(nullptr); //调用重载函数f的f(void*)版本
-
nullptr还可以提高代码清晰性,尤其是当涉及到与
auto
声明的变量一起使用时。举个例子,假如你在一个代码库中遇到了这样的代码:auto result = findRecord( /* arguments */ ); if (result == 0) { … }
如果你不知道
findRecord
返回了什么(或者不能轻易的找出),那么你就不太清楚到底result
是一个指针类型还是一个整型。毕竟,0
(用来测试result
的值的那个)也可以像我们之前讨论的那样被解析。但是换一种假设如果你看到下面这样的代码,这就没有任何歧义:result
的结果一定是指针类型。auto result = findRecord( /* arguments */ ); if (result == nullptr) { … }
-
模板类型推导将
0
和NULL
推导为一个“错误”的类型(即它们的实际类型,而不是作为空指针的隐含意义),这就导致在当你想要一个空指针时,它们的替代品nullptr
很吸引人。使用nullptr
,模板不会有什么特殊的转换。另外,使用nullptr
不会让你受到同重载决议特殊对待0
和NULL
一样的待遇。当你想用一个空指针,使用nullptr
,不用0
或者NULL
。template<typename FuncType, typename MuxType, typename PtrType> decltype(auto) lockAndCall(FuncType func, //C++14 MuxType& mutex, PtrType ptr) { MuxGuard g(mutex); return func(ptr); } auto result1 = lockAndCall(f1, f1m, 0); //错误! auto result2 = lockAndCall(f2, f2m, NULL); //错误! auto result3 = lockAndCall(f3, f3m, nullptr); //没问题
要点速记
- 优先考虑
nullptr
而非0
和NULL
- 避免重载指针和整型
条款9:优先选用别名声明,而非typedef
-
当声明一个函数指针时别名声明更容易理解:
//FP是一个指向函数的指针的同义词,它指向的函数带有 //int和const std::string&形参,不返回任何东西 typedef void (*FP)(int, const std::string&); //typedef //含义同上 using FP = void (*)(int, const std::string&); //别名声明
-
有一个地方使用别名声明吸引人的理由是存在的:模板。特别地,别名声明可以被模板化(这种情况下称为别名模板alias templates)但是
typedef
不能。这使得C++11程序员可以很直接的表达一些C++98中只能把typedef
嵌套进模板化的struct
才能表达的东西。考虑一个链表的别名,链表使用自定义的内存分配器,MyAlloc
。使用别名模板,这真是太容易了:// 使用typedef template<typename T> //MyAllocList<T>是 struct MyAllocList { //std::list<T, MyAlloc<T>> typedef std::list<T, MyAlloc<T>> type; //的同义词 }; MyAllocList<Widget>::type lw; //用户代码 // 使用using template<typename T> //MyAllocList<T>是 using MyAllocList = std::list<T, MyAlloc<T>>; //std::list<T, MyAlloc<T>> //的同义词 MyAllocList<Widget> lw; //用户代码
更糟糕的是,如果你想使用在一个模板内使用
typedef
声明一个链表对象,而这个对象又使用了模板形参,你就不得不在typedef
前面加上typename
:// typedef template<typename T> class Widget { //Widget<T>含有一个 private: //MyAllocLIst<T>对象 typename MyAllocList<T>::type list; //作为数据成员 … }; // using template<typename T> class Widget { private: MyAllocList<T> list; //没有“typename” … //没有“::type” }; // 当编译器处理Widget模板时遇到MyAllocList<T>(使用模板别名声明的版本),它们知道MyAllocList<T>是一个类型名,因为MyAllocList是一个别名模板:它一定是一个类型名。因此MyAllocList<T>就是一个非依赖类型(non-dependent type),就不需要也不允许使用typename修饰符。
这里
MyAllocList<T>::type
使用了一个类型,这个类型依赖于模板参数T
。因此MyAllocList<T>::type
是一个依赖类型(dependent type),在C++很多讨人喜欢的规则中的一个提到必须要在依赖类型名前加上typename
。当编译器在Widget
的模板中看到MyAllocList<T>::type
(使用typedef
的版本),它不能确定那是一个类型的名称。因为可能存在一个MyAllocList
的它们没见到的特化版本,那个版本的MyAllocList<T>::type
指代了一种不是类型的东西。那听起来很不可思议,但不要责备编译器穷尽考虑所有可能。因为人确实能写出这样的代码。举个例子,一个误入歧途的人可能写出这样的代码:class Wine { … }; template<> //当T是Wine class MyAllocList<Wine> { //特化MyAllocList private: enum class WineType //参见Item10了解 { White, Red, Rose }; //"enum class" WineType type; //在这个类中,type是 … //一个数据成员! };
就像你看到的,
MyAllocList<Wine>::type
不是一个类型。如果Widget
使用Wine
实例化,在Widget
模板中的MyAllocList<Wine>::type
将会是一个数据成员,不是一个类型。在Widget
模板内,MyAllocList<T>::type
是否表示一个类型取决于T
是什么,这就是为什么编译器会坚持要求你在前面加上typename
。 -
C++11在type traits(类型特性)中给了你一系列工具去实现类型转换,如果要使用这些模板请包含头文件
<type_traits>
。里面有许许多多type traits,也不全是类型转换的工具,也包含一些可预测接口的工具。给一个你想施加转换的类型T
,结果类型就是std::
transformation<T>::type
,比如:std::remove_const<T>::type //从const T中产出T std::remove_reference<T>::type //从T&和T&&中产出T std::add_lvalue_reference<T>::type //从T中产出T&
注意类型转换尾部的
::type
。如果你在一个模板内部将他们施加到类型形参上(实际代码中你也总是这么用),你也需要在它们前面加上typename
。至于为什么要这么做是因为这些C++11的type traits是通过在struct
内嵌套typedef
来实现的。直到C++14它们才提供了使用别名声明的版本。这些别名声明有一个通用形式:对于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 等价形式 std::remove_reference<T>::type //C++11: T&/T&& → T std::remove_reference_t<T> //C++14 等价形式 std::add_lvalue_reference<T>::type //C++11: T → T& std::add_lvalue_reference_t<T> //C++14 等价形式
要点速记
typedef
不支持模板化,但是别名声明支持。- 别名模板避免了使用“
::type
”后缀,而且在模板中使用typedef
还需要在前面加上typename
- C++14提供了C++11所有type traits转换的别名声明版本
条款10:优先选用限定作用域的枚举类型,而非不限作用域的枚举类型
enum Color { black, white, red }; //black, white, red在
//Color所在的作用域
auto white = false; //错误! white早已在这个作用
//域中声明
// --------------------------------------
enum class Color { black, white, red }; //black, white, red
//限制在Color域内
auto white = false; //没问题,域内没有其他“white”
Color c = white; //错误,域中没有枚举名叫white
Color c = Color::white; //没问题
auto c = Color::white; //也没问题(也符合Item5的建议)
-
使用限域
enum
来减少命名空间污染,这是一个足够合理使用它而不是它的同胞未限域enum
的理由,其实限域enum
还有第二个吸引人的优点:在它的作用域中,枚举名是强类型。未限域enum
中的枚举名会隐式转换为整型(现在,也可以转换为浮点类型)。因此下面这种歪曲语义的做法也是完全有效的:enum Color { black, white, red }; //未限域enum std::vector<std::size_t> //func返回x的质因子 primeFactors(std::size_t x); Color c = red; … if (c < 14.5) { // Color与double比较 (!) auto factors = // 计算一个Color的质因子(!) primeFactors(c); … }
在
enum
后面写一个class
就可以将非限域enum
转换为限域enum
,接下来就是完全不同的故事展开了。现在不存在任何隐式转换可以将限域enum
中的枚举名转化为任何其他类型:enum class Color { black, white, red }; //Color现在是限域enum Color c = Color::red; //和之前一样,只是 ... //多了一个域修饰符 if (c < 14.5) { //错误!不能比较 //Color和double auto factors = //错误!不能向参数为std::size_t primeFactors(c); //的函数传递Color参数 … }
如果你真的很想执行
Color
到其他类型的转换,和平常一样,使用正确的类型转换运算符扭曲类型系统:if (static_cast<double>(c) < 14.5) { //奇怪的代码, //但是有效 auto factors = //有问题,但是 primeFactors(static_cast<std::size_t>(c)); //能通过编译 … }
-
似乎比起非限域
enum
而言,限域enum
有第三个好处,因为限域enum
可以被前置声明。也就是说,它们可以不指定枚举名直接声明:enum Color; //错误! enum class Color; //没问题
其实这是一个误导。在C++11中,**非限域
enum
也可以被前置声明,但是只有在做一些其他工作后才能实现。**这些工作来源于一个事实:**在C++中所有的enum
都有一个由编译器决定的整型的底层类型。**对于非限域enum
比如Color
,enum Color { black, white, red };
编译器可能选择
char
作为底层类型,因为这里只需要表示三个值。然而,有些enum
中的枚举值范围可能会大些,比如:enum Status { good = 0, failed = 1, incomplete = 100, corrupt = 200, indeterminate = 0xFFFFFFFF };
这里值的范围从
0
到0xFFFFFFFF
。除了在不寻常的机器上(比如一个char
至少有32bits的那种),编译器都会选择一个比char
大的整型类型来表示Status
。为了高效使用内存,编译器通常在确保能包含所有枚举值的前提下为enum
选择一个最小的底层类型。在一些情况下,编译器将会优化速度,舍弃大小,这种情况下它可能不会选择最小的底层类型,但它们当然希望能够针对大小进行优化。为此,C++98只支持enum
定义(所有枚举名全部列出来);enum
声明是不被允许的。这使得编译器能在使用之前为每一个enum
选择一个底层类型。但是不能前置声明
enum
也是有缺点的。最大的缺点莫过于它可能增加编译依赖。再次考虑Status
enum
:enum Status { good = 0, failed = 1, incomplete = 100, corrupt = 200, indeterminate = 0xFFFFFFFF };
这种
enum
很有可能用于整个系统,因此系统中每个包含这个头文件的组件都会依赖它。如果引入一个新状态值,enum Status { good = 0, failed = 1, incomplete = 100, corrupt = 200, audited = 500, indeterminate = 0xFFFFFFFF };
那么可能整个系统都得重新编译,即使只有一个子系统——或者只有一个函数——使用了新添加的枚举名。这是大家都不希望看到的。C++11中的前置声明
enum
s可以解决这个问题。比如这里有一个完全有效的限域enum
声明和一个以该限域enum
作为形参的函数声明:enum class Status; //前置声明 void continueProcessing(Status s); //使用前置声明enum
即使
Status
的定义发生改变,包含这些声明的头文件也不需要重新编译。而且如果Status
有改动(比如添加一个audited
枚举名),continueProcessing
的行为不受影响(比如因为continueProcessing
没有使用这个新添加的audited
),continueProcessing
也不需要重新编译。但是如果编译器在使用它之前需要知晓该
enum
的大小,该怎么声明才能让C++11做到C++98不能做到的事情呢?答案很简单:限域enum
的底层类型总是已知的,而对于非限域enum
,你可以指定它。默认情况下,限域枚举的底层类型是
int
:enum class Status; //底层类型是int
如果默认的
int
不适用,你可以重写它:enum class Status: std::uint32_t; //Status的底层类型 //是std::uint32_t //(需要包含 <cstdint>)
不管怎样,编译器都知道限域
enum
中的枚举名占用多少字节。要为非限域
enum
指定底层类型,你可以同上,结果就可以前向声明:enum Color: std::uint8_t; //非限域enum前向声明 //底层类型为 //std::uint8_t
底层类型说明也可以放到
enum
定义处:enum class Status: std::uint32_t { good = 0, failed = 1, incomplete = 100, corrupt = 200, audited = 500, indeterminate = 0xFFFFFFFF };
-
你可能会很惊讶听到至少有一种情况下非限域
enum
是很有用的。那就是牵扯到C++11的std::tuple
的时候。比如在社交网站中,假设我们有一个tuple保存了用户的名字,email地址,声望值:using UserInfo = //类型别名,参见Item9 std::tuple<std::string, //名字 std::string, //email地址 std::size_t> ; //声望
虽然注释说明了tuple各个字段对应的意思,但当你在另一文件遇到下面的代码那之前的注释就不是那么有用了:
UserInfo uInfo; //tuple对象 … auto val = std::get<1>(uInfo); //获取第一个字段
作为一个程序员,你有很多工作要持续跟进。你应该记住第一个字段代表用户的email地址吗?我认为不。可以使用非限域
enum
将名字和字段编号关联起来以避免上述需求:enum UserInfoFields { uiName, uiEmail, uiReputation }; UserInfo uInfo; //同之前一样 … auto val = std::get<uiEmail>(uInfo); //啊,获取用户email字段的值
之所以它能正常工作是因为
UserInfoFields
中的枚举名隐式转换成std::size_t
了,其中std::size_t
是std::get
模板实参所需的。对应的限域enum
版本就很啰嗦了:enum class UserInfoFields { uiName, uiEmail, uiReputation }; UserInfo uInfo; //同之前一样 … auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)> (uInfo);
为避免这种冗长的表示,我们可以写一个函数传入枚举名并返回对应的
std::size_t
值,但这有一点技巧性。std::get
是一个模板(函数),需要你给出一个std::size_t
值的模板实参(注意使用<>
而不是()
),因此将枚举名变换为std::size_t
值的函数必须在编译期产生这个结果。如条款15提到的,那必须是一个constexpr
函数。事实上,它也的确该是一个
constexpr
函数模板,因为它应该能用于任何enum
。如果我们想让它更一般化,我们还要泛化它的返回类型(让函数toUType
具有更广泛的适用性。在C++中,不同的枚举类型可能有不同的底层类型(underlying type
))。较之于返回std::size_t
,我们更应该返回枚举的底层类型(返回枚举的底层类型可以确保toUType
返回的值与枚举的实际存储类型一致,减少了潜在的类型转换错误,避免类型不匹配的问题)。这可以通过std::underlying_type
这个type trait获得。(参见条款9关于type trait的内容)。最终我们还要再加上noexcept
修饰(参见条款14),因为我们知道它肯定不会产生异常(constexpr
函数在编译期执行,如果编译期执行的代码产生异常,编译器会直接报错,确保了constexpr
函数在运行时不会产生异常,同时static_cast
也是一种编译时类型转换)。根据上述分析最终得到的toUType
函数模板在编译期接受任意枚举名并返回它的值:template<typename E> //C++14 constexpr std::underlying_type_t<E> toUType(E enumerator) noexcept { return static_cast<std::underlying_type_t<E>>(enumerator); }
还可以再用C++14
auto
(参见条款3)打磨一下代码:template<typename E> //C++14 constexpr auto toUType(E enumerator) noexcept { return static_cast<std::underlying_type_t<E>>(enumerator); }
不管它怎么写,
toUType
现在允许这样访问tuple的字段了:auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);
这仍然比使用非限域
enum
要写更多的代码,但同时它也避免命名空间污染,防止不经意间使用隐式转换。大多数情况下,你应该会觉得多敲几个(几行)字符作为避免使用未限域枚举这种老得和2400波特率猫同时代技术的代价是值得的。
要点速记
- C++98的
enum
即非限域enum
。 - 限域
enum
的枚举名仅在enum
内可见。要转换为其它类型只能使用cast。 - 非限域/限域
enum
都支持底层类型说明语法,限域enum
底层类型默认是int
。非限域enum
没有默认底层类型。 - 限域
enum
总是可以前置声明。非限域enum
仅当指定它们的底层类型时才能前置。
条款11:优先选用删除函数,而非private未定义函数
-
在C++98中防止调用“特种成员函数”的方法是将它们声明为私有(
private
)成员函数并且不定义。在C++11中有一种更好的方式达到相同目的:用“= delete
”将拷贝构造函数和拷贝赋值运算符标记为deleted函数。删除这些函数(译注:添加"
= delete
")和声明为私有成员可能看起来只是方式不同,别无其他区别。其实还有一些实质性意义。deleted函数不能以任何方式被调用,即使你在成员函数或者友元函数里面调用deleted函数也不能通过编译。这是较之C++98行为的一个改进,C++98中不正确的使用这些函数在链接时才被诊断出来。 -
通常,deleted函数通常被声明为
public
而不是private
。这也是有原因的。当客户端代码试图调用成员函数,C++会在检查deleted状态前检查它的访问性。当客户端代码调用一个私有的deleted函数,一些编译器只会给出该函数是private
的错误(译注:而没有诸如该函数被deleted修饰的错误),即使函数的访问性不影响它是否能被使用。所以值得牢记,如果要将老代码的“私有且未定义”函数替换为deleted函数时请一并修改它的访问性为public
,这样可以让编译器产生更好的错误信息。 -
创建deleted重载函数,其参数就是我们想要过滤的类型:
bool isLucky(int number); //原始版本 bool isLucky(char) = delete; //拒绝char bool isLucky(bool) = delete; //拒绝bool bool isLucky(double) = delete; //拒绝float和double
(上面
double
重载版本的注释说拒绝float
和double
可能会让你惊讶,但是请回想一下:将float
转换为int
和double
,C++更喜欢转换为double
。使用float
调用isLucky
因此会调用double
重载版本,而不是int
版本。好吧,它也会那么去尝试。事实是调用被删除的double
重载版本不能通过编译。不再惊讶了吧。)虽然deleted函数不能被使用,但它们还是存在于你的程序中。也即是说,重载决议会考虑它们。这也是为什么上面的函数声明导致编译器拒绝一些不合适的函数调用。
-
另一个deleted函数用武之地(
private
成员函数做不到的地方)是禁止一些模板的实例化。假如你要求一个模板仅支持原生指针(尽管第四章建议使用智能指针代替原生指针):template<typename T> void processPointer(T* ptr);
在指针的世界里有两种特殊情况。一是
void*
指针,因为没办法对它们进行解引用,或者加加减减等。另一种指针是char*
,因为它们通常代表C风格的字符串,而不是正常意义下指向单个字符的指针。这两种情况要特殊处理,在processPointer
模板里面,我们假设正确的函数应该拒绝这些类型。也即是说,processPointer
不能被void*
和char*
调用。要想确保这个很容易,使用delete
标注模板实例:template<> void processPointer<void>(void*) = delete; template<> void processPointer<char>(char*) = delete;
现在如果使用
void*
和char*
调用processPointer
就是无效的,按常理说const void*
和const char*
也应该无效,所以这些实例也应该标注delete
:template<> void processPointer<const void>(const void*) = delete; template<> void processPointer<const char>(const char*) = delete; // 如果你想做得更彻底一些,你还要删除const volatile void*和const volatile char*重载版本,另外还需要一并删除其他标准字符类型的重载版本:std::wchar_t,std::char16_t和std::char32_t。
-
有趣的是,如果类里面有一个函数模板,你可能想用
private
(经典的C++98惯例)来禁止这些函数模板实例化,但是不能这样做,因为不能给特化的成员模板函数指定一个不同于主函数模板的访问级别。如果processPointer
是类Widget
里面的模板函数, 你想禁止它接受void*
参数,那么通过下面这样C++98的方法就不能通过编译:class Widget { public: … template<typename T> void processPointer(T* ptr) { … } private: template<> //错误! void processPointer<void>(void*); };
问题是模板特例化必须位于一个命名空间作用域,而不是类作用域。deleted函数不会出现这个问题,因为它不需要一个不同的访问级别,且他们可以在类外被删除(因此位于命名空间作用域):
class Widget { public: … template<typename T> void processPointer(T* ptr) { … } … }; template<> //还是public, void Widget::processPointer<void>(void*) = delete; //但是已经被删除了
事实上C++98的最佳实践即声明函数为
private
但不定义是在做C++11 deleted函数要做的事情。作为模仿者,C++98的方法不是十全十美。它不能在类外正常工作,不能总是在类中正常工作,它的罢工可能直到链接时才会表现出来。所以请坚定不移的使用deleted函数。
要点速记
- 比起声明函数为
private
但不定义,使用deleted函数更好 - 任何函数都能被删除(be deleted),包括非成员函数和模板实例(译注:实例化的函数)
条款12:为意在改写的函数添加override声明
虽然“重写(override)”听起来像“重载(overload)”,然而两者完全不相关,所以让我澄清一下,正是虚函数重写机制的存在,才使我们可以通过基类的接口调用派生类的成员函数:
class Base {
public:
virtual void doWork(); //基类虚函数
…
};
class Derived: public Base {
public:
virtual void doWork(); //重写Base::doWork
… //(这里“virtual”是可以省略的)
};
std::unique_ptr<Base> upb = //创建基类指针指向派生类对象
std::make_unique<Derived>(); //关于std::make_unique
… //请参见Item21
upb->doWork(); //通过基类指针调用doWork,
//实际上是派生类的doWork
//函数被调用
要想重写一个函数,必须满足下列要求:
- 基类函数必须是
virtual
- 基类和派生类函数名必须完全一样(除非是析构函数)
- 基类和派生类函数形参类型必须完全一样
- 基类和派生类函数常量性
const
ness必须完全一样 - 基类和派生类函数的返回值和异常说明(exception specifications)必须兼容
除了这些C++98就存在的约束外,C++11又添加了一个:
- 函数的引用限定符(reference qualifiers)必须完全一样。成员函数的引用限定符是C++11很少抛头露脸的特性,所以如果你从没听过它无需惊讶。它可以限定成员函数只能用于左值或者右值。成员函数不需要
virtual
也能使用它们:
class Widget {
public:
…
void doWork() &; //只有*this为左值的时候才能被调用
void doWork() &&; //只有*this为右值的时候才能被调用
};
…
Widget makeWidget(); //工厂函数(返回右值)
Widget w; //普通对象(左值)
…
w.doWork(); //调用被左值引用限定修饰的Widget::doWork版本
//(即Widget::doWork &)
makeWidget().doWork(); //调用被右值引用限定修饰的Widget::doWork版本
//(即Widget::doWork &&)
C++11提供一个方法让你可以显式地指定一个派生类函数是基类版本的重写:将它声明为override
。
比起让编译器(译注:通过warnings)告诉你想重写的而实际没有重写,不如给你的派生类重写函数全都加上override
。如果你考虑修改修改基类虚函数的函数签名,override
还可以帮你评估后果。如果派生类全都用上override
,你可以只改变基类函数签名,重编译系统,再看看你造成了多大的问题(即,多少派生类不能通过编译),然后决定是否值得如此麻烦更改函数签名。
要点速记
- 为重写函数加上
override
- 成员函数引用限定让我们可以区别对待左值对象和右值对象(即
*this
)
条款13:优先选用const_iterator,而非iterator
STL const_iterator
等价于指向常量的指针(pointer-to-const
)。它们都指向不能被修改的值。标准实践是能加上const
就加上,这也指示我们需要一个迭代器时只要没必要修改迭代器指向的值,就应当使用const_iterator
。
C++11现在const_iterator
既容易获取又容易使用。容器的成员函数cbegin
和cend
产出const_iterator
,甚至对于non-const
容器也可用,那些之前使用iterator指示位置(如insert
和erase
)的STL成员函数也可以使用const_iterator
了:
std::vector<int> values; //和之前一样
…
auto it = //使用cbegin
std::find(values.cbegin(), values.cend(), 1983);//和cend
values.insert(it, 1998);
现在使用const_iterator
的代码就很实用了!
唯一一个C++11对于const_iterator
支持不足(C++14支持但是C++11的时候还没)的情况是:当你想写最大程度通用的库,并且这些库代码为一些容器和类似容器的数据结构提供begin
、end
(以及cbegin
,cend
,rbegin
,rend
等)作为非成员函数而不是成员函数时。其中一种情况就是原生数组,还有一种情况是一些只由自由函数组成接口的第三方库。(译注:自由函数free function,指的是非成员函数,即一个函数,只要不是成员函数就可被称作free function)最大程度通用的库会考虑使用非成员函数而不是假设成员函数版本存在。
举个例子,我们可以泛化下面的findAndInsert
:
template<typename C, typename V>
void findAndInsert(C& container, //在容器中查找第一次
const V& targetVal, //出现targetVal的位置,
const V& insertVal) //然后在那插入insertVal
{
using std::cbegin;
using std::cend;
auto it = std::find(cbegin(container), //非成员函数cbegin
cend(container), //非成员函数cend
targetVal);
container.insert(it, insertVal);
}
它可以在C++14工作良好,但是很遗憾,C++11不在良好之列。由于标准化的疏漏,C++11只添加了非成员函数begin
和end
,但是没有添加cbegin
,cend
,rbegin
,rend
,crbegin
,crend
。C++14修订了这个疏漏。
要点速记
- 优先考虑
const_iterator
而非iterator
- 在最大程度通用的代码中,优先考虑非成员函数版本的
begin
,end
,rbegin
等,而非同名成员函数
条款14:只要函数不会发射异常,就为其加上noexcept声明
**在C++11标准化过程中,大家一致认为异常说明真正有用的信息是一个函数是否会抛出异常。**非黑即白,一个函数可能抛异常,或者不会。这种"可能-绝不"的二元论构成了C++11异常说的基础,从根本上改变了C++98的异常说明。(C++98风格的异常说明也有效,但是已经标记为deprecated(废弃))。在C++11中,无条件的noexcept
保证函数不会抛出任何异常。
关于一个函数是否已经声明为noexcept
是接口设计的事。函数的异常抛出行为是客户端代码最关心的。调用者可以查看函数是否声明为noexcept
,这个可以影响到调用代码的异常安全性(exception safety)和效率。就其本身而言,函数是否为noexcept
和成员函数是否const
一样重要。当你知道这个函数不会抛异常而没加上noexcept
,那这个接口说明就有点差劲了。
int f(int x) throw(); //C++98风格,没有来自f的异常
int f(int x) noexcept; //C++11风格,没有来自f的异常
如果在运行时,f
出现一个异常,那么就和f
的异常说明冲突了。在C++98的异常说明中,调用栈(the call stack)会展开至f
的调用者,在一些与这地方不相关的动作后,程序被终止。C++11异常说明的运行时行为有些不同:调用栈只是可能在程序终止前展开。
- 展开调用栈和可能展开调用栈两者对于代码生成(code generation)有非常大的影响。在一个
noexcept
函数中,当异常可能传播到函数外时,优化器不需要保证运行时栈(the runtime stack)处于可展开状态;也不需要保证当异常离开noexcept
函数时,noexcept
函数中的对象按照构造的反序析构。而标注“throw()
”异常声明的函数缺少这样的优化灵活性,没加异常声明的函数也一样。
还有一些函数更符合这个情况。移动操作是绝佳的例子。假如你有一份C++98代码,里面用到了std::vector<Widget>
。Widget
通过push_back
一次又一次的添加进std::vector
:
std::vector<Widget> vw;
…
Widget w;
… //用w做点事
vw.push_back(w); //把w添加进vw
…
假设这个代码能正常工作,你也无意修改为C++11风格。但是你确实想要C++11移动语义带来的性能优势,毕竟这里的类型是可以移动的(move-enabled types)。因此你需要确保Widget
有移动操作,可以手写代码也可以让编译器自动生成,当然前提是能满足自动生成的条件(参见条款17)。
当新元素添加到std::vector
,std::vector
可能没地方放它,换句话说,std::vector
的大小(size)等于它的容量(capacity)。这时候,std::vector
会分配一个新的更大块的内存用于存放其中元素,然后将元素从老内存区移动到新内存区,然后析构老内存区里的对象。在C++98中,移动是通过复制老内存区的每一个元素到新内存区完成的,然后老内存区的每个元素发生析构。这种方法使得push_back
可以提供很强的异常安全保证:如果在复制元素期间抛出异常,std::vector
状态保持不变,因为老内存元素析构必须建立在它们已经成功复制到新内存的前提下。
在C++11中,一个很自然的优化就是将上述复制操作替换为移动操作。但是很不幸运,这会破坏push_back
的异常安全保证。如果n个元素已经从老内存移动到了新内存区,但异常在移动第n+1个元素时抛出,那么push_back
操作就不能完成。但是原始的std::vector
已经被修改:有n个元素已经移动走了。恢复std::vector
至原始状态也不太可能,因为从新内存移动到老内存本身又可能引发异常。
- 这是个很严重的问题,因为老代码可能依赖于
push_back
提供的强烈的异常安全保证。因此,C++11版本的实现不能简单的将push_back
里面的复制操作替换为移动操作,除非知晓移动操作绝不抛异常,这时复制替换为移动就是安全的,唯一的副作用就是性能得到提升。
std::vector::push_back
受益于“能移动就移动,必须复制才复制”策略,并且它不是标准库中唯一采取该策略的函数。C++98中还有一些函数(如std::vector::reverse
,std::deque::insert
等)也受益于这种强异常保证。对于这个函数只有在知晓移动不抛异常的情况下用C++11的移动操作替换C++98的复制操作才是安全的。但是如何知道一个函数中的移动操作是否产生异常?答案很明显:它检查这个操作是否被声明为noexcept
。(这个检查非常弯弯绕。像是std::vector::push_back
之类的函数调用std::move_if_noexcept
,这是个std::move
的变体,根据其中类型的移动构造函数是否为noexcept
的,视情况转换为右值或保持左值(参见条款23)。反过来,std::move_if_noexcept
查阅std::is_nothrow_move_constructible
这个type trait,基于移动构造函数是否有noexcept
(或者throw()
)的设计,编译器设置这个type trait的值。)
-
swap
函数是noexcept
的另一个绝佳用地。swap
是STL算法实现的一个关键组件,它也常用于拷贝运算符重载中。它的广泛使用意味着对其施加不抛异常的优化是非常有价值的。有趣的是,标准库的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))); … };
-
实际上大多数函数都是异常中立(exception-neutral)的。这些函数自己不抛异常,但是它们内部的调用可能抛出。此时,异常中立函数允许那些抛出异常的函数在调用链上更进一步直到遇到异常处理程序,而不是就地终止。异常中立函数决不应该声明为
noexcept
,因为它们可能抛出那种“让它们过吧”的异常(译注:也就是说在当前这个函数内不处理异常,但是又不立即终止程序,而是让调用这个函数的函数处理异常。)因此大多数函数缺少noexcept
设计。
然而,一些函数很自然的不应该抛异常,更进一步——尤其是移动操作和swap
——使其noexcept
有重大意义,只要可能就应该将它们实现为noexcept
。老实说,当你确保函数决不抛异常的时候,一定要将它们声明为noexcept
。
- 在C++98,允许内存释放(memory deallocation)函数(即
operator delete
和operator delete[]
)和析构函数抛出异常是糟糕的代码设计,C++11将这种作风升级为语言规则。默认情况下,内存释放函数和析构函数——不管是用户定义的还是编译器生成的——都是隐式noexcept
。因此它们不需要声明noexcept
。(这么做也不会有问题,只是不合常规)。析构函数非隐式noexcept
的情况仅当类的数据成员(包括继承的成员还有继承成员内的数据成员)明确声明它的析构函数可能抛出异常(如声明“noexcept(false)
”)。这种析构函数不常见,标准库里面没有。如果一个对象的析构函数可能被标准库使用(比如在容器内或者被传给一个算法),析构函数又可能抛异常,那么程序的行为是未定义的。
值得注意的是一些库接口设计者会区分有宽泛契约(wild contracts)和严格契约(narrow contracts)的函数。有宽泛契约的函数没有前置条件。这种函数不管程序状态如何都能调用,它对调用者传来的实参不设约束(“不管程序状态如何”和“不设约束”对已经行为未定义的程序无效)。宽泛契约的函数决不表现出未定义行为。区分严格/宽泛契约库设计者一般会将noexcept
留给宽泛契约函数。
要点速记
noexcept
是函数接口的一部分,这意味着调用者可能会依赖它noexcept
函数较之于non-noexcept
函数更容易优化noexcept
对于移动语义,swap
,内存释放函数和析构函数非常有用- 大多数函数是异常中立的(译注:可能抛也可能不抛异常)而不是
noexcept
条款15:只要有可能使用constexpr,就使用它
当用于对象上面,它本质上就是const
的加强形式,但是当它用于函数上,意思就大不相同了。有必要消除困惑,因为你绝对会用它的,特别是当你发现constexpr
“正合吾意”的时候。你不能假设constexpr
函数的结果是const
,也不能保证它们的返回值值是在编译期可知的。最有意思的是,这些都是是有意设计的特性。关于constexpr
函数返回的结果不需要是const
,也不需要编译期可知这一点是良好的行为!
- constexpr对象其实真的剧本const属性,并且在编译阶段就已知,在编译阶段就已知拥有种种特权:它们可能被存放到只读存储空间中,更广泛的应用是“其值编译期可知”的常量整数会出现在需要“整型常量表达式(integral constant expression)的上下文中。
- 注意
const
不提供constexpr
所能保证之事,因为const
对象不需要在编译期初始化它的值。简而言之,所有constexpr
对象都是const
,但不是所有const
对象都是constexpr
。如果你想编译器保证一个变量有一个值,这个值可以放到那些需要编译期常量(compile-time constants)的上下文的地方,你需要的工具是constexpr
而不是const
。
涉及到constexpr
函数时,constexpr
对象的使用情况就更有趣了。如果实参是编译期常量,这些函数将产出编译期常量;如果实参是运行时才能知道的值,它们就将产出运行时值。这听起来就像你不知道它们要做什么一样,那么想是错误的,请这么看:
constexpr
函数可以用于需求编译期常量的上下文。如果你传给constexpr
函数的实参在编译期可知,那么结果将在编译期计算。如果实参的值在编译期不知道,你的代码就会被拒绝。- 当一个
constexpr
函数被一个或者多个编译期不可知值调用时,它就像普通函数一样,运行时计算它的结果。这意味着你不需要两个函数,一个用于编译期计算,一个用于运行时计算。constexpr
全做了。
C++11中,constexpr
函数的代码不超过一行语句:一个return
。听起来很受限,但实际上有两个技巧可以扩展constexpr
函数的表达能力。第一,使用三元运算符“?:
”来代替if
-else
语句,第二,使用递归代替循环。在C++14中,constexpr
函数的限制变得非常宽松了。
constexpr
函数限制为只能获取和返回字面值类型,这基本上意味着那些有了值的类型能在编译期决定。在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;
};
在C++11中,有两个限制使得Point
的成员函数setX
和setY
不能声明为constexpr
。第一,它们修改它们操作的对象的状态, 并且在C++11中,constexpr
成员函数是隐式的const
。第二,它们有void
返回类型,void
类型不是C++11中的字面值类型。这两个限制在C++14中放开了,所以C++14中Point
的setter(赋值器)也能声明为constexpr
:
class Point {
public:
…
constexpr void setX(double newX) noexcept { x = newX; } //C++14
constexpr void setY(double newY) noexcept { y = newY; } //C++14
…
};
还有个重要的需要注意的是constexpr
是对象和函数接口的一部分。加上constexpr
相当于宣称“我能被用在C++要求常量表达式的地方”。如果你声明一个对象或者函数是constexpr
,客户端程序员就可能会在那些场景中使用它。如果你后面认为使用constexpr
是一个错误并想移除它,你可能造成大量客户端代码不能编译。(为了debug或者性能优化而添加I/O到一个函数中这样简单的动作可能就导致这样的问题,因为I/O语句一般不被允许出现在constexpr
函数里)“尽可能”的使用constexpr
表示你需要长期坚持对某个对象或者函数施加这种限制。
要点速记
constexpr
对象是const
,它被在编译期可知的值初始化- 当传递编译期可知的值时,
constexpr
函数可以产出编译期可知的结果 constexpr
对象和函数可以使用的范围比non-constexpr
对象和函数要大constexpr
是对象和函数接口的一部分
条款16:保证const成员函数的线程安全性
const成员函数意味着它代表的是一个只读操作,虽然不会改变他的对象,但其内可能有缓存数据(声明为mutable),这种情况就不是线程安全的了。声明为const函数没有问题,符号const的语义,所以是线程安全性缺失的问题。
解决这个问题可以使用互斥锁,但一些特定情况使用互斥量是杀鸡用牛刀之举,如计算一个成员函数被调用的次数,使用std::atomic
将是一种成本较低的途径。
对于需要同步的是单个的变量或者内存位置,使用std::atomic
就足够了。不过,一旦你需要对两个以上的变量或内存位置作为一个单元来操作的话,就应该使用互斥量。
现在,这个条款是基于,多个线程可以同时在一个对象上执行一个const
成员函数这个假设的。如果你不是在这种情况下编写一个const
成员函数——你可以保证在一个对象上永远不会有多个线程执行该成员函数——该函数的线程安全是无关紧要的。比如,为独占单线程使用而设计的类的成员函数是否线程安全并不重要。在这种情况下,你可以避免因使用互斥量和std::atomics
所消耗的资源,以及包含它们的类带来的副作用。然而,这种线程无关的情况越来越少见,而且很可能会越来越少。可以肯定的是,const
成员函数应支持并发执行,这就是为什么你应该确保const
成员函数是线程安全的。
要点速记
- 确保
const
成员函数线程安全,除非你确定它们永远不会在并发上下文(concurrent context)中使用。 - 使用
std::atomic
变量可能比互斥量提供更好的性能,但是它只适合操作单个变量或内存位置。
条款17:理解特种成员函数的生成机制
- 默认构造函数仅在类完全没有构造函数的时候才生成。
- 生成的特种成员函数都具有public访问层级且是inline,并且都是非虚的,除非讨论的是一个位于派生类中的析构函数,并且基类的析构函数是个虚函数。
- 移动操作仅在需要的时候生成,如果生成了,就会对类的non-static数据成员执行逐成员的移动。那意味着移动构造函数根据
rhs
参数里面对应的成员移动构造出新non-static部分,移动赋值运算符根据参数里面对应的non-static成员移动赋值。移动构造函数也移动构造基类部分(如果有的话),移动赋值运算符也是移动赋值基类部分。 - 执行移动构造或移动复制时,并不能保证移动操作真的会发生,实际上,更像是逐成员移动请求,因为对不可移动类型(即对移动操作没有特殊支持的类型,比如大部分C++98传统类)使用“移动”操作实际上执行的是拷贝操作。其核心在于把
std::move
应用于每一个移动源对象,其返回值被用于函数重载决议,最终决定是执行一个复制操作还是移动操作,条款23说明了这里的具体细节。 - 两种复制操作独立,声明了其中一个,不会阻止编译器生成另一个。
- 两种移动操作不独立,声明了其中一个,就会阻止编译器生成另一个。理由在于:既然声明了一个移动操作,说明你实际的移动操作的实现方法和编译器生成的实现方法有所不同,那么另一个移动操作若又编译器生成就极有可能有不合用之处。
- 一旦声明了复制操作,编译器就不会生成移动操作,反之亦然。
- 大三律:如果你声明了拷贝构造函数,拷贝赋值运算符,或者析构函数三者之一,你应该也声明其余两个。它来源于长期的观察,即用户接管拷贝操作的需求几乎都是因为该类会做其他资源的管理,这也几乎意味着(1)无论哪种资源管理如果在一个拷贝操作内完成,也应该在另一个拷贝操作内完成(2)类的析构函数也需要参与资源的管理(通常是释放)。通常要管理的资源是内存,这也是为什么标准库里面那些管理内存的类(如会动态内存管理的STL容器)都声明了“the big three”:拷贝构造,拷贝赋值和析构。
- 大三律规则加上对声明拷贝操作阻止移动操作隐式生成的观察,推动了C++11这样一个规定:只要用户声明了析构函数,就不会生成移动操作。
- C++11抛弃标准规定:在已经存在复制操作或析构函数的条件下,仍然自动生成复制操作已经成为了被废弃的行为
- 如果编译器生成的这些函数正是你预期的,那么可以使用
=default
来显示表达这个想法。
要点速记
- 特殊成员函数是编译器可能自动生成的函数:默认构造函数,析构函数,复制操作,移动操作。
- 移动操作仅当类没有显式声明移动操作,拷贝操作,析构函数时才自动生成。
- 复制构造函数仅当类没有显式声明复制构造函数时才自动生成,并且如果用户声明了移动操作,复制构造就是delete。复制赋值运算符仅当类没有显式声明复制赋值运算符时才自动生成,并且如果用户声明了移动操作,复制赋值运算符就是delete。当用户声明了析构函数,复制操作的自动生成已被废弃。
- 成员函数模板不抑制特殊成员函数的生成。
参考:Effective Modern C++(中文版)和这里。