现代C++实战30讲
本文记录学习吴咏炜老师的《现代C++实战》课程的心得体会,记录自认为值得记录的知识点。
重新认识的点
-
如果临时对象被绑定到一个引用上,则它的生命周期会延长到跟这个引用变量一样长。以下是例子:
result process_shape(const shape& shape1, const shape& shape2) { puts("process_shape()"); return result(); } // 返回值r的生命周期会延长,而不是执行完 process_shape 就结束 result&& r = process_shape(circle(), triangle());
-
std::move
将左值引用强制转换为右值引用 -
std::forward
不改变传入参数的左右值属性。左值传入,调用左值版本的重载函数,右值传入,调用右值版本的重载函数。// 左值版本 void foo(const circle&) { puts("foo const shape&"); } // 右值版本 void foo(circle&&) { puts("foo shape&&"); } template<typename T> void bar(T&& s) { // std::forward 不改变传入参数的左右值属性 // 左值传入,调用左值版本的重载函数 // 右值传入,调用右值版本的重载函数 foo(std::forward<T>(s)); } void test_bar() { circle a; bar(a); // 调用左值版本 bar(std::move(a)); // 调用右值版本 bar(circle()); // 调用右值版本 }
-
vector
通常保证强异常安全性。
对于自定义类型:- 应当定义移动构造函数,并标记其为
noexcept
。如果定义了但没标记noexcept
,vector
在拷贝时,不会调用移动构造函数,而是拷贝构造函数。这是为什么呢?因为vector
提供强异常安全,也就是说不能在拷贝过程中发生异常,如果在移动构造途中抛出异常,会破坏原有vector
状态,这就异常不安全了。
例如:要将vector中两个对象移动到新vector,第一个移动成功,第二个移动时有异常,这会导致新旧vector状态都不正常。
因此,需要保证移动构造函数不能抛出异常,且将此特性通过标记 noexcept 的方式告知编译器。
拷贝构造函数允许抛出异常,这是因为拷贝操作不会影响旧容器,即使发生异常,旧容器的还是好的,达到异常安全效果。
-
容器中存放对象的智能指针
-
如果能预估数据个数,建议使用
reserve
提前分配内存,提升性能。
class Obj1 { public: Obj1() { puts("Obj1 ctor"); } Obj1(const Obj1&) { puts("Obj1 copy ctor"); } Obj1(const Obj1&&) { puts("Obj1 move ctor"); } }; class Obj2 { public: Obj2() { puts("Obj2 ctor"); } Obj2(const Obj2&) { puts("Obj2 copy ctor"); } Obj2(const Obj2&&) noexcept { puts("Obj2 move ctor with noexcept"); } }; void test_vector_emplace_back() { vector<Obj1> v1; v1.reserve(2); v1.emplace_back(); v1.emplace_back(); v1.emplace_back(); // 预计这里会触发拷贝构造 // 在 MSVC 编译器中,调用的是移动构造(估计是有什么优化吧) // 在 G++ 编译器中,调用的是拷贝构造 vector<Obj2> v2; v2.reserve(2); v2.emplace_back(); v2.emplace_back(); v2.emplace_back(); // 预计这里会触发移动构造 }
- 应当定义移动构造函数,并标记其为
-
deque
是分段连续的双端队列,适合经常在首尾增删元素的场景,其内存布局一般如下图: -
list
是双向链表,适合在中间位置增删的场景。list
容器中有如下定制算法:- sort 排序链表
- merge 合并两个有序链表(前置条件:两个链表要有相同的顺序)
- remove 删除特定数值的元素
- remove_if 删除满足指定条件的元素
- reverse 链表反转
- unique 对于连续重复元素,只保留1个,其他都删除。
-
有序关联容器(
map
、set
)保存自定义类型,建议通过重载<
运算符来对该类型对象进行排序;同时注意要保证严格弱序关系(即两个元素数值相等时,返回false
)。更近一步,所有STL中排序比较的函数,都需要保证严格弱序。 -
无序关联容器,如
unordered_map、unordered_set
等,要求保存的数据类型支持Hash
函数以及相等判断。Hash
可通过标准hash
函数特化来定义,已达到快速查找,通过==
来处理哈希碰撞。// 自定义类型 struct KEY { int first; int second; int third; KEY(int f, int s, int t) :first(f), second(s), third(t) {} // 一定要重写 == 运算符 bool operator==(const KEY& rhs)const noexcept { return first == rhs.first && second == rhs.second && third == rhs.third; } }; // 自定义类型的hash函数,在声明时,需要显式指定hash函数 struct KeyHashFunc { // 定义hash函数 size_t operator()(const KEY& Key) const { using std::size_t; using std::hash; return hash<int>()(Key.first) ^ hash<int>()(Key.second << 1) ^ hash<int>()(Key.third << 2); } }; void test_hash() { auto hp = std::hash<int*>(); cout << "hash(nullptr) = " << hp(nullptr) << endl; auto hs = std::hash<string>(); cout << "hash(hello) = " << hs(string("hello")) << endl; // 自定义类型的 unordered_map 实现 unordered_map<KEY, string, KeyHashFunc> hashMap = { {{01,02,03}, "one"}, {{11,12,13 }, "two" } }; KEY key(01, 02, 03); auto it = hashMap.find(key); if (it != hashMap.end()) { cout << it->second << endl; } }
-
异常安全
异常安全是指当异常发生时,既不会发生资源泄露,系统也不会处于不一致状态。如今主流C++编译器,在异常关闭和开启时,当异常未抛出时,能产生性能差不多的代码,其代价是二进制文件尺寸增加10%~20%。这是因为异常产生的位置,位置不同,栈展开不同,这些栈展开数据需要存储,因此会增加尺寸。
异常比较隐蔽,不容易看出来哪些地方会发生异常和发生什么异常。因为C++不会对异常进行编译期检查,开发者只能在声明某个函数不会抛出异常(noexcept、noexcept(true)、throw())。如果一个函数声明了不会抛出异常,但结果却抛出,那么C++运行时会终止该程序。
如果代码可能抛出异常,那么需要在文档中声明可能发生的异常类型和条件,确保使用者能在不了解内部实现的前提下,知道处理哪些异常。
对于肯定不会抛出异常的函数,将其标记为 noexcept,它内部调用的其他函数,也都要确保不会抛出异常,且都标记为 noexcept。
-
易用性改进
auto是值类型推导,auto&是左值引用类型,auto&&是转发引用(可以是左值,也可以是右值)- decltype(变量名) 获得变量的精确类型
- decltype(表达式) 获得表达式的引用类型。如果表达式的结果是纯右值,那么会是值类型
int a; decltype(a) --> int decltype((a)) --> int& 因为(a)是表达式 decltype(a+a) --> int 结果是值
数据成员支持默认初始化,即:
class Complex{ public: Complex(){} Complex(float re):re_(re){} private: float re_{0}; float im_{0}; };
从
C++14
开始,允许在数字型字面量中任意添加'来增加可读性,unsigned mask = 0b111'000'000; long r_earth_equatorial = 6'378'137; double pi = 3.14159'25653'89793; unsigned magic = 0x44'42'47'4e; this_thread::sleep_for(100ms); // 休眠100ms,简洁明了,除了ms之外,还支持 s、us、ns、min、h等单位
标准库提供 std::literals
静态断言,语法为 static_assert(编译期条件表达式, 可选输出信息);
const int align = 1023; static_assert( (align & (align - 1)) == 0, "Alignment must be power of two");
delete关键字用于声明该函数是私有,不可调用。
override修饰符说明该函数是覆盖了基类虚函数,有以下作用:
* 更明确的提示,说明该函数覆写了基类虚函数
* 有利于编译器检查,防止因拼写错误或代码改动,没有让基类和派生类中函数签名一致
final声明该成员是不可覆盖的虚函数,后续派生类不可再继承;当标记类时,表明该类不可被派生 -
函数返回数值时,尽量使用返回值而非输出参数
matrix getMatrix() { matrix result; // xxx return result; } // 编译器会尝试移动返回值而不是拷贝 // 1. 先试图匹配 移动构造 matrix(matrix&&) // 2. 没有移动构造时,试图匹配 拷贝构造 matrix(const matrix&) 这个不需要人工干预,使用 std::move 对移动行为没有帮助,反而会影响返回值优化。 // 编译器的返回值优化(NRVO),直接在调用者栈上进行返回结果构造,而不进行传递。 auto r = getMatrix()
返回值是可以自我描述的,而 & 参数既可能是输入输出,也可能仅是输出,且容易被误用。
C++对返回对象做了大量优化,在函数里直接返回对象可得到更可读、可组合的代码。