现代C++实战30讲

本文记录学习吴咏炜老师的《现代C++实战》课程的心得体会,记录自认为值得记录的知识点。

重新认识的点

  1. 如果临时对象被绑定到一个引用上,则它的生命周期会延长到跟这个引用变量一样长。以下是例子:

    result process_shape(const shape& shape1, const shape& shape2)
    {
    	puts("process_shape()");
    	return result();
    }
    
    // 返回值r的生命周期会延长,而不是执行完 process_shape 就结束
    result&& r = process_shape(circle(), triangle());
    
  2. std::move 将左值引用强制转换为右值引用

  3. 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());		// 调用右值版本
    }
    
    
  4. vector通常保证强异常安全性。
    对于自定义类型:

    • 应当定义移动构造函数,并标记其为 noexcept。如果定义了但没标记 noexceptvector在拷贝时,不会调用移动构造函数,而是拷贝构造函数。这是为什么呢?因为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();	// 预计这里会触发移动构造
    
    }
    
    
  5. deque是分段连续的双端队列,适合经常在首尾增删元素的场景,其内存布局一般如下图:

    image.png

  6. list是双向链表,适合在中间位置增删的场景。list容器中有如下定制算法:

    • sort 排序链表
    • merge 合并两个有序链表(前置条件:两个链表要有相同的顺序)
    • remove 删除特定数值的元素
    • remove_if 删除满足指定条件的元素
    • reverse 链表反转
    • unique 对于连续重复元素,只保留1个,其他都删除。
  7. 有序关联容器(mapset)保存自定义类型,建议通过重载 < 运算符来对该类型对象进行排序;同时注意要保证严格弱序关系(即两个元素数值相等时,返回 false)。更近一步,所有STL中排序比较的函数,都需要保证严格弱序。

  8. 无序关联容器,如 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;
    		}
    	}
    
    
  9. 异常安全
    异常安全是指当异常发生时,既不会发生资源泄露,系统也不会处于不一致状态。

    如今主流C++编译器,在异常关闭和开启时,当异常未抛出时,能产生性能差不多的代码,其代价是二进制文件尺寸增加10%~20%。这是因为异常产生的位置,位置不同,栈展开不同,这些栈展开数据需要存储,因此会增加尺寸。

    异常比较隐蔽,不容易看出来哪些地方会发生异常和发生什么异常。因为C++不会对异常进行编译期检查,开发者只能在声明某个函数不会抛出异常(noexcept、noexcept(true)、throw())。如果一个函数声明了不会抛出异常,但结果却抛出,那么C++运行时会终止该程序。

    如果代码可能抛出异常,那么需要在文档中声明可能发生的异常类型和条件,确保使用者能在不了解内部实现的前提下,知道处理哪些异常。

    对于肯定不会抛出异常的函数,将其标记为 noexcept,它内部调用的其他函数,也都要确保不会抛出异常,且都标记为 noexcept。

  10. 易用性改进
    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声明该成员是不可覆盖的虚函数,后续派生类不可再继承;当标记类时,表明该类不可被派生

  11. 函数返回数值时,尽量使用返回值而非输出参数

    matrix getMatrix()
    {
    	matrix result;
    	// xxx
    	return result;
    }
    
    // 编译器会尝试移动返回值而不是拷贝
    // 1. 先试图匹配 移动构造 matrix(matrix&&)
    // 2. 没有移动构造时,试图匹配 拷贝构造 matrix(const matrix&)  这个不需要人工干预,使用 std::move 对移动行为没有帮助,反而会影响返回值优化。
    
    // 编译器的返回值优化(NRVO),直接在调用者栈上进行返回结果构造,而不进行传递。
    auto r = getMatrix() 
    

    返回值是可以自我描述的,而 & 参数既可能是输入输出,也可能仅是输出,且容易被误用。

    C++对返回对象做了大量优化,在函数里直接返回对象可得到更可读、可组合的代码。

posted @ 2023-02-03 09:21  浩天之家  阅读(239)  评论(0编辑  收藏  举报