标签: C++

  • 论抽象还得是C++

    最近刷leetcode挺多的,在学算法的同时,还通过其他人的代码学了不少C++新标准的东西。
    今天看到Split和Join,轮抽象还得是C++啊。
    实现Split是很多语言都有的基础功能,C++迟迟没有推出,是因为有杠精觉得为什么一定要用std::vector呢?不能用std::list吗?
    然后这次标准推出了一个新的抽象对象,View。

    类似于python迭代器的一种抽象,包含了一个Ranges的一些信息,但是又没实例化。

    论抽象还得是C++

  • 了解一下boost的fiber

    fiber是纤程,用户级纤程,在用户层提供调度管理器,可以在系统线程上切换纤程,它主要的好处:

    • 切换非常快,按照boost的数据,快100倍[1];
    • 纤程访问父线程的数据不需要加锁,因为不具备竞争;
    • 基于完全同步的写法来实现的fiber阻塞和切换;

    引用

    1. https://www.boost.org/doc/libs/1_83_0/libs/fiber/doc/html/fiber/performance.html
    2. https://blog.csdn.net/hezhanran/article/details/110792367
    3. https://agraphicsguynotes.com/posts/fiber_in_cpp_understanding_the_basics/
  • C++ 继承和虚拟继承的内存分布

    菱形继承

    菱形继承:菱形继承的问题在于数据冗余和二义性。

    将子类转化成爷爷类的时候会报错,因为不知道要转化到哪个类上去,这里需要加入static_cast才行。爷爷类会在两个父类中都被定义,解决的办法是:虚拟继承。

    虚拟继承让被菱形继承的父类只会存在一份,消除数据冗余,那这里就得问一下,内存是如何布局的呢?如何访问内存呢?

    虚拟继承跟普通的继承不一样,普通的继承是通过将父类的内存放在子类的前面来实现,但虚拟继承父类的内存是放在后面的,那菱形继承会让父类和爷爷类的偏移不一致,那如何寻找爷爷类呢?

    虚拟继承依然会前置一个值,只是不再是前置父类而是前置一个虚基表指针,指向的是内存中,父类相对于自己的偏移量。

    虚继承支持基类的常规转换,主要是解决了多个派生类对基类的拷贝问题,并没有解决多重继承的二义性问题。

    然后关于vs和g++的实现是不完全一样的,g++的实现是虚函数和虚基类地址偏移共享一个虚表,类的实例开始处即为所属类的虚指针。因为存在虚基类地址偏移,所以几乎每个类都会有一张独一无二的虚表。

    虽然有一些大概的了解了,但是能不用就别用,这个机制很慢,内存很大,并且很违反直觉。

    继承的内存分布

    然后在实际实验的时候一直有一个不太理解的地方,就是虚函数表似乎总是对不上:

    class A
    {
    public:
        virtual void Test(){return;}
        int _a;
    };
    
    class B
    {
    public:
        virtual int Test2(){return 1;}
        int _b;
    };
    
    class C : public A, public B
    {
    public:
        virtual int Test2(){return 2;}
        virtual void Test(){return;}
        int _c;
    };
    
    int main()
    {
        C *c = new C();
        c->_a = 1;
        c->_b = 2;
        c->_c = 3;
    
        return 0;
    }
    

    然后我打开gdb去分析,去看具体的内存布局:

    (gdb) start
    Temporary breakpoint 1 at 0x400666: file test.cpp, line 25.
    Starting program: /tmp/test
    Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.x86_64
    n
    Temporary breakpoint 1, main () at test.cpp:25
    25          C *c = new C();
    Missing separate debuginfos, use: debuginfo-install libgcc-4.8.5-44.el7.x86_64 libstdc++-4.8.5-44.el7.x86_64
    (gdb) n
    26          c->_a = 1;
    (gdb)
    27          c->_b = 2;
    (gdb)
    28          c->_c = 3;
    (gdb)
    30          return 0;
    (gdb) x /16wa c
    0x602010:       0x400830 <_ZTV1C+16>    0x0     0x1     0x0
    0x602020:       0x400850 <_ZTV1C+48>    0x0     0x2     0x3
    (gdb) x /8wa 0x400830
    0x400830 <_ZTV1C+16>:   0x400702 <C::Test()>    0x0     0x4006ec <C::Test2()>   0x0
    0x400840 <_ZTV1C+32>:   0xfffffffffffffff0      0xffffffffffffffff      0x4008a0 <_ZTI1C>       0x0
    (gdb) x /8wa 0x400850
    0x400850 <_ZTV1C+48>:   0x4006fb <_ZThn16_N1C5Test2Ev>  0x0     0x0     0x0
    0x400860 <_ZTV1B>:      0x0     0x0     0x4008e0 <_ZTI1B>       0x0

    其实很好理解的,0x400830是A类的虚函数指针指向虚函数表,虚函数表第一项是A的Test,第二项是B的Test2,那如果转化成A类也能按照同样的规则获取到Test这个虚函数。

    那0x400850是什么呢?它所指向的0x4006fb <_ZThn16_N1C5Test2Ev>是什么意思呢?真的琢磨了很久,然后想起来gdb 的x指令里/i是可以输出汇编指令。

    再进一步查看这个地址的汇编:

    (gdb) x /2wi 0x4006fb
       0x4006fb <_ZThn16_N1C5Test2Ev>:      sub    $0x10,%rdi
       0x4006ff <_ZThn16_N1C5Test2Ev+4>:    jmp    0x4006ec <C::Test2()>

    忽然就理解了,0x400850地址依然是一个虚函数表,第一项仍然是虚函数,并且就是C::Test2,那为什么不是直接指向C::Test2呢?

    因为C::Test2()是C这个类的函数,它的this应该要指向一个C的类,但是使用0x400850虚表的对象偏移是不对的,所以这个函数在实际执行C::Test2()之前要将this向前挪16个字节。

    至此,我觉得我比较深刻的理解了C++的虚函数机制。

    菱形继承的内存分布

    之前没完全看懂的,现在我觉得又可以了,然后再回头看一次。

    class A
    {
    public:
        virtual int Test(){return 1;}
        int _a;
    };
    
    class B: virtual public A
    {
    public:
        virtual int Test(){return 2;}
        int _b;
    };
    
    class C : virtual public A
    {
    public:
        virtual int Test(){return 3;}
        int _c;
    };
    
    class D : public B, public C
    {
    public:
        virtual int Test(){return 4;}
        int _d;
    };
    
    int main()
    {
        D *d = new D();
        d->_a = 1;
        d->_b = 2;
        d->_c = 3;
        d->_d = 4;
    
        return 0;
    }

    这是一个经典的菱形继承,再来看一下内存分布就比较清晰了:

    (gdb) start
    Temporary breakpoint 1 at 0x400666: file test.cpp, line 31.
    Starting program: /tmp/test
    Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.x86_64
    
    Temporary breakpoint 1, main () at test.cpp:31
    31          D *d = new D();
    Missing separate debuginfos, use: debuginfo-install libgcc-4.8.5-44.el7.x86_64 libstdc++-4.8.5-44.el7.x86_64
    (gdb) n
    32          d->_a = 1;
    (gdb)
    33          d->_b = 2;
    (gdb)
    34          d->_c = 3;
    (gdb)
    35          d->_d = 4;
    (gdb)
    37          return 0;
    (gdb) p *d
    $1 = {<B> = {<A> = {_vptr.A = 0x400958 <vtable for D+88>, _a = 1}, _vptr.B = 0x400918 <vtable for D+24>, _b = 2}, <C> = {_vptr.C = 0x400938 <vtable for D+56>, _c = 3}, _d = 4}
    (gdb) print sizeof(D)
    $2 = 48
    (gdb) x /16wa d
    0x603010:       0x400918 <_ZTV1D+24>    0x0     0x2     0x0
    0x603020:       0x400938 <_ZTV1D+56>    0x0     0x3     0x4
    0x603030:       0x400958 <_ZTV1D+88>    0x0     0x1     0x0
    (gdb) x /16wa 0x400918
    0x400918 <_ZTV1D+24>:   0x400722 <D::Test()>    0x0     0x10    0x0
    (gdb) x /16wa 0x400938
    0x400938 <_ZTV1D+56>:   0x400731 <_ZThn16_N1D4TestEv>   0x0     0xffffffffffffffe0      0xffffffffffffffff
    (gdb) x /16wi 0x400731
       0x400731 <_ZThn16_N1D4TestEv>:       sub    $0x10,%rdi
       0x400735 <_ZThn16_N1D4TestEv+4>:     jmp    0x400722 <D::Test()>
    (gdb) x /16wa 0x400958
    0x400958 <_ZTV1D+88>:   0x400737 <_ZTv0_n24_N1D4TestEv> 0x0     0x400918 <_ZTV1D+24>    0x0
    (gdb) x /16wi 0x400737
       0x400737 <_ZTv0_n24_N1D4TestEv>:     mov    (%rdi),%r10
       0x40073a <_ZTv0_n24_N1D4TestEv+3>:   add    -0x18(%r10),%rdi
       0x40073e <_ZTv0_n24_N1D4TestEv+7>:   jmp    0x400722 <D::Test()>

    file

    如前文所述,g++的实现是将虚函数表和虚基地址偏移量一起在虚表里,通过虚指针表明,虚基地址偏移量在虚表的最后一项。

    详细内容可以看一下下面引用的百科。

    参考

    https://www.sandordargo.com/blog/2020/12/23/virtual-inheritance
    https://zh.wikipedia.org/zh-sg/%E8%99%9A%E7%BB%A7%E6%89%BF

  • 现代C++教程 读书笔记

    习题答案:https://github.com/changkun/modern-cpp-tutorial/tree/master/exercises

    序言

    本文传统C++ 是指C++ 98及之前的标准。

    C++ 14/17是对C++ 11的重要补充和优化;而C++ 20则将这门语言领进了现代化的大门。

    关于一些特性的初探:

    • auto关键字语义给操纵极为复杂的模板类型提供了底层支持;
    • lambda表达式基于C++匿名函数的闭包特性;
    • 右值引用的出现解决了C++长期被人诟病的临时对象效率问题;

    第一章迈向现代C++

    被弃用的特性

    弃用并非不能用,只是暗示这些特性将从未来的标准消失。

    • 不允许使用字符串面值常量赋值给char *,如果使用应该是用const char**或者auto。
    • C++98异常说明、unexpected_handler, set_unexpected()等特性被弃用,应该使用noexcept。
    • auto_ptr被弃用,用使用unique_ptr。
    • register关键词被弃用。
    • bool类型的++操作被弃用
    • 如果一个类有析构函数,为其生成拷贝构造函数和拷贝复制运算符的特性被弃用了。
    • C风格的类型转换被弃用了(即在变量前是用(convert_type)),应该使用static_cast、reinterpret_cast、const_cast来进行类型转换。
    • ……等等

    与C的兼容

    C++不是C的一个超集。

    用extern "C"特性时,将C语言代码和C++语言进行分离编译,再统一链接。

    第二章语言可用性的强化

    变量/常量/流程

    nullptr

    nullptr的是替代NULL,C++不允许将void *隐式转换到其他类型。

    void foo(char *);
    void foo(int);
    
    //直接调用将会调用void foo(int);;这违反常理
    foo(NULL);

    所有的空指针一律使用nullptr,而不要用NULL。

    constexpr

    修饰变量,const并未区分编译器常量和运行期常量,constexpr限定编译器常量。

    修饰函数,constexpr修饰的函数返回值不一定是编译期常量,这个有点像inline。

    只读的语义用const,常量的语义用constexpr。

    if/switch变量声明强化

    C++中可以将变量声名放在if和switch中,作用于就从函数下降到本身的作用域中。

        if (int a = f(); a != 1) {
            // 代码块A
            cout << a << endl;
        } else if (int b = g(); b != 2) {
            // 代码块B
            a += b;
            cout << a << endl;
        } else {
            // 代码块C
            a -= b;
            cout << a << endl;
        }

    初始化列表

    用initializer_list的构造函数被称为初始化列表构造函数,具有这种构造函数的类型将在初始化时被特殊处理。

    结构化绑定

    std::tuple<int, double, std::string> f(){
        return std::make_tuple(1, 2.3, "456");
    }
    
    int main(void){
        auto [x, y, z] = f();
        std::cout << x << ", " << y << ", " << z << std::endl;
        return 0;
    }

    auto,decltype

    用于类型推导。

    auto:

    • 当类型不为引用时,auto 的推导结果将不保留表达式的 const 属性;
    • 当类型为引用时,auto 的推导结果将保留表达式的 const 属性;
    • auto 关键字不能定义数组。

    还有一个尾返回类型(C++11),利用auto关键字将返回类型后置,但是在C++14中,可以让普通函数具备返回值推导:

    // C++ 11
    template<typename T, typename U>
    auto add(T x, U y) -> decltype(x+y){
        return x + y;
    }
    
    // C++ 14
    template<typename T, typename U>
    auto add2(T x, U y){
        return x + y;
    }

    decltype(auto)

    C++ 14开始提供的用法,和直接使用auto相比最大的区别在于这个能够自动识别是否是引用&,auto只能识别到数据类型,demo:

    std::string lookup1();
    std::string& lookup2();
    
    // C++ 11的封装形式如下:
    std::string look_up_string_1(){ return lookup1(); }
    std::string& look_up_string_2(){ return lookup2(); }
    
    // C++ 14可以如下:
    decltype(auto) look_up_string_1(){ return lookup1(); }
    decltype(auto) look_up_string_2(){ return lookup2(); }

    if constexpr

    C++ 17将constexpr关键字引入if判断,在编译期完成判断。

    template<typename T>
    auto print_type_info(const T& t){
        if constexpr(std::istegral<T>::value){
            return t + 1;
        } else {
            return t + 0.001;
        }
    }
    int main() {
        std::cout << print_type_info(5) << std::endl;
        std::cout << print_type_info(3.14) << std::endl;
    }

    会被编译成这样:

    int print_type_info(const int& t){
        return t + 1;
    }
    double print_type_info(const double& t){
        return t + 0.001;
    }
    //...

    其实这个语法糖…我不太能明白用处,写成两个函数不行吗,可能因为我对模板编程接触实在少。

    区间for迭代

    像python的for循环,要注意的主要是使用auto想修改的话,需要使用auto &。

    std::vector<obj_class> vec;
    //...
    for(auto &v: vec){
      // do sth
    }

    对象/模板

    外部模板

    传统C++中,模板只有使用时才会被实例化。并且每个编译单元(文件)都会被实例化,这增加了时间,C++11引入了外部模板:

    template class std::vector<bool>;                   // 强制实例化
    extern template class std::vector<double>;          // 不在当前编译文件中实例化模板

    类型别名模板

    模板和类型是不同的,模板是用来生产类型的。

    typedef可以为类型定义一个新的名称,但没法为模板定义一个新名称,因为木板不是类型,C++ 11引入了using解决这个问题。

    所以typedef给类型定义别名,using除了typedef能做的,还能够给模板定义别名。

    总结一下using的三个用途:

    • 引入命名空间
    • 指定别名(对比typedef 有两个好处,第一更加清晰,第二可以对模板)
    • 在之类中引用基类成员

    默认模板参数

    template<typename T = int, typename U = int>
    auto add(T x, U y){
        return x + y;
    }

    变长参数模板

    这个好像篡改了语法,所有的关键词后面加上…就变成了这个相关的功能,感觉还挺复杂的,列一下demo:

    template<typename... Ts> class Magic;      // 其中Ts就是变长模板的“类型名”,可以接受0~n个
    template<typename... Args> void printf(const std::string &str, Args... args);  // 有类型安全的printf
    template<typename... Ts>
    void magic(Ts... args){
        std::cout << sizeof...(args) << std::endl;
    }
    // 参数解包,1. 递归模板函数
    template<typename T0>
    void printf1(T0 value){
        std::cout << value << std::endl;
    }
    template<typename T, typename... Ts>
    void printf1(T value, Ts... args){
        std::cout << value << std::endl;
        printf1(args...);
    }
    // 参数解包,2. 变参模板展开
    template<typename T0, typename... T>
    void printf2(T0 t0, T... t){
        std::cout << t0 << std::endl;
        if constexpr(sizeof...(t) > 0) printf2(t...);
    }
    // 参数解包,3. 初始化列表展开
    template<typename T, typename... Ts>
    auto printf3(T value, Ts... args){
        std::cout << value << std::endl;
        (void) std::initializer_list<T>{([&args]{
            std::cout << args << std::endl;
        }(), value)...};
    }

    折叠表达式

    template<typename... T>
    auto sum(T... t){
        return (t + ...);
    }

    非类型模板参数推导

    template <auto value>
    void foo() {
        std::cout << value << std::endl;
    }
    
    int main() {
        foo<10>();     // value被推导为int类型
    }

    委托构造

    构造函数可以在同一个类的一个构造函数中调用另一个构造函数。

    class Base{
    public:
      int value1;
      int value2;
      Base() {
        value1 = 1;
      }
      Base(int value): Base() {
        value2 = value;
      }
    };

    使用委托构造函数,就不能再使用初始化列表构造其他成员了。

    继承构造

    class Base{
    public:
        Base();
        Base(int);
    }:
    
    class SubClass: public Base{
        using Base::Base;    // 继承构造
    };

    假设一旦使用了继承构造函数,编译器就不会为派生类生成默认构造函数。这样,我们得注意继承构造函数无參版本号是不是有须要。

    显示虚函数重载

    override显示告诉编译器这个函数是重载虚函数。

    final是显示告诉编译器,防止被继承这个函数。

    显示禁用默认函数

    传统C++中,如果程序员没有提供,编译器会为对象生成默认构造、复制构造、赋值算符以及析构函数,同时也为所有类定义了注入new delete这些运算符。

    C++ 11的解决方案:

    class Magic {
    public:
        Magic() = default;                        // 显式使用编译器生成的构造
        Magic& operator=(const Magic&) = delete;  // 显示声明拒绝编译器生成构造
        Magic(int magic_number);
    };

    强类型枚举

    C++ 11引入了枚举类,使用enum class的语法进行声名:

    enum class new_enum: unsigned int {
        value1,
        value2,
        value3 = 100,
        value4 = 100
    };

    这样的枚举不能被隐式转化为整数,不能与整数进行比较,不能与不同枚举类型的值进行比较。

    也解决了传统C++中,同一个命名空间的不同枚举类型的枚举值名字不能相同的问题。

    第三章 语言运行期的强化

    lambda表达式

    值拷贝:被捕获的变量在lambda表达式创建时拷贝,而非调用时才拷贝;

    1. 引用拷贝:和正常引用无异;
    2. 隐私捕获:直接填[=]或者[&]让编译器自行推导;
    3. 表达式捕获:有些变量不允许复制和引用,C++提供了表达式捕获可以进行任意的初始化,让我们可以把这些变量变成右值。

    其中当lambda表达式的捕获列表为空时,闭包对象还能够转换为函数指针值进行传递。

    using foo = void(int);
    void functional(foo f){ f(1); }
    int main() {
        auto f = [](int value) { std::cout << value << std::endl; };
        functional(f);
    }

    泛型lambda

    C++ 14支持lambda使用auto作为类型,提供泛型lambda:

    auto add = [](auto x, auto y){
        return x + y;
    }
    add(1, 2);
    add(1.1, 2.2);

    std::function

    函数的容器,对可调用实体的一种类型安全的包裹(函数指针的调用不是类型安全的)。

    std::bind和std::placeholder

    • 其中std::placeholder::_1表示bind对象调用时的第一个参数;
    • std::bind支持嵌套绑定,如下demo
    int foo(int a, int b, int c) { return a + b + c; }
    int g(int n) { return n + 2; }
    
    int main(){
        auto bindFoo = std::bind(foo, std::placeholders::_1, 1, std::bind(g, 3));
        bindFoo(3);
    }

    右值引用

    关于左值、右值、将亡值的概念我大概清楚了,但是书中这个例子我没理解清楚:

    #include <iostream>
    
    using namespace std;
    
    class ClassA{
    public:
        int *pointer;
        ClassA(): pointer(new int(1)){
            cout << " 构造" << pointer << endl;
        }
        ClassA(ClassA& a): pointer(new int(*a.pointer)){
            cout << " 拷贝构造" << pointer << endl;
        }
        ClassA(ClassA&& a): pointer(a.pointer) {
            a.pointer = nullptr;
            cout << " 移动" << pointer << endl;
        }
        ~ClassA(){
            cout << " 析构" << pointer << endl;
            delete pointer;
        }
    };
    
    ClassA return_rvalue(bool test){
        ClassA a, b;
        if(test) return a;
        else return b;
    }
    
    int main(void){
        ClassA obj = return_rvalue(false);
        cout << "obj: " << endl;
        cout << obj.pointer << endl;
        cout << *obj.pointer << endl;
    }
    
    /* 打印结果如下:
     构造0x555b80d39e70
     构造0x555b80d3a2a0
     移动0x555b80d3a2a0
     析构0
     析构0x555b80d39e70
    obj:
    0x555b80d3a2a0
    1
     析构0x555b80d3a2a0
    */

    这里我觉得移动构造应该会执行两次才对,return_rvalue从b对象到将亡值一次复制,从将亡值到main函数中的obj一次复制,实在不解为什么只执行了一次,就深入看了一下汇编,便有了本笔记的最后一章。

    我不放汇编代码,直接给出看汇编的结论:

    main函数在调用return_rvalue函数时,除了传递参数false,还传递了一个8字节的内存地址进去,return_rvalue在返回时并不是创建了一个新的对象返回给上一层,而是直接把b移动给了上一层传进来的对象里。

    换句话说,obj内存开辟是在main函数里做的,但拷贝构造函数发生在return_rvalue返回之前。

    在这里我还做了几个测试,在C中,当返回的对象内存大于4字节时,返回值是通过调用者提供的内存返回的。C++是的规则好像特殊一点,跟引用和右值有关系。

    右值引用

    T&& a是一个右值引用,它引用的对象是一个右值,但是它本身是一个左值,可以继续引用其他对象。

    移动语义:std::move

    会将参数的内容“移动”到左边,参数的对象会被清空。

    完美转发

    引用坍缩规则:在传统C++中,不能对一个引用类型继续进行引用,但右值引用的出现放宽了这一做法,从而引起坍缩规则。无论模板参数是什么类型的引用,当且仅当实参类型为右引用的时候,模板参数才能被推到位右引用类型。

    第四章 容器

    std:array

    • 相比vector,array大小是固定的,空间消耗可控;
    • 相比传统数组,array封装的更加安全。

    和C风格的接口兼容时:

    void foo(int *p, int len){ return ; }
    std::array<int, 4> arr = {1, 2, 3, 4};
    
    // foo(arr, arr.size());  // 非法,无法隐式转换
    foo(&arr[0], arr.size());
    foo(arr.data(), arr.size());

    std:forward_list

    std::list是双向链表,这个是单向链表,不展开

    std::unordered_map, std::unorder_set, std::unordered_multimap, …

    std::map和std::set都是用红黑树实现的;

    这里unordered_xx都是用hash表实现的;

    元组

    元组的三个核心函数:

    1. std::make_tuple: 构造元组
    2. std::get: 获得元祖某个位置的值
    3. std::tie: 元祖拆包
    auto student = std::make_tuple(1.7, 'D', "张三");
    auto lv = std::get<2>(student);
    auto score = std::get<1>(student);
    double gpa;
    char grade;
    std::string name;
    std::tie(gpa, grade, name) = student;

    运行期索引

    上面的例子中,std::get 其中index必须是常量,不能是变量。

    C++ 17 引入了variant<>,但用起来还挺复杂的,用到再深究吧。

    元组的合并与遍历

    std::tuple_cat(student1, student2);可以合并两个元组

    遍历需要用到上面variant和元组模板的::value属性。

    书里没写,我在这多插嘴一句,元组的实现在底层其实是多个类的继承,所以每个元组类型都是通过变长参数模板+继承来做的,因为继承内存其实非常紧凑,而每个元组本质上是一个对象,每个元组的类的长度是固定的,所以有一个value的静态变量来获取长度。

    从这里向下的内容严格来说,只算是库的内容,不算是语言层面的内容了

    从这里向下的内容严格来说,只算是库的内容,不算是语言层面的内容了

    第五章 智能指针与内存管理

    RAII和引用计数

    RAII是指在析构函数中释放资源,就不会忘记释放资源了。

    引用计数是指,记录对象被引用的次数。

    这俩是所有智能指针实现的低层级制。

    std::shared_ptr, std::make_shared

    引用计数变为0时,对象会被delete。

    std::unique_ptr

    禁止与其他的智能指针共享一个对象,从而保证代码的安全。

    不可复制,但可以移动。

    std::weak_ptr

    weak_ptr不会引起计数增加

    第六章 正则表达式

    std::regex

    之前一般的解决方案是使用boost的正则表达式库。

    C++ 11 将正则表达式引入了标准库的支持。

    第七章 并行与并发

    线程基础

    std::thread 用于创建一个执行的线程实例。

    get_id() 可以获得所创建线程的线程ID,使用join() 来等待一个线程结束。

    #include <iostream>
    #include <thread>
    
    int main() {
        std::thread t([](){
            std::cout << "hello world." << std::endl;
        });
        t.join();
        return 0;
    }

    互斥量与临界区

    std::mutex是最基本的mutex类,实例化可以创建互斥量,然后通过lock()上锁,unlock()解锁。还有一个RAI语法的模板类std::lock_guard和std::unique_lock。

    std::unique_lock更自由,可以主动lock和unlock。

    std::future

    跟python里的future挺像的,通过回调的形式来给予异步线程的返回值。

    条件变量

    std::condition_variable是为了解决死锁而生,实例被创建主要就是用于唤醒等待线程。std::conditioin_variable的notify_one()用于唤醒一个线程;notify_all()用于唤醒所有线程。

    原子操作与内存模型

    a=1, b=3; 因为CPU可能乱序,可能导致另外一个线程看到b先为3;

    mutex可以解决这个问题,因为mutex是操作系统级别的功能;

    同时C++11还引入了std::automic模板,为浮点整数提供了基本的数据成员函数。

    一致性模型

    当多个线程对一个变量v操作时,每个线程都能感受到v的变化,但对于v而言,表现为顺序执行的程序,v并没有因为引入多线程而得到任何效率上的收益。如何适当加速呢?削弱原子操作在进程间的同步调节。

    • 线性一致性;
    • 顺序一致性;
    • 因果一致性;
    • 最终一致性;

    内存顺序

    为了追求极致的性能,实现各种强度要求的一致性,C++为原子操作定义了六种不同的内存顺序std::memory_order的选项,表达了四种多线程间的同步模型:

    1. 宽松模型:std::memory_order_relaxed
    2. 释放/消费模型:std::memory_order_consume, std::memory_order_release
    3. 释放/获取模型:std::memory_order_acquire, std::memory_order_acq_rel, std::memory_order_release
    4. 顺序一致模型:std::memroy_order_seq_cst

    第八章 其他杂项

    long long in

    C99就已经引入了,C++ 11中也引入了,至少是一个64位的比特数。

    noexcept的修饰和操作

    C++11将异常声明简化为以下两种情况,并用noexcept进行限制:

    1. 函数可能抛出异常;
    2. 函数不能抛出异常;

    noexcept还能做操作符,用于操作一个表达式,无异常时返回true,否则返回false。

    noexcept(may_throw())
    noexcept(no_throw())

    noexcept修饰完函数之后,外部不会捕获到异常。

    自定义字面量

    没想到使用场景,demo:

    std::string operator"" _wow1(const char *wow1, size_t len){
        return std::string(wow1) + "woooooow, amazing!";
    }
    
    std::string operator"" _wow2(unsigned long long i){
        return std::to_string(i) + "woooooooooooow, amazing!!";
    }
    
    int main(){
        auto str = "abc"_wow1;
        auto num = 1_wow2;
        std::cout << str << std::endl << num << std::endl;
    }

    内存对齐

    C++11引入了两个新的关键字alignof和alignas来支持内存对齐进行控制。

    alignof类似于sizeof,获取跟平台相关的std::size_t类型的值,用于查询该平台的对齐方式。

    alignas用于修饰某个结构的对齐方式。

    第九章 展望:C++20简介

    诸如Concept/Module/Coroutine/Ranges等特性的提案都蓄势待发。

    概念与约束

    int main() {
        std::list<int> l = {1, 2, 3};
        std::sort(l.begin(), l.end());
    }

    上面的例子会产生不可读的编译错误,因为std::sort对排序容器必须提供随机迭代器,但std::list是不支持随机访问的。引入概念之后,我们可以对模板进行约束:

    template<typename T>
    requires Sortable<T>  // Sortable 是一个概念
    void sort(T& c);

    还有模块、合约、范围、协程、事务内存等。

    其他部分作者还没写完,就到这吧。

    汇编学习

    这一部分不是书里的内容,是在学C++右值引用的时候对一些细节不理解,但没找到相关的资料,只能深入汇编去看实现细节,这里也记录一下相关的信息:

    ELF文件格式

    ELF文件主要有四种类型:

    1. 可重定位文件(Relocatable File),可以用来和其他目标文件链接,即xx.o文件。
    2. 可执行文件(Executable File),规定了exec() 如何创建程序的进程印象。
    3. 共享目标文件(Shared Object File),可以和其他的可重定位文件一起链接成另一个目标文件;链接器可以将它与某个可执行文件组合,创建成可执行文件,即xx.so。
    4. 内核转储(core dumps),存放当前进程执行上下文,用于dump信号触发。

    ELF提供两种视图,分别是链接视图和执行视图:

    链接视图:以节(section)为单位,在连接时用到的视图。

    执行视图:以段(segment)为单位,在执行时用到的视图。

    可以这样来理解,在链接时,会将文件的相同的节组成一个段,在执行时把对应的段载入内存即可。

    比较重要的一些节:

    系统固定的section

    1. .text 代码段
      可以通过objdump -d 反汇编查看ELF文件代码段的内容。
    2. .strtab/.shstrtab 字符串表
      所有使用到的字符串都放在这里,以’\0’分隔。
    3. .symtab 符号表
      在链接的过程中需要把多个不同的目标文件合并在一起,不同的目标文件相互之间会引用变量和函数。在链接过程中,我们将函数和变量统称为符号,函数名和变量名就是符号名。
      这里的每个符号对应一个地址。
    4. .eh_frame / .eh_frame_hdr
      在调试程序的时候经常需要进行堆栈回溯,早期使用通用寄存器(ebp)来保存每层函数调用的栈帧地址,但局限性很大。后来现代Linux操作系统在LSB(Linux Standard Base)标准中定义了一个.eh_frame section,用来描述如何去unwind the stack。
      GAS(GCC Assembler)汇编编译器定义了一组伪指令来协助eh_frame生成调用栈信息CFI(Call Frame Information)。
    5. 重定位表(.relname)
      链接器在处理目标文件时,需要对目标文件中的某些部位进行重定位,即代码段和数据中中那些绝对地址引用的位置。对于每个需要重定位的代码段或数据段,都会有一个相应的重定位表。比如”.rel.text”就是针对”.text”的重定位表,”.rel.data”就是针对”.data”的重定位表。

    GOT是全局偏移表( Global Offset Table),用于存储外部符号地址;PLT是程序链接表(Procedure Link Table),用于存储记录定位信息的额外代码。

    寄存器

    常用寄存器:

    EAX:一般用作累加器
    EBX:一般用作基址寄存器(Base)
    ECX:一般用来计数(Count)
    EDX:一般用来存放数据(Data)
    ESP:一般用作堆栈指针(Stack Pointer)
    EBP:一般用作基址指针(Base Pointer)
    ESI:一般用作源变址(Source Index)
    EDI:一般用作目标变址(Destinatin Index)

    其中esp是栈顶,但是低地址;ebp是栈底,是高地址。

    关于x86平台寄存器使用的一些约定:

    • 当该函数是处于调用者角色时,如果该函数执行过程中产生的临时数据会已存储在%eax,%edx,%ecx这些寄存器中,那么在其执行call指令之前会将这些寄存器的数据写入其栈帧内指定的内存区域,这个过程叫做调用者保存约定(英文原名称:Caller Save)。
    • 当该函数是处于被调用者角色时,那么在其使用这些寄存器%ebx,%esp,%edi之前,那么该函数会保存这些寄存器中的信息到其栈帧指定的内存区域,这个过程叫被调用者保存约定。
    • %eax总会被用作返回整数值。
    • %esp,%ebp总被分别用着指向当前栈帧的顶部和底部,主要用于在当前函数推出时,将他们还原为原始值

    X86_64平台的约定

    相比于寄存器而言,存储器的访问太慢了,因为X86_64有16个通用寄存器,当函数调用时少于6个参数是直接通过寄存器来存储参数的,多于6个还是依然通过入栈实现。

    所以需要注意两点:

    • 为了效率,函数参数尽量少于6个;
    • 寄存器只有64位,传递较大参数时,尽量使用指针。

    帧栈原理

    32位帧栈:

    主要依靠的汇编指令:call和ret。前者跳转到函数入口执行,后者处理函数返回。

    call _func做了两件事:

    1. pushl %eip // 保存下一条指令的地址,用于函数返回继续执行
    2. jmp _func // 跳转到函数_func

    ret指令的作用相当于

    1. popl %eip // 将栈中的地址复制到eip寄存器中,返回前一个函数的上下文执行

    leave指令相当于下面两条:

    1. moveq %rbp, %rsp // 将现在的栈底赋值给栈顶
    2. popq %rbp // 将前一个栈底弹出

    x86_64帧栈

    通常情况下,x86_64函数简化了帧栈,不再将x86中的%rbp寄存器作为栈底寄存器,只是用%rsp记录当前栈顶。唯一写入栈的操作就是执行到call时会push返回地址 8字节,但是以下情况需要用到帧栈:

    1. 局部变量太多,64位寄存器处理不过来;
    2. 局部变量中存在数组或者结构体(或者叫做类类型)的变量;
    3. 使用取址操作符&就计算局部变量的的内存地址;
    4. 调用另外一个超过6个参数的函数;
    5. 需要在修改它们之前保存被调用者保存寄存器的状态;

    栈回溯

    在早期技术中,栈回溯是通过(%ebp)获得上一个栈的地址,再对上一个地址取值就是上上个地址;但现代Linux放弃了这个技术,节省出了一个ebp寄存器,原有的栈调用被放在eh_frame这个专用于存储栈回溯相关的段中。

    CFI伪指令生成在汇编文件中,根据链接选项(是否开启debug以及是否使用eh_frame段)来确定指令的执行内容,形式如下:

    cfi_startproc
    pushl %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset ebp, -8

    CFI(calling frame info)的作用是出现异常时stack的回滚(unwind),回滚的过程是一级级CFA往上回退,直到异常被catch。

    其中eh_frame是编译完就静态生成好的,这个点困扰了我好久,现在大概理解了回溯的要点,eh_frame是根据汇编代码生成的一张表,运行时汇编对cfi的修改实际上就是在改动eh_frame中的索引。

    引用:

    https://zhuanlan.zhihu.com/p/286088470

    https://blog.csdn.net/a568921915/article/details/103427976

    https://zhuanlan.zhihu.com/p/288636064

    https://stackoverflow.com/questions/7534420/gas-explanation-of-cfi-def-cfa-offset

    https://www.jianshu.com/p/e7e21a51093e

    https://zhuanlan.zhihu.com/p/290689333

    https://zhuanlan.zhihu.com/p/302726082