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

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

共合体

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

union QueryKey {
  int id;
  char name[16];
};

int Query(const QueryKey &key);

上例中用于查找某个数据的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;
}

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

struct QueryKey {
  union {
    int id;
    char name[16];
  } key;
  enum {
    kCaseId,
    kCaseName
  } key_case;
};

用过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;
}

因此,这里的需求就是“用两种方式来访问同一份数据”,才是完全符合共合体定义的用法。
共合体应该是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]

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

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

所以“变量”其实指“内存变量”,它一定拥有一个内存地址,和可变不可变没啥关系。
因此,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}; // 只读变量
左右值

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

var = val; // 赋值表达式

赋值表达式的左边表示即将改变的变量,右边表示从什么地方获取这个值。因此,很自然地,右值不会改变,而左值会改变。那么在这个定义下,“常量”自然是只能做右值,因为常量仅仅有“值”,并没有“存储”或者“地址”的概念。而对于变量而言,“只读变量”也只能做右值,原因很简单,因为它是“只读”的。
虽然常量和只读变量是不同的含义,但它们都是用来“读取值”的,也就是用来做右值的,所以,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并不是引用,而是一个只读变量
}

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

const int a = 5;

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

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

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

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

右值引用与移动语义

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

右值引用

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

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

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

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

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

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

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

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

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

struct Test { // 随便写一个结构体,大家可以脑补这个里面有很多复杂的成员
  int a, b;
};

Test GetAnObj() { // 一个函数,返回一个结构体类型
  Test t {1, 2};  // 大家可以脑补这里面做了一些复杂的操作
  return t; // 最终返回了这个对象
}

void Demo() {
  Test t1 = GetAnObj();
}

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

int f1() {
  int t = 5;
  return t;
}

翻译成汇编就是:

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

之所以能这样返回,主要就是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
  // 函数调用结束,临时空间释放
}

那么整个过程发生了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); // 释放临时空间
}

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

struct Test {
  int a, b;
};

Test GetAnObj() {
  Test t {1, 2}; 
  return t; // t会复制给临时空间
}

void Demo() {
  Test &&t1 = GetAnObj(); // 我设法引用这篇临时空间,并且让他不要立刻释放
  // 临时空间被t1引用了,并不会立刻释放
} // 等代码块结束,t1被释放了,才让临时空间释放

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

const引用绑定将亡值

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

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

这样看来,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
}

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

void ProcessBuf(Buffer &buf) {
  buf.at(2) = 100;
}

void Demo() {
  ProcessBuf(Buffer{16}); // ERR,普通引用不可接收将亡对象
}

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

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

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

void ProcessBuf(const Buffer &buf) {
  buf.at(2) = 100; // 但是这里会报错
}

void Demo() {
  ProcessBuf(Buffer{16}); // 这里确实OK了
}

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

void ProcessBuf(Buffer &&buf) {
  buf.at(2) = 100; // 右值引用完成绑定后,相当于普通引用,所以这里操作OK
}

void Demo() {
  ProcessBuf(Buffer{16}); // 用右值引用绑定将亡对象,OK
}

我们再来看下面的场景:

void Demo() {
  Buffer buf1{16};
  // 对buf进行一些操作
  buf1.at(2) = 50;

  // 再把buf传给ProcessBuf
  ProcessBuf(buf1); // ERR,相当于Buffer &&buf= buf1;右值引用绑定非将亡对象
}

因为右值引用是要来绑定将亡对象的,但这里的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

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);
}

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

template <typename T>
T &&move(T &ref) {
  return static_cast<T &&>(ref);
}

哈?就这么简单?是的!真的就这么简单,这个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导致报错

所以,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原本的东西不要了
}

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

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_;
};

这里主要考虑的问题是,既然是用将亡对象来构造新对象,那么我们应当尽可能多得利用将亡对象的“遗体”,在将亡对象中有一个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;
}

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

引用折叠

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

template <typename T>
void f(T &t) {
}


void Demo() {
  int a = 3;
  
  f<int>(a);
  f<int &>(a);
  f<int &&>(a);
}

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

void f(int &t);

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

void f(int & &t);
void f(int && &t);

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

左值引用短路右值引用

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

& + & -> &
& + && -> &
&& + & -> &
&& + && -> &&
auto &&

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

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

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

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

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

void f1(int &&t1) {}

void f2(int &&t2) {
  f1(t2); // 注意这里
}

void Demo() {
  f2(5);
}

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

void f1(int &&t1) {}

void f2(int &&t2) {
  f1(std::move(t2)); // 保证右性
}

void Demo() {
  f2(5);
}

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

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); // 传左值
}

由于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); // 传左值
}

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

补充

关于上面章节说的「const引用/右值引用绑定常量时相当于普通变量」的说法主要是为了方便大家理解,但本着严谨的态度,笔者还是希望额外解释一下这个部分。

右值引用和const引用绑定常量时,其实是先生成了一个匿名的临时变量,然后再引用它的。从汇编结果来说,最准确的说法是引用匿名的临时变量:

const int &r = 5;
// 等价于
const int &r = int{5};

const std::string &s = "abc";
// 等价于
const std::string &s = std::string("abc"); // 这个更明显,隐式构造

// 同理,右值引用也是
int &&r = 5;
// 等价于
int &&r = int{5};

std::string &&s = "abc";
// 等价于
std::string &&s = std::string("abc"); // 隐式构造临时对象

从汇编结果来看是最直观的

const int &a = 5;

生成的x86汇编是

mov     DWORD PTR [rbp-12], 5
lea     rax, [rbp-12]
mov     QWORD PTR [rbp-8], rax

对应的生成临时对象部分

int a = 5;

生成的是

mov     DWORD PTR [rbp-12], 5

而引用绑定相当于指针的语法糖

const int &r = a;

生成的是

lea     rax, [rbp-12]
mov     QWORD PTR [rbp-8], rax

所以右值引用/const引用绑定常量的真实效果是隐式构造了匿名临时对象,再用引用接收。不过完全可以按照“定义普通变量”的方式来理解这个语法。希望读者明了。

小结

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

  • 7
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

borehole打洞哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值