Effective Modern C++ 11 笔记
1. 型别推导
1.1. 条款1 理解模型型别推导
理解 auto 和 decltype
伪代码:
1 | template<typename T> |
编译期间, 编译器通过expr推导两个型别: T 和 ParamType
T的型别推导结果, 不仅仅依赖表达式expr
的型别, 还依赖ParamType的形式, 分下面三种情况:
- ParamType具有指针或引用型别, 但不是万能引用(条款24)
- ParmaType 是万能引用
- ParamType 非指针也非引用
1.1.1. ParamType具有指针或引用型别
- expr 具有引用类型, 先将引用忽略
- 执行模式匹配, 决定T的型别
ParamType 引用类型
ex:
1 | void(T& param); |
ParamType指针类型
ex:
1 | void f(T* param); |
1.1.2. ParamType 是万能引用
万能引用 T&&
- 如
expr
是左值, T和ParamType都为左值引用 expr
是右值, 符合情况1中的规则1
2
3
4
5
6
7void f(T&& param);
const int cx = x; //T->const int
const int& rx = x; //T->const int&
f(x); //int&, int&
f(cx); //const int&, const int&
f(rx); //const int&, const int&
f(27); // 27是右值, T变为int, ParamType为int&&
1.1.3. ParamType非指针也非引用
按值传递, 形参是原对象的一份拷贝
expr
如具有引用, 则忽略- 忽略后, 若还是const, 也忽略.若是volatile, 也忽略
1
2
3
4
5
6void f(T param);
f(x);
f(cx); 都是int
f(rx);
const char* const p = "abc"; //p->const char* const
f(p); //推导为const char* 指针指向的内容不能更改, 但指针可以指向其他的地方
1.1.4. 数组实参
1 | const char name[] = "who"; //const char [4] |
可以通过编译器常量形式返回数组尺寸
1 | template<typename T, std::size_t N> |
1.1.5. 函数实参
1 | template<typename T> |
1.1.6. 要点速记
- 在模板性别推导过程中, 具有引用型别的实参会被当成非引用型别来处理, 引用性被忽略
- 对万能引用形参, 左值实参会进行特殊处理
- 按值传递的形参, 是拷贝复制, const 和 volatile被丢弃
- 数组或函数型的实参会退化成对应的指针, 除非被用来初始化引用
1.2. 条款2 理解auto型别推导
和模板型别推导类似, 修饰的左值的类型极为模板型别推导中的ParamType
ex:
1 | auto x= 27; |
1.2.1. 跟模板不同的例外情况:
当采用auto声明的变量使用{}初始化表达式来进行初始化时, 得到的型别是std::initializer_list
, 而模板推导不出来, 编译错误
1 | auto x {1,2,3,4}; |
使用auto {}统一初始化时注意std::initializer_list的情况
lambda表达式的形参中使用auto, 是使用模板型别推导而非auto型别推导, 传递{}表达式实参会有编译报错的情况
1.2.2. 要点速记
- 一般情况下, auto型别推导 = 模板型别推导, 但使用{}初始化表达式会产生
std::initializer_list
, 而模板会报错 - 在函数返回值中或lambda表达式的形参中使用auto, 不能传递{}表达式实参.
1.3. 条款3 理解decltype
decltype返回给定的名字或表达式的确切类型
主要用于声明那些返回值型别依赖于形参型别的函数模板
1 | // C++ 11 |
大多数含有型别T对象的容器的operator []会返回T&, 但在模型型别推导中, 初始化表达式的引用会被丢弃
1 | std::deque<int> d; |
最终版本, 使用万能引用, 这样上面直接作为左值就可以编译通过了. 可以进行赋值.
1 | template<typename Container, typename Index> |
1.3.1. 要点速记
- 绝大多数情况下, decltype会得出变量或表达式的型别而不做任何修改
- 对于T的左值表达式, 除非该表达式仅有一个名字, deletype总是得出型别T&
- C++14支持decltype(auto), 会从初始化表达式推导型别, 使用decltype的规则
1.4. 条款4 查看型别推导的结果的方法
- IDE查看, 作用有限
- 编译器诊断信息, 导致编译错误
1
2
3
4
5
6template<typename T>
class TD;
const int s = 0;
auto x = s;
// 输出错误
x = "error"; - 运行时输出使用Boost.TypeIndex来产生精确的型别识别信息
1
2//编译器会做一些特殊处理, 同时结果因为使用了按值传递, 会丢弃一些信息
typeid(param).name();1
2
3
4
using boost:typeindex:type_id_with_cvr;
type_id_with_cvr<T>().pretty_name();
type_id_with_cvr<decltype(param)>().pretty_name();
1.4.1. 要点速记
- 利用IDE编辑器\编译器错误消息和Boost.TypeIndex库能查到推导的类型
- 有些工具产生的结果无用或不准确, 理解是必要的
2. auto
2.1. 条款5 优选使用auto, 而非显示型别声明
使用auto必须初始化, 避免了变量未初始化引起的错误;
auto可以直接表示函数指针, 比std::function使用的内存更少
避免隐式类型转换, 避免无谓的内存开销
1 | std::unordered_map<std::string, int>m; |
std::unordered_map的键值部分是const, 遍历时应该是std::pair<const std::string,int>
而不是std::pair<std::string,int>
每循环一次, 都会发生一次隐式转换, p也指向的临时对象, 每次迭代结束, 临时对象需要析构.
1 | for(const auto &p:m){ |
2.1.1. 要点速记
- auto变量必须初始化, 基本上对会导致兼容性和效率问题的型别不匹配现象免疫, 还可以简化重构流程
- auto型别的变量有条款2和条款6的毛病
2.2. 条款 6 当auto推导型别不符合要求时, 使用(强制类型转换)显示型别的初始化习惯用法
std::vector<bool>
类型[]返回值不能用auto, 返回的不是bool类型. 而是std::vector<bool>::reference
, 它是std::vector的代理类
由上面的规则推导出普遍规律: 隐形代理类和auto无法和平共处. 防止写出auto var = "隐形"代理型别表达式
对于上面的隐形代理类, 使用强制类型转换来使用auto
1 | std::vector<bool> features {0,1,0}; |
使用static_cast强制类型转换, 可以表明我是故意这样转换的, 比如降低精度, double->float double->int等.
2.2.1. 要点速记
- 隐形的代理型别可以导致auto根据初始化表达式推导出错误的型别
- 带显示型别的初始化习惯用法强制auto推倒想要的类型(强制类型转换)
3. 转向现代C++
3.1. 条款7 创建对象时区分() 和 {}
1 | class D; |
C++ 11引入了统一初始化
{}初始化禁止内建类型之间进行隐式窄化类型转换
缺陷
:
伴随意外行为
std::initializer_list, 见条款2
如果类中的构造函数重载了以std::initializer_list
作为参数的构造函数, 使用{}初始化会优先使用该构造函数进行初始化
1 | class D { |
只有找不到实参可以转换为initializer_list中的形参类型的情况下, 才会去找别的构造函数
std::vector
中就使用了这样的构造方法, 所以一定要注意vector构造时使用()还是{}进行初始化.
{}初始化会进行auto的型别推导
3.1.1. 要点速记
- {}初始化可以应用的语境最为广泛, 可以阻止隐式窄化转换, 对函数表达不带形参的()初始化免疫
- 在构造函数重载决议期间, 只要有任何可能, {}初始化就会与带有
std::initializer_list
型别的形参相匹配,即使其他重载版本有更加匹配的形参表 - 使用()还是{}, 最后的结果可能不一样, 如vector对象的初始化
1
2
3
4
5
6
7
8std::vector<int> vx(10,20);
std::vector<int> vd{10,20};
for(const auto& p : vx) {
cout<< p << endl;
}
for(const auto& p : vd) {
cout<< p << endl;
} - 在模板内容进行对象创建时, 到底使用()还是{}会成为一个棘手的问题
3.2. 条款8 优先使用nullptr, 而非NULL或0
在只能使用指针的语境中使用0, 会被勉强解释为空指针.
0和NULL都不具体有指针型别, nullptr不具备整形型别.
f(NULL)的不确定性, 在函数重载时, 调用出错.
1 | void f(void *); |
nullptr可以隐式转换为所有的裸指针型别.
使用auto声明变量时, 如果还是用0或NULL, 如返回值返回0或NULL, auto声明变量的类型会推导出错.
错误的用法:
1 | char* findRecord(char* name) { |
nullptr在有函数模板的前提下表现最亮眼
1 | int f1(std::shared_ptr<D> ptr); |
上述的函数模板可以编译通过, 如果使用NULL或0就不可以.
3.2.1. 要点速记
- 想对于0或NULL, 优先使用nullptr
- 避免在整形和指针型别之间重载
3.3. 条款9 优先使用别名声明, 而非typedef
别名声明可以模板化, typedef就不行
1 | template<typename T> |
3.3.1. 要点速记
- typedef 不支持模版化,但别名声明支持
- 别名模板可以免写::type后缀,并且在模板内, 对于内嵌typedef的引用经常要求加上typename前缀
3.4. 条款10 优先使用限定作用域的枚举类型
通用规则, 如果在{}内声明一个名字,则改名字的可见性就被限定在{}内
上面的规则不适用于枚举量, 此作用域内不能有其他实体去相同名字. 即存在名字污染
问题
不限范围的枚举类型可以隐式转换为整数型别, 限定的就不可以, 如果确实需要转换为int, 可以使用static_cast
强转.
1 | enum class Color{black, white, red}; |
3.4.1. 要点速记
- c++ 98风格的枚举类型, 称为不限范围的枚举类型
- 限定作用域的枚举类型仅在枚举型别内可见, 只能通过强制类型转换转到其他型别
- 限定作用域的枚举型别和不限制范围的都支持底层型别指定. 限定的默认是int, 不限定的没有默认
- 限定作用域的枚举型别可以前置声明, 而不限的在指定了默认底层型别的前提下才可以.
3.5. 条款11 优先使用删除函数, 而非private未定义函数
压制某函数, 不让别人用
1 | class C{ |
如果客户代码访问了delete的函数, 会在编译阶段就报错.
成员函数和友元函数也会无法访问
任何函数都可以成为删除函数
可以删除某些有些转换后能够变成重载的形参的函数的特定版本
1 | bool isLucky(int number); |
阻止不应该进行的模板具现
1 | template<typename T> |
3.5.1. 要点速记
- 优先使用删除函数, 而非private未定义函数
- 任何函数都可以删除, 包括非成员函数和模板具现
3.6. 条款12 为改写的函数添加override声明
改写(override), 重载(overload)
好处:
- 编译器在你想要改写的函数实际上并未改写是提示
- 如果派生类都写了override生命,则改函数签名时可以知道有多少派生类被影响到了区分返回左值版本还是右值版本
1
2
3
4
5
6
7
8
9
10
11
12
13class Base{
virtual void f();
virtual doWork();
}
class Dp:public Base{
public:
void f() overide;
void doWork() & override; //仅在*this是左值时调用
void doWork() && override; //仅在*this是右值时调用
}
Dp dp;
dp.doWork(); //以左值调用doWork
makeDp().doWork(); // 以右值调用doWork
3.6.1. 要点速记
- 为在意改写的函数添加override声明
- 成员函数引用饰词使得对于左值和右值对象(*this)的处理能够区分
3.7. 条款13 优先使用const_iterator, 而不是iterator
1 | std::vector<int> values; |
容器的cbegin, cend返回const_iterator.
STL成员函数若要取用指示位置的迭代器, 也要求使用const_iterator型别
如果容器的成员函数未提供cbegin, cend.
非成员函数版本cbegin的一个实现:
1 | template<class C> |
3.7.1. 要点速记
- 优先使用const_iterator, 而不是iterator
- 在最通用的代码中, 优先使用非成员函数版本的begin, end, rbegin等
3.8. 条款14 只要函数不发生异常, 加上noexcept声明
3.8.1. 要点速记
- noexcept声明是函数接口的组成部分, 调用方可能对它有依赖
- 想对于不带noexcept声明的函数, 带有的有可能得到优化
- noexcept性质对于移动操作/swap/内存释放函数/和析构函数最有价值
- 大多数函数都是异常中立的, 不具备noexcept性质
3.9. 条款15 优先使用constexpr
- 所有的constexpr对象都是const对象
- 并非所有的const对象都是constexpr
- constexpr可以保证编译期可知
- constexpr函数可以用在编译期常量的语境中.即base和exp都是编译期常量, pow的返回结果就可以当成编译期常量使用
1
2
3
4
5
6constexpr int pow(int base, int exp) noexcept
{
return (exp == 0)? 1: base * pow(base, exp - 1);
}
constexpr auto exp = 5;
std::array<int, pow(3, exp)> results;
如果base和exp有一个不是编译期常量, 则pow的返回结果就是执行期量.constexpr的函数实现因为需要保证在编译期能返回编译期结果, 因此需要对函数实现添加限制
- 只能有一条return语句.
可以通过条件表达式或者递归的方式实现.
用户自定义型别同样可能是字面型别, 因它的构造函数和其他成员函数可能也是constexpr函数 - constexpr函数除了void, 其他的内建型别都支持, 另外不允许有io语句
3.9.1. 要点速记
- constexpr对象都具备const属性, 并由编译期已知的值完成初始化
- constexpr函数在调用时若传入的实参是编译期已知的, 则会产生编译期结果
- 比起非constexpr对象或constexpr函数而言, constexpr对象或函数都可以用在作用域更广的语境中
3.10. 条款16 保证const成员函数的线程安全性
3.10.1. 要点速记
- 保证const成员函数的线程安全性, 除非可以确认它们不会用在并发语境下
- 运用std::atomic型别的变量会比运用互斥量提供更好的性能 , 但前者仅仅适用于单个变量或内存区域的操作
3.11. 条款17 理解特种成员函数的生成机制
特种成员函数是指从c++自动生成的成员函数
默认构造函数. 析构函数 复制构造函数. 复制赋值运算符函数
C++11中, 加入了两个新成员:
移动构造函数和移动赋值运算符
1 | class D{ |
两种移动操作不是独立的, 声明了其中一个, 会阻止生成另一个.
- 按成员移动由两部分构成:
- 支持移动操作的成员上执行的移动操作
- 不支持…
复制操作和移动操作一般来说也不是独立的, 一旦声明了移动操作, 就废弃了复制操作.
大三律:
声明了复制构造函数/复制赋值运算符或析构函数的任一个, 就需要同时声明这三个
大三律的推论:
如果声明了析构函数, 复制操作就不应该自动生成, 因为自动生成的行为一定不正确.
如声明了析构函数, 就不会生成移动操作
移动操作的生成条件(自动生成, 按需生成), 仅当以下三者同时成立的条件下:
- 该类未声明任何复制操作
- 该类未声明任何析构函数
- 该类未声明任何移动操作
如果不想让上述规则生效, 需要添加 =default
来显示的表达我就想用某操作
1 | class D |
特种函数名称 | 机制 |
---|---|
默认构造函数 | 仅仅当类中不包含用户声明的构造函数时才生成 |
析构函数 | 默认为noexecpt, 仅当基类的是虚的, 派生类的才是虚的 |
复制构造函数 | 仅当类中不包含用户声明的复制构造函数时才生成, 如果声明了移动操作, 则复制构造会被删除.在已经存在复制赋值运算符或析构函数的条件下,不会生成复制构造函数 |
复制赋值运算符 | 如果声明了移动操作, 复制赋值运算符将被移除,在已经存在了复制构造或析构函数的条件下,废弃 |
移动构造函数和移动赋值运算符 | 类中不包含用户声明的复制操作/移动操作/析构函数时才生成 |
特例: |
成员函数模板的存在不会阻止编译器生成任何特种成员函数
1 | class D |
编译器会始终自动生成D的复制和移动操作, 即使这些模板具现了复制或复制赋值运算符的函数签名(T为D也会生成)
3.11.1. 要点速记
- 特种成员函数是指那些C++自行生成的成员函数, 默认构造 析构函数 复制操作 移动操作
- 移动操作仅当类中未包含用户显示声明的复制 移动 析构时才生成
- 复制构造仅当类中不包含显示声明的复制构造时才生成.如声明了移动操作, 则复制构造会被废弃; 复制赋值仅当类中不包含显示声明的复制赋值时才生成, 如果声明了移动操作,会废弃;已经存在显示析构的条件下, 生成复制操作会被废弃
- 成员函数模板在任何情况下都不会抑制特种成员函数的生成
4. 智能指针
关于裸指针
- 裸指针在声明中没有指出指到的是单个对象还是一个数组
- 裸指针在生命中也没有提示在使用完指的对象后, 是否需要被析构.从声明中看不出指针是否指到了对象
- 即使知道需要析构指针所指的对象, 也不要知道如果适当析构, 是使用delelte, 还是专门的析构函数
- 即使知道了使用delete, 会是不知道是指单个对象还是数组, 是使用delelte还是delelte []
- 即使知道delelte的形式, 也不能保证析构在所有代码路径上都仅执行一次(包括异常导致的代码路径).只要少在一路上执行, 就会导致资源泄漏,执行多了会出现未定义行为
- 没有任何正规的方式能检测出指针是否空悬
在上面裸指针的种种问题下, 请优先使用智能指针, 保证在合适的时机下析构
std::auto_ptr
C++ 98残留,后来变成了unique_ptr,不再建议使用std::unique_ptr
在C++11中用来代替auto_ptr的std::shared_ptr
std::weak_ptr
4.1. 条款18 使用std::unique_ptr
管理专属所有权的资源
考虑使用智能指针时, 优先使用unique_ptr, 和裸指针的代价差不多, 实现专属所有权语义.std::unique_ptr
是只移动型别,资源的析构是通过对其内部的裸指针实施delete完成
常见用法是在对象继承谱系中作为工厂函数的返回类型, 调用者需要对工厂函数返回的资源负责, 属于专属所有权
1 | class Investment{...}; |
如果所有权链由于异常或其他非典型控制流(如函数提早返回)而中断时, 有托管资源所有权的std::unique_ptr最终将调用该资源的析构函数. 其托管资源最终被析构
在析构过程中, 可以设定自定义析构器.
1 | auto delInvmt = [](Investment* pInvestment) |
自定义析构器接受一个型别为Investment* 的形参, 终究会在lambda表达式中作为一个Investment* 对象被删除,意味着我们会通过基类指针删除一个派生类对象.
基类必须具备一个虚析构函数
1 | class Investment |
还可以这样定义工厂函数的返回值, 使用函数作为自定义析构器
1 | void delInvmt2(Investmt* pInv) { |
lambda表达式的方案更好.
std::unique_ptr以两种方式提供, 一种是单个对象, (std::unique_ptr
- std::unique_ptr可以更加高效的转换为std::shared_ptr;
4.1.1. 要点速记
- std::unique_ptr是小巧/高速的/具备只移型别的智能指针, 对托管资源实施专属所有权语义.
- 默认采用delete运算实现, 但可以指定自定义删除器.有状态的删除器和采用函数指针实现的删除器会增加该型别的大小.
- std::unique_ptr可以很容易的转换为
std::shared_ptr
4.2. 条款19 使用std::shared_ptr管理具备共享所有权的资源
采用共享所有权来管理生存期,当最后一个指到该对象的指针不再指它时, 该std::shared_ptr会析构所指的对象
std::shared_ptr的复制赋值动作会执行两种操作:sp1 = sp2
- 最初sp1所指的对象的引用技数递减, 如减到0, 资源析构
- sp2所指对象的引用计数递增
引用计数的存在会带来一些性能影响: - 尺寸是裸指针的两倍, 一个指到裸指针, 一个指到引用计数
- 引用计数的内存必须动态分配
- 引用计数的递增和递减必须是原子操作
移动构造函数, 会将源std::shared_ptr悬空, 原来的指针不再指到资源
自定义析构器的型别不是std::shared_ptr指针的一部分, 而unique_ptr是相对unique_ptr, shared_ptr的设计更有弹性1
2
3
4
5
6
7
8std::unique_ptr<Investment, decltype(delInvmet)> upw(new Investment, delInvmet);
std::shared_ptr<Investment> spw(new Investment, delInvmet);
auto cd1 = [](Investment* p) {...};
auto cd2 = [](Investment* p) {...};
//std::shared_ptr的设计更有弹性
std::shared_ptr<Investment> spw1(new Investment, cd1);
std::shared_ptr<Investment> spw1(new Investment, cd1);
std::vector<std::shared_ptr<Investment>> vpw{spw1, spw1};
shared_ptr的尺寸永远是裸指针的两倍, 不随析构器的尺寸发生变化
尽可能避免将裸指针传递给一个std::shared_ptr的构造函数,常用的替换手法是使用std::make_shared
如果必须将裸指针传递给std::shared_ptr的构造函数, 请在构造函数中直接new
反面教材:std::make_shared总是创建一个控制块1
2
3
4
5auto pw = new D;
std::shared_ptr<D> spw1(pw, cd1); //not ok
std::shared_ptr<D> spw3(new D, cd1); //ok
std::shared_ptr<D> spw2(pw, cd2); // not ok
std::shared_ptr<D> spw4(spw3) //ok
4.2.1. 要点速记
- std::shared_ptr提供方便的手段, 实现了任意资源在共享所有权语义下进行生命周期管理的垃圾回收机制
- 与unique_ptr相比, 其尺寸通常是裸指针的两倍, 还带来了控制块的开销, 并要求原子引用计数
- 默认的资源析构是delete, 同时支持定制自定义析构器, 自定义析构器不影响shared_ptr的型别
- 避免使用裸指针型别的变量创建shared_ptr指针, 直接通过传入形参构造的方式.
4.3. 条款20 对于类似std::shared_ptr但有可能悬空的指针使用std::weak_ptr
这种指针像std::shared_ptr那样运作,但又不影响其所指对象的引用计数,真正的智能指针, 能够跟踪何时指针空悬.
std::weak_ptr是std::shared_ptr的一种扩充
一般是通过shared_ptr来创建的,
1 | auto spw = make_shared<D>(); |
在非严格继承谱系的数据结构中, 以及缓存和观察者的列表实现等情况下, std::weak_ptr非常适用
4.3.1. 要点速记
- 使用std::weak_ptr来代替可能空悬的shared_ptr
- weak_ptr可能的用武之地包括缓存/观察者列表以及避免std::shared_ptr指针回路
4.4. 条款21 优先选用make_shared和std::make_unique, 而非直接使用new
make_unique是C++ 14中的.
make系列函数会把一个任意实参集合完美转发给动态分配的内存对象的构造函数,并返回一个指到该对象的智能指针.
1 | template<typename T, typename... Ts> |
优先使用make系列与异常安全有关,性能有关
但使用make系列无法自定义析构器
1 | auto spw1(std::make_shared<D>()); |
4.4.1. 要点速记
- 相比直接使用new表达式, make系列消除了重复代码, 引进了异常安全性, 并且对于std::make_shared和std::allocate_shared而言, 生成的目标代码会尺寸更小/速度更快
- 不适用make系列函数的场景包括自定义析构器,以及期望直接传递{}初始物
- 对于std::shared_ptr, 不建议使用make的额外场景包括:1. 自定义内存管理的类, 2. 内存紧张的系统/非常大的对象,以及存在比指到相同shared_ptr对象生存期更久的weak_ptr
4.5. 条款22 使用Pimpl习惯用法时, 将特殊成员函数的定义放到实现文件中
4.5.1. 要点速记
- Pimpl惯用法通过降低类的客户和类实现者之间的依赖性, 减少了构建遍数
- 对于采用unique_ptr来实现的pImpl指针, 须在类的头文件中声明特种成员函数, 但需要在实现文件中实现他们
- 上述建议使用与unique_ptr, 不适用与shared_ptr
5. 右值引用 移动语义和完美转发
移动语义: 以代价比较小的移动操作代替昂贵的复制操作,移动构造函数和移动复制赋值运算符函数也给了移动语义的能力
完美转发:使得可以任意撰写接受任意实参的函数模板, 并将其转发给其他函数, 目标函数会接受到与转发函数完全相同的实参.
注意点: 形参总是左值, 即使其型别是右值引用
5.1. 理解std::move和std::forward
std::move无条件的将实参转换为右值, std::forward则仅在某个条件下才执行强制转换
std::move做的是强制类型转换, 不做的是移动, 转换为右值后, 表明其具备了可移动的条件
经验:
- 如果想取得对某个对象执行移动操作的能力, 不要将其声明为常量, 以为针对常量对象执行的移动操作将一声不吭的变换为复制操作
- std::move不仅不实际移动, 甚至不保证转换后的对象具有可移动的能力, 唯一可以确定的是转换后, 会变为右值
- std::move 执行了移动构造函数, 移动的原目标的值会被清空。其内容移动给了新的目标。
1
2
3
4
5
6
7
8
9
10
11
12
13void process(const D& d1) //处理左值
void process(D&& d1) //处理右值
template<typename T>
void logAndProcess(T&& param)
{
auto now = std::chrono::system_clock::now();
makeLogEntry("calling process", now);
//进行转发
process(std::forward<T>(param));
}
D d; //左值
logAndProcess(d);
logAndProcess(std::move(d)); //调用时转换为右值
5.1.1. 要点速记
- std::move实施的是无条件的转换为右值, 对其本身而言, 不会执行移动
- 仅当传入的实参被绑定为右值时, std::forward才会针对该实参实施转换为右值, 实现的是转发语义
- 在运行期间, std::move和std::forward不执行任何操作
5.2. 条款24 区分万能引用和右值引用
1 | void f(D&& param); 右值引用 |
T&& 为右值引用, 主要的理由是能识别出可移对象
不涉及型别推导, 就是右值引用
必须指定为T&&的模式. 加上修饰符也不行
1 | template<typename T> |
5.3. 条款25 针对右值引用实施std::move, 针对万能引用实施std::forward
这里只给出一个错误示范:
1 | class D |
std::move将引用形参无条件的强制转换为右值, n的值就被移入d.name中.
返回值优化:
- 局部对象型别和函数返回值型别形同
- 返回的就是局部对象本身
5.3.1. 要点速记
- 针对右值引用的最后一次实施std::move, 针对万能引用的最后一次实施std::forward
- 若局部对象可能适用于返回值优化, 则请勿针对其实施std::move或std::forward
5.3.2. 要点速记
避免使用万能引用进行函数重载
- 把万能引用作为重载候选型别, 几乎总会让该重载版本在始料未及的情况下被调用到
- 完美转发构造函数的问题尤其严重, 因为对于非常量的左值型别, 他们一般都会形成想对于复制构造函数的更佳匹配.并且还会劫持派生类中对基类的复制和移动构造函数的调用
5.4. 条款27 熟悉万能引用作为函数重载方案的替代方案
- 传递const T& 型别的形参
- 传值
将传递的形参从引用型替换为值型别1
2
3
4
5
6
7class Person{
public:
explicit Person(std::string n):name(std::move(n)){};
explicit Person(int idx):name(nameFromIdx(idx)){};
private:
std::string name;
} - 标签分类
传递左值常量还是传值, 都不支持完美转发, 标签分类支持完美转发, 标签值决定了调用了哪个重载版本1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(std::forward<T>(name),
std::is_illegal<typename std::remove_reference<T>::type>());
}
template<typename T>
void logAndAddImpl(T&& name, std::false_type)
{
auto now = ...
log...
names.emplace(std::forward<T>(name));
}
template<typename T>
void logAndAddImpl(int idx, std::true_type)
{
logAndAdd(nameFromIdx(idx));
} - 对接受万能引用的模板施加限制
完美转发构造函数使用标签分类的方式并不能保证自动生成的特种函数一定会经过标签分派设计.
完美转发的效率更高, 但写起来非常麻烦, 而且当客户代码传递了非法形参时, 编译信息的可理解性非常差
5.4.1. 要点速记
- 如果不能使用万能引用和重载的组合, 则替代方案包括使用彼此不同的函数名字/传值/传递const T&型别的形参,和标签分类
- 万能引用形参通常在性能方面有优势, 但在易用性方面非常劣势
5.5. 条款28 理解引用折叠
引用的引用是非法的, 编译器会报错.
但是在模板推导过程中和auto型别推导过程中,如果遇到引用的引用,即&&, 会进行引用折叠
引用合成
1 | T& & => T& |
5.5.1. 要点速记
- 引用折叠会在四种语境中发生, 模板实例化 auto型别生成 创建和运用typedef和别名声明以及decltype
5.6. 条款29 假设移动操作不存在,成本高,未使用
整个C++98标准库都已经被C++11彻底翻修过, 但还是有很多库未进行过修改.如果不支持移动操作, 就没有任何区别
即使支持移动操作, 也不一定代价小
在这样几种场景下, C++11的移动语义不会带来什么好处:
- 没有移动操作: 待移动的对象未能提供移动操作
- 移动未能更快:虽然有移动, 但并不比复制更快
- 移动不可用:移动可以发生的语境下, 要求移动不可发生异常,但该操作未加上noexcept声明
对于那些型别或对于移动语义的支持情况已知的代码,则无需上述假定
5.7. 条款30 熟悉完美转发的失败情形
转发函数天然就是泛型的, 接受可变长形参模板, 从而能够接受任意数量的实参
1 | template<typename... Ts> |
会导致完美转发失败的实参种类有分为下面几种情况:
- {}初始化expr
1
2
3void f(const std::vector<int>& v);
f({1,2,3,4});
fwd({1,2,3,4});//error
- 编译器无法为一个或多个fwd的形参推导出型别结果
- 编译器为一个或多个fwd的形参推导出了错误的型别
- 0和NULL用作空指针
不能推倒出指针, 用nullptr代替 - 仅有声明的整形static const成员变量
static const std::size_t MinVals = 28;
仅给出了声明, 并没有定义. - 重载的函数名字和模板名字
- 位域
非const引用不能绑定到位域
只能进行复制,再转发
万能引用的作用机制是引用, 在硬件级别, 引用就是指针的提领
5.7.1. 要点速记
- 完美转发的失败情形, 源于模板型别推导失败, 或推导结果是错误的型别
- 会导致完美转发失败的实参种类有…, 见上面
6. lambda表达式
用法:
- 自定义析构器, shared_ptr unique_ptr
- 标准库 std::find_of remove_if count_if等
- 制作回调函数,接口适配函数或语境相关的特化版本等
闭包可以复制
6.1. 条款31 避免默认捕获模式
默认捕获模式:
- 按引用捕获
会导致闭包包含指到局部变量的引用, 一旦超出作用域, 闭包内引用可能空悬上面例子中filters可能为全局变量, 但其加入的lamda表达式中的divisor在离开了addDivisorFilter函数作用域后,就不存在了1
2
3
4
5
6
7void addDivisorFilter()
{
auto divisor = computeDivisor(cal1, cal2);
filters.emplace_back(
[&](int value) {return value % divisor == 0;}
);
}
解决这个问题的一个办法是对divisor
采用按值的默认捕获模式捕获只能对创建lamda式内的作用域内1
filters.emplace_back([=](int value) {return value%divisor==0;});
可见的非静态局部变量进行捕获, 如果是成员变量等, 不能进行捕获
上面这个成员函数中lamda等同于下面这个1
2
3
4
5
6
7
8
9class D{
public:
void addFilter(){
filters.emplace_back(
[=](int value) {return value%divisor == 0;}
);
};
private int divisor;
}被捕获的实际是D的this指针而不是divisor, 因此在离开D对象作用域后, 生命周期结束后, 会导致指针空悬1
2
3
4
5
6
7
8
9
10class D{
auto p = this;
public:
void addFilter(){
filters.emplace_back(
[p](int value) {return value%p->divisor == 0;}
);
};
private int divisor;
}
可以通过复制解决,使用默认捕获模式的另一个缺点是与闭包外的变化绝缘1
2
3
4
5
6
7
8
9
10
11class D{
auto p = this;
public:
void addFilter(){
auto devisor1 = devisor;
filters.emplace_back(
[devisor1](int value) {return value%divisor1 == 0;}
);
};
private int divisor;
}
不能捕获静态变量, 依赖于静态变量存储期, 会引起代码误读1
2
3
4
5
6
7
8
9
10static int divisor = 1;
void addF(){
divisor++;
}
void addDivisorFilter()
{
filters.emplace_back(
[=](int value) {return value % divisor == 0;} //未捕获任何东西, 但会指到静态变量上, 静态变量可能变化, 导致该表达式的含义变化
);
}
6.1.1. 要点速记
- 按引用的默认捕获会导致空悬指针问题
- 按值的默认捕获易受空悬指针影响, 并且容易引起代码误读
6.2. 条款32 使用初始化捕获将对象移入闭包
C++11 不支持初始化捕获, c++14 支持
1 | auto func = [pw=std::move(pw)] {return pw->isValidated();}; |
上面的[]内的即为初始化表达式, =两侧的作用域不同.
也称为广义lamda捕获模式
C++ 11中需要使用std::bind, 按移动捕获
- 把需要捕获的对象移动到std::bind产生的函数对象中
- 给到lambda一个指到捕获对象的引用std::bind返回的函数对象为绑定对象, 第一个实参是可调用对象, 接下来的所有实参是传给该对象的值
1
2std::vector<double> data;
auto func=std::bind([](const std::vector<double>& data){/*对data进行操作*/}, std::move(data));
绑定对象被调用时,采用了右值的实参传给了std::bind绑定的lambda式1
auto func=std::bind([const std::unique_ptr<D>& d]{return d->isValidate();}, std::make_unique<D>());
6.2.1. 要点速记
- 使用C++14的初始化捕获将对象移入闭包
- 在C++11中, 经由手工实现的类或std::bind去模拟初始化捕获
6.3. 条款33 对auto&& 型别的形参使用decltype, 以std::foward之
1 | auto f=[](auto&& param) { |
6.4. 条款34 优先使用lambda表达式, 而非std::bind
6.4.1. 要点速记
- lambda表达式比起使用std::bind而言, 可读性更好,表达力更强, 运行效率更高
- 仅在C++11中, std::bind在实现移动捕获或是绑定到具备模版化的函数调用运算符的对象的场合中,可有余热. 见条款32