读effective_modern_cpp有感

1. c++知识点补充

1.1. 杂项

  1. c++11 lambda表达式不支持移动捕获和初始化捕获,c++14起开始支持,c++11可以使用std::bind实现近似功能
  2. string内存存储在堆上,但是libstdc++实现了对于字节长度小于十五的短字符串的优化(sso),string所存储的字符串被构造在栈内存中,不用重新从堆内申请内存,这样虽然string所提供的移动操作为常数时间,拷贝操作为线性时间,但对于短字符串来说移动所需时间不一定比拷贝短
  3. 当一个异常发生时,程序需要沿着运行时栈向上查找合适的异常处理器(catch block)。为了做到这一点,运行时栈必须处于可展开状态,即每个栈帧都有相应的信息来指示如何恢复函数的执行环境,并释放其中的资源。这些信息通常是由编译器在生成代码时添加的,称为展开信息(unwind information) 。当抛出异常并且控制从 try 块传递到处理程序时,C++ 运行时会调用自 try 块开始以来构造的所有自动对象的析构函数。这个过程称为堆栈展开(stack unwind)。
  4. 函数std::terminate()调用作为set_terminate()参数指定的函数, 程序会直接退出
  5. 在成员初始化器列表中可以聚合初始化聚合体,用花括号代替圆括号
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
      class X
    {
    int a, b, i, j;
    public:
    const int& r;
    X(int i)
    : r(a) // 初始化 X::r 以指代 X::a
    , b{i} // 初始化 X::b 为形参 i 的值
    , i(i) // 初始化 X::i 为形参 i 的值
    , j(this->i) // 初始化 X::j 为 X::i 的值
    {}
    };

1.2. c++异常处理

1
2
3
4
5
6
D(char) try: D(55){
printf("D::D(char). Throws.\n");
throw 0;
}catch(...){
printf("\n");
}
  1. 所有异常类都可继承std::exception,并重载其what方法,注意what方法应该是不抛出异常的,即应做异常规格说明throw(),通常在函数签名尾部加入;
  2. throw(A,B,C,D)声明函数可能抛出A,B,C,D中的任意一种异常
  3. try-catch中try块可以抛出异常并被catch块捕捉到,catch后的小括号内可以指定你要处理的异常类型Myexception &e,并在块中使用其what方法输出异常信息,也可以直接用一个变量缩写名代替特定异常

1.3. emplace_back置入函数 vs push_back插入函数

1.3.1. 不同点

  • emplace_back使用完美转发,可以使用传入的实参通过容器元素的某一构造函数来直接在容器内进行构造; push_back只接受对象去插入,这里可能涉及一些隐式转换所导致的临时对象的构造与析构所带来的开销,emplace_back可以避免这一点
  • 若接受某类型实参的构造函数是explicit的,则push_back会拒绝这一构造,因为内部其实动用了复制初始化,而explicit_back会动用直接初始化,调用声明explicit的构造函数时也可以通过编译
  • emplace_back在已经存在元素的地方进行置入时,比如某一vector的begin()处,此时会使用移动赋值函数,而这意味着临时对象的构造与析构,所以通过赋值操作增加元素时,emplace_back的优势会消失殆尽
  • 若容器拒绝重复项作为新值,若std:set,则emplace_back通常会创建一个具有新值的节点,以便可以将该节点的值与现有容器中节点的值进行比较。如果要添加的值不在容器中,则链接该节点。然后,如果值已经存在,置入操作取消,创建的节点被销毁,意味着构造和析构时的开销被浪费了。这样的节点更多的是为置入函数而创建,相比起为插入函数来说。

1.3.2. 相同点

  • emplace_back和push_back在对于实参为同类型对象时都会选择拷贝/移动构造函数

1.4. 智能指针

  1. unique_ptr move only
  2. make函数也实现了完美转发,所以参数为构造函数的参数
  3. 对于std::unique_ptr,只有这两种情景(自定义删除器和花括号初始化)使用make函数有点问题,使用make函数1是可以原子化操作创建指针,2是可以一次性创建控制块和对象,减少代码生成,提高执行代码的速度
  4. weak_ptr shared_ptr之间可以相互创建,先创建shared_ptr保证有引用计数,之后赋值给weak_ptr,可以储存到容器中充当缓存来按索引检查是否过期,之后如果用户需要返回cache
  5. unique只支持移动操作,移动构造表现为源指针置空,移动赋值表现为将被赋值指针销毁
  6. pImpl

用来减少类的实现和类使用者之间的编译依赖的一种方法,将原本类中含有的对象剥离出去,只留下它的指针,如果实现文件中修改成员或函数定义,而类的使用方法没有什么变化, 那么在编译源文件时只需要重新编译实现文件就可以

1
2
3
4
5
6
7
8
9
10
class Widget {                  //跟之前一样,在“widget.h”中
public:
Widget();
~Widget(); //只有声明语句


private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};
  • unique_ptr 和 shared_ptr 的一大区别在于,unique_ptr对象本身has_a 默认或自定义删除器, 而shared_ptr内部只包含一个引用计数指针,指向
    control_block, 其内包含引用计数,weak_count, 删除器对象等等(这在unique_ptr, shared_ptr两者不同构造函数上可以看出),所以shared_ptr在生成速度和数据结构体积上远远大于unique_ptr
  • 默认删除器是一个函数对象,它使用delete来销毁内置于std::unique_ptr的原始指针. unique_ptr的析构函数会调用删除器的operator(), 而该方法使用C++11的特性static_assert(sizeof(资源类型))在编译阶段就确保原始指针指向的类型不是一个不完整类型, 根据上面widget例子来说,若Impl的定义在别的翻译单元,而析构函数在Widget.h文件定义或使用编译器自动生成的版本,则编译器会因为看不到类的定义而报错

诸如noexcept,sizeof, decltype, typeid(不用于指针指向的多态对象), 统称为unevaluated expression

  • 为了避免这种情况发生,在类定义时只声明默认构造和析构函数,具体实现放在实现文件中原始指针指向对象的类型定义之后实现, 移动赋值同理, 移动构造时,编译器自动生成的代码里,包含有抛出异常的事件,在这个事件里会生成销毁pImpl的代码。
  • shared_ptr则没有这种要求,它只要求构造时必须看到完整定义(在clang和gcc编译器上实验,尽管在头文件中就定义了空的默认构造函数, 编译器仍然可以正常编译,代码如下), 析构函数可以由编译器正常产出, 析构时shared_ptr使用指向control block的指针调用删除器的delete方法, 过程类似于虚函数调用,在运行期才能知道是否调用成功, 在代码中具体表现为shared_ptr构造基类子对象**__shared_ptr时会使用static_assert来判断sizeof(资源类型), ~~析构时也有一个get_del方法,该方法返回删除器的指针,但是没有static_assert, 只有static_cast一个void to 删除器的指针Del~~,不清楚是不是这样解释, 源码看的一知半解, 等之后更精进了再来解决吧
1
2
3
4
5
6
7
8
9
10
11
D::D() [base object constructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call std::shared_ptr<D::Impl>::shared_ptr() [complete object constructor]
nop
leave
ret
- 所以在使用unique_ptr来实现pImpl的近似代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "widget.h"                 //跟之前一样,在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl { //跟之前一样,定义Widget::Impl
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
}

Widget::Widget() //跟之前一样
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() //析构函数的定义(译者注:这里高亮)
{}
//Widget::~Widget() = default
  • 而对std::shared_ptr而言,删除器的类型不是该智能指针的一部分,这让它会生成更大的运行时数据结构和稍微慢点的代码,但是当编译器生成的特殊成员函数被使用的时候,指向的对象不必是一个完整类型。

关于shared_ptr和unique_ptr的具体实现,请参见这篇博客

1.4.1. make_shared & make_unique

根本上讲,像std::shared_ptr和std::unique_ptr这样的资源管理类的高效性是以资源(比如从new来的原始指针)被立即传递给资源管理对象的构造函数为条件的。实际上,std::make_shared和std::make_unique这样的函数自动做了这些事,是使它们如此重要的原因。若不使用make_shared或make_unique,我们应确保在资源的获取(比如使用new)和资源管理对象的创建之间没有其他操作。

比如在 std::list<std::shared_ptr<Widget>> ptrs中分别使用push_back和emplace_back插入带自定义删除器的shared_ptr时,使用push_back插入临时对象的方法可以保证异常安全,而emplace_back由于new之后没有及时创建资源管理对象,而是先在list分配节点内存,导致内存泄漏(比如list分配内存过程中内存溢出异常被抛出, 随着异常的传播,push_back可以销毁临时对象从而达成对堆内存的回收,而emplace_back此时只有一个new出来的原始指针指向堆内存,但却因为异常丢失,导致内存泄漏)

节点: 大多数标准库容器都是基于节点的。例外的容器只有std::vector,std::deque,std::string。(std::array也不是基于节点的,但是它不支持置入和插入,所以它与这儿无关。)在不是基于节点的容器中,你可以依靠emplace_back来使用构造向容器添加元素,对于std::deque,emplace_front也是一样的。

所以将智能指针置入/插入list正确且美观的方法是,先在list外构建一个智能指针,之后将其转为右值移动构造置入/插入进list

1
2
3
4
std::shared_ptr<Widget> spw(new Widget,      //创建Widget,让spw管理它
killWidget);
ptrs.push_back(std::move(spw)); //添加spw右值
//ptrs.emplace_back(std::move(spw));

1.5. 特殊成员函数的生成

  1. 拷贝,或者移动操作,若有一方是用户定义的,则不会生成另一方,并且对于两个移动操作来说,显式定义一方也会导致另一方不会被自动生成
  2. 所以仅当下面条件成立时才会生成移动操作(当需要时):
    • 类中没有拷贝操作
    • 类中没有移动操作
    • 类中没有用户定义的析构,但如果出现定义析构,编译器也会自动生成拷贝操作
  3. 注意没有“成员函数模版阻止编译器生成特殊成员函数”的规则

1.6. volatile vs atomic

1.6.1. std::atomic

  1. 原子化读写操作,多线程访问时不会产生数据竞争,而且也可以用来当作条件变量,单纯使用时反应函数需要不断轮询,会增加线程开销,线程阻塞而不挂起,内存中的资源一直被占用
  2. 会限制编译器和底层硬件对于代码执行顺序的重排,维护atomic的顺序一致性模型
  3. 无法进行拷贝操作,但是可以进行load和store操作,但是若出现冗余访问或者无用存储, 编译器也会进行优化

1.6.2. keyword volatile

避免编译器对于代码的优化,在变量用来存储做内存IO映射内存上报的数据时,非常有用

  1. std::atomic用在并发编程中,对访问特殊内存没用。
  2. volatile用于访问特殊内存,对并发编程没用。
    因为std::atomic和volatile用于不同的目的,所以可以结合起来使用:
    volatile std::atomic<int> vai; //对vai的操作是原子性的,且不能被优化掉
    如果vai变量关联了内存映射I/O的位置,被多个线程并发访问,这会很有用。

1.7. move操作

  1. std::move只是强制将左值/右值统统转换为右值
    1
    2
    3
    4
    5
    template<typename T>
    std::remove_reference<T>::type && move(T&& param){
    using Returntype = std::remove_reference<T>::type&&;
    return static_cast<Returntype>(param);
    }
  2. 虽然string提供了常数时间的移动操作和线性时间的复制操作由于string的小字符串优化(SSO,指的是当字符串的长度小于某数时,编译器会将其存入缓冲区中,而不是在堆上分配内存,这个长度每个编译器的实现略有区别),所以此时对字符串的移动操作不一定比复制操作时间快很多

1.9. lock_guard vs unique_lock vs shard_lock

在C++中,std::lock_guard、std::unique_lock和std::shared_lock是用于多线程编程的关键同步原语。std::lock_guard提供轻量级的互斥锁,适用于简单短暂的锁定;std::unique_lock功能更丰富,支持手动解锁、重新锁定和所有权转移;std::shared_lock用于实现共享所有权,允许多个线程同时访问共享资源。选择适当的锁定机制对于有效管理并发在C++中至关重要。


读effective_modern_cpp有感
http://example.com/2023/11/06/effective-modern-cpp/
作者
李凯华
发布于
2023年11月6日
许可协议