前言
现代C++中像auto、智能指针、移动语义等都是一些重大的优化特性,但也有一些像constexpr、nullptr等等这样一个小的特性。这章的内容就是这些小特性的集合。
条款7:在创建对象时注意区分()和{}
在现代C++中有3种方式来以指定的值初始化对象,分别时小括号、等号和大括号:
int x(0); //初始化值在小括号中
int y = 0; //初始化值在等号后
int z{0}; //初始化值在大括号中
其中,大括号形式的初始化时C++11引入的统一初始化方式。大括号初始化可以应用的语境最为宽泛,可以阻止隐式窄化的类型转换,还对最令人苦恼的解析语法免疫。
先说阻止隐式窄化的类型转换,比如下面代码可以通过编译:
double x,y,z;
int sum1(x+y+z); //可以通过编译,表达式的值被截断为int
int sum2 = x+y+z; //同上
而以下代码不可以通过编译,因为大括号初始化禁止内建类型直接进行隐式窄化类型的转换。
int sum3{x+y+z}; //编译不通过
再说最令人苦恼的解析语法免疫。C++规定:任何能够解析为声明的都要解析为声明,而这会带来副作用。所谓最令人库娜的解析语法就是——程序员本来想要以默认方式构造一个对象,结果却不小心声明了一个函数。举个例子,我想调用一个没有形参的Widget构造函数,如果写成Widget w();
,那结果就变成了声明了一个函数(名为w,返回一个Widget类型对象)而非对象。而用大括号初始化Widget w{};
就不存在这个问题了。
但是,不能盲目的都使用大括号初始化。在构造函数被调用时,只要形参中没有任何一个具备std::initializer_list类型,那么大括号和小括号没有区别 ;如果又一个或多个构造函数声明了任何一个具备std::initializer_list类型的形参,那么采用了大括号初始化语法的调用语句会强烈地优先选用带有std::initializer_list类型形参的重载版本。也就是说,因为std::initializer_list的存在,大括号初始化和小括号初始化会产生大相径庭的结果。
这点最突出的例子是:使用两个实参来创建一个std::vector对象。std::vector有一个两个参数的构造函数,允许指定容器的初始大小(第一个参数),以及所有元素的初始值(第二个参数);但它还有一个std::initializer_list类型形参的构造函数。如果要创建一个元素为数值类型的std::vector(比如std::vector),并且传递两个实参给构造函数,那么使用大括号和小括号初始化的差别就比较大了:
std::vector v1(10, 20); //创建一个含有10个元素的vector,所有元素的初始值都是20
std::vector v1{10, 20}; //创建一个含有2个元素的vector,元素的值分别时1,20
所以,如果是作为一个类的作者,最好把构造函数设计成客户无论使用小括号还是大括号都不会影响调用得重载版本才好。
条款8:优先选用nullptr,而非0或NULL
因为0和NULL都不是指针类型,而nullptr才是真正的指针类型。比如在重载指针类型和整型的函数时,如果使用0或者NULL调用这样的重载函数,则永远不会调用到指针类型的重载版本,只有使用nullptr才能调用到。当然为了兼容我们仍然需要遵循C++98的指导原则:避免在整型和指针类型之间重载。
条款9:优先选用别名声明,而非typedef
C++11提供了别名声明来替换typedef,两者作用在大部分情况下是一样的。比如下面的typedef:
typedef std::unique_ptr UPtrMapSS;
typedef void (*FP)(int, const std::string&);
可以用下面的别名声明来替换:
using UPtrMapSS = std::unique_ptr;
using FP = void (*)(int, const std::string&);
但还有有一种场景是只能使用别名声明的,那就是在定义模板的时候,typedef不支持模板化,但别名声明支持。在C++98中需要用嵌套在模板化的struct里的typedef才能达到相同效果。比如下面这段:
template
struct MyAllocList {
typedef std::list type; //MyAllocList::type是std::list的同义词
};
MyAllocList::type lw; //客户代码
在C++11中用别名声明就很简单了:
template
using MyAllocList = std::list; //MyAllocList是std::list的同义词
MyAllocList lw; //客户代码
这里还可以看到,别名模板可以让人免写“::type”后缀。并且在模板内,对于内嵌typedef的引用经常要求加上typename的前缀,而别名模板没有这个要求。
条款10:优先选用限定作用域的枚举类型,而非不限作用域的枚举类型
推荐优先选用C++11提供的限定作用域的枚举类型有3个理由。第一,它可以降低名字空间的污染,因为限定作用域的枚举类型仅在枚举类型内可见。比如下面C++98的代码会报错:
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; // 没有问题
第二,它的枚举量是更强类型的,只能通过强制类型转换以转换为其他类型。这样可以避免奇怪的使用枚举值与数值类型比较的代码,真要使用时也必须进行一次强制转换来提醒这里有一个别扭的比较。
第三,限定作用域的枚举类型总是可以进行前置声明,而不限作用域的枚举类型却只有在指定了默认底层类型的前提下才可以进行前置声明。
还有一点需要记住,这两种枚举类型都支持指定底层类型。限定作用域的枚举类型默认底层类型是int。而不限作用域的枚举类型则没有默认底层类型,编译器会为枚举类型选择足够表示枚举值的最小类型,这也是为什么它不能直接进行前置声明,在没定义前编译器无法确认底层类型的。
条款11:优先选用删除函数,而非private未定义函数
C++11提供了使用“=delete”的方法将想阻止客户调用得函数标识为删除函数的方法,用以替代C++98中传统的将这些函数声明为private的方法。
删除函数的一个重要优点在于,任何函数都能成为删除函数,包括非成员函数和模板的具现。比如,我想定义一个判断是否是幸运数字的函数,因为隐式转换的存在会有一些奇怪的调用,而将他们定义为删除函数后就可以阻止这些奇怪的调用:
bool isLucky(int number);
bool isLucky(char) = delete;
bool isLucky(bool) = delete;
bool isLucky(double) = delete;
// 下面奇怪的调用无法通过编译
if (isLucky('a')) ...
if (isLucky(true)) ...
if (isLucky(3.5)) ...
事实上,C++98中把函数声明为private并且不去定义,这样的实践想要的就是C++11中的删除函数实际达到的效果。前者作为后者的一种模拟动作,当然不如本尊来的好用。比如,前者无法应用于类外部的函数,也不总是能够应用于类内部的函数(类内部的函数模板)。就是它能用,也可能直到链接阶段才发挥作用。所以,请始终使用删除函数。
条款12:为意在改写的函数添加override声明
由于对于声明派生类中的改写,保证正确性很重要,而出错又很容易,C++11提供了一种方法来显示地标识派生类中的函数时为了改写基类版本:为其加上override。这样如果派生类中的改写出错,编译器在编译阶段就会报错。
它还有一个好处就是可以在你打算更改基类中虚函数的签名时,衡量以下波及的影响面。
条款13:优先选用const_iterator,而非iterator
const_iterator是STL中相当于指涉到const的指针的等价物。它们指涉到不可被修改的值。
C++11中获取和使用const_iterator相比于C++98变得很容易了。容器的成员函数cbegin和cend都返回const_iterator类型,甚至对于非const容器也是如此,并且STL成员函数若要取用指示位置的迭代器(例如,作插入或删除只用),它们也要求使用const_iterator类型。下面是一段C++11中使用const_iterator的示例代码:
std::vector values;
auto it = std::find(values.cbegin(), values.cend(), 1983);
values.insert(it, 1988);
条款14:只要函数不会抛出异常,就为其加上noexcept声明
当你明知道一个函数不会抛出异常却未给它加上noexcept声明的话,这就是接口规格缺陷。对于不会抛出异常的函数应用noexcept声明还有一个动机,那就是它可以让编译器生成更好的目标代码。相对于不带noexcept声明的函数,它有更多机会的得到优化。
noexcept性质对于移动操作,swap、内存释放函数和析构函数最有价值。默认地,内存释放函数和所有的析构函数都隐式地具备noexcept性质。
大多数函数都是异常中立的。此类函数自身并不抛出异常,但它们调用得函数则可能会抛出异常。当这种情况真的发生时,异常中立函数会允许该异常经由它传至调用栈的更深一层。异常中立函数用于不具备noexcept性质,因为它们可能会抛出这种“路过”的异常。
条款15:只要有可能使用constexpr,就使用它
constexpr对象都具备const属性,并由编译期已知的值完成初始化。所有的constexpr对象都是const对象,而并非所有的const对象都是constexpr对象。
constexpr函数在调用时若传入的实参值时编译器已知的,则会产生编译器结果。如果传入的值有一个或多个在编译期未知,则它的运作方式和普通函数无异,亦即它也是在运行期执行结果的计算。
在C++11中,constexpr函数不得包含多余一个可执行语句,即一条return语句。但在C++14中,这种限制被大大地放宽了,可以有多条语句。
条款16:保证const成员函数的线程安全性
保证const成员函数的线程安全性,除非可以确信它们不会用在并发语境中。
运用std::atomic类型的变量会比运用互斥量有更好的性能,因为其开销往往较小。
对于单个要求同步的变量或内存区域,使用std::atomic就足够了。但是如果有两个或更多个变量或内存区域需要作为以整个单位进行操作时,就要动用互斥量了。
条款17:理解特种成员函数的生成机制
特种成员函数是指那些C++会自行生成的成员函数:默认构造函数、析构函数、复制操作和移动操作。其中移动操作时C++11新增的,包括两个成员——移动构造函数和移动赋值运算符。示例如下:
class Widget {
public:
...
Widget(Widget&& rhs); // 移动构造函数
Widget& operator=(Widget&& rhs); // 移动赋值运算符
}
C++11中,特种成员函数的生成机制如下:
- 默认构造函数:与C++98的机制相同。仅当类中不包含用户声明的构造函数时才生成。
- 析构函数:与C++98的机制基本相同,唯一的区别在于析构函数默认为noexcept。与C++98的机制相同的是,仅当基类的析构函数为虚的,派生类的析构函数才是虚的。
- 复制构造函数和复制赋值运算符:运行期行为与C++98相同——按成员进行非静态数据成员的复制构造和复制赋值。复制构造函数仅当类中不包含用户声明的复制构造函数时才生成,如果该类声明了移动操作则复制构造函数将被删除。复制赋值运算符仅当类中不包含用户声明的复制赋值运算符时才生成,如果该类声明了移动操作则复制赋值运算符将被删除。在已经存在显示声明的析构函数的条件下,生成复制操作已经成为了被废弃的行为。
- 移动构造函数和移动赋值运算符:都按成员进行非静态数据成员的移动操作。仅当类中不包含用户声明的复制操作、移动操作和析构函数时才生成。
综上,如果想声明一个基类,且提供默认的移动操作和复制操作,则需要如下定义:
class Base {
public:
virtual ~Base() = default;
Base(Base&&) = default; //提供移动操作
Base& operator=(Base &&) = default;
Base(const Base&) = default; //提供复制操作
Base& operator=(const Base &) = default;
}
这里解释一下:通常情况下,虚析构函数的默认实现就是正确的,而“=default”则是表达这一点的很好方式。不过,一旦用户声明了析构函数,移动操作的生成就被抑制了,而如果可移动性是能够支持的,加上“=default”就能够再次给予编译器以生成移动操作的机会。声明移动操作又会废除复制操作,所以如果还要可复制性,就再加一轮“=default”。
还有一点需要注意的是,成员函数模板在任何情况下都不会已知特种成员函数的生成。