关注公众号【高性能架构探索】,第一时间获取最新文章;公众号内回复【pdf】,免费获取经典书籍
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 个方面的含义:
-
作为只读变量的引用(指针的语法糖)
-
作为只读变量
换言之,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 引用类似,仍然是同一语法不同意义,并且右值引用的定义强依赖“右值”的定义。根据上一节对“左右值”的定义,我们知道,左右值来源于赋值语句,常量只能做右值,而变量做右值时仅会读取,不会修改。按照这个定义来理解,“右值引用”就是对“右值”的引用了,而右值可能是常量,也可能是变量,那么右值引用自然也是分两种情况来不同处理:
-
右值引用绑定一个常量
-
右值引用绑定一个变量
我们先来看右值引用绑定常量的情况:
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 { 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
就是跟编译器玩了一个文字游戏罢了。
所以,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; }
细心的读者应该能发现,所谓的“移动构造/赋值”,其实就是一个“浅复制”而已。当出现移动语义的时候,我们想象中是“把旧对象里的东西 移动 到新对象中”,但其实没法做到这种移动,只能是“把旧对象引用的东西转为新对象来引用”,本质就是一次浅复制。
原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/1256
非原创文章文中已经注明原地址,如有侵权,联系删除
关注公众号【高性能架构探索】,第一时间获取最新文章
转载文章受原作者版权保护。转载请注明原作者出处!