对C++11中的`移动语义`与`右值引用`的介绍与讨论

本文主要介绍了C++11中的移动语义右值引用, 并且对其中的一些坑做了深入的讨论. 在正式介绍这部分内容之前, 我们先介绍一下rule of three/five原则, 与copy-and-swap idiom最佳实践.

本文参考了stackoverflow上的一些回答. 不能算是完全原创

rule of three/five

rule of three是自从C++98标准问世以来, 大家总结的一条最佳实践. 这个实践其实很简单, 用一句话就能说明白:

析构函数, 拷贝构造函数, =操作符重载应当同时出现, 或同时不出现

那么, 这背后的缘由是什么呢? 这里就来说道说道.

C++中, 所有变量都是值类型变量, 这意味着在C++代码中, 隐式的拷贝是非常常见的, 最常见的一个隐式拷贝就是参数传递: 非引用类型的参数传递时, 实质上发生的是一次拷贝, 首先我们要明白, 所谓的发生了一次拷贝, 所谓的拷贝, 到底是指什么.

我们从一段短的代码片断开始:

class person
{
    std::string name;
    int age;

public:

    person(const std::string& name, int age) : name(name), age(age)
    {
    }
};

int main()
{
    person a("Bjarne Stroustrup", 60);  // Line 1: 这里显然是调用了构造函数
    person b(a);                        // Line 2: 这里发生了什么?
    b = a;                              // Line 3: 这里又发生了什么?
}

上面是一个简单的类, 仅实现了一个构造函数.

到底什么是拷贝的本质? 在上面代码片断中, Line 1显然不是拷贝, 这是一个非常显然的初始化, 它调用的也很显然是我们定义的唯一的那个构造函数: person(const std::string& name, int age). Line 2和Line 3呢?

Line 2: 也是一个初始化: 初始化了对象b. 它调用的是类person拷贝构造函数.
Line 3: 是一个赋值操作. 它调用的是person=操作符重载

但问题是, 在Line 2中, 我们并没有定义某个构造函数符合person b(a)的调用. 在Line 3中, 我们也并没有实现=操作符的重载. 但上面那段代码, 是可以被正常编译执行的. 所以, 谁在背后搞鬼?

答案是编译器, 编译器在背后给你偷偷实现了拷贝构造函数(person(const person & p))与=操作符重载(person& operator =(const person & p)). 根据C++98的标准:

  1. 拷贝构造函数(copy constructor), =操作符(copy assignment operator), 析构函数(destructor)特殊的成员函数(special member functions
  2. 当用户没有显式的声明特殊的成员函数的时候, 编译器应当隐式的声明它们.
  3. 当用户没有显式的声明特殊的成员函数(显然也并没有实现它们)的时候, 如果代码中使用了这些特殊的成员函数, 编译器应当为被使用到的特殊的成员函数提供一个默认的实现

并且, 根据C++98标准, 编译器提供的默认实现遵循下面的准则:

  1. 拷贝构造函数的默认实现, 是对所有类成员的拷贝. 所谓拷贝, 就是对类成员拷贝构造函数的调用.
  2. =操作符重载的默认实现, 是对所有类成员的=调用.
  3. 析构函数默认情况下什么也不做

也就是说, 编译器为person类偷偷实现的拷贝构造函数=操作符大概长这样:

// 拷贝构造函数
person(const person& that) : name(that.name), age(that.age)
{
}

// =操作符
person& operator=(const person& that)
{
    name = that.name;
    age = that.age;
    return *this;
}

// 析构函数
~person()
{
}

问题来了: 我们需要在什么情况下显式的声明且实现特殊的成员函数呢? 答案是: 当你的类管理资源的时候, 即类的对象持有一个外部资源的时候. 这通常也意味着:

  1. 资源是在对象构造的时候, 交给对象的. 换句话说, 对象是在构造函数被调用的时候获取到资源的
  2. 资源是在对象析构的时候被释放的.

为了形象的说明管理资源的类与普通的POD类之间的区别, 我们把时光倒退到C++98之前, 那时没有什么标准库, 也没有什么std::string, C++仅是C的一个超集, 在那个旧时光, person类可能会被写成下面这样:

class person
{
    char* name;
    int age;

public:

    // 构造函数获取到了一个资源: 即是C风格的字符串
    // 本例中, 是将资源数据拷贝一份, 对象以持有资源的副本: 存储在动态分配的内存中
    // 对象所持有的资源, 即是动态分配的这段内存(资源的副本)
    person(const char* the_name, int the_age)
    {
        name = new char[strlen(the_name) + 1];
        strcpy(name, the_name);
        age = the_age;
    }

    // 析构的时候需要释放资源, 在本例中, 就是要释放资源副本占用的内存
    ~person()
    {
        delete[] name;
    }
};

这种上古风格的代码, 其实直到今天都还在有人这样写, 并且在将这种残缺的类套进std::vector, 并且调用push_back后发出痛苦的嚎叫: "MMP为什么代码一跑起来一大堆内存错误?", 就像下面这样:

int main(void ) {
	std::vector<person> vec;

	vec.push_back(person("allen", 27));
	
	return 0;
}

这是因为: 你并没有提供拷贝构造函数, 所以编译器给你实现了一个. 你调用vec.push_back(person("allen", 27))的时候, 调用了编译器的默认实现版本. 编译器的默认实现版本仅是简单的复制了值, 意味着同一段内存被两个对象同时持有着. 然后这两个对象在析构的时候都会去试图delete[]同一段内存, 所以就炸了.

这就是为什么, 如果你写了析构函数的话, 就应当再写复制构造函数=操作符重载, 它的逻辑是这样的:

  1. 你自行实现了析构函数, 说明这个类并不是简单的POD类, 它有一些资源需要在析构的时候进行释放, 或者是内存, 或者是其它句柄
  2. 为了避免上面示例中的资源重复释放问题, 你需要自行实现对象的拷贝语义, 根据资源是否能被安全的重复释放, 或者资源是否能被安全的多个对象持有多份拷贝, 来决定拷贝的语义
  3. 为了实现拷贝的语义, 你需要自行实现拷贝构造函数=操作符重载

所以一个安全的person类应当实现如下的拷贝构造函数=操作符重载

// 拷贝构造函数
person(const person& that)
{
    name = new char[strlen(that.name) + 1];
    strcpy(name, that.name);
    age = that.age;
}

// =操作符重载
person& operator=(const person& that)
{
    if (this != &that)
    {
        delete[] name;
        // 这其实是一个很危险的写法, 但如何正确的写一个=操作符重载并不属于本节所要讨论的范畴
        // 所以暂时先可以凑合这样写着
        name = new char[strlen(that.name) + 1];
        strcpy(name, that.name);
        age = that.age;
    }
    return *this;
}

注意上面的=操作符重载的实现是很不安全的, 但如何正确的写一个=操作符重载并不是本节所要讨论的内容(下一节"copy-and-swap idiom"中再进行讨论). 这里只要明白为什么析构函数, 拷贝构造函数, =操作符重载应当同生共死就行了.

某些场合中, 对象所持有的资源是不允许被拷贝的, 比如文件句柄或互斥量. 在这种场合, 与其费尽心机的去研究如何让多个对象同时持有同一个资源, 不如直接禁止这种行为: 禁止对象的拷贝与赋值. 要实现这种功能, 在C++03标准之前, 有一个很简单的方式, 即是把拷贝构造函数=操作符在类内声明为private, 并且不去实现它们, 如下:

private:

    person(const person& that);
    person& operator=(const person& that);

在C++11标准下, 你可以使用delete关键字显式的说明, 不允许拷贝操作, 如下:

person(const person& that) = delete;
person& operator=(const person& that) = delete;

所以, 至此, 就基本说明白了为什么rule of three是大家自从C++98以来, 总结出来的一个最佳实践. 比较遗憾的是, 在语言层面, C++并没有强制要求所有程序员都必须这样写, 不然不给编译通过. 所以说呀, C++这门语言还真是危险呢.

而自C++11以来, 类内的特殊的成员函数由三个, 扩充到了五个. 由于移动语义的引入, 拷贝构造函数=操作符重载都可以有其右值引用参数版本以支持移动语义, 所以rule of three就自然而然的被扩充成了rule of five, 下面是例子:

class person
{
    std::string name;
    int age;

public:
    person(const std::string& name, int age);        // 构造函数
    
    person(const person &) = default;                // 拷贝构造函数
    person(person &&) noexcept = default;            // 拷贝构造函数: 右值引用版. 也被称为移动构造函数
    person& operator=(const person &) = default;     // =操作符重载
    person& operator=(person &&) noexcept = default; // =操作符重载: 右值引用版
    ~person() noexcept = default;                    // 析构函数
};

copy-and-swap idiom

rule of three/five小节, 我们已经讨论了, 任何一个管理资源的类, 都应当实现拷贝构造函数, 析构函数=操作符重载. 这三者中, 实现拷贝构造函数析构函数的目的是很显而易见的, 但=操作符重载的实现目的, 以及实现手段在很长一段时间内都是有争论的, 人们在实践中发现, 要实现一个完善的=操作符重载, 其实并不像表面上想象的那么简单, 那么, 到底应当如何去写一个完美的=操作符重载呢? 这其中又有哪些坑呢? 这一节我们将进行深入讨论.

简单来说, copy-and-swap就是一种实现=操作符重载的最佳实践, 它主要解决(或者说避免了)两个坑:

  1. 避免了重复代码的书写
  2. 提供了强异常安全的保证

逻辑上来讲, copy-and-swap在内部复用了拷贝构造函数去拷贝资源, 然后将拷贝好的资源副本, 通过一个swap函数(注意, 这不是标准库的std::swap模板函数), 将旧资源与拷贝好的资源副本进行交换. 然后复用析构函数将旧资源进行析构. 最终仅保留拷贝后的资源副本.

上面这段话你看起来可能很头晕, 这不要紧, 后面会进行详细说明.

copy-and-swap套路的核心有三:

  1. 一个实现良好的拷贝构造函数
  2. 一个实现良好的析构函数
  3. 一个实现良好的swap函数.

所谓的swap函数, 是指这样的函数:

  1. 不抛异常
  2. 交换两个对象的所有成员
  3. 不使用std::swap去实现这个swap函数. 因为std::swap内部调用了=操作符, 而我们要写的这个swap函数, 正是为了实现=操作符重载

上面说了那么多, 可能看的你脑壳越来越痛, 不要紧, 现在我们用代码来阐述. 比如下面这个dump_array类, 内部持有堆区的资源(也就是一个通过new分配的数组), 我们先给它把拷贝构造函数析构函数实现掉.

#include <algorithm>    // std::copy
#include <cstddef>      // std::size_t

class dumb_array
{
public:
    // 构造函数
    dumb_array(std::size_t size = 0)
        : mSize(size),
          mArray(mSize ? new int[mSize]() : nullptr)
    {
    }

    // 拷贝构造函数
    dumb_array(const dumb_array& other)
        : mSize(other.mSize),
          mArray(mSize ? new int[mSize] : nullptr),
    {
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }

    // 析构函数
    ~dumb_array()
    {
        delete [] mArray;
    }

private:
    std::size_t mSize;
    int* mArray;
};

我们先来看一个失败的=操作符重载实现

// the hard part
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other)         // (1)
    {
        delete [] mArray;       // (2)
        mArray = nullptr;       // (2) *(see footnote for rationale)

        mSize = other.mSize;                                    // (3)
        mArray = mSize ? new int[mSize] : nullptr;              // (3)
        std::copy(other.mArray, other.mArray + mSize, mArray);  // (3)
    }

    return *this;
}

表面上看这个实现好像也没什么大问题, 但实际上它有三个缺陷:

  1. (1)处, 需要首先判断=操作符左右是不是同一个对象. 这个逻辑上来讲其实没什么问题. 但实际应用中, =左右两边都是同一个对象的可能性非常低, 几乎为0. 但这种判断你又不得不做, 你做了就是拖慢了代码运行速度. 但坦白讲这并不是一个什么大问题.
  2. (2)处, 先是把旧资源释放掉, 然后再在(3)处进行新资源内存的再申请与数据拷贝. 但如果第(3)步, 申请内存的时候抛异常失败了呢? 整个就垮掉了.一个改进的实现是先申请内存与数据拷贝, 成功了再做旧资源的释放, 如下
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other)
    {
        std::size_t newSize = other.mSize;
        int* newArray = newSize ? new int[newSize]() : nullptr;
        std::copy(other.mArray, other.mArray + newSize, newArray);

        delete [] mArray;
        mSize = newSize;
        mArray = newArray;
    }

    return *this;
}
  1. 整个=重载的实现, 几乎就是抄了拷贝构造函数中的代码(虽然在本例中不是很明显: 因为拷贝构造函数中使用了成员初始化列表).

看到这里你可能觉得我在吹毛求疵, 但你稍微想一下, 如果我们要管理的资源的非常复杂的初始化步骤的话, 上面的写法其实就很恶心了. 首先是异常安全的保证就需要非常小心, 其次就是抄代码的情况就会非常明显: 同样的逻辑, 你要在拷贝构造函数=操作符重载里, 写两遍!

那么一个正确的实现应当怎么写呢? 我们上面说过, copy-and-swap套路能规避掉上面的三个缺陷, 但在继续讨论之前, 我们首先要实现一个swap函数. 这个swap函数是如此的重要与核心, 我甚至愿意为此, 将所谓的rule of three改名叫成rule of three and a half, 其中的a half就是指这个swap函数. 多说无益, 我们来看swap的实现, 如下:

class dumb_array
{
public:
    // ...

    // 首先, 这是一个函数, 只是声明与实现都放在了类定义中, 而不是一个成员函数
    // 其次, 这个函数不抛异常
    friend void swap(dumb_array& first, dumb_array& second)
    {
        // 通过这条指令, 在找不到合适的swap函数时, 去调用std::swap
        using std::swap;

        // 由于两个成员都是基础类型, 它们没有自己的 swap 函数
        // 所以这里调用的是两次 std::swap 
        swap(first.mSize, second.mSize);
        swap(first.mArray, second.mArray);
    }

    // ...
};

这个swap的实现初看起来很平平无奇, 其目的也十分显而易见(交换两个对象中的所有成员), 但实际上, 上面这个写法里也是有一些门道的, 限于篇幅关系, 这里不会掰开揉碎细细讲, 你最好仔细琢磨一下这个swap的写法, 比如:

  1. 为什么它非要写成friend void swap, 而不是写成一个普通函数
  2. 里面那句using std::swap有什么玄机? 想一想, 如果dumb_array的成员变量不是基础类型, 而是一个类类型, 并且这个类类型也完整的实现了rule of three and a half, 会发生什么?

总之, 现在先不关心swap实现上的一些细节, 仅仅只需要关注它的功能即可: 它是一个函数, 它能完成两个dumb_array对象的交换, 而所谓的交换, 是交换两个对象的成员的值.

在此基础上, 我们的=操作符重载可以实现成下面这样:

dumb_array& operator=(dumb_array other) // (1)
{
    swap(*this, other); // (2)

    return *this;
}

是的, 就这是么简洁. 你没有看错, 就是这么有魔力! 那么, 为什么说它规避了我们先前提到的三个缺陷呢? 它又是如何规避的呢?

首先再回顾一下, 我们实现=操作符重载的逻辑思路:

  1. fuck = shit的内部, 我们先将shit拷贝一份, 称其为shit2好了
  2. 然后使用一个swap函数, 将fuckshit2进行交换: 即交换两个对象的所有成员变量的值. 这样就达到了"把shit的值赋给fuck"的目的
  3. 第三步, 在=操作符实现的内部退栈的时候, shit2会自动由于退栈而被析构.

整个过程没有风险, 没有异常, 很是流畅.

这里有几个点也需要额外说明一下:

  1. 首先, 这个=操作符重载的实现, 其参数是值类型, 而不是const dumb_array & other. 这里面是有门道的, 如果采用引用类型, 如下所示:
dumb_array& operator=(const dumb_array& other)
{
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
}

从功能上看, 和先前的值引用版本没什么区别. 但内在上, 你实质上放弃了一个"让编译器自动优化代码"的契机. 这个细节展开来说也比较复杂, 具体缘由在这里 有详细解释, 但总结起来就是: 在C++中, 普通的拷贝操作(调用拷贝构造函数), 比起在函数传参时, 编译器在背后执行的拷贝操作(虽然从表面看它也是在调用拷贝构造函数), 效率要低, 并且还低得多!

  1. 使用值传递来自动使编译器在背后调用拷贝构造函数(实质上编译器会做一些优化, 但你可以这样理解), 保证了只要执行流程进入到了=操作符的内部, 数据拷贝就已经完成了. 这暗地里还复用了拷贝构造函数的代码. 所以, 代码重复的问题解决了.
  2. 并且由于这样的写法, 只要函数调用这个动作被成功发起了, 就代表着数据拷贝已经成功: 这意味着拷贝过程中发生的内存分配等其它高危操作已经完成, 如果有异常, 应当在函数调用之前被扔出来, 而一旦代码执行进调用内部, 就不可能再抛异常了. 这解决了异常安全的问题
  3. 我们也规避了用以检查=左右两边是否为同一个对象的逻辑. 虽然如果这种情况发生, 这种写法会导致一次额外的数据拷贝与析构, 但这也是可以接受的, 毕竟, 如果出现了这种情况, 你应当反思的是为什么出现了自己 = 自己这种奇怪的逻辑, 而不是去苛责自己 = 自己执行的不够快.

至此, 就是copy-and-swap套路的所有内容.

那么, 在C++11中, 事情发生了任何变化了吗? 我们在rule of three/five这一小节说过, 由于C++11引入了右值引用移动语义, 所以threefive: 你要新增一个移动构造函数, 与右值引用版的=操作符重载. 但实质上, 使用copy-and-swap套路的话, 你并不需要为=操作符再写一个右值引用版本的重载, 你只需要像下面这样, 添加一个移动构造函数就可以了:

class dumb_array
{
public:
    // ...

    // 移动构造函数
    dumb_array(dumb_array&& other)
        : dumb_array() // 调用默认构造函数, 这在本例中不是必须的.
    {
        swap(*this, other);
    }

    // ...
};

关于为什么不需要再写一个右值引用版的=操作符重载, 这个, 你可以先了解一下下一节的内容: 移动语义后, 再来看这里. 总之, 就是, 使用copy-and-swap套路, 在C++11中, 可以将所谓的rule of five变成rule of four and a half, 分别是:

1.      析构函数
2.      移动构造函数
3.      拷贝构造函数
4.      `=`操作符重载 
4.5.    `swap`函数

移动语义

要理解移动语义, 其实用代码说话更容易让人理解. 我们就从下面的代码片断开始: 这是一个非常简单简陋的string的实现(注意它不是标准库中的std::string, 这里仅是我们自己实现的一个非常简陋的字符串类), 它内部使用一个指针成员持有着数据:

#include <cstring>
#include <algorithm>

class string
{
    char* data;

public:

    string(const char* p)
    {
        size_t size = strlen(p) + 1;
        data = new char[size];
        memcpy(data, p, size);
    }

由于我们在这个简陋的实现里选择使用指针来管理数据, 即是作为类的设计者, 我们需要手动管理具体数据占用的内存的分配与释放, 所以按C++03标准的最佳实践, 我们应当遵循rule of three. 即是: 析构函数, 拷贝构造函数, =操作符的重载三者必须同时在场. 我们先在这里把析构函数拷贝构造函数补上, 关于=的重载, 后面一点再谈

    ~string()
    {
        delete[] data;
    }

    string(const string& that)
    {
        size_t size = strlen(that.data) + 1;
        data = new char[size];
        memcpy(data, that.data, size);
    }

拷贝构造函数实现了拷贝的语义, 参数const string & thatconst引用, 这代表着它可以指向C++03标准中的右值, 即是一个表达式的值的最终类型是为上面这个简陋的string, 都可以作为拷贝构造函数的参数使用. 所以, 在假定我们还实现了类似于标准库中std::string+的重载的话, 我们可以以如下三种方式调用拷贝构造函数:

...
// x和y是两个string类型的变量
string a(x);                                    // Line 1
string b(x + y);                                // Line 2, 这里假设我们实现了+的重载, 使得表达式 x + y 的类型也是 string
string c(some_function_returning_a_string());   // Line 3

现在就到了理解移动语义的关键点:

注意在第一行, 我们使用x作为参数去调用拷贝构造函数初始化a, 拷贝构造函数内部实现了深拷贝: 即完整的把x底层持有的数据拷贝了一份. 这没有任何毛病, 因为这就是我们想要的, 完成初始化a之后, ax分别持有两份数据, 后续再对x做一些数据修改的操作, 不会影响到a, 反之亦然. x显然也是C++03标准中的左值.

而第二行和第三行的参数, 无论是x + y还是some_function_returning_a_string(), 显然都不能算是C++03中的左值, 显然它们都是右值. 因为这两个表达式的运算结果虽然确实是一个string的实例, 但没有一个变量名去持有这些实例, 这些实例都是临时性的实例: 阅后即焚. 即在这个表达式之后, 你没有任何办法再去访问先前表达式指代的那个string实例. 按照C++03的规范, 这种临时量占用的内存在下一个分号之后就可以被扔掉了(更精确一点的说: 在整个包含着这个右值的表达式单元执行完毕之后. 再精确一点: 编译器的实现是不确定的, 你应当假定在表达式执行完毕后这个对象就被析构了, 但编译器多数情况下只会在遇到下个}的时候才析构这种临时对象).

这就给了我们一个灵感: 既然在下个分号之后, 再也无法访问x + ysome_function_returning_a_string()这两个表达式指向的临时string对象, 意味着我们可以在下个分号之前(换句话说, 在初始化bc的过程中: 在拷贝构造函数中), 随意蹂躏这两个临时量! 反正蹂躏完了也不会产生任何额外副作用.

基于这种思路, C++11标准中引入了一种新的机制叫右值引用, 右值引用一般用于函数重载(的参数列表)中, 它的目的是探测调用者传入的参数是否是C++03中的临时量. 一旦探测到调用者传入的是一个临时量的话, 重载调用机制就会匹配到有右值引用参数的重载中. 在这种函数内部, 你通过右值引用可以去访问这个临时量, 并在内部随意蹂躏这个临时量.

说起来有一点绕, 我们直接使用右值引用这个机制去写一个拷贝构造函数的重载, 如下所示:

    string(string&& that)   // string&& is an rvalue reference to a string
    {
        data = that.data;
        that.data = nullptr;
    }

在向string的内部添加了这个拷贝构造函数后, string类内部目前就有了两个拷贝构造函数: string(const string& that)string(string&& that). 我们再回到上面的a, b, c三个初始化语句上. 这时, 由于x是一个左值, 所以a的初始化会匹配至string(const string& that). 而由于x + ysome_function_returning_a_string()是两个显然的临时量右值, 所以对于bc的初始化, 就会匹配到string(string&& that).

那么string(string&& that)内部到底做了什么事情呢? 看上面的代码就很显然, 它并没有像string(const string& that)那样去真正的拷贝一份数据, 而仅仅是把临时量内部持有的数据偷了过来, 用读书人的说法, 就叫移动.

这里需要注意, 在string(string&& that)执行结束之后, 临时量x + ysome_function_returning_a_string()还是会和C++03一样, 阅后即焚. 这两个临时对象依然会被析构. 临时量始终都是临时量, 从C++03到C++11, 这个行为没有变化. 只不过, 在析构之前, 我们已经通过string(string&& that)把它内部的数据偷掉了! 真正这两个临时量被析构的时候, 执行的只不过是delete nullptr罢了.

恭喜你, 到目前为止, 理解了C++11中移动语义的基本概念.

现在, 在进一步讨论之前, 让我们先把string类的=操作符重载再补上. 根据C++03的最佳实践之copy and swap idiom, 一个行为正确异常安全的=操作符重载应当被实现成下面这样:

    string& operator=(string that)
    {
        std::swap(data, that.data);
        return *this;
    }
};

看到上面这个代码你是不是准备问我, "右值引用哪去了? ". 我的回答是: "这里并不需要右值引用", 至于为什么, 我们再来看下面三行代码:

// x, y, a, b, c 均是string类型的变量

a = x;                                      // Line 4
b = x + y;                                  // Line 5
c = some_function_returning_a_string();     // Line 6

我们先来分析第四行(Line 4).

  1. 由于string& operator=(string that)是值类型参数, 所以在调用发生时, 参数的传递会使用x先去初始化that, 你可以理解为string that(x)这种. 由于x是一个左值. 所以that的初始化使用的是string(const string& that)这个构造函数: 即thatx的一个完整副本, 深度拷贝了x的数据
  2. 在执行std::swap(data, that.data)的过程中, a持有的数据与that持有的数据相互交换. 至此, a持有的数据其实就是x数据的一个完整副本.
  3. return *this执行之后, that由于函数退栈, 被析构. that中持有的数据(其实是原a持有的数据)被析构函数安全释放

总结起来: a = x内部, 将x的数据完整的复制了一份给a, 再把a原持有的数据安全析构掉了.

我们再来分析第五行(Line 5)

  1. 由于string& operator=(string that)是值类型参数, 所以在调用发生时, 参数的传递会使用x + y先去初始化that, 你可以理解为string that(x)这种. 由于x + y是一个临时量右值, 所以that的初始化使用的是string(string&& that)这个构造函数, 在这个构造函数内部, that偷掉了x + y内部持有的数据, 并没有发生数据拷贝.
  2. 在执行std::swap(data, that.data)的过程中, b持有的数据与that持有的数据相互交换. 至此, x + y原持有的数据经过二次转手, 来到了b的手上. 而b原持有的数据, 则交换给了that
  3. return *this执行之后, that由于函数退栈, 被析构. that中持有的数据(其实是原b持有的数据)被析构函数安全释放

总结起来: b = x + y内部, 经过两次转手, 将x + y持有的数据转交给了b, 而b原持有的数据被完全的析构掉了.

第六行和第五行类似.

至此, 你可算是基本明白了C++11中的移动语义. 现在, 请回头再看copy-and-swap小节的末尾, 你就会明白, 为什么copy-and-swap + rule of three + C++11 == rule of four and a half

移动语义, 值类别, 右值引用, 将亡值等新概念的深入讨论

我们在这里, 再对移动语义, 右值引用等内容做一些补充

概览

移动语义允许一个对象, 在一些受限的上下文中, 去夺取另外一个同类型对象的内部资源. 这有两个点:

  1. 它将C++03标准中, 代价昂贵的拷贝操作进行了优化. 但如果一个类类型, 内部并不掌管任何外部资源的话(无论是直接掌管, 还是由成员对象间接掌管), 移动语义是没有任何卵用的: 它实质上就是拷贝! 也就是说, 在这种情况下, 移动拷贝, 指的是同一件事. 比如下面这个POD类:
class cannot_benefit_from_move_semantics
{
    int a;        // 移动一个int, 其实就是拷贝
    float b;      // 移动一个float, 其实也是拷贝
    double c;     // 移动一个double, 其实还是拷贝
    char d[64];   // 移动一个字节数组, 其实还他妈是拷贝

    // ...
};
  1. 移动语义的引入可以让程序员写出这样一种类: 它的对象仅能移动, 而不能被拷贝. 这种对象中或许掌管着诸如锁, 文件句柄, 智能指针这样的全局或局部单例资源.

移动的本质是什么?

C++98中的标准库提供了一个智能指针模板类, 其语义是唯一性的指向一个对象. 即是大家熟悉的std::auto_ptr<T>. 如果你不熟悉auto_ptr, 可以将它理解为一个"保证new出来的对象一定会妥善析构(甚至在有异常抛出的场合里)而不需要程序员手动delete"的小工具, 比如下面这种用法:

{
    std::auto_ptr<Shape> a (new Triangle);
}   // <- 当代码执行流程跳出这个作用域的时候, 对象a就会被自动析构

其实, auto_ptr中值得称道的就是它的"拷贝"操作, 下面用一个简略的ASCII图来说明:

auto_ptr<Shape> a(new Triangle);            // 智能指针a指向一个新创建和Triangle对象

      +---------------+
      | triangle data |
      +---------------+
        ^
        |
        |
        |
  +-----|---+
  |   +-|-+ |
a | p | | | |
  |   +---+ |
  +---------+

auto_ptr<Shape> b(a);                       // 使用a去初始化另外一个智能指针b, 其实a与b均指向了同一个Triangle对象

      +---------------+
      | triangle data |
      +---------------+
        ^
        |
        +----------------------+
                               |
  +---------+            +-----|---+
  |   +---+ |            |   +-|-+ |
a | p |   | |          b | p | | | |
  |   +---+ |            |   +---+ |
  +---------+            +---------+

关键点在于, 用a去初始化b的时候, 在智能指针对象这一层, 确实新建了一个智能指针对象, 也就是auto_ptr<Shape>类的实例. 但在内部, 并没有新建一个Triangle对象, 两个智能指针对象指向的是同一个Triangle对象. 这就是移动语义的最初发源, 所以, 正确理解移动语义需要理解下面两句话:

  1. 当我们讲, 将a移动到b的时候. ab其实还是相互独立的两个实例, 各自在内存中占用着各自的空间.
  2. 移动语义中的移动, 其实说的是a将它"持有的资源"交给了b, 这种资源一般都是以指针形式指向的动态资源.

移动并不是说, 内存中的a对象本身改名叫b了, 并不是. ab还是各自独立的两个对象, 分别有自己的内存. 这一点一定要正确理解.

auto_ptr之所以能实现这种功能, 其实是auto_ptr<T>拷贝构造函数使用了如下的实现方式(就说这么个意思, 但并不是真的是这样写的代码):

auto_ptr(auto_ptr & source) {
    p = source.p;
    source.p = 0;
}

移动语义的错误理解导致的误用

auto_ptr时至今日已经被抛弃, 其缘由就是, 它的行为看起来让程序员以为是"拷贝", 但实际上是"移动". 比如下面的例子:

auto_ptr<Shape> a(new Triangle);
auto_ptr<Shape> b(a);           // a的资源, 也就是实际的"Triangle对象", 已经交由b了
double area = a->area();        // 完犊子

上面进行到b(a)这一步的时候, 其实a已经丢失了对Triangle对象的所有权, 其所有权转交给了b. 之后, a其实已经不持有任何对象了. 再往后a->area()显然就是做无米之炊.

当然, auto_ptr虽然来说比较危险, 但也有它自己适合的应用场合. 工厂函数就是一个特别适合auto_ptr发光发热的地方, 如下:

auto_ptr<Shape> make_triangle() {
    return auto_ptr<Shape>(new Triangle);
}

auto_ptr<Shape> c(make_triangle());     // c指向的是一个工厂生产的全新的Triangle对象
double area = make_triangle()->area();  // area是另外一个全新Triangle对象算出来的面积

上面就是一个安全的例子: 确实有两个Triangle对象. 其实这个安全的例子, 和上面那个完犊子的例子, 其实都有同样的代码书写方式:

auto_ptr<Shape> var(狗);
double area = 狗->area();

你心里明白, 同样的写法, 完犊子的例子之所以完犊子, 是因为把自己的资源在第一步交给了var, 自己一无所有了. 而工厂例子中, 每次狗都持有一个全新的资源.

但从另外一个角度来看问题: 两个例子中, amake_triangle()这两个表达式还有什么其它区别吗? 表面上看, 它们都是同一类型的表达式(auto_ptr<Shape>), 那为什么二者表现不一致呢? 这是因为二者的值类别(value categories)不同: a是一个左值(lvalue), make_triangle()是一个右值(rvalue).

值类别, value categories

我们依然从C++98与C++03的标准来一步一步的看这个问题. 在C++98与C++03中, 值类别是如此的不言自明(注意, 当我们讨论值类别的时候, 讨论的是表达式, 而不是变量), 以至于很长一段时间里, 大家都不去关心这个事情, 值类别只有两种选择: 左值(lvalue)右值(rvalue). 所谓左值, 就是可以出现在赋值操作符左侧的表达式. 所谓右值, 是指仅可以出现在赋值操作符右侧的表达式.

上面的例子中, 表达式a是一个auto_ptr<Shape>类型的变量, 这显然是一个左值, 因为a是一个变量, 可以被赋值. 而make_triangle()这个表达式是一个函数调用表达式, 其值是为其返回的对象(按值返回的对象), 每次调用它都会在内部创建一个新的auto_ptr<Shape>然后通过值返回的方式返回回来. 这显然是一个右值表达式.

我们从值类型的角度来看auto_ptr中的移动语义, 结合上面提到的"完犊子"与"工厂"两个鲜活的例子, 可以得出一个例子:

  1. 移动左值是一种危险的行为. 因为移动代表着剥夺, 但左值可能在内部资源被剥夺之后, 错误的再去尝试使用内部资源
  2. 移动诸如make_triangle()这样的右值则是一种安全的行为. 因为这种右值本身就是一次性的, 阅后即焚的, 即便你不剥夺它的内部资源, 它也会在下个;后被析构.

或者形象一点, 左值就像是一个正常的人, 能活到90岁(所属作用域终止), 你不能随便就把一个正常人的肾挖掉. 但右值就像是一个被判决死刑立即执行的人一样, 我们可以心安理得的将死刑犯的肾挖掉. 反正下午就嗝屁了, 不如死前给和谐社会做一点贡献.

auto_ptr<Shape> c(make_triangle());
                                  ^ make_triangle()表达式的值所指向的Triangle对象, 活不过这个分号

其实左值右值这个概念是从C语言一脉相承继承过来的. 左值可以出现在=左边, 右值只能出现在=右边这句话在C语言范畴中, 是绝对正确的. 但在C++98或C++03中, 并不完全正确, 这里举几个反例:

  1. 数组变量, 或删除了=操作符的类变量, 是没法出现在=左边的, 但它们都是货真价实的左值
  2. 如果一个类, 实现了=操作符, 但它的语义并不是赋值的话, 这种类的变量也有可能出现了=的左边(这确实有点抬杠了, 但你不能说这不是一个反例)

在C++98与C++03中, 或许我们这样定义左值右值可能会更精确一点:

  1. 无论左值还是右值, 本质都是, 都是对象, 都在内存中占用一块区域
  2. 左值是有名字的, 像变量就是典型的左值(变量名, 或者引用名就是名字), 意味着这块内存区域在它的作用域范围内, 可以通过名字被多次访问. 它的生命周期一般与作用域一致
  3. 右值则是没有名字的, 一般仅在表达式求值的那一个时刻可以访问这块内存区域, 之后就没有办法再去访问这块内存了.

注意, 我们当前的讨论, 还没有超出C++03的范畴.

右值引用

auto_ptr的例子我们可以看出移动语义本身的性能潜力, 但也看到了潜在的安全风险. 那么, 有没有一种机制, 能自动判断表达式的值类别, 如果是左值, 就对其执行拷贝, 如果是右值, 就对其执行移动呢?

在C++11中, 这个问题的答案就是右值引用. 右值引用是一种新的引用类型, 但它仅能绑定在右值上, 语法是T &&, 我们将原来C++03/98中的引用类型T &称为左值引用. (注意, T &&不是"对引用的引用", 就是右值引用, C++中没有"引用的引用"这种东西).

现在, 我们有两种引用: 左值引用右值引用, 如果再加上const修饰符, 我们能得到四种引用类型, 下图是一个表格, 展示了何种表达式能绑定到何种引用上:

            lvalue   const lvalue   rvalue   const rvalue
---------------------------------------------------------              
X&          yes
const X&    yes      yes            yes      yes
X&&                                 yes
const X&&                           yes      yes

是不是有点蛋疼呢? 其实实践中, 你完全可以把上表中的最后一行抹掉, const X&&代表了一种对右值的, 不可更改其值的引用, 这种类型你告诉我有什么用?

所以, 右值引用其实就是一种引用类型, 但它仅能绑定在右值

注意, 此时我们对值类别的讨论依然没有超出C++03的范畴, 我们仅是介绍了一种新的引用类型: 右值引用

隐式转换

C++在进行函数调用的时候, 默认会执行一步类型转换, 比如下面就是一个生动的例子:

#include <iostream>

class Fuck {
friend std::ostream & operator << (std::ostream & out, const Fuck & fuck) {
    return out << "Fuck[" << fuck._name << "]";
}
private:
    std::string _name;
public:
    Fuck(const std::string & name) {        // 该构造函数可以在函数调用时, 将std::string隐式的转换成Fuck对象
        _name = name;
    }

};

// 这个函数接受右值引用参数
void Jesus(Fuck && fuck) {
    std::cout << fuck << std::endl;
}

int main(void) {

    // 我们传递给Jesus的参数其实是 std::string 类型
    // 在函数调用时会被转换成 Fuck 类型
    // 并且由于表达式 std::string("shit") 是一个右值
    // 所以转换后的 Fuck 对象也是一个右值
    // 故能匹配调用Jesus成功
    Jesus(std::string("shit"));

    // 这里的fuck是一个左值, 所以调用Jesus会失败, 因为Jesus仅接受右值引用参数
    // 左值是不能匹配函数参数表的
    Fuck fuck("you");
    Jesus(fuck);

    
    return 0;
}

上例中的Jesus函数接受右值引用参数, 但实际调用的时候我们传递的是std::string("shit"), 这是一个类型为std::string的右值, 但经过类型转换被转换成Fuck类型, 这个过程中相当创建了两个临时对象:

  1. std::string("shit")创建了一个临时的std::string对象
  2. Jesus函数的调用, 由于参数的自动类型转换, 相当于再创建了一个临时的Fuck对象

最终在函数内部, 右值引用参数绑定的是2中创建的那个临时的Fuck对象.

上例中Jesus(fuck)的调用是失败的, 并且无法成功编译, 原因在于fuck是一个左值, 不匹配函数参数表.

移动构造函数

右值引用一个很重要的应用场合就是作为构造函数的参数, 即所谓的移动构造函数. 其目的是从右值中夺取资源初始化当前对象, 以节省拷贝开销.

在C++11中, std::auto_ptr<T>这个模板类被正式盖上了废弃的章, 取而代之的是std::unique_ptr<T>, 上位的手段就是右值引用. 下面我们会写一个简化版的unique_ptr的实现, 首先, 我们需要将指针类型包裹起来, 并且重载->*操作符以提供更好的使用体验:

template<typename T>
class unique_ptr{
private:
    T * _ptr;

public:
    T* operator->() const {
        return _ptr;
    }
    
    T& operator*() const {
        return *_ptr;
    }
};

然后给它加上一个构造函数与析构函数, 构造函数的目的是接管对象, 析构函数用以释放对象:

    explicit unique_ptr(T * p = nullptr) {
        _ptr = p;
    }
    
    ~unique_ptr() {
        delete _ptr;
    }

接下来就是有意思的地方: 我们来写一个移动构造函数:

    unique_ptr(unique_ptr && source) {
        _ptr = source.ptr;
        source._ptr = nullptr;
    }

这个移动构造函数所做的事情, 其实就是上面我们说的auto_ptr中的拷贝构造函数做的事情, 但是: 这个移动构造函数仅能通过右值去调用. 这样就避免了像auto_ptr那样, 掠夺左值内部资源的危险操作.

unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a);                 // 这一步调用不能成功, 因为a是一个左值, 并且我们没有定义任何拷贝构造函数
unique_ptr<Shape> c(make_triangle());   // 没有毛病, 因为表达式 `make_triangle()`的值是右值, c其实内部掠夺了`make_triangle()`表达式值的资源

b(a)是不能通过编译的, 这是因为:

  1. 由于我们已经显式的定义了一个移动构造函数, 所以编译器不再提供默认的拷贝构造函数的实现
  2. a是一个左值, 并不能匹配移动构造函数. 而它想匹配的拷贝构造函数, 没有实现

这种行为就避免了像auto_ptr那样, 对左值资源的错误掠夺

移动赋值操作符

我在先前的陈述中一直避免使用移动赋值操作符这个术语, 这是我个人的习惯, 因为我更习惯将其称之为使用右值引用作为参数的=操作符重载.

移动赋值操作符的目的是释放旧资源, 并从=右边获取(夺取)新的资源. 下面我们给unique_ptr实现一个移动赋值操作符

    unique_ptr & operator = (unique_ptr && source) {
        if (this != &source) {
            delete ptr;
            ptr = source.ptr;
            source.ptr = nullptr;
        }
        return *this;
    }
};

我们在copy-and-swap idiom中已经讲过了这种写法的缺陷, 上面的写法有两个特点:

  1. 它仅能实现资源的移动, 真是移动赋值操作符, 但并不能实现拷贝赋值的语义. 即如果=右边是一个左值, 会编译失败
  2. 这个实现只是很直观的在向你展示移动赋值操作符的语义

真正的良好实践, 如我们在copy-and-swap idiom中讲的那样, 应当如下写:

    unique_ptr & operator = (unique_ptr source) {
        std::swap(_ptr, source.ptr);
        return *this;
    }
};

这个写法有两个特点:

  1. 是否需要实现拷贝语义, 要看拷贝构造函数是否存在
  2. 避免了代码重复, 异常不安全等缺陷.

这些内容在copy-and-swap我们已经讲过了, 这里就不再重复陈述了.

在左值上实施移动: 掠夺左值的内部资源

有时候我们确实想掠夺一个左值的资源, 并且我们确实明白这样做风险的话, C++11也为我们提供了一个途径: std::move.

在继续讲之前我实再是忍不住要吐槽一下, 这就是C++吸引人的地方, 也是C++ Fucked up的地方: 真他妈是给你自由过了火, 你想怎么整都行, C++恨不得把挖祖坟的能力给你.

在头文件<utility>中, C++11新提供了一个标准库设计:模板函数std::move. 坦白讲这个模板函数的名字取的非常有误导性, 其实std::move并不实施任何与移动有关的操作, 它的功能仅是把一个左值, 转换成一个右值, 从而使得可以调用仅接受右值引用的函数. 其实讲道理这个模板函数应该把名字取成std::cast_to_rvaluestd::enable_move, 但标准已经是这样了, 咱就少吐槽乖乖接受算了.

下面是如何使用它的示例:

unique_ptr<std::string> a(new string("fuck"));
unique_ptr<std::string> b(a);                   // 按先前的讲解, 我们知道这一句会编译错误, 因为unique_ptr并没有实现拷贝构造函数, 而a是一个左传
unique_ptr<std::string> c(std::move(a));        // 这样就可以通过编译了, 但这是一个危险操作: a中的资源被掠夺了, 像我们在讨论auto_ptr的实现时那样

将亡值(xvalue)

std::move最神奇的一点就是, 虽然std::move从表面上把一个左值改成了一个右值, 但std::move本身并没有创建一个新的临时对象. 是的, std::move的求值结果, 本质上还是指向之前的那个对象, 那么, std::move的运算结果, 到底算是左值, 还是右值呢?

在C++11中, std::move的运算结果, 是一种全新的值类别, 叫将亡值(xvalue,(eXpiring value)). , 所以让人头大的事情就来了: 先来看下面这张图:

        expressions
          /     \
         /       \
        /         \
    glvalues   rvalues
      /  \       /  \
     /    \     /    \
    /      \   /      \
lvalues   xvalues   prvalues

最底层的三种值类型, 是C++11中每个表达式的值类别: 你要么是一个lvalue(左值), 要么是一个xvalue(将亡值), 要么是一个prvalue(纯右值). 其中lvalue就是C++03中的左值, 而prvalue就是C++03中的右值. 而xvalue, 则是一个全新的概念: 你可以暂时将它理解为, std::move的值类别.

函数返回值的移动(大坑)

截止目前, 我们对移动语义的讨论还未涉及函数返回, 下面是一个函数返回时移动资源的例子:

unique_ptr<Shape> make_triangle()
{
    return unique_ptr<Shape>(new Triangle);
}          \-----------------------------/
                  |
                  | return 语句中创建的临时对象中的资源, 被移动到了c中
                  | 这个过程和移动构造函数无关, 是编译器的优化行为
                  v
unique_ptr<Shape> c(make_triangle());

函数返回的过程中, make_triangle返回的是一个临时量, 用这个临时量去初始化c时, 编译器会自动将临时量的资源移动给c, 特别吊诡的事情是: 这个移动操作的过程, 移动构造函数并没有参与, 拷贝构造函数也没有参与!

函数按值返回时, 发生的诡异的移动行为, 与右值引用无关, 和C++11甚至都没有关系, 这就是一个编译器的优化行为, 这个优化行为诡异的点在于:

  1. 按C++98或03标准的眼光去看, c的初始化应当调用拷贝构造函数. 但实际上, 并没有
  2. 按C++11标准的眼光去看, c的初始化应当调用移动构造函数. 但实际上, 也没有
  3. 既然既没调用拷贝构造函数, 也没调用移动构造函数, 好吧你编译器要搞黑魔法你去搞, 那我把拷贝构造函数和移动构造函数都声明为delete行不行呢? 不行, 编译器(至少是gcc)会提示你: 类缺乏拷贝构造函数, 故函数无法返回.

编译器看起来让你以为, 它在调用拷贝构造函数或者移动构造函数, 但实际上并没有. 它内部实现了一个很诡异的移动操作: 是的, 这个临时量持有的资源, 被转交给了c. 而不是拷贝.

更诡异的事情在下面: 你按值将一个函数内部的自动变量返回的时候, 编译器都会进行资源的移动操作!!!

unique_ptr<Shape> make_square()
{
    unique_ptr<Shape> result(new Square);
    return result;
}          \----/
              |
              |    编译器将result的资源交给了d, 是移动
              |    是的, 是那种既不调移动构造函数, 也不调拷贝构造函数的, 诡异的移动
              v
unique_ptr<Shape> d(make_square());

这个编译器的鬼逻辑是这样的: 虽然从函数内部看, result是一个变量, 一个左值, 但从函数外部调用来看, result的生命周期也是短暂的, 函数调用结束后, 它就不存在了. 这和临时量很像, 所以, 我将这个result中的资源剥夺出来, 是一种安全的行为.

从下面这个简单而又完整的例子你就会看到: 函数返回时, 返回值本身就是按移动返回的, 这种移动甚至更高级: 被返回的变量, 无论是自动变量还是临时变量, 其实并没有在函数退栈的时候被析构, 这种被返回的变量, 是真真切切的存在于内存中, 只是把其名字改成了返回值的接收者! 这个点并不容易被人理解, 特别是对函数调用在汇编层面上的原理不熟悉的人来说, 显得特别诡异.

#include <iostream>

struct POD{
    int _f1;
    int _f2;
};

class Foo {
public:
    POD* _pod;

public:
    // 默认构造函数
    Foo() {
        _pod = new POD{1,2};
        std::cout << "constructor: _pod == " << _pod << std::endl;
    }

    // 拷贝构造函数
    Foo(const Foo & foo) {
        std::cout << "copy constructor" << std::endl;
        _pod = new POD{
            foo._pod->_f1,
            foo._pod->_f2,
        };
    }

    // 移动构造函数
    Foo(Foo && foo) {
        std::cout << "move constructor" << std::endl;
        _pod = foo._pod;
        foo._pod = nullptr;
    }

    // 拷贝赋值操作符
    Foo& operator = (const Foo & foo) {
        if(this != &foo) {
            _pod = new POD{
                foo._pod->_f1,
                foo._pod->_f2,
            };
        }

        return *this;
    }

    // 移动赋值操作符
    Foo& operator =(Foo && foo) {
        _pod = foo._pod;
        foo._pod = nullptr;
        return *this;
    }

    // 析构函数
    ~Foo() {
        std::cout << "destructor: _pod == " << _pod << std::endl;
        delete _pod;
        _pod = nullptr;
    }
};

Foo ReturnAutoVariableFooFromFunc() {
    std::cout << "create auto variable inside func then return it" << std::endl;
    Foo foo;    // 调用默认构造函数
    return foo; // <- 这里并没有对foo这个内部变量的析构操作
}

Foo ReturnTempVariableFooFromFunc() {
    std::cout << "create temp variable inside func then return it" << std::endl;
    return Foo(); // <- 这里也并没有对Foo()表达式创建的临时变量的析构操作
}

int main(void) {
    Foo foo = ReturnAutoVariableFooFromFunc();
    std::cout << "foo._pod outside function == " << foo._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    Foo foo2(ReturnAutoVariableFooFromFunc());
    std::cout << "foo2._pod outside function == " << foo2._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    Foo foo3 = ReturnTempVariableFooFromFunc();
    std::cout << "foo3._pod outside function == " << foo3._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    Foo foo4(ReturnTempVariableFooFromFunc());
    std::cout << "foo4._pod outside function == " << foo4._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    return 0;
}

这段代码的输出长下面这样:

create auto variable inside func then return it
constructor: _pod == 0x1ae9010
foo._pod outside function == 0x1ae9010              <- 观察这里, 并没有对析构函数的调用, 并没有对拷贝构造函数或移动构造函数的调用
--------------------
create auto variable inside func then return it
constructor: _pod == 0x1ae9030
foo2._pod outside function == 0x1ae9030             <- 观察这里, 并没有对析构函数的调用, 并没有对拷贝构造函数或移动构造函数的调用
--------------------
create temp variable inside func then return it
constructor: _pod == 0x1ae9050
foo3._pod outside function == 0x1ae9050             <- 观察这里, 并没有对析构函数的调用, 并没有对拷贝构造函数或移动构造函数的调用
--------------------
create temp variable inside func then return it
constructor: _pod == 0x1ae9070  
foo4._pod outside function == 0x1ae9070             <- 观察这里, 并没有对析构函数的调用, 并没有对拷贝构造函数或移动构造函数的调用
--------------------
destructor: _pod == 0x1ae9070
destructor: _pod == 0x1ae9050
destructor: _pod == 0x1ae9030
destructor: _pod == 0x1ae9010                       <- 这里倒是有四个次析构, 不过这是由于main函数退栈而对 foo/foo2/foo3/foo4 的析构

然而, 更大的坑在这里: C++11引入了右值引用, 我能不能手动的, 显式的返回一个右值引用, 将函数内部的临时量, 或自动变量的资源, 交给调用者呢? 答案是: 不行.

我们在上面那个小例子的基础上, 写这样的一个函数, 然后试图去调用它:

Foo && TryToReturnAnRvalueReference() {
    std::cout << "create auto variable inside func then return std::move(it)" << std::endl;
    Foo foo;
    return std::move(foo);
}

int main(void) {
    Foo foo5 = TryToReturnAnRvalueReference();
    std::cout << "foo5._pod outside function == " << foo5._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    Foo foo6(TryToReturnAnRvalueReference());
    std::cout << "foo6._pod outside function == " << foo6._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    return 0;
}

这段代码的输出在不同的编译器下面还有点不一样, 在clang++ 3.4.2编译后, 长下面这样:

create auto variable inside func then return std::move(it)
constructor: _pod == 0x1b0a010
destructor: _pod == 0x1b0a010                               <- 观察这里, 函数内的自动变量在返回之前就析构了
move constructor                                            <- 完事在这里调用了移动构造函数. 将一个已经不存在(已被析构)的对象中的已被释放(被析构函数释放)的资源进行移动
foo5._pod outside function == 0x7ffd16ec8a60                <- 导致main中的foo5已经放飞自我了
--------------------
create auto variable inside func then return std::move(it)
constructor: _pod == 0x1b0a010
destructor: _pod == 0x1b0a010                               <- 和foo5的症状基本一样
move constructor
foo6._pod outside function == 0x7ffd16ec8a48
--------------------
destructor: _pod == 0x7ffd16ec8a48
*** Error in `./bin/hello': free(): invalid pointer: 0x00007ffd16ec8a48 ***
======= Backtrace: =========
//.... 输出了错误发生时的调用栈
======= Memory map: ========
//.... 输出了错误发生时的进程内存表

clang++的编译二进制在运行后, 通过echo $?查看二进制的最终返回值, 会发现进程临死前发出的呻吟错误码不为0, 也就是说, clang认为这是一段导致进程crash掉的代码.

而上面这段代码经过g++ 4.8.5编译后, 输出长下面这样:

create auto variable inside func then return std::move(it)
constructor: _pod == 0x9c6010
destructor: _pod == 0x9c6010                                <- 观察这里, 函数内的自动变量在返回之前就析构了
move constructor
foo5._pod outside function == 0                             <- main中的foo5没有放飞自我, 而是其资源句柄_pod字段的值, 被移动构造函数置为了0. 
--------------------                                           合理的解释就是, 函数内的自动变量在析构之后, 内存置0了而已
create auto variable inside func then return std::move(it)
constructor: _pod == 0x9c6010
destructor: _pod == 0x9c6010
move constructor
foo6._pod outside function == 0
--------------------
destructor: _pod == 0
destructor: _pod == 0

g++编译的二进制在运行后, 通过echo $?查看二进制的最终返回值, 是0, 也就是说, g++暂且不认为进程崩掉了. 但这也并不代码你用g++来做开发工作就能写这样的代码!!

上面的编译过程中, 编译参数中的 -O均被设置为-O0

总结一下, 截止目前为止, C++11提供的所谓的右值引用+移动语义, 只能用在两个场合:

  1. 函数调用时的参数传递(通过移动构造函数)
  2. 对象之间的相互拷贝(通过移动赋值操作符)

而关于函数返回这里, 从C语言一路沿袭下来的内存模型(函数退栈返回的时候, 返回值对象在内存或寄存器中, 是直接改名"移动"的, 而不是进行拷贝, 这是编译器的成果: 汇编层面的行为, 与程序员写的任何构造函数都没关系)决定了, 这和C++11中的右值引用移动语义没有任何卵关系. 当然, 右值引用在一些很特殊的条件下, 可以作为函数的返回值, 但最佳实践的建议是:

  1. 不要这样做
  2. 如果你非要这样做, 不要用std::move(函数中的自动变量或临时变量)这种方式去返回

将资源移动给类成员(小坑)

我们来看下面这段代码, 然后你猜一猜它能不能编译通过, 为了你阅读方便, 我把完整的unique_ptr的定义都附带上了:

#include <iostream>
#include <utility>

template<typename T>
class unique_ptr{
private:
    T * _ptr;

public:
	// 解引用操作符重载
    T* operator->() const { return _ptr; }
    
	// 取地址操作符重载
    T& operator*() const { return *_ptr; }

	// 构造函数(默认构造函数)
    explicit unique_ptr(T * p = nullptr) { _ptr = p; }

    // 拷贝构造函数被显式删除
    unique_ptr(const unique_ptr & other) = delete;
    
	// 析构函数
    ~unique_ptr() { delete _ptr; }

	// 移动构造函数
    unique_ptr(unique_ptr && source) { _ptr = source._ptr; source._ptr = nullptr; }

	// 移动赋值操作符, 使用了 copy-and-swap idiom
    unique_ptr & operator = (unique_ptr source) { std::swap(_ptr, source.ptr); return *this; }
};

class Foo {
private:
    unique_ptr<int> _member;

public:
    // 构造函数
    Foo(unique_ptr<int> && param) :
        _member(param)                  // <-- 关键在这里, 编译错误在这里
    {
        
    }
};

int main(void) {
    return 0;
}

结果是不能编译通过的, 编译器(g++ 4.8.5)给出的错误提示大致如下:

main.cpp: In constructor ‘Foo::Foo(unique_ptr<int>&&)’:
main.cpp:39:22: error: use of deleted function ‘unique_ptr<T>::unique_ptr(const unique_ptr<T>&) [with T = int]’
         _member(param)
                      ^
main.cpp:20:5: error: declared here
     unique_ptr(const unique_ptr & other) = delete;

我们再来看看clang++ 3.4.2给出的错误提示信息吧:

main.cpp:39:9: error: call to deleted constructor of 'unique_ptr<int>'
        _member(param)
        ^       ~~~~~
main.cpp:20:5: note: function has been explicitly marked deleted here
    unique_ptr(const unique_ptr & other) = delete;
    ^

看来这次gcc与clang算是达成一致了.

编译器的意思是说, 你试图在 _member(param)这一行调用一个已经被删除的拷贝构造函数. 但是这很不符合我们的直觉: 我们认为param是一个右值引用啊, 我们试图调用的是移动构造函数, 而不是被显式删除的拷贝构造函数, 这是怎么回事呢?

原因在于, 编译器认为, param是一个左值...其内在逻辑是这样的:

param作为一个形参, 被声明为右值引用类型, 这包含了两个意思:

  1. 你只能用一个右值引用去初始化param
  2. param本身并不是一个右值引用. 相反, 它是一个普通的左值

所以, 上面代码编译失败的原因在于: 你试图用一个左值(param)去初始化_member成员, 但_member成员所属的类, 并没有实现拷贝构造函数!

有点想骂人是吧? 所以, 核心逻辑在于, 我再换个说法再说一遍: 右值引用参数限定了只能通过右值引用去初始化这个参数, 但这个参数其实是个左值

显然这种逻辑有点不合理, 那么如何才能把我们上面的代码改正确呢? 这时候就要祭出std::move了, 如下修改即可:

class Foo {
private:
    unique_ptr<int> _member;

public:
    // 构造函数
    Foo(unique_ptr<int> && param) :
        _member(std::move(param))                  // <-- 你说你是左值是吧? 我把你强转成xvalue
    {
        
    }
};

特殊的成员函数

C++98标准定义了三个特殊的类内成员函数, 并且一直沿用至C++03标准, 这三个特殊的成员函数分别是:

  1. X::X(const X&); 拷贝构造函数
  2. X& X::operator=(const X&); 拷贝赋值操作符
  3. X::~X(); 析构函数

C++11标准由于右值引用移动语义的引入, 追加了两个特殊的类内成员函数:

  1. X::X(X&&); 移动构造函数
  2. X& X::operator=(X&&); 移动赋值操作符

对于特殊成员函数, 编译器在某些情况下会提供默认实现, 规则如下:

  1. 编译器仅在以上五个特殊的成员函数都没有声明实现的时候, 才会去默认给你生成一个默认的移动构造函数默认的移动赋值操作符.
  2. 一旦你自己实现了移动构造函数, 或移动赋值操作符. 编译器就不会给你生成拷贝构造函数拷贝赋值操作符的默认实现

那么, 在日常实践中, 应当怎么做呢? 很简单:

if(类内没有掌管任何资源) {
    五个特殊成员函数一个都不用实现, 编译器自动提供的默认实现就完全够用
    并且你能从其提供的默认移动构造函数与默认移动赋值操作符中获得性能提升
} else if(类掌管了资源){
    if (拷贝资源的开销 > 移动资源的开销) {
        五个特殊成员函数都实现一遍, 当然具体实践的时候可以采用 rule of four and a half的方式, 不实现移动赋值操作符
    } else {
        仅实现三个古典的特殊成员函数. 不需要实现移动构造函数和移动赋值操作符
    }
}

我们在copy-and-swap idom中说过, rule of five可以被简化为rule of four and a half, 这里再重温一遍, 这种最佳实践下, 你无需显式实现移动赋值操作符, 仅需要如下实现一个赋值操作符的重载即可:

X& X::operator=(X source)
{
    swap(source);
    return *this;
}

引用转发

来看下面这个模板函数的签名:

template<typename T>
void foo(T&&);

第一眼看上去, 你可以会认为, 这个模板函数的形式参数类型是为T&&, 这显然是一个右值引用嘛, 所以你会认为: 要调用这个模板函数, 必须使用右值引用作为参数.

但实际情况是: 你竟然可以使用左值去调用这个模板函数..

#include <iostream>

template<typename T>
void foo(T&& t) {
    std::cout << t << std::endl;
}

int main(void) {
    foo(23);

    foo("i love you");

    int a = 2333;

    foo(a);         // <-- 可以使用一个左值去调用foo模板函数!
    return 0;
}

这他妈的....先骂会娘..

那么到底是哪个环节出了问题呢? 明明我形参的类型写的是T&&, 是显而易见的右值引用类型的形参啊!! 问题出在模板函数的类型推导上了..这里的逻辑是这样的:

foo(23)的调用中, 参数是int的左值, 所以模板类型T被推导为int, 所以整个模板函数会被实例化为void foo(int&& t), 没有任何毛病, 这个模板函数的实例确实是个接受右值引用类型参数的函数

但在foo(a)的调用中, 参数是int类型的左值, 这里由于模板函数类型推导的一个特殊规则, 模板函数的类型参数T实质上会被推导为int &类型, 而不是int. 现在问题来了: 如果T被推导成了int &, 那么, T&&的意思难道是int& &&吗? 这是什么鬼玩意?

C++中并不存在一种类型, 可以后面带三个&, 真实的T&&被降格为int &, 换句话说, foo(a)调用的那个实例函数, 它其实长这样:

void foo(int & t) {
    std::cout << t << std::endl;
}

这种从int & &&降格到int &的类型推导过程, 被称为collapsed. 这个类型推导过程中的特殊逻辑, 是C++11中另外一个新特性: 完美转发(perfect forwarding)的基石.

那么如果你真的想写一个函数模板, 让它的参数仅接受右值引用, 正确的写法应该怎么写呢? 正确的写法如下:

#include <type_traits>

template<typename T>
typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
foo(T&&);

下面就是生动的例子:

#include <iostream>
#include <type_traits>

template<typename T>
typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
foo(T&& t) {
    std::cout << t << std::endl;
}

int main(void) {
    foo(23);

    int a = 2333;
    foo(a);         // <-- 编译失败

    return 0;
}

编译失败的信息如下(clang 3.4.2):

main.cpp:14:5: error: no matching function for call to 'foo'
    foo(a);         // <-- 编译失败
    ^~~
main.cpp:5:25: note: candidate template ignored: disabled by 'enable_if' [with T = int &]
typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
                        ^

这个写法为什么会生效, 是一个比较复杂的问题, 有兴趣的话可以去研究一下头文件<type_traits>中的内容. 这里就不展开了.

std::move的实现

通过上面的陈述, 你明白了在模板参数的类型推导中, 有一个特殊逻辑叫collapsed, 而其实std::move的实现就与这个特性有关, 下面就是std::move的源代码:

template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

上面是原样复制自libstdc++ 4.8.5中的源代码, 将它稍微修整一下, 长这样:

template<typename T>
typename std::remove_reference<T>::type&&
move(T&& t)
{
    return static_cast<typename std::remove_reference<T>::type&&>(t);
}

我们来解读一波:

首先, 由于(T&& t)的参数类型声明, 与collapsed的推断规则, 我们可以知道, move其实可以接受任何类型的参数.

其次, 它的返回值类型是为 typename std::remove_reference<T>::type&&, 其中std::remove_reference<T>::type保证了在入参为int &类型的情况下, 返回值类型是int&&, 即始终保证返回值是右值引用类型

最后, 函数内部的具体实现, 其实就是调用static_cast将入参强转为右值引用. 由于在函数内部, 形参t已经被初始化为一个左值引用, 根据collapsed可知, 它在函数内部是一个左值(如果引发类型推断的实参是int类型, 则形参会被推导为int && t, 但在被初始化后, t就是一个左值引用. 如果引发类型推断的实参是int &类型, 则形参由于collapsed会被推断为int & t, 在被初始化后, t还是一个左值引用). 所以, 这里先用std::remove_reference脱掉引用, 再用&&将其强转为右值引用.

posted @ 2019-05-16 22:09  张浮生  阅读(1693)  评论(0编辑  收藏  举报