C++为什么会有这么多难搞的值类别?(下)

时间:2022-12-27 20:56:36

先序文章请看C++为什么会有这么多难搞的值类别?(上)

回顾上篇

上一篇我们从内存的分配、汇编指令的角度来介绍了C++的几个值类型的来由。这里再次总结一下上一篇的结论(如果读者对下面任意位置还有疑问的话,那么建议前完整阅读一下上篇):

  1. 单纯讨论函数返回值是什么值类型并没有意义,而是要根据上下文,接收(或是说使用)函数返回值的方式决定了编译器对它的处理方式。
  2. 当完整使用函数返回值的时候(无论是用变量接收还是用常引用接收),都是相当于在调用方定义了一个局部变量,然后把这个变量的地址传入到被调用的函数中,用于处理返回值(也就是当做出参处理)。这时,接收返回值的变量(或常引用)对于调用方来说,就是一个普通的局部变量,生命周期也是跟着当前栈空间走的(当前代码块结束时析构),因此这种情况下它是lvalue。
  3. 在处理函数返回值的过程中,优先会选择通过寄存器的方式(先复制到寄存器,再复制给外部的变量),如果寄存器长度不够,那么就会选择直接在内存上处理的方式(利用外部传进来的,用于接收返回值的变量的地址来直接处理)。但无论哪种方式,外部的变量都是lvalue。
  4. 单纯的常数(例如54.9'A')一般会直接翻译成汇编指令中的常数(比如mov rax 5中的5),这种常量是prvalue。
  5. 当且仅当「部分」使用返回值(例如:f().a)时,会生成一片临时空间,它是匿名的,并且在当前语句结束后立即析构(不跟随栈空间),这种情况下的临时空间是xvalue。

这一篇我们继续来研究一些其他的情况。

再来研究一次返回局部变量(C++14的情况)

上一篇我们提到过类似于下面这样的实例:

struct Test {
  Test() {}
  ~Test() {}
};

Test Demo1() {
  Test t;
  return t;
}

void Demo2() {
  Test t = Demo1();
}

C++17对应的汇编指令在上篇已经贴出,需要的读者可以去上篇取用。当时我们说「常引用去接受函数返回值的情况,跟一个普通变量去接受返回值的情况一模一样」,我相信有读者一定在这里有千百万个问号,为什么会有这样奇怪的设计。这里毫不意外地命中了历史遗留问题,也就是说这个问题也是「找补」之后出现的。所以要想搞清楚,我们就得看看老版本的C++标准下,它是怎么回事。

这里,我们给出C++14标准下的汇编(编译参数:-fno-elide-constructors -std=c++14):

Test::Test() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::~Test() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::Test(Test const&) [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     QWORD PTR [rbp-16], rsi
        nop
        pop     rbp
        ret
Demo1():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        mov     QWORD PTR [rbp-24], rdi
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::Test() [complete object constructor]
        lea     rdx, [rbp-1]
        mov     rax, QWORD PTR [rbp-24]
        mov     rsi, rdx
        mov     rdi, rax
        call    Test::Test(Test const&) [complete object constructor]
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        nop
        mov     rax, QWORD PTR [rbp-24]
        leave
        ret
Demo2():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Demo1()
        lea     rdx, [rbp-1]
        lea     rax, [rbp-2]
        mov     rsi, rdx
        mov     rdi, rax
        call    Test::Test(Test const&) [complete object constructor]
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        lea     rax, [rbp-2]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        leave
        ret

当时我们说,针对这种情况,由于Test类型是「非平凡」的(因为构造、析构函数都已经自定义了),为了保证对象行为的完整性,Demo1中的局部变量t需要在其对应的栈空间结束时进行析构。因此就不能再按照平凡类型的方式,直接使用外部的变量了,而是要经过一次「复制」。也就是说,用Demo1中的t作为参数,调用一次拷贝构造函数来构造Demo2中的t(也就是Demo1的返回值),然后再把Demo1中的t进行析构。

这些都没变,但唯一变化的是相比C++17标准多了一次复制和析构,这是哪里的问题呢?通过观察汇编代码我们可以发现,多的一次拷贝是在Demo2中。那么也就是说,在早版本的C++中,对于用变量接收非平凡类型的返回值时,按xvalue处理。也就是说当我们是用一个变量来接收函数返回值的时候,编译器还是会划分一片匿名的临时空间来接收返回值的,接收完之后再用这个临时对象来构造新的局部变量。因此,这种情况下,返回值就是xvalue,然后我们用xvalue来构造了一个lvalue。(这是很多其他资料给出的结论,大家不用再质疑了!因为C++14及以前版本就是这样设计的)。

正是因为这种设计,我们再去解释「常引用可以接收函数返回值」这件事就更容易了。从语义上来说,常引用是可以直接绑定这片匿名的临时空间的,绑定后,就相当于不再「匿名」。那么,直接用变量接收返回值,其实就等价于先用常引用接收返回值,然后再用它来构造新的局部变量:

void Demo2() {
  Test t = Demo1(); 
  // 等价于
  const Test &tmp = Demo1(); // 常引用接收返回值(临时空间)
  Test t = tmp; // 拷贝构造
}

原本这样设计其实就是能够让这个临时空间拥有一个名字(引用),但这就会出现另一个问题,如果这时,临时空间还是立即释放的话,那么再使用的时候就会出现野指针错误。用上面的例子来说,假如Demo1()返回值按xvalue来处理的话,那么const Test &tmp = Demo1();语句结束时,这片空间就应该释放了,临时空间中的对象也要析构掉。那么此时,tmp这个引用就会指向了已经释放的空间,成为野引用。之后再用tmp去构造t的时候,就会出现解野指针错误,这显然是违背了原本「给临时空间起个名字」的用意。

为了解决这个问题,C++不得不让这片临时空间「延长它的寿命」,这样后面才能去使用它。所以,当用常引用接收函数返回值时,临时空间不会立即释放,而是跟随常引用成为了栈上的变量。所以上面例子中,tmp所指的对象并不会立刻析构,而是会等到Demo2函数结束。

这样来说事情就特别奇怪了,用常引用接收的函数返回值不仅没有成为xvalue,反而是成为了一个独立的变量了,比较违背直觉(直觉是,引用了临时空间,但实际上这种情况下反而没有临时空间了,而是会出现一个lvalue)。但如果直接用变量来接收返回值的话,倒是会出现一个临时空间(返回xvalue),然后再多一次拷贝(用临时对象拷贝构造局部对象)和析构(析构临时对象)。这也很反直觉(直觉是用局部变量接收返回值,但其实是多生成了一次xvalue)。

离谱……相当离谱!难道就没有一种完美的方案,可以表达这种「用局部变量接收返回值」并且「不出现额外的临时对象」吗?右值引用就这么诞生了!

右值引用(rvalue-reference)与复制省略(Copy Elision)

以C++14及以前的标准来说,我们发现,如果直接用一个变量来接收返回值,会多一次临时对象的拷贝和析构,用常引用虽然可以减少这一次拷贝,但常引用是用const修饰的,不可修改(如果要修改的话,还是得再去拷贝构造一个新的变量)。而为了解决这个问题,C++引入了「右值引用」。

其实这个语法完完全全就是为了解决函数返回值问题的,但为什么叫「右值引用」呢?我们在上篇解释过,从语义上来说,返回值可以理解为都是rvalue(可能是prvalue,可能是xvalue),因此用来接收rvalue的引用,就被叫做了rvalue-reference,翻译为「右值引用」。但大家一定一定要知道的是,这是「语义」上的解释,实际只要有引用来接收函数返回值的话,它就会变成lvalue。

void Demo2() {
  Test &&t = Demo1(); // 用右值引用接收函数返回值
}

从行为上来说,右值引用接收函数返回值和用常引用接收函数返回值的情况几乎完全相同,区别仅仅在于,右值引用不需要const修饰,因此可以更改。相比直接用变量来接收的情况,少了一次xvalue的中间值,也就减少了一次复制和析构。那么结论也就呼之欲出了:右值引用从语义上来说,是对右值的引用,但一旦完成了这种引用,其实整个过程就不会出现右值了,而是用一个左值来保存返回值,这就是我们为什么一直强调说「右值引用本身是左值」了。

来看一下完整的实例:

struct Test {
  Test() {}
  ~Test() {}
};

Test Demo1() {
  Test t;
  return t;
}

void Demo2() {
  Test &&t = Demo1();
}

汇编结果(编译参数:-fno-elide-constructors -std=c++14):

Test::Test() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::~Test() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::Test(Test const&) [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     QWORD PTR [rbp-16], rsi
        nop
        pop     rbp
        ret
Demo1():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        mov     QWORD PTR [rbp-24], rdi
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::Test() [complete object constructor]
        lea     rdx, [rbp-1]
        mov     rax, QWORD PTR [rbp-24]
        mov     rsi, rdx
        mov     rdi, rax
        call    Test::Test(Test const&) [complete object constructor]
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        nop
        mov     rax, QWORD PTR [rbp-24]
        leave
        ret
Demo2():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rax, [rbp-9]
        mov     rdi, rax
        call    Demo1()
        lea     rax, [rbp-9]
        mov     QWORD PTR [rbp-8], rax
        lea     rax, [rbp-9]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        leave
        ret

有没有发现,这种情况下跟C++17标准下,直接用变量接收函数返回值的情况是一样的了。那么也就是说,C++17标准下,针对于变量直接接收函数返回值的这种情况,规定了减少一次xvalue的生成,我们称之为「复制省略(Copy Elision)」。

小结一下

所以整件事情的心路历程就很有意思了,我们来小结一下整个「找补」的过程:

  1. 对于非平凡类型,为了保证对象的行为完整性,函数返回值会单独作为一个临时对象,如果需要在栈上使用,那么会拷贝给栈上的变量。
  2. 为了希望这片临时空间能够被代码捕获到,于是允许了用常引用来绑定函数返回值。但如果这时返回值仍然保持xvalue的特性的话,会引入野指针问题,违背了「引用临时空间」的原意,因此不得不将这种情况改成lvalue,让常引用所引用的空间跟随其所在的栈空间来「延长」声明周期。
  3. 又因为常引用有const修饰,不能修改对象,因此引入了「右值引用」,当用右值引用绑定函数返回值时,行为跟常引用是一致的,可以减少一次xvalue的生成,「延长」声明周期,同时还可以修改对象。
  4. 又发现还是直接用变量来接收函数返回值更加直观、符合直觉,而这种情况下xvalue的生成并没有太大的必要,因此又规定了「复制省略」,来优化这一次复制。(优化之后,用变量接收函数返回值和用右值引用接收函数返回值就完全没有区别了;而用const变量接收函数返回值跟用常引用接收函数返回值也没有区别了。)

这里需要额外解释一下,上面的实例我们都添加了-fno-elide-constructors这个编译参数,其实它就是用于关闭编译器的自动复制省略的。在C++17以前,虽然语言标准是没有定义复制省略的,但编译器早早就发现了这个问题,于是做了一些定制化的优化(称为返回值优化,Return Value Optimization,或RVO),这个参数就是关闭RVO,完全按照语言标准来进行编译。而在C++17标准中,定义了复制省略的方式,因此编译器就必须按照语言标准定义的那样来处理返回值了,所以在C++17标准下,这个编译参数也就不再生效了。

通过上面的介绍,这个value体系应该闭环了吧?不!还差得远呢……

移动语义

原本,右值引用概念的引入就是为了做返回值优化的,但有了Copy Elision(以下简称CE)以后,仿佛右值引用在这个场景下了一个菜鸡,但这并不意味着右值引用将会成为历史语法而惨遭淘汰。因为它还有另一个重要的用途——移动语义。

移动语义原本是为了解决资源复用问题的,我们来看下面这个实例:

class String {
 public:
  String();
  ~String();

  String(const String &);

 private:
  char *buf_;
};

// 由于算法本身不是本例程的重点,因此忽略掉一切扩容和优化问题,简单书写
String::String(): buf_(new char[1024]) {}
String::~String() {
  if (buf != nullptr) {
    delete [] buf_;
  }
}

String::String(const String &str) : buf_(new char[1024]) {
  std::memcpy(buf_, str.buf_, 1024);
}

void Demo1() {
  String str1;
  // 这里对str1做了一些操作,比如说添加了一些数据之类的
  return str1;
}

void Demo2() {
  String str = Demo1(); // 会触发一次拷贝构造
}

注意在上例中,我们用一个简单的字符串处理类来说明问题。Demo2中,用str来接收Demo1的返回值,这里会触发CE,直接用Demo1中的局部变量来拷贝构造这里的str。拷贝构造会调用拷贝构造函数,而我们可以看到,拷贝构造函数中是一次内存的深复制。也就是说,我们构造str会先分配一片空间,然后把str1中的buf_对应的数据拷贝到了strbuf_中,然后跟随着Demo1的结束,刚才str1的这片空间会被释放掉(析构函数中有delete [])。

这平白多一次内部的数据复制,就成为了C++希望优化的点。假如说,新的对象能够「直接拾取」原有对象的内部空间,岂不是可以节约资源,减少复制?于是C++引入了「移动构造函数」和「移动赋值函数」,就是说,当你用了一个「马上就不用的对象」来构造新对象的时候,就调用这个移动构造函数,里面应当执行浅复制,来延长内部资源的寿命。

那么,怎么区分「马上就不用的对象」和「一会还要继续用的对象」呢?看这里所谓「马上就不用的对象」是不是很符合xvalue的定义?那我就看看,如果我是用一个xvalue来构造新对象的话,我就复用资源;而如果是一个普通的lvalue的话,那说明它后面还有用,我就复制资源。那如何表示这个参数只接受xvalue呢?有三种方法:1.用变量接收;2.用常引用接收;3.用右值引用接收。

那么这里,C++又从「语义」上做了区分。当右值引用做函数参数时,认为优先匹配函数返回值。什么意思呢?就是对于重载函数的情况,如果我们直接用函数返回值作为实参的话,优先匹配右值引用的重载。用例子来说就是:

#include <iostream>

struct Test {};

Test Demo1() {
  Test t;
  return t;
} 

void f(const Test &) {
  std::cout << 1 << std::endl;
}

void f(Test &&) {
  std::cout << 2 << std::endl;
}

void Demo2() {
  Test t;

  f(t); // 1
  f(Demo1()); // 2
}

这里有2个f函数的重载,对于f(t)这种调用来说,由于t本身是普通变量,不是直接函数返回值,那么这种情况只能命中常引用的版本,所以会打印1。而对于f(Demo1())这种调用来说,两个版本的f都可以匹配,但由于我们刚才提到的优先原则,如果存在右值引用的重载版本,遇到直接用函数返回值作为形参的这种情况,优先匹配右值引用的重载,所以会打印2

大家注意,这里的这种优先原则并没有什么道理可言,就是语言标准强行规定的,用于区分你是变量传入,还是函数返回值直接传入。所以,有了这个原则,我们就可以完善刚才的移动构造函数了:

class String {
 public:
  String();
  ~String();

  String(const String &);
  String(String &&); // 移动构造函数

 private:
  char *buf_;
};

String::String(String &&str): buf_(str.buf_) { // 直接浅复制
  str.buf_ = nullptr;
}
// 【其他函数实现省略,可以看前面的例程】

void Demo2() {
  String str = Demo1(); // 调用移动构造函数
}

有了这个例子我们就知道了,右值引用最大的价值已经不在于优化返回值了,而是用于标记实参是否是直接的函数返回值

!!重点来了!! 有些教程资料可能会这么解释:函数返回值是右值,所以有右值引用接收,所以表「移动」语义的函数参数是右值引用。乍一看这个说法好像没问题,但其实经不起推敲,因为其实整个过程没有出现任何一个右值。对于String的移动构造函数来说,str是右值引用,在它的内部就是一个普通的变量,当我们在Demo2中用他来接收Demo1返回值的时候,命中了「右值引用接收函数返回值」这一情况,根据我们之前的分析,此时str是lvalue。所以整个过程是没有出现一个rvalue的。

这就是笔者反复强调,C++的「语义」和「实际处理」的区别。所以这里从语义上来说函数返回值是rvalue,包括常数也是一种rvalue,所以右值引用做函数参数时,用于「接收」一个rvalue。那么这里更加强调的是语义上的「接收」,这里希望接收一个右值。但右值引用本身其实就是一个栈上的普通变量,它是lvalue。

笔者更希望大家能够看到它的本质,右值引用做函数参数是为了优先匹配直接传入函数返回值的情况,从而跟常引用做参数来进行区分。匹配之后会按照返回值转出参的这种方式,成为一个栈上的普通变量,自然就是lvalue。

而通常情况下,用右值引用接收一个对象,是为了复用它的资源,来进行浅复制的。就好像,我们把原本的资源「移动」到了新的对象当中去,因此称之为「移动语义」。含有移动语义的构造函数就称为「移动构造函数」、含有移动语义的赋值函数就称为「移动赋值函数」。所以大家一定要清楚,这里的「移动」是「语义」上的,并没有真的移动,一般就是用来做浅复制的。当然了,你确实可以用右值引用做参数但是不做「移动」的事情(就比如说我们之前的例子中那个f函数一样),所以更加说明了这是「语义」上的,而实际只是一个软约束。

这样一来这个值类型体系总该闭环了吧?兄弟你还是太天真了,接下来就是整个体系里最复杂的一个环节——std::move。

std::move

刚才我们解释了如果用一个右值(函数返回值、函数返回值的一部分、或者常数)做参数时,会命中右值引用的重载版本,从而实现移动语义,做浅复制,来节省资源。

但如果我们想对一个不是右值的量做同样的事情呢?这里还是用上一节的String为例:

void Demo2() {
  String str1;

  String str2 = str1; // 这里会调用拷贝构造,因为str1是左值
}

如果我希望,用str1构造str2时,不用拷贝构造,而是用移动构造呢?或者说,虽然str1是个左值,但我仍然希望复用它里面的资源给到新的对象,这怎么办?这就要用到魔法操作了。我们知道,如果要进行移动语义,那么就需要用右值引用来接收。但现在str1是个左值,我们要是能给他强行「掰右」的话,不就可以「欺骗」编译器,把它当做右值来处理了嘛。反正移动语义本身就是个软约束,又不会真的去check入参的左右性。

所以,我们的魔法操作就是,把这个str1,伪装成右值,骗过编译器去触发右值引用的重载函数。像这样:

void Demo2() {
  String str1;

  String str2 = static_cast<String &&>(str1); // 强制转成右值引用,去触发移动构造函数
}

这里多说一嘴,定义新变量时后面的等号并不是赋值,而是构造参数的语法糖,也就是说上面等价于

String str2(static_cast<String &&>(str1)); // 构造参数,所以是用来匹配函数参数的

上面的这个操作由于过于魔幻,因此STL提供了一个工具函数来封装这个魔法操作,由于它的目的是为了触发移动语义,因此这个函数被命名为std::move,下面是它的实现:

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

因此,刚才的代码也可以写作:

void Demo2() {
  String str1;

  String str2 = std::move(str1); // 强制转成右值引用,去触发移动语义
}

那么这里请读者一定一定要把握一个原则,std::move的本质是为了伪装,它并不能改变变量的左右性。也就是说,std::move(str1)并不能把str变成rvalue,它本身是个变量,那么它就是lvalue,一直都是。move的作用仅仅在于,构造str2的时候能触发移动构造函数,仅此而已,其他的什么都没变。

那么也就是说,尽管我们用了「移动语义」来构造了str2,但其实str1还是存在的,该是什么样还是什么样,并不会真的被「移动」。而此时的str2str1的浅复制版本,原本的话它们的buf_会指向同一片空间的,但因为我们在移动构造函数中强制把原来的对象buf_给置空了,因此这里str1内部会出现空指针。所以这里有一次印证了「移动语义是软约束」这件事,使用之后行为如何,会不会出问题,完全取决我们代码怎么写的。

总结

还有一个概念笔者一直都没有提,那就是glvalue(广义左值,Generalized Left-side-hand Value),lvalue和xvalue合称glvalue,原因就是他们都有内存实体。但其实这个概念并不常用,主要是因为xvalue虽然有内存实体,但是无法直接取地址,因此在主框架的设计中,还是把xvalue当做rvalue来处理了。

C++之所以会出现这么多难搞的值类别,就是为了在兼容C方式的同时,提供一种更高级的语义封装。所以C++纠结就纠结在这里,一方面希望提供一些高级的语法,让程序员可以屏蔽掉一些底层的概念。另一方面反倒又引入了奇怪的概念让程序员不得不去深入理解底层。所以笔者自己对C++的经验就是,学习的时候要「深」,一定要搞清底层;而实际使用的时候要「高」,应当使用更加符合直觉的高级语法来屏蔽底层实现。

本篇文章并没有直接去按理论列举C++有哪些值类型,分别是什么定义。而是带着大家从汇编指令出发,一点一点的去猜测和体会这样设计的初衷和底层原理,希望能够给读者提供一些不同角度的理解和不一样的思路。

如果读者还有问题的话,欢迎评论留言!