C++11 左右值引用及工程应用详解

C++11 左右值引用及工程应用详解

版权声明:未经作者允许,请勿转载!

C++ 为什么要搞个引用岀来,特别是右值引用,感觉破坏了语法的简洁和条理,拷贝一个指针不是很好吗?

不管是左值引用还是右值引用,底层的设计逻辑是一样的,一句话概括就是:如何解决函数调用的问题,让参数和返回值的传递更便捷高效,同时保持代码的简洁优雅。C++的左右值引用确实让语法变复杂了,但从工程应用的角度,代码更简洁高效了。

下面我从C++语言发展的角度来解释这个问题,并提出更好理解左右值引用的底层逻辑,最后给出工程应用的示例,目的是让初学者能准确地掌握C++引用知识,并快速应用到工程实践中。

C 函数调用:值与指针的传递

在 C 代码的函数调用中传递简单类型的变量是没什么问题的,如 int、float 的变量,也就几个字节,多拷贝几次都不会有太大的资源开销。但在工程应用上,我们的代码充满了业务相关的结构体的变量,这些结构体可能很复杂。

下面以图形编程中的矩阵运算为例,我们先实现一个简单的矩阵类型及配套的加法函数:

// 矩阵运算 V1.0: C 传值版本
struct Matrix {
    float data[3][3];
Matrix add(Matrix ma, Matrix mb) {
    Matrix mr;
    for (int i=0; i<3*3; i++)
        mr.data[i] = ma.data[i] + mb.data[i];
    return mr;
int main() {
    Matrix a = {{{1,2,3},{4,5,6},{7,8,9}}};
    Matrix b = {{{2,2,2},{3,3,3},{4,5,6}}};
    Matrix c = add(a, b);
    return 0;

在定义 Matrix 数据结构时,为了简化模型便于理解,我们使用了固定 3*3 的静态内存分配 float data[3][3] ,所以当我们用 Matrix 定义一个局部变量时,整个矩阵数据 data[3][3] 都将在函数调用栈上进行分配。 虽然整个 Matrix 仅占用 3*3*4 = 36B 内存,但显然比基本类型的变量大很多了,如果矩阵的维数是 100 * 100 ,一个 Matrix 变量占用的内存将达到近 10KB,这种情况下,我们调用 add(a, b) 这个函数,a 和 b 分别被拷贝了一份给 ma 和 mb,然后把加法结果返回给 c 时,又会产生一次临时变量并分别拷贝了两次,总计额外拷贝了 4 次共40KB,对于我们的业务来说,这些内存拷贝操作是昂贵的,而且是多余的。

为什么编译器要帮我们自动创建和拷贝两次临时变量?函数传参好理解,因为调用语义就是传值调用,肯定要重新分配一个新的空间来容纳传入的值;函数返回值稍复杂一些,我们知道,局部变量(这里指 Matrix 对象)分配在函数调用的栈空间上,当函数调用 add(a, b) 退出时,本次调用的栈空间也被自动释放了(栈是后进先出),那当你返回一个局部变量时,编译器肯定得在外层函数的栈空间上临时给你分配一块新空间,并把刚才函数退出前的要 return 的栈变量所占的存储空间的值拷贝一份过去,然后才敢放心地执行下一行代码,等效于 临时变量 = add(a, b) 。因为不这样通过临时及时保护现场的话,下一行代码调用新的函数,原来函数调用的栈空间很快就会被新的调用给征用,原来的局部变量搞不好就被新的变量覆盖。然后才执行到 Matrix c = 临时变量 ,编译器才会把这个没有名字的临时变量赋值给外层函数的局部变量 c ,出现第二次拷贝。虽然现在的编译器大都可以把这种重复拷贝构建的情形优化掉,但在某些情况,编译器的确还弄不清楚具体的逻辑,不敢擅自优化,所以编译器的优化选项不是总是有效。

怎么解决这个问题?使用指针!指针就是一个变量的地址(占用内存才几个字节),我们完全可以传递大变量的地址给函数,计算结果也放入指定的地址中:

// 矩阵运算 V1.1: C 传指针版本
void add(Matrix* ma, Matrix* mb, Matrix* mr) {
    for (int i=0; i<3*3; i++)
        mr->data[i] = ma->data[i] + mb->data[i];
int main() {
    Matrix a = {{{1,2,3},{4,5,6},{7,8,9}}};
    Matrix b = {{{2,2,2},{3,3,3},{4,5,6}}};
    Matrix result;
    add(&a, &b, &result);
    return 0;

传指针的开销几乎可以忽略不计,所以整体效率终于提上去了,但像 add(&a, &b, &result) 的书写代码的形式,以及通过指针访问内部变量 -> 的符号实在不太优雅。而且关键是,编程的思维方式变了,我们时刻想着要传指针,要提前准备好空变量( result )让函数内操作它,这种函数内部操作外部资源的扭曲思想,确实不是一个好法子。

C++ 函数调用:引用传递和操作符重载

为了解决 V1.1 版本代码书写难看、思想扭曲的问题,C++ 在诞生之初,就把这个问题列入关键问题,提出了引用类型。所谓引用,就是绑定到某个对象的别名,相当于一个对象有两条名,我们无论使用哪一条名,效果是一样。我们在声明一个引用时,就必须初始化它,把它绑定到一个对象上。一旦一条别名绑定了一个对象,后续该别名不能再绑定到另一个对象。

Matrix a, b; 
Matrix* pb = &b;
Matrix& ra = a;  // 把 ra 绑定到 a 上,ra 就是 a
// 终于可以愉快地使用 . 访问成员了,抛弃了丑陋的 ->
ra.data[1][1] = 2.5f; 
ra = b;   // ra 是 a 的一个别名,所以 a 的值也跟着改变
ra = *pb  // 指针版还得使用丑陋的 * 转化为对象

看见了吗,上述代码中 ra 就是 a,它们是同一个对象,只不过有两条名字而已。引用定义的变量 ra 本质上是一个指针(编译器在底层就是这么干的),但它抛弃了指针那一套操作符号(* 和 ->),和对象的使用完全没两样,代码上直观多了。

可能有人会说,为了这点直观,引入引用这个新的机制,大大增加了语言的复杂性,得不偿失。我们换一个角度,C++诞生之初,完全可以抛弃指针,只用引用那一套,做得像java一样,也是完全可以的。java 中自定义类型都是对象,对象可以传来传去,但要显式使用 new 来创建。java 的对象类型就是 C++ 的引用类型的加强版。这样的话,就不需要指针那一套了,是不是让语言机制更简化?虽然真实世界是 C++ 要兼容 C 还要保留指针,但我们可以不学它不用它,只学引用,行不行?

事实上,实际项目通常是不会写出上面这样的代码的,这里只是为了说明引用的概念及基本操作。引用更多是应用在函数的传入传出上:

// 矩阵运算 V2.0: C++ 传引用版本
class Matrix {
private:
    float* data_;  // 数据
    int rows_;     // 行数
    int cols_;     // 列数
public:
    // 构造一个空矩阵
    Matrix() : rows_(0), cols_(0), data_(nullptr) {}
    // 构造一个 rows * cols 的矩阵
    Matrix(int rows, int cols) : rows_(rows), cols_(cols) {
        data_ = new float[rows_ * cols_];
    // 拷贝构造一个矩阵,深拷贝
    Matrix(const Matrix& m) : rows_(m.rows_), cols_(m.cols_) {
        data_ = new float[rows_ * cols_];
        memcpy(data_, m.data_, rows_ * cols_ * sizeof(float));
    ~Matrix() {
        if (data_) delete[] data_;
        data_ = nullptr;
        rows_ = cols_ = 0;
    // 下标操作符重载,返回第 i 个元素的引用
    float& operator[](int i) {
        return data_[i];
    // 在当前矩阵上加上矩阵 m
    void add(const Matrix& m) {
        for (int i=0; i < m.rows_ * m.cols_; i++)
            data_[i] = data_[i] + m.data_[i];
    // + 操作符重载,返回 a + b 的值
    friend Matrix operator+(const Matrix& a, const Matrix& b) {
        Matrix temp(a.rows_, a.cols_);
        for (int i=0; i < temp.rows_ * temp.cols_; i++)
            temp.data_[i] = a.data_[i] + b.data_[i];
        return temp;
int main() {
    Matrix a(8, 9);   // 构建一个 8 * 9 的矩阵a
    a[18] = 5.5f;     // 给第18个元素赋值,返回引用的简洁优雅
    Matrix b(a);      // 构建一个与a拥有一样数据的新矩阵b
    b.add(a);         // 相当于 b = b + a,传引用效率高
    Matrix c(a);      // 构建一个与a拥有一样数据的新矩阵c
    Matrix d = b + c; // 重载+并传递引用后,代码极其简洁高效
    return 0;

在定义 Matrix 数据结构时,区别于 C 版本的固定 3*3 的对象内存分配 float data[3][3] ,我们在 C++ 版本中使用了堆内存 data_ 来保存具体的矩阵数据。为了管理好这个堆内存,我们需要为其编写析构函数,以便在对象被销毁时能正确释放这块堆内存;同时我们为其提供拷贝构造函数,以实现深拷贝,避免编译器默认为我们直接拷贝指针造成指向同一块内存,以致于对象释放时被多次释放同一块内存。

对上述代码几次使用引用场景的解析如下:

  1. 拷贝构建函数 Matrix(const Matrix& m) 传入了一个(常量)引用作为构建新对象的模板。在该函数内部,形参 m 是一个引用,被绑定到了实参 a 中,m 就是 a 的一个别名,编译器并不会为 m 在栈中分配内存,因为 m 仅仅是一个别名而已,底层是一个指针,所以传递效率非常高。
  2. 因为操作符重载 operator[i] 函数返回了 float& 的引用,虽然返回的引用我们没有取名字,是匿名的,但它实现在在绑定到了第i个元素,所以我们可以给它赋值: a[18] = 5.5f; 可以看到引用机制和操作符重载的加入,我们才能写出如此优雅简洁的C++代码,Java 都不行!
  3. 因为 add(const Matrix& a) 函数的形参是 Matrix 的(常量)引用,所以我们在调用 b.add(a) 时,传入的 a 仅仅相当于传了一个指针,避免了局部对象的创建引起的深度拷贝。
  4. 函数 operator+(a, b) 也是传了两个引用,避免了两个局部对象的创建;而且因为重载了 + 操作符,最终的调用代码 d = b + c; 及其优雅美观简洁大方,与内置数据类型一致。只是这个函数在返回时仍然只能返回局部变量,造成了编译器为其在上层函数调用栈拷贝构建了一个临时对象,然后这个临时对象给 d 赋值,再一次调用拷贝构建函数,把临时对象深度拷贝给 d,最终造成额外的两次拷贝构造。理想的设计是,这里应该返回引用,但在 C++11 推出右值引出前,我们返回一个局部变量的引用是非法的,因为局部变量在函数返回后即自动销毁,使用它的引用会产生不可预料的后果。所以目前只能允许这个不和谐的声音继续存在。

可以看到,对于 C++98 的引用,我们用得更多的是函数传入参数,以避免临时对象的产生和深度拷贝开销;而在函数传出(返回值)上,只能返回成员对象的引用,应用面很窄。其实,在函数传出上还有一些特殊的应用场景:

  • 返回 *this 当前对象的引用,实现链式初始化的编程效果;
  • 返回 静态对象的引用,实现单例设计模式;

返回引用:链式调用和链式初始化

还记得我们照着教程写的第一个 C++ 程序吗?

int main() {
    string name, age;
    cout<<"请输入你的名字: " << endl; 
    cin>>name>>age;
    cout<<"你的名字叫 "<< name << endl << "只要我想可以<<到天荒地老"; 
    return 0;

我们为什么可以连续不断链式追加 >> << 来输入输出多个变量? 因为 STL 的内置对象 cin 和 cout 的 iostream 类中实现了操作符 >> << 的重载,关键是,这些重载的函数中,返回了 this 对象的引用。我们自己的普通函数(非操作符重载),也可以返回当前对象的引用也实现链式调用的效果,比如这样:

// 矩阵运算 V2.1: C++ 通过返回引用实现链式初始化的编程方法
class Matrix {
    // ... 省略 V2.0 原来的 Matrix 成员函数和变量
    // 新增加如下几个返回本对象引用的函数
    Matrix& rows(int rs) {
        rows_ = rs;
        return *this;
    Matrix& cols(int ls) {
        cols_ = ls;
        return *this;
    Matrix& build() {
        data_ = new float[rows_ * cols_];
        return *this;
    Matrix& set(float initValue) {
        memset(data_, initValue, rows_ * cols_ * sizeof(float));
        return *this;
int main() {
    Matrix a(8, 9);   // 构建初始化:一个 8 * 9 的矩阵a
    Matrix b;         // 空矩阵
    // 链式初始化:设置 b 的维数为 8*9,然后构建,然后设置所有元素为1 
    b.rows(8).cols(9).bulid().set(1.0f);
    // 传统 settor 初始化
    Matrix c;
    c.rows(8);
    c.cols(9);
    c.build();
    c.set(1.0f);
    return 0;

大家仔细品一下构造初始化、传统settor初始化、链接初始化的优劣势。

  • 构造函数初始化,必须按照定义的顺序传值,看代码时没有明确的提示,且不能缺省中间某一个参数;
  • 传统 settor 初始化虽然可以灵活的根据需求设置特定某些值,设置的值的意义也很明确,但要占用多行代码;
  • 链式初始化既简洁、又灵活、设置的值的意思还非常明确,特别是要设置的参数很多时,优势更明显;

实现链式初始化的代价,仅仅是在传统的 settor 函数的最后,返回当前对象的引用即可。性能完全不用担心,因为都是 inline 函数,编译器会自动帮我们优化。

返回静态对象的引用:最简单的单例模式

在 C++11 之前,实现单例的设计模式会稍微麻烦点,但在 C++11 之后,因为保证了 static 成员对象构造的原子操作语义,直接返回局部静态对象的引用,即可实现赖汉模式的单例(在第一次调用时才构建),而且因为返回了引用,我们在使用这个单例对象时,用 . 代替了 -> ,代码看起来更加简洁分明。

class FileManager {
private:
    // 构建函数被声明为 private,外部不能构造新对象
    FileManager() = default;
public:
    static FileManager& instance() { // 获取唯一的对象
        static FileManager manager;  // 懒汉,C++11及以上线程安全
        return manager;
    void createFile(string path);    // 业务函数
int main() {
    FileManager::instance().createFile("C:\\temp\\haha.txt");
    return 0;

如果你有耐心读到这里,相信你已经把C++的引用机制的由来、优势和典型应用场景等掌握得差不多了,是不是很简单?实际软件工程应用上,引用的应用场景也就这么些了,如果你能融汇贯通上面的知识点并加以应用,已经比很多 C++ 程序员牛了。

不和谐的声音

还记得上面提到的那个不和谐的声音吗?因为不能返回一个局部变量的引用,只能退而求其次,返回整个局部对象,造成了外层函数栈中临时对象的拷贝构建,且还要把临时对象再一次拷贝构建给另一个局部对象,性能及其低下。大家忍了十多年,为了干掉这个不和谐的声音,人们是想尽办法。现在,假设我们自己是 C++ 的设计者,我们来设计一种新方法,来终结这个不和谐的声音。

我们再次回顾一下 Matrix 的代码,加上打印信息,便于跟踪问题。

// 矩阵运算 V2.2: 函数返回说明
class Matrix {
private:
    float* data_;  // 数据
    int rows_;     // 行数
    int cols_;     // 列数
public:
    // 构造一个空矩阵
    Matrix() : rows_(0), cols_(0), data_(nullptr) {}
    // 构造一个 rows * cols 的矩阵
    Matrix(int rows, int cols) : rows_(rows), cols_(cols) {
        data_ = new float[rows_ * cols_];
        cout << "构造:" << hex << data_ << endl;
    // 拷贝构造一个矩阵,深拷贝
    Matrix(const Matrix& m) : rows_(m.rows_), cols_(m.cols_) {
        data_ = new float[rows_ * cols_];
        memcpy(data_, m.data_, rows_ * cols_ * sizeof(float));
        cout << "拷贝构造:" << hex << data_ << endl;
    ~Matrix() {
        cout << "析构:" << hex << data_ << endl;
        if (data_) delete[] data_;
        data_ = nullptr;
        rows_ = cols_ = 0;
    // 返回 a + b 的值
    friend Matrix sum(const Matrix& ma, const Matrix& mb) {
        Matrix temp(ma.rows_, ma.cols_);
        for (int i=0; i < temp.rows_ * temp.cols_; i++)
            temp.data_[i] = ma.data_[i] + mb.data_[i];
        return temp;
int main() {
    Matrix a(8, 9); 
    Matrix b(8, 9); 
    cout << "---- 执行 sum(a, b) ----" << endl;
    Matrix c = sum(a, b);
    return 0;
// 编译选项: g++ -std=c++11 Matrix.cpp -fno-elide-constructors

我们仅关注 Matrix c = sum(a, b) 的执行情况,忽略其它输出:

---- 执行 sum(a, b) ----
构造:0x618520        // temp
拷贝构造:0x618A30    // 匿名对象
析构:0x618520        // temp
拷贝构造:0x619510    // c
析构:0x618A30        // 匿名对象

从上面程序的输出可知,因为我们的传入参数 ma mb 均为常量引用,相当于是实参 a b 的一个别名,所以没有传值,没有发生任何拷贝构造。但在函数 sum(a, b) 调用返回时,为了及时保存待返回的局部变量 temp ,编译器会在栈上先拷贝构建一个临时对象,然后才把临时对象赋值给 c —— 函数返回过程等价于:

匿名临时对象 = temp;      // 第一次调用拷贝构造函数,深度拷贝 temp
Matrix c = 匿名临时对象;  // 第二次调用拷贝构造函数,深度拷贝 匿名临时对象

关键在于那个匿名临时对象!要解决两次拷贝构造,核心问题有两个:

  • 针对第一次拷贝,避免匿名临时对象的构建。临时对象完全是多此一举的,编译器有能力忽略它,只要编译器知道足够多的信息;
  • 针对第二次拷贝,避免返回值的拷贝构造,因为函数返回前, temp 是一个局部变量,函数退出后 temp 不会再有应用,完全可以把 temp.data_ 所指的内存块直接赋值给 c.data_,以避免重新给 c 分配新内存块、拷贝、并析构 temp 的内存。

我们首先很自然地想到,可以在代码中插入一些特殊的标志,比如 [no_temp] 明确告诉编译器此处不需要产生临时对象, [may_take] 告诉编译器该对象可以直接浅拷贝,把它的堆内存挪用即可。按这个规则,我们重新写代码:

friend Matrix sum(const Matrix& ma, const Matrix& mb) {
        Matrix [may_take] temp(ma.rows_, ma.cols_);
        // ......
        return [no_temp] temp;

问题是解决了,但这样写出来的代码一点也不美观,而且是侵入式的,会大大影响我们读写代码。最大的问题是,把代码优化提示写到源代码中,实在不是一个好的方法。

右值引用和移动构造语义

那有没有更简单更成体系的方法?有,那就是 C++11 标准新引入的语言机制:右值引用和移动构造语义。

右值

首先, C++11 把这些没有名字的、临时的值统称为右值。右值是相对于左值提出来的。在 C 时代,左右值就提出来了,没有严谨的定义,只有约定俗成的判别方法,即是,在赋值表达式中,出现在等号左边的就是左值(lvalue),出现在等号右边的就是右值(rvalue)。或者说,有名字的、可以取址(&)的就是左值,而没有名字的、不能取址的就是右值。比如:

int c, d;
c = a + b + 2;        // c 是左值,a + b + 2 是右值
++d = max(a+2, c);  // a+2、max(a+2, c) 是右值

看出来了吗,右值通常是一个较复杂的、要产生临时变量的表达式,比如 max(a+2, c) 函数调用整体就是一个右值。右值和右值表达式是同一个概念,我们没必要细究它。左值通常是一个变量,但也有例外,比如上述代码的 ++d 稍复杂一些,可理解为它是 operater+ 函数返回的一个引用。哪些表达式是右值,其实编译器是可以自动推断出来的,并不需要我们显式地告知。

C++11 扩展了右值的概念,C++98 所定的右值改名叫纯右值(prvalue, pure rvalue),再加上新鲜出炉的将亡值(xvalue, eXpiring value),统称为右值。

纯右值通常出现在是要产生临时变量才能继续的场景,包括:

  • 字面量:3、 "Hello"、flase
  • 运算表达式: (a + b + 2)、 (3 + 4)
  • 非引用的函数值返回:如内部 return temp; 的某一函数调用,temp 虽然是局部变量,是左值,但最后都要返回了,已经可以视作右值。

将亡值是程序员显式标识可以被占据挪用的值,就是上面我们自己发明的 [may_take] 标识符,只不过 C++11 比我们更高明,使用 T&& 标识符,具体来说有以下几种情况:

  • 返回 T&& 类型的函数调用,如: Matrix&& sum() {return Matrix(7,8)};
  • 转换为 T&& 的类型转换,如: static_cast<Matrix&&> temp
  • std::move(temp) 的返回值,本质上与上一条类型转换一样;

右值引用

所谓右值引用,就是对一个右值进行引用的类型,比如下面代码中的 s

Matrix&& s = sum(a, b);

右值引用首先是一个引用,引用有的属性它全都有,比如,右值引用也是一个别名,也要在声明时就对其初始化,它定义出的变量本质上也是一个指针,这个变量也不会在函数调用栈中分配空间。

然后,右值引用只能绑定那些没有名字的、临时产生的、不能取址的右值。因为右值没有名字、平时都隐藏在后面,我们在 C++11 之前想抓它都抓不着,甚至通常都意识不到它的存在。现在好了,我们通过右值引用给了它一个名字(如上式的 s ),让它(如上式 sum() 调用返回产生的匿名临时对象)暴露在光天化日之下,以后我们可以名正言顺的通过这条名字使唤它了。

如果我们没有定义右值引用变量 s, sum() 调用返回产生的匿名临时对象的生命期很短,是表达式级的,即是,在这行语句执行完后,这个临时对象就会被析构。现在,因为给它绑定了一个名字,这个临时对象获得新生,生命期得到延续,扩展到本层函数结束。

可以看到,我们使用 Matrix&& s = sum(a, b); 减少了一次对象的拷贝构造,避免了临时对象的第二次拷贝,但临时对象仍然被拷贝构造了,我们得进一步解决这个问题。

移动构造

我们知道,在函数调用返回局部对象时,编译器为了及时保存即将被释放的栈空间上的这个局部对象,一定会构造一个临时对象,这点无法避免,即使打开编译器优化,编译器也无法保证所有场合都能优化这个临时对象。既然无法避免构造,那么,是否可以换用另一种构造方式,抛弃原来重型的深拷贝构建,换用一个轻量的,把不再使用的局部对象的资源挪为已用呢?没错,这就是移动构造语意。

我们在 矩阵运算 V2.2 版本的基础上,增加一个移动构造函数,其余不作任何修改,如下代码:

// 矩阵运算 V2.3: 移动构造和右值引用
class Matrix {
private:
    float* data_;  // 数据
    int rows_;     // 行数
    int cols_;     // 列数
public:
    // 构造一个空矩阵
    Matrix() : rows_(0), cols_(0), data_(nullptr) {}
    // 构造一个 rows * cols 的矩阵
    Matrix(int rows, int cols) : rows_(rows), cols_(cols) {
        data_ = new float[rows_ * cols_];
        cout << "构造:" << hex << data_ << endl;
    // 拷贝构造一个矩阵,深拷贝
    Matrix(const Matrix& m) : rows_(m.rows_), cols_(m.cols_) {
        data_ = new float[rows_ * cols_];
        memcpy(data_, m.data_, rows_ * cols_ * sizeof(float));
        cout << "拷贝构造:" << hex << data_ << endl;
    // 移动构造一个矩阵,把传入的对象的资源移为已用
    Matrix(Matrix&& m) : rows_(m.rows_), cols_(m.cols_) {
        data_ = m.data_;
        m.data_ = nullptr;
        m.rows_ = m.cols_ = 0;
        cout << "移动构造:" << hex << data_ << endl;
    ~Matrix() {
        cout << "析构:" << hex << data_ << endl;
        if (data_) delete[] data_;
        data_ = nullptr;
        rows_ = cols_ = 0;
    // 返回 a + b 的值
    friend Matrix sum(const Matrix& ma, const Matrix& mb) {
        Matrix temp(ma.rows_, ma.cols_);
        for (int i=0; i < temp.rows_ * temp.cols_; i++)
            temp.data_[i] = ma.data_[i] + mb.data_[i];
        return temp;
int main() {
    Matrix a(8, 9); 
    Matrix b(8, 9); 
    cout << "---- 执行 sum(a, b) ----" << endl;
    Matrix c = sum(a, b);
    return 0;
// 编译选项: g++ -std=c++11 Matrix.cpp -fno-elide-constructors

移动构造函数 Matrix(Matrix&& m) 的形参是一个右值引用,它将要接收(绑定)一个右值。就是说,在外面调用时,传入的实参一定得是一个右值,才会匹配到这个移动构造函数,否则只会匹配到拷贝构造函数。现在,m 已经是一个外面传入的临时对象的一个别名了,我们紧接着把 m 内部的 data_ 所指的资源(内存块)移交给当前对象,最后还把这个临时对象的 data_ 清空,避免临时对象析构时重复释放同一块内存(注意临时对象仍会析构,移动语意只会移动对象内部资源,并不会把对象移动消失)。可以看到,通过移动构造语意,我们实现了把临时对象的内部资源移为已用,让临时对象变成一个空壳子。这样做是安全的,因为既然匹配了右值,那么 m 一定是一个临时对象,我们操纵一个临时对象又有什么关系呢。

Matrix c = sum(a, b) 执行结果如下:

---- 执行 sum(a, b) ----
构造:0x618520        // temp
移动构造:0x618520    // 匿名对象,占用了 temp 的内存
析构:0               // temp,已空
移动构造:0x618520    // c,占用了 temp 的内存
析构:0               // 匿名对象,已空

我们依照程序的输出结果,理一理这里面发生了什么。当函数 sum(a, b) 调用返回时,为了及时保存待返回的局部变量 temp ,编译器会在栈上先构造一个临时对象,那么,在构造临时对象时,我们到底该调用哪个版本的构造函数?这个简单,既然 Matrix 提供了多个重载的构造函数,那就进行参数匹配吧!回顾一个上几节我们罗列的右值吧,其中有一项就是非引用的函数值返回,这里我们返回了一个局部对象 temp,它明显是一个值而非引用,所以,最终会匹配到移动构造函数。所以,会以移动构造的方式,把temp的内部资源移过去构建一个临时对象。然后,执行到 c = 临时对象; ,这是一条右值赋值语句,会优先匹配右值版本的赋值操作符( operator=(Matrix&&) )的重载,而我们类中没有,会转而匹配到右值版本的移动构造函数,于是,再一次调用了移动构造构造 c 对象,把临时对象的内部资源转移给 c。

总结一下,通过移动构造语句,我们成功地把函数要返回的 temp 对象内部的资源,经过两次转手,最终转移到了 c 手上。虽然整个过程还是要调用两次构造函数,但调用的是移动构造,效率很高,两次又有什么关系呢?再者,编译器优化通常会优化掉一次移动构造,所以我们一般没必要写成 Matrix&& c = sum(a, b) ,虽然这种写法中 c 是临时对象的别名,表示后面直接使用这个临时变量,不优化的情况下少了一次移动构造,效率差不了多少。

右值类型转换

到目前为止,我们的 Matrix 对象拥有了移动构造能力,如果使用一个右值来创建新对象或赋值给另一对象,就会触发这个移动构造函数,把临时对象里面的资源移给新对象使用,从而完成高效的构建工作。具体来说,常用场景就两个,函数返回和函数传参,如下代码:

void saveToFile(Matrix m) {
    // 把 m 保存到文件中
int main() {
    Matrix a(8, 9); 
    Matrix b(8, 9); 
    Matrix c = sum(a, b);    // 场景1.1:函数返回赋值,移动构造了 c
    Matrix d(sum(a, b));     // 场景1.2:函数返回构造,移动构造了 d
    saveToFile(a + b);       // 场景2.1:函数传参,移动构造了形参 m
    saveToFile(c);           // 拷贝构造
    Matrix&& r = a + b;
    saveToFile(r);           // 拷贝构造,r是右值引用,不是右值,引用本质是变量
    saveToFile(
      static_cast<Matrix&&>(c)); // 场景2.2:函数传参,移动构造了形参 m
    saveToFile(std::move(d));    // 场景2.3:函数传参,移动构造了形参 m
    return 0;

我们知道函数调用的返回值是一个右值,所以无论是给变量赋值(场景1.1),还是构造(场景1.2),都将触发移动构造;而当我们调用 saveToFile(a + b) 时, a+b 本质上也是函数(操作符+的重载函数)调用的返回,是一个地地道道的右值,所以将移动构造形参 m 。

很多时候,我们想将一个局部变量传递给一个函数调用,如上例中 saveToFile(c) ,而且我们知道这个局部变量 c 接下来都不会再用到了,完全可以把里面的资源转移给 saveToFile(Matrix m) 的形参 m ,但因为 c 是一个变量,是一个左值,所以 saveToFile(c) 调用的结果就是触发拷贝构造函数,拷贝构造了形参 m 。那我们有没有办法把左值转化成右值呢?有,那就是场景 2.2 的情形,调用显式的转换函数 static_cast<T&&> 。事实上, C++11 为我们提供了更方便的库函数 std::move() ,它能把一个普通变量里面的资源转移出去,成为一个临时的右值,从而显式的触发移动构造(如上述场景2.3),避免低效的拷贝构造。

值得注意的是,右值引用是一个地地道道的左值,因为右值引用本质上是引用,是一个变量,已经有名字有地址了,所以上述代码 saveToFile(r) 触发的是拷贝构造函数,将拷贝构造形参 m,而非触发移动构造。

右值引用的正确使用姿势

在我们学会了右值引用、移动构造语意及 std::move() 之后,针对上一节示例,我们很自然地想到,是否可以进一步优化,让函数调用及返回不需要调用哪怕一次移动构造函数呢?可以的,代码如下:

void saveToFile(Matrix&& m) { // m是一个临时值的引用
    // 把 m 保存到文件中
Matrix&& loadFromFile() {   // 编译警告,不能返回一个局部的引用
    Matrix m;
    // 从文件中解析并反序列化出一个 Matrix 对象
    return std::move(m);
int main() {
    Matrix a(8, 9); 
    Matrix b(8, 9); 
    saveToFile(a + b);       // 形参 m 是 a+b 临时结果的一个别名
    Matrix&& r = loadFromFile(); // r 是一个临时对象的引用
    return 0;

上述代码中, saveToFile(a + b) 整个函数调用没有产生一次移动构造,效率比移动构造还高一点点;同样, Matrix&& r = loadFromFile() 的整个函数返回过程也不会产生一次临时对象的移动构造,是不是很美好?事实上,关于右值引用,我们一般不在函数传入传出时直接传递右值引用,而是仍会按上一节这样自然的方式写代码 —— 仅仅把右值引用当作是移动构造的触发器,这是因为:

  1. 从易维护性角度来看,右值引用传递是侵入式的,我们的上层调用代码必然充斥着显式的右值类型转化和右值引用定义;而传统的值传递方式显然更加直观、美观、易理解;而且在函数传参时,传递常量左值引用通常更为合适,因为它既可以接受左值也可接受右值,效率完全一样;
  2. 从性能的角度来看,值传递在我们提供移动构造函数时,加上编译器的默认优化,效率几乎与引用传递是一样的。

愉快的使用 STL 库

值得 C++ 码农们高兴的是, C++11 的 STL 库全部被重写,那些常用的容器和算法等,大都实现了移动语义,这意味着,我们在用 STL 写代码时,再也不用纠结性能问题了,我们可以愉快的把一个装满大对象的容器传来传去,不用再去考虑是传其引用、指针,直接传对象完事,代码又直观又好看,移动构造和编译器优化能让传 STL 对象的性能几乎没有损失!

vector<Matrix> loadFromFile(string filePath) {
    vector<Matrix> matrixs;
    // ... (从文件中加载多个Matrix后插入到matrixs中)
    return matrixs;   // 直接返回一个 vector 对象,将自动调用移动构造
list<string> getPaths() {
    list<string> paths;
    string dataDir = "C:\\app\\data";
    string path = dataDir + "\\" + fileName;  // +号重载函数,传入传出都是移动构造
    paths.push_back(std::move(path));         // string 实现移动语义
    // ... 
    return paths;    // list 实现了移动语义,将自动调用移动构造

上述代码中,我们看到了一种新的编程套路,就是经常会把临时弄出来的对象,立马调用 move() 把其资源转移到新的容器、对象。我们在写 C++11 代码时,肯定会经常使用 move() 提升性能。

我们可以从中学习到的是,在自己写库或者类时,也一定要实现移动构造语义,为我们的对象提供移动构造函,让我们的用户用得省心、开心、放心。

引用折叠与完美转发

右值引用还有另一个典型的使用场景:在模板函数编程中,如何保证完全依照模块参数的类型(左值引用、右值引用),将参数传递给另一个函数?

下面是一个常见的封装应用示例,模板函数 save() 隐藏了具体如何把一个对象序列化保存到某一文件的细节,让调用者只需要传递待保存的对象即可,而不用了解过多的实现细节。

template <typename T>
void save(T t) {
    // ...
    // 调用参数更为丰富的具体保存函数
    saveToFile(t, "/opt/temp.dat", Serialization::Protobuf);

不过,这个函数也有缺陷,其执行效率,在实例化函数时,取决于 T 的具体类型是什么。具体来说,当 T 被定义为:

  • Matrix 时,不管是 save() 还是 saveToFile() 都是传值调用,调用多次无意义的拷贝构造,性能低下;
  • (const) Matrix& 时,效率很好,不过我们必须保证 saveToFile() 有其对应的引用版本的重载版本;
  • Matrix&& 时,效率不好,我们调用 saveToFile() 其实是左值版本,最终被拷贝构造;

也许你会说,我们多重载几个 saveToFile() 版本也没什么,无非是代码量大一点。但是,这个函数本来就有多个可选的参数,再加上不同引用的版本,排列组合的数量就很多了,作为一个有追求的程序员,是不会允许出现这种情况的。我们希望有一种机制,能够一劳永逸解决这个问题,达到:当我们传递左值引用时,内层函数调用也自动传递左值引用;当我们传递右传引用时,内层函数调用也自动传递右值引用;而且我们只有一个版本的 saveToFile() 实现,无须提供代码重复性高的几个重载版本。这就是完美转发!

我们首先要解决一个基本问题,当 T 被定义成 Matrix& ,我们再用 T&& t 定义的变量 t ,到底是什么类型?直接展开是 Matrix& && t ,这是什么鬼?如果 T 被定义成 Matrix&& 又是什么情况呢?问题好像很复杂,而 C++11 引入一个简单粗暴的规则:在 T 的定义场合和用 T 定义变量的场合,只要有一个场合出现左值引用,那么该变量最终被定义为左值引用,这个规则被称为引用折叠,折叠的核心思想是左值引用优先。具体来说,引用折叠的效果如下:

T 本身定义 T 定义变量 直接展开 变量a最终类型
Matrix& T& a Matrix& & a Matrix&
Matrix&& T& a Matrix&& & a Matrix&
Matrix& T&& a Matrix& && a Matrix&
Matrix&& T&& a Matrix&& && a Matrix&&

有了引用折叠,我们重新编写上面的对象保存代码:

template <typename T>
void save(T&& t) {
    // ...
    // 调用参数更为丰富的具体保存函数
    saveToFile(static_cast<T&&>(t), "/opt/temp.dat", Serialization::Protobuf);

根据引用折叠的规则,当我们的 T 是 Matrix& (左值引用类型)时, save() 函数被实例化为:

void save(Matrix& t) {
    saveToFile(static_cast<Matrix&>(t), "/opt/temp.dat", Serialization::Protobuf);

当我们的 T 是 const Matrix& (常量左值引用类型)时, save() 函数被实例化为:

void save(const Matrix& t) {
    saveToFile(static_cast<const Matrix&>(t), "/opt/temp.dat", Serialization::Protobuf);

当我们的 T 是 Matrix&& (右传引用类型)时, save() 函数被实例化为:

void save(Matrix&& t) {
    saveToFile(static_cast<Matrix&&>(t), "/opt/temp.dat", Serialization::Protobuf);

由上可见,我们通过定义 T&& t 的模板函数,经过折叠,最终实现了完美转发。就像在移动构造语意中,我们通常不使用显式类型转换,而是使用 STL 中的库函数 std::move() ,这里也一样,我们也不用 static_cast,而且使用 std::forward() (事实上三者都一样)实现完全转发,标准写法应该这样:

template <typename T>