本文继续来介绍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的语法而已。
先来吐槽一件事,就是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个方面的含义
换言之,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++14的右值引用语法的引入,其实也完全是针对于底层实现的,而不是针对于上层的语义友好。换句话说,右值引用是为了优化性能的,而并不是让程序变得更易读的。
右值引用跟const引用类似,仍然是同一语法不同意义,并且右值引用的定义强依赖“右值”的定义。根据上一节对“左右值”的定义,我们知道,左右值来源于赋值语句,常量只能做右值,而变量做右值时仅会读取,不会修改。按照这个定义来理解,“右值引用”就是对“右值”的引用了,而右值可能是常量,也可能是变量,那么右值引用自然也是分两种情况来不同处理:
我们先来看右值引用绑定常量的情况:
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++14以前并没有要去从语法层面来解决,但这样做就会让代码不得不“面相底层实现”来编程。C++14引入的右值引用,就是希望从“语法层面”解决这种问题。
试想,这片非常短命的临时空间,究竟是否有必要存在?既然这片空间是用来返回的,返回完就会被释放,那我何必还要单独再搞个变量来接收,如果这片临时空间可以持续使用的话,不就可以减少一次复制吗?于是,“右值引用”的概念被引入。
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引用的语义。
// 省略Test的定义,见上节
void Demo() {
const Test &t1 = GetAnObj(); // OK
}
这样看来,const引用同样可以让将亡对象延长生命周期,但其实这里的出发点并不同,const引用更倾向于“引用一个不可变的量”,既然这里的将亡对象是一个“不可变的值”,那么,我就可以用const引用来保存“这个值”,或者这里的“值”也可以理解为这个对象的“快照”。所以,当一个const引用绑定一个将亡值时,const引用相当于这个对象的“快照”,但背后还是间接地延长了它的生命周期,但只不过是不可变的。
在解释移动语义之前,我们先来看这样一个例子:
class Buffer final {
public:
Buffer(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;右值引用绑定非将亡对象
}
因为右值引用是要来绑定将亡对象的,但这里的buf1是Demo函数的局部变量,并不是将亡的,所以右值引用不能接受。但如果我有这样的需求,就是说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,以及传入ProcessBuf的buf,但由于“右值引用控制了对象的生命周期”,因此,当右值引用销毁时,会去析构所引用的对象,而原本的对象在销毁时还会析构一次:
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 {
public:
Buffer(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 &&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函数中调用f2,f2的参数是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); // 传左值
}
由于f1和f2都是模板,因此,传入左值和传入右值的可能性都要有的,我们没法在f2中再强制std::move了,因为这样做会让左值变成右值传递下去,我们希望的是保持其左右性
但如果不这样做,当我向f2传递右值时,右性无法传递下去,也就是t2是int &&类型,但是传递给f1的时候,t1变成了int &类型,这时t1是t2的引用(就是左值引用绑定右值引用的场景),并不是我们想要的。那怎么解决,如何让这种左右性质传递下去呢?就要用到模板元编程来完成了:
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又被称为“完美转发”,意义就在于传递引用时能保持其左右性。
本篇介绍了一些C++语法晦涩难懂的地方,笔者将其称之为“缺陷”,但解释了出现这种“缺陷”的原因,希望读者可以明白其所以然,更好地使用它们而不会踩坑。
本篇是系列文章的第二篇,后续文章会介绍C++的一些其他缺陷和笔者的思考