学懂现代C++——《Effective Modern C++》之转向现代C++

前言

现代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<int> v1(10, 20); //创建一个含有10个元素的vector,所有元素的初始值都是20

std::vector<int> 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<<std::unordered_map<std::string, std::string>>> UPtrMapSS;

typedef void (*FP)(int, const std::string&);

可以用下面的别名声明来替换:

using UPtrMapSS = std::unique_ptr<<std::unordered_map<std::string, std::string>>>;

using FP = void (*)(int, const std::string&);

但还有有一种场景是只能使用别名声明的,那就是在定义模板的时候,typedef不支持模板化,但别名声明支持。在C++98中需要用嵌套在模板化的struct里的typedef才能达到相同效果。比如下面这段:

template<typename T>
struct MyAllocList {
    typedef std::list<T, MyAlloc<T>> type; //MyAllocList<T>::type是std::list<T, MyAlloc<T>>的同义词
};

MyAllocList<Widget>::type lw; //客户代码

在C++11中用别名声明就很简单了:

template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>; //MyAllocList<T>是std::list<T, MyAlloc<T>>的同义词

MyAllocList<Widget> 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<int> 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”。

还有一点需要注意的是,成员函数模板在任何情况下都不会已知特种成员函数的生成。

现代C++程序设计(原书第2版)》图文并茂,通俗易懂,真正做到寓教于乐,是一本难得的C++面向对象设计入门教材。 出版者的话 译者序 前言 第1章 C++概述与软件开发 1.1 什么是C语言C++ 1.1.1 C和C++历史回顾 1.1.2 C/C++是一门编译语言 1.1.3 为什么许多程序员都选择C++ 1.2 什么是面向对象 1.2.1 C++程序并不一定是面向对象的 1.2.2 一个简单的面向对象程序示例 1.2.3 面向对象的软件更有优势 1.3 结构化设计与面向对象设计 1.3.1 ATM——结构化设计 1.3.2 采用面向对象方法的ATM——究竟是谁的任务 1.3.3 汽车维护——结构化设计 1.3.4 采用面向对象方法的汽车维护——究竟是谁的任务 1.4 软件开发技术概述 1.5 问题发现与解决 复习题 第2章 C++的入门知识 2.1 编程基础 2.1.1 算法设计 2.1.2 正确的软件开发步骤 2.2 专业术语及工程创建 2.3 C++程序的一般格式 2.3.1 “Hello World!”程序 2.3.2 “How’s the Weather?”程序 2.4 程序的数据及数据类型 2.4.1 C++的数据类型 2.4.2 容器=数据类型,标签=变量名 2.4.3 数据类型修饰符 2.4.4 问题分析:整型数据究竟有多大 2.5 C++中的变量声明 2.5.1 C++的命名规则 2.5.2 在哪里声明变量 2.6 C++中的运算符 2.6.1 计算路程的程序 2.6.2 从键盘输入程序所需数据 2.6.3 赋值运算符 2.6.4 运算符的优先级 2.6.5 数据类型及其存储的值 2.6.6 算术运算符 2.6.7 自增运算符和自减运算符 2.6.8 复合赋值运算符 2.7 #define、const和数据类型转换 2.7.1 #define预处理指令 2.7.2 const修饰符 2.7.3 const比#define好吗 2.7.4 数据类型转换 2.8 关于键盘输入和屏幕输出的更多内容 2.8.1 转义序列 2.8.2 ios格式标记 2.8.3 流的IO控制符 2.9 开始使用类和对象、C++string类 2.10 练习 复习题 第3章 控制语句和循环 3.1 关系运算符和逻辑运算符 3.2 if语句 3.2.1 if-else语句 3.2.2 问题分析:在if语句中使用大括号 3.2.3 if-else if-else语句 3.2.4 低效的编程方法 3.2.5 if-else程序示例 3.2.6 嵌套if-else语句 3.2.7 条件运算符“?” 3.3 switch语句 3.4 循环 3.4.1 括号的用法 3.4.2 无限循环 3.5 for循环 3.5.1 不要改变循环索引 3.5.2 for循环示例 3.6 while循环 3.7 do while循环 3.8 跳转语句 3.8.1 break语句 3.8.2 continue语句 3.9 问题发现与解决 3.9.1 五个常见错误 3.9.2 调试程序 3.10 C++类与vector类 3.11 总结 3.12 练习 复习题 第4章 函数一:基础 4.1 C++中的函数 4.1.1 只由一个main函数构成的程序 4.1.2 包含多个函数的程序 4.1.3 函数是个好东西 4.1.4 三个重要的问题 4.2 函数:基本格式 4.3 函数的编写要求 4.3.1 你想住在C++旅馆中吗 4.3.2 函数为先 4.3.3 函数声明或函数原型 4.3.4 函数定义、函数标题行与函数体 4.3.5 函数调用 4.3.6 传值调用 4.3.7 问题分析:未声明的标识符 4.4 重载函数 4.5 具有默认输入参数列表的函数 4.6 局部变量、全局变量和静态变量 4.6.1 局部变量 4.6.2 块范围 4.6.3 全局变量 4.6.4 危险的全局变量 4.6.5 问题分析:全局变量y0、y1与cmath 4.6.6 静态变量 4.7 C++stringstream类 4.8 总结 4.9 练习 复习题 第5章 函数二:变量地址、指针以及引用 5.1 数据变量和内存 5.1.1 sizeof运算符 5.1.2 预留内存 5.1.3 计算机内存和十六进制 5.2 取地址运算符& 5.3 指针 5.4 函数、指针以及间接运算符 5.4.1 解决思路 5.4.2 指针和函数 5.4.3 有效处理大型数据 5.5 函数和引用 5.5.1 复习:两种机制 5.5.2 为什么要强调指针的重要性 5.6 queue类 5.7 总结 5.8 练习 复习题 第6章 数组 6.1 使用单个数据变量 6.2 数组基础 6.2.1 数组的索引值从0开始 6.2.2 使用for循环和数组来实现的电话账单程序 6.2.3 数组的声明和初始化 6.2.4 数组越界==严重的问题 6.2.5 vector与数组的比较 6.3 数组和函数 6.3.1 每个数组都有一个指针 6.3.2 数组指针 6.3.3 向函数传递数组:最开始的引用调用 6.3.4 利用数组和函数生成随机数并进行排序 6.4 C字符串,也称为字符数组 6.4.1 字符数组的初始化 6.4.2 null字符 6.4.3 C字符串的输入 6.4.4 C++中提供的字符数组函数 6.5 多维数组 6.5.1 二维数组的初始化 6.5.2 嵌套的for循环和二维数组 6.5.3 利用二维数组来实现Bingo游戏 6.6 多维数组和函数 6.6.1 改进的Bingo卡片程序 6.6.2 白雪公主:利用二维数组来存储姓名 6.7 利用数据文件对数组赋值 6.8 总结 6.9 练习 复习题 第7章 类和对象 7.1 我们所了解的类和对象 7.2 编写自己的类 7.2.1 入门实例:自定义日期类 7.2.2 第一个C++类:Date类 7.2.3 揭开类的生命之谜 7.2.4 set和get函数的作用与VolumeCalc类 7.2.5 PICalculator类 7.3 作为类成员的对象 7.4 类的析构函数 7.5 对象数组 7.6 重载运算符与对象 7.7 指针、引用和类 7.7.1 指针和引用实例 7.7.2 处理日期和时间的程序实例 7.8 总结 7.9 练习 复习题 第8章 继承和虚函数 8.1 为什么继承如此重要 8.1.1 IceCreamDialog实例 8.1.2 Counter类实例 8.2 继承基础 8.2.1 Counter和DeluxeCounter实例 8.2.2 保护成员 8.2.3 员工、老板和CEO 8.3 访问控制符的规范和多继承 8.4 继承、构造和析构 8.4.1 构造函数和析构函数回顾 8.4.2 基类和派生类的默认构造函数——没有参数 8.4.3 在重载的构造函数中使用参数 8.4.4 基类和派生类的析构函数 8.4.5 医生也是人 8.4.6 关于派生类和基类构造函数的规则 8.5 多态和虚函数 8.5.1 多态——同一个接口,不同的行为 8.5.2 什么是虚函数 8.5.3 虚函数的作用 8.6 总结 8.7 练习 复习题 附录A 学习使用Visual C++2005Express Edition 附录B C++关键字表 附录C C++运算符 附录D ASCII码 附录E 位、字节、内存和十六进制表示 附录F 文件输入/输出 附录G 部分C++类 附录H 多文件程序 附录I Microsoft visual C++2005Express Edit
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值