• C++的缺陷和思考(二)


    本文继续来介绍C++的缺陷和笔者的一些思考。先序文章请看C++的缺陷和思考(一)

    共合体

    共合体的所有成员共用内存空间,也就是说它们的首地址相同。在很多人眼中,共合体仅仅在“多选一”的场景下才会使用,例如:

    union QueryKey {
      int id;
      char name[16];
    };
    
    int Query(const QueryKey &key);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    上例中用于查找某个数据的key,可以通过id查找,也可以通过name,但只能二选一。
    这种场景确实可以使用共合体来节省空间,但缺点在于,共合体的本质就是同一个数据的不同解类型,换句话说,程序是不知道当前的数据是什么类型的,共合体的成员访问完全可以用更换解指针类型的方式来处理,例如:

    union Un {
      int m1;
      unsigned char m2;
    };
    
    void Demo() {
      Un un;
      un.m1 = 888;
      std::cout << un.m2 << std::endl;
      // 等价于
      int n1 = 888;
      std::cout << *reinterpret_cast<unsigned char *>(&n1) << std::endl;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    共合体只不过把有可能需要的解类型提前写出来罢了。所以说,共合体并不是用来“多选一”的,笔者认为这是大家曲解的用法。毕竟真正要做到“多选一”,你就得知道当前选的是哪一个,例如:

    struct QueryKey {
      union {
        int id;
        char name[16];
      } key;
      enum {
        kCaseId,
        kCaseName
      } key_case;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    用过google protobuf的读者一定很熟悉上面的写法,这个就是proto中oneof语法的实现方式。
    在C++17中提供了std::variant,正是为了解决“多选一”问题存在的,它其实并不是为了代替共合体,因为共合体原本就不是为了这种需求的,把共合体用做“多选一”实在是有点“屈才”了。
    更加贴合共合体本意的用法,是我最早是在阅读处理网络报文的代码中看到的,例如某种协议的报文有如下规定(例子是我随便写的):

    二进制位意义
    0~3协议版本号
    4~5超时时间
    6协商次数
    7保留位,固定 为0
    8~15业务数据

    这里能看出来,整个报文有2字节,一般的处理时,我们可能只需要关注这个报文的这2个字节值是多少(比如说用十六进制表示),而在排错的时候,才会关注报文中每一位的含义,因此,“整体数据”和“内部数据”就成为了这段报文的两种获取方式,这种场景下非常适合用共合体:

    union Pack {
      uint16_t data; // 直接操作报文数据
      struct {
        unsigned version : 4;
        unsigned timeout : 2;
        unsigned retry_times : 1;
        unsigned block : 1;
        uint8_t bus_data;
      } part; // 操作报文内部数据
    };
    
    void Demo() {
      // 例如有一个从网络获取到的报文
      Pack pack;
      GetPackFromNetwork(pack);
      // 打印一下报文的值
      std::printf("%X", pack.data);
      // 更改一下业务数据
      pack.part.bus_data = 0xFF;
      // 把报文内容扔到处理流中
      DataFlow() << pack.data;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    因此,这里的需求就是“用两种方式来访问同一份数据”,才是完全符合共合体定义的用法。
    共合体应该是C语言的特色了,其他任何高级语言都没有类似的语法,主要还是因为C语言更加面相底层,C++仅仅是继承了C的语法而已。

    const引用

    先说说const

    先来吐槽一件事,就是C/C++中const这个关键字,这个名字起的非常非常不好!为什么这样说呢?const是constant的缩写,翻译成中文就是“常量”,但其实在C/C++中,const并不是表示“常量”的意思。
    我们先来明确一件事,什么是“常量”,什么是“变量”?常量其实就是衡量,比如说1就是常量,它永远都是这个值。再比如'A'就是个常量,同样,它永远都是和它ASCII码对应的值。
    “变量”其实是指存储在内存当中的数据,起了一个名字罢了。如果我们用汇编,则不存在“变量”的概念,而是直接编写内存地址:

    mov ax, 05FAh
    mov ds, ax
    mov al, ds:[3Fh]
    
    • 1
    • 2
    • 3

    但是这个05FA:3F地址太突兀了,也很难记,另一个缺点就是,内存地址如果固定了,进程加载时动态分配内存的操作空间会下降(尽管可以通过相对内存的方式,但程序员仍需要管理偏移地址),所以在略高级一点的语言中,都会让程序员有个更方便的工具来管理内存,最简单的方法就是给内存地址起个名字,然后编译器来负责翻译成相对地址。

    int a; // 其实就是让编译器帮忙找4字节的连续内存,并且起了个名字叫a
    
    • 1

    所以“变量”其实指“内存变量”,它一定拥有一个内存地址,和可变不可变没啥关系。
    因此,C语言中const用于修饰的一定是“变量”,来控制这个变量不可变而已。用const修饰的变量,其实应当说是一种“只读变量”,这跟“常量”根本挨不上。
    这就是笔者吐槽这个const关键字的原因,你叫个read_only之类的不是就没有歧义了么?
    C#就引入了readonly关键字来表示“只读变量”,而const则更像是给常量取了个别名(可以类比为C++中的宏定义,或者constexpr,后面章节会详细介绍constexpr):

    const int pi = 3.14159; // 常量的别名
    readonly int[] arr = new int[]{1, 2, 3}; // 只读变量
    
    • 1
    • 2

    左右值

    C++由于保留了C当中的const关键字,但更希望表达其“不可变”的含义,因此着重在“左右值”的方向上进行了区分。左右值的概念来源于赋值表达式:

    var = val; // 赋值表达式
    
    • 1

    赋值表达式的左边表示即将改变的变量,右边表示从什么地方获取这个值。因此,很自然地,右值不会改变,而左值会改变。那么在这个定义下,“常量”自然是只能做右值,因为常量仅仅有“值”,并没有“存储”或者“地址”的概念。而对于变量而言,“只读变量”也只能做右值,原因很简单,因为它是“只读”的。
    虽然常量和只读变量是不同的含义,但它们都是用来“读取值”的,也就是用来做右值的,所以,C++引入了“const引用”的概念来统一这两点。
    所谓const引用包含了2个方面的含义

    1. 作为只读变量的引用(指针的语法糖)
    2. 作为只读变量

    换言之,const引用可能是引用,也可能只是个普通变量,如何理解呢?请看例程:

    void Demo() {
      const int a = 5; // a是一个只读变量
      const int &r1 = a; // r1是a的引用,所以r1是引用
      const int &r2 = 8; // 8是一个常量,因此r2并不是引用,而是一个只读变量
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    也就是说,当用一个const引用来接收一个变量的时候,这时的引用是真正的引用,其实在r1内部保存了a的地址,当我们操作r的时候,会通过解指针的语法来访问到a

    const int a = 5;
    
    const int &r1 = a;
    std::cout << r1;
    // 等价于
    const int *p1 = &a; // 引用初始化其实是指针的语法糖
    std::cout << *p1; // 使用引用其实是解指针的语法糖
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    但与此同时,const引用还可以接收常量,这时,由于常量根本不是变量,自然也不会有内存地址,也就不可能转换成上面那种指针的语法糖。那怎么办?这时,就只能去重新定义一个变量来保存这个常量的值了,所以这时的const引用,其实根本不是引用,就是一个普通的只读变量。

    const int &r1 = 8;
    // 等价于
    const int c1 = 8; // r1其实就是个独立的变量,而并不是谁的引用
    
    • 1
    • 2
    • 3

    思考

    const引用的这种设计,更多考虑的是语义上的,而不是实现上的。如果我们理解了const引用,那么也就不难理解为什么会有“将亡值”和“隐式构造”的问题了,因为搭配const引用,可以实现语义上的统一,但代价就是同一语法可能会做不同的事,会令人有疑惑甚至对人有误导。
    在后面“右值引用”和“因式构造”的章节会继续详细介绍它们和const引用的联动,以及可能出现的问题。

    右值引用与移动语义

    C++14的右值引用语法的引入,其实也完全是针对于底层实现的,而不是针对于上层的语义友好。换句话说,右值引用是为了优化性能的,而并不是让程序变得更易读的。

    右值引用

    右值引用跟const引用类似,仍然是同一语法不同意义,并且右值引用的定义强依赖“右值”的定义。根据上一节对“左右值”的定义,我们知道,左右值来源于赋值语句,常量只能做右值,而变量做右值时仅会读取,不会修改。按照这个定义来理解,“右值引用”就是对“右值”的引用了,而右值可能是常量,也可能是变量,那么右值引用自然也是分两种情况来不同处理:

    1. 右值引用绑定一个常量
    2. 右值引用绑定一个变量

    我们先来看右值引用绑定常量的情况:

    int &&r1 = 5; // 右值引用绑定常量
    
    • 1

    和const引用一样,常量没有地址,没有存储位置,只有值,因此,要把这个值保存下来的话,同样得按照“新定义变量”的形式,因此,当右值引用绑定常量时,相当于定义了一个普通变量:

    int &&r1 = 5;
    // 等价于
    int v1 = 5; // r1就是个普通的int变量而已,并不是引用
    
    • 1
    • 2
    • 3

    所以这时的右值引用并不是谁的引用,而是一个普普通通的变量。
    我们再来看看右值引用绑定变量的情况:
    这里的关键问题在于,什么样的变量适合用右值引用绑定? 如果对于普通的变量,C++不允许用右值引用来绑定,但这是为什么呢?

    int a = 3;
    int &&r = a; // ERR,为什么不允许右值引用绑定普通变量?
    
    • 1
    • 2

    我们按照上面对左右值的分析,当一个变量做右值时,该变量只读,不会被修改,那么,“引用”这个变量自然是想让引用成为这个变量的替身,而如果我们希望这里做的事情是“当通过这个引用来操作实体的时候,实体不能被改变”的话,使用const引用就已经可以达成目的了,没必要引入一个新的语法。
    所以,右值引用并不是为了让引用的对象只能做右值(这其实是const引用做的事情),相反,右值引用本身是可以做左值的。这就是右值引用最迷惑人的地方,也是笔者认为“右值引用”这个名字取得迷惑人的地方。
    右值引用到底是想解决什么问题呢?请看下面示例:

    struct Test { // 随便写一个结构体,大家可以脑补这个里面有很多复杂的成员
      int a, b;
    };
    
    Test GetAnObj() { // 一个函数,返回一个结构体类型
      Test t {1, 2};  // 大家可以脑补这里面做了一些复杂的操作
      return t; // 最终返回了这个对象
    }
    
    void Demo() {
      Test t1 = GetAnObj();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    我们忽略编译器的优化问题,只分析C++语言本身。在GetAnObj函数内部,t是一个局部变量,局部变量的生命周期是从创建到当前代码块结束,也就是说,当GetAnObj函数结束时,这个t一定会被释放掉
    既然这个局部变量会被释放掉,那么函数如何返回呢?这就涉及了“值赋值”的问题,假如t是一个整数,那么函数返回的时候容易理解,就是返回它的值。具体来说,就是把这个值推到寄存器中,在跳转会调用方代码的时候,再把寄存器中的值读出来:

    int f1() {
      int t = 5;
      return t;
    }
    
    • 1
    • 2
    • 3
    • 4

    翻译成汇编就是:

    push    rbp                                     
    mov     rbp, rsp
    mov     DWORD PTR [rbp-4], 5     ; 这里[rbp-4]就是局部变量t 
    mov     eax, DWORD PTR [rbp-4]   ; 把t的值放到eax里,作为返回值
    pop     rbp
    ret
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    之所以能这样返回,主要就是eax放得下t的值。但如果t是结构体的话,一个eax寄存器自然是放不下了,那怎么返回?(这里汇编代码比较长,而且跟编译器的优化参数强相关,就不放代码了,有兴趣的读者可以自己汇编看结果。)简单来说,因为寄存器放不下整个数据,这个数据就只能放到内存中,作为一个临时区域,然后在寄存器里放一个临时区域的内存地址。等函数返回结束以后,再把这个临时区域释放掉。
    那么我们再回来看这段代码:

    struct Test {
      int a, b;
    };
    
    Test GetAnObj() {
      Test t {1, 2}; 
      return t; // 首先开辟一片临时空间,把t复制过去,再把临时空间的地址写入寄存器
    } // 代码块结束,局部变量t被释放
    
    void Demo() {
      Test t1 = GetAnObj(); // 读取寄存器中的地址,找到临时空间,再把临时空间的数据复制给t1
      // 函数调用结束,临时空间释放
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    那么整个过程发生了2次复制和2次释放,如果我们按照程序的实际行为来改写一下代码,那么其实应该是这样的:

    struct Test {
      int a, b;
    };
    
    void GetAnObj(Test *tmp) { // tmp要指向临时空间
      Test t{1, 2};
      *tmp = t; // 把t复制给临时空间
    }  // 代码块结束,局部变量t被释放
    
    void Demo() {
      Test *tmp = (Test *)malloc(sizeof(Test)); // 临时空间
      GetAnObj(tmp); // 让函数处理临时空间的数据
      Test t1 = *tmp; // 把临时空间的数据复制给这里的局部变量t1
      free(tmp); // 释放临时空间
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    如果我真的把代码写成这样,相信一定会被各位前辈骂死,质疑我为啥不直接用出参。的确,用出参是可以解决这种多次无意义复制的问题,所以C++14以前并没有要去从语法层面来解决,但这样做就会让代码不得不“面相底层实现”来编程。C++14引入的右值引用,就是希望从“语法层面”解决这种问题。
    试想,这片非常短命的临时空间,究竟是否有必要存在?既然这片空间是用来返回的,返回完就会被释放,那我何必还要单独再搞个变量来接收,如果这片临时空间可以持续使用的话,不就可以减少一次复制吗?于是,“右值引用”的概念被引入。

    struct Test {
      int a, b;
    };
    
    Test GetAnObj() {
      Test t {1, 2}; 
      return t; // t会复制给临时空间
    }
    
    void Demo() {
      Test &&t1 = GetAnObj(); // 我设法引用这篇临时空间,并且让他不要立刻释放
      // 临时空间被t1引用了,并不会立刻释放
    } // 等代码块结束,t1被释放了,才让临时空间释放
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    所以,右值引用的目的是为了延长临时变量的生命周期,如果我们把函数返回的临时空间中的对象视为“临时对象”的话,正常情况下,当函数调用结束以后,临时对象就会被释放,所以我们管这个短命的对象叫做“将亡对象”,简单粗暴理解为“马上就要挂了的对象”,它的使命就是让外部的t1复制一下,然后它就死了,所以这时候你对他做什么操作都是没意义的,他就是让人来复制的,自然就是个只读的值了,所以才被归结为“右值”。我们为了让它不要死那么快,而给它延长了生命周期,因此使用了右值引用。所以,右值引用是不是应该叫“续命引用”更加合适呢~
    当用右值引用捕获一个将亡对象的时候,对象的生命周期从“将亡”变成了“右值引用共存亡”,这就是右值引用的根本意义,这时的右值引用就是“将亡对象的引用”,有因为这时的将亡对象已经不再“将亡”了,那它既然不再“将亡”,我们再对它进行操作(改变成员的值)自然就是有意义的啦,所以,这里的右值引用其实就等价于一个普通的引用而已。既然就是个普通的引用,而且没用const修饰,自然,可以做左值咯。右值引用做左值的时候,其实就是它所指对象做左值而已。不过又因为普通引用并不会影响原本对象的生命周期,但右值引用会,因此,右值引用更像是一个普通的变量,但我们要知道,它本质上还是引用(底层是指针实现的)。
    总结来说就是,右值引用绑定常量时相当于“给一个常量提供了生命周期”,这时的“右值引用”并不是谁的引用,而是相当于一个普通变量;而右值引用绑定将亡对象时,相当于“给将亡对象延长了生命周期”,这时的“右值引用”并不是“右值的引用”,而是“对需要续命的对象”的引用。

    const引用绑定将亡值

    需要知道的是,const引用也是可以绑定将亡对象的,正如上文所说,既然将亡对象定义为了“右值”,也就是只读不可变的,那么自然就符合const引用的语义。

    // 省略Test的定义,见上节
    void Demo() {
      const Test &t1 = GetAnObj(); // OK
    }
    
    • 1
    • 2
    • 3
    • 4

    这样看来,const引用同样可以让将亡对象延长生命周期,但其实这里的出发点并不同,const引用更倾向于“引用一个不可变的量”,既然这里的将亡对象是一个“不可变的值”,那么,我就可以用const引用来保存“这个值”,或者这里的“值”也可以理解为这个对象的“快照”。所以,当一个const引用绑定一个将亡值时,const引用相当于这个对象的“快照”,但背后还是间接地延长了它的生命周期,但只不过是不可变的。

    移动语义

    在解释移动语义之前,我们先来看这样一个例子:

    class Buffer final {
     publicBuffer(size_t size);
      Buffer(const Buffer &ob);
      ~Buffer();
      int &at(size_t index);
     private:
      size_t buf_size_;
      int *buf_;
    };
    
    Buffer::Buffer(size_t size) : buf_size_(size), buf_(malloc(sizeof(int) * size)) {}
    Buffer::Buffer(const Buffer &ob) :buf_size_(ob.buf_size_), 
                                      buf_(malloc(sizeof(int) * ob.buf_size_)) {
      memcpy(buf_, ob.buf_, ob.buf_size_);
    }
    Buffer::~Buffer() {
      if (buf_ != nullptr) {
        free(buf_);
      }
    }
    int &Buffer::at(size_t index) {
      return buf_[index];
    }
    
    void ProcessBuf(Buffer buf) {
      buf.at(2) = 100; // 对buf做一些操作
    }
    
    void Demo() {
      ProcessBuf(Buffer{16}); // 创建一个16个int的buffer
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    上面这段代码定义了一个非常简单的缓冲区处理类,ProcessBuf函数想做的事是传进来一个buffer,然后对这个buffer做一些修改的操作,最后可能把这个buffer输出出去之类的(代码中没有体现,但是一般业务肯定会有)。
    如果像上面这样写,会出现什么问题?不难发现在于ProcessBuf的参数,这里会发生复制。由于我们在Buffer类中定义了拷贝构造函数来实现深复制,那么任何传入的buffer都会在这里进行一次拷贝构造(深复制)。再观察Demo中调用,仅仅是传了一个临时对象而已。临时对象本身也是将亡对象,复制给buf后,就会被释放,也就是说,我们进行了一次无意义的深复制。
    有人可能会说,那这里参数用引用能不能解决问题?比如这样:

    void ProcessBuf(Buffer &buf) {
      buf.at(2) = 100;
    }
    
    void Demo() {
      ProcessBuf(Buffer{16}); // ERR,普通引用不可接收将亡对象
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    所以这里需要我们注意的是,C++当中,并不只有在显式调用=的时候才会赋值,在函数传参的时候仍然由赋值语义(也就是实参复赋值给形参)。所以上面就相当于:

    Buffer &buf = Buffer{16}; // ERR
    
    • 1

    所以自然不合法。那,用const引用可以吗?由于const引用可以接收将亡对象,那自然可以用于传参,但ProcessBuf函数中却对对象进行了修改操作,所以const引用不能满足要求:

    void ProcessBuf(const Buffer &buf) {
      buf.at(2) = 100; // 但是这里会报错
    }
    
    void Demo() {
      ProcessBuf(Buffer{16}); // 这里确实OK了
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    正如上一节描述,const引用倾向于表达“保存快照”的意义,因此,虽然这个对象仍然是放在内存中的,但const引用并不希望它发生改变(否则就不叫快照了),因此,这里最合适的,仍然是右值引用:

    void ProcessBuf(Buffer &&buf) {
      buf.at(2) = 100; // 右值引用完成绑定后,相当于普通引用,所以这里操作OK
    }
    
    void Demo() {
      ProcessBuf(Buffer{16}); // 用右值引用绑定将亡对象,OK
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    我们再来看下面的场景:

    void Demo() {
      Buffer buf1{16};
      // 对buf进行一些操作
      buf1.at(2) = 50;
    
      // 再把buf传给ProcessBuf
      ProcessBuf(buf1); // ERR,相当于Buffer &&buf= buf1;右值引用绑定非将亡对象
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    因为右值引用是要来绑定将亡对象的,但这里的buf1Demo函数的局部变量,并不是将亡的,所以右值引用不能接受。但如果我有这样的需求,就是说buf1我不打算用了,我想把它的控制权交给ProcessBuf函数中的buf,相当于,我主动让buf1提前“亡”,是否可以强制把它弄成将亡对象呢?STL提供了std::move函数来完成这件事,“期望强制把一个对象变成将亡对象”:

    void Demo() {
      Buffer buf1{16};
      // 对buf进行一些操作
      buf1.at(2) = 50;
    
      // 再把buf传给ProcessBuf
      ProcessBuf(std::move(buf1)); // OK,强制让buf1将亡,那么右值引用就可以接收
    } // 但如果读者尝试的话,在这里会出ERROR
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    std::move的本意是提前让一个对象“将亡”,然后把控制权“移交”给右值引用,所以才叫「move」,也就是“移动语义”。但很可惜,C++并不能真正让一个对象提前“亡”,所以这里的“移动”仅仅是“语义”上的,并不是实际的。如果我们看一下std::move的实现就知道了:

    template <typename T>
    constexpr std::remove_reference_t<T> &&move(T &&ref) noexcept {
      return static_cast<std::remove_reference_t<T> &&>(ref);
    }
    
    • 1
    • 2
    • 3
    • 4

    如果这里参数中的&&符号让你懵了的话,可以参考后面“引用折叠”的内容,如果对其他乱七八糟的语法还是没整明白的话,没关系,我来简化一下:

    template <typename T>
    T &&move(T &ref) {
      return static_cast<T &&>(ref);
    }
    
    • 1
    • 2
    • 3
    • 4

    哈?就这么简单?是的!真的就这么简单,这个std::move不是什么多高大上的处理,就是简单把普通引用给强制转换成了右值引用,就这么简单。
    所以,我上线才说“C++并不能真正让一个对象提前亡”,这里的std::move就是跟编译器玩了一个文字游戏罢了。
    再回到上面的代码,既然,并不能真的将亡,那么原本的对象是实实在在存在的,那么此时,对象是有2个引用在持有的,原本的buf1,以及传入ProcessBufbuf,但由于“右值引用控制了对象的生命周期”,因此,当右值引用销毁时,会去析构所引用的对象,而原本的对象在销毁时还会析构一次:

    void Demo() {
      Buffer buf1{16};
      // 对buf进行一些操作
      buf1.at(2) = 50;
    
      // 再把buf传给ProcessBuf
      ProcessBuf(std::move(buf1)); // 这里仅仅是多了一个引用而已
      // 函数结束,buf这个右值引用被销毁,会调用对象的析构函数
    } // Demo函数结束,buf1被销毁,还会调用一次对象的析构函数,析构函数中有free,重复free导致报错
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    所以,C++的移动语义仅仅是在语义上,在使用时必须要注意,一旦将一个对象move给了一个右值引用,那么不可以再操作原本的对象,并且要考虑非平凡析构的问题。因为右值引用会跟对象“共存亡”,也就是绑定了其生命周期。

    移动构造、移动赋值

    有了右值引用和移动语义,C++还引入了移动构造和移动赋值,这里简单来解释一下:

    void Demo() {
      Buffer buf1{16};
    
      Buffer buf2(std::move(buf1)); // 把buf1强制“亡”,但用它的“遗体”构造新的buf2
    
      Buffer buf3{8};
      buf3 = std::move(buf2); // 把buf2强制“亡”,把“遗体”转交个buf3,buf3原本的东西不要了
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    为了解决用一个将亡对象来构造/赋值另一个对象的情况,引入了移动构造和移动赋值函数,既然是用一个将亡对象,那么参数自然是右值引用来接收了。

    class Buffer final {
     publicBuffer(size_t size);
      Buffer(const Buffer &ob);
      Buffer(Buffer &&ob); // 移动构造函数
      ~Buffer();
      Buffer &operator =(Buffer &&ob); // 移动赋值函数
      int &at(size_t index);
     private:
      size_t buf_size_;
      int *buf_;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    这里主要考虑的问题是,既然是用将亡对象来构造新对象,那么我们应当尽可能多得利用将亡对象的“遗体”,在将亡对象中有一个buf_指针,指向了一片堆空间,那这片堆空间就可以直接利用起来,而不用再复制一份了,因此,移动构造和移动赋值应该这样实现:

    Buffer::Buffer(Buffer &&ob) : buf_size_(ob.buf_size_), // 基本类型数据,只能简单拷贝了
                                  buf_(ob.buf_) { // 直接把ob中指向的堆空间接管过来
        // 为了防止ob中的空间被重复释放,将其置空
        ob.buf_ = nullptr;
    }
    
    Buffer &Buffer::operator =(Buffer &&ob) {
      // 先把自己原来持有的空间释放掉
      if (buf_ != nullptr) {
        free(buf_);
      }
      // 然后继承ob的buf_
      buf_ = ob.buf_;
      // 为了防止ob中的空间被重复释放,将其置空
      ob.buf_ = nullptr;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    细心的读者应该能发现,所谓的“移动构造/赋值”,其实就是一个“浅复制”而已。当出现移动语义的时候,我们想象中是“把旧对象里的东西 移动 到新对象中”,但其实没法做到这种移动,只能是“把旧对象引用的东西转为新对象来引用”,本质就是一次浅复制

    引用折叠

    引用折叠指的是在模板参数以及auto类型推导时遇到多重引用时进行的映射关系,我们先从最简单的例子来说:

    template <typename T>
    void f(T &t) {
    }
    
    
    void Demo() {
      int a = 3;
      
      f<int>(a);
      f<int &>(a);
      f<int &&>(a);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    T实例化为int时,函数变成了:

    void f(int &t);
    
    • 1

    但如果T实例化为int &int &&时呢?难道是这样吗?

    void f(int & &t);
    void f(int && &t);
    
    • 1
    • 2

    我们发现,这种情况下编译并没有出错,T本身带引用时,再跟参数后面的引用符结合,也是可以正常通过编译的。这就是所谓的引用折叠,简单理解为“两个引用撞一起了,以谁为准”的问题。引用折叠满足下面规律:

    左值引用短路右值引用
    
    • 1

    简单来说就是,除非是两个右值引用遇到一起,会推导出右值引用以外,其他情况都会推导出左值引用,所以是左值引用优先。

    & + & -> &
    & + && -> &
    && + & -> &
    && + && -> &&
    
    • 1
    • 2
    • 3
    • 4

    auto &&

    这种规律同样同样适用于auto &&,当auto &&遇到左值时会推导出左值引用,遇到右值时才会推导出右值引用:

    auto &&r1 = 5; // 绑定常量,推导出int &&
    int a;
    auto &&r2 = a; // 绑定变量,推导出int &
    int &&b = 1;
    auto &&r3 = b; // 右值引用一旦绑定,则相当于普通变量,所以绑定变量,推导出int &
    
    • 1
    • 2
    • 3
    • 4
    • 5

    由于&&&优先级高,因此auto &一定推出左值引用,如果用auto &绑定常量或将亡对象则会报错:

    auto &r1 = 5; // ERR,左值引用不能绑定常量
    auto &r2 = GetAnObj(); // ERR,左值引用不能绑定将亡对象
    int &&b = 1;
    auto &r3 = b; // OK,左值引用可以绑定右值引用(因为右值引用一旦绑定后,相当于左值)
    auto &r4 = r3; // OK,左值引用可以绑定左值引用(相当于绑定r4的引用源)
    
    • 1
    • 2
    • 3
    • 4
    • 5

    右值引用传递时失去右性

    前面的章节笔者频繁强调一个概念:右值引用一旦绑定,则相当于普通变量(左值)。
    这也就意味着,“右值”性质无法传递,请看例子:

    void f1(int &&t1) {}
    
    void f2(int &&t2) {
      f1(t2); // 注意这里
    }
    
    void Demo() {
      f2(5);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    Demo函数中调用f2f2的参数是int &&,用来绑定常量5没问题,但是,在f2函数内,t2是一个右值引用,而右值引用一旦绑定,则相当于左值,因此,不能再用右值引用去接收。所以f2内部调f1的过程会报错。这就是所谓“右值引用传递时会失去右性”。
    那么如何保持右性呢?很无奈,只能层层转换:

    void f1(int &&t1) {}
    
    void f2(int &&t2) {
      f1(std::move(t2)); // 保证右性
    }
    
    void Demo() {
      f2(5);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    但我们来考虑另一个场景,在模板函数中这件事会怎么样?

    template <typename T>
    void f1(T &&t1) {}
    
    template <typename T>
    void f2(T &&t2) {
      f1<T>(t2);
    }
    
    void Demo() {
      f2<int &&>(5); // 传右值
      
      int a;
      f2<int &>(a); // 传左值
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    由于f1f2都是模板,因此,传入左值和传入右值的可能性都要有的,我们没法在f2中再强制std::move了,因为这样做会让左值变成右值传递下去,我们希望的是保持其左右性
    但如果不这样做,当我向f2传递右值时,右性无法传递下去,也就是t2int &&类型,但是传递给f1的时候,t1变成了int &类型,这时t1t2的引用(就是左值引用绑定右值引用的场景),并不是我们想要的。那怎么解决,如何让这种左右性质传递下去呢?就要用到模板元编程来完成了:

    template <typename T>
    T &forward(T &t) {
      return t; // 如果传左值,那么直接传出
    }
    
    template <typename T>
    T &&forward(T &&t) {
      return std::move(t); // 如果传右值,那么保持右值性质传出
    }
    
    template <typename T>
    void f1(T &&t1) {}
    
    template <typename T>
    void f2(T &&t2) {
      f1(forward<T>(t2));
    }
    
    void Demo() {
      f2<int &&>(5); // 传右值
      
      int a;
      f2<int &>(a); // 传左值
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    上面展示的是std::forward的一个示例型的代码,便于读者理解,实际实现要稍微复杂一点。思路就是,根据传入的参数来判断,如果是左值引用就直接传出,如果是右值引用就std::move变成右值再传出,保证其左右性。std::forward又被称为“完美转发”,意义就在于传递引用时能保持其左右性。

    小结

    本篇介绍了一些C++语法晦涩难懂的地方,笔者将其称之为“缺陷”,但解释了出现这种“缺陷”的原因,希望读者可以明白其所以然,更好地使用它们而不会踩坑。
    本篇是系列文章的第二篇,后续文章会介绍C++的一些其他缺陷和笔者的思考

  • 相关阅读:
    Linux性能优化实战CPU篇之总结(四)
    Linux网卡状态查看
    导航栏参考代码
    [华为杯] my_lcg
    MAC层协议总结
    Linux读写文件
    IDEA2022.1创建maven项目,规避idea2022新建maven项目卡死,无反应问题
    关于使用命令行打开wps word文件
    RedisTemplate操作ZSet的API
    腾讯智慧交通的「KPI底座」
  • 原文地址:https://blog.csdn.net/fl2011sx/article/details/126300435