C++避坑指南(五)

关注公众号【高性能架构探索】,第一时间获取最新文章;公众号内回复【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 个方面的含义:

  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 {  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;右值引用绑定非将亡对象 }

因为右值引用是要来绑定将亡对象的,但这里的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就是跟编译器玩了一个文字游戏罢了。

所以,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

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2022年11月2日 下午12:16
下一篇 2022年11月2日 下午12:18

相关推荐