对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的标准:
拷贝构造函数(copy constructor)
,=操作符(copy assignment operator)
,析构函数(destructor)
是特殊的成员函数(special member functions
- 当用户没有显式的声明
特殊的成员函数
的时候, 编译器应当隐式的声明它们. - 当用户没有显式的声明
特殊的成员函数
(显然也并没有实现它们)的时候, 如果代码中使用了这些特殊的成员函数
, 编译器应当为被使用到的特殊的成员函数
提供一个默认的实现
并且, 根据C++98标准, 编译器提供的默认实现遵循下面的准则:
拷贝构造函数
的默认实现, 是对所有类成员的拷贝
. 所谓拷贝
, 就是对类成员拷贝构造函数
的调用.=操作符重载
的默认实现, 是对所有类成员的=调用
.析构函数
默认情况下什么也不做
也就是说, 编译器为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()
{
}
问题来了: 我们需要在什么情况下显式的声明且实现特殊的成员函数
呢? 答案是: 当你的类管理资源
的时候, 即类的对象持有一个外部资源
的时候. 这通常也意味着:
- 资源是在对象构造的时候, 交给对象的. 换句话说, 对象是在构造函数被调用的时候获取到资源的
- 资源是在对象析构的时候被释放的.
为了形象的说明管理资源的类与普通的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[]
同一段内存, 所以就炸了.
这就是为什么, 如果你写了析构函数
的话, 就应当再写复制构造函数
与=操作符重载
, 它的逻辑是这样的:
- 你自行实现了析构函数, 说明这个类并不是简单的POD类, 它有一些资源需要在析构的时候进行释放, 或者是内存, 或者是其它句柄
- 为了避免上面示例中的资源重复释放问题, 你需要自行实现对象的
拷贝
语义, 根据资源是否能被安全的重复释放, 或者资源是否能被安全的多个对象持有多份拷贝, 来决定拷贝
的语义 - 为了实现
拷贝
的语义, 你需要自行实现拷贝构造函数
与=操作符重载
所以一个安全的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
就是一种实现=操作符重载
的最佳实践, 它主要解决(或者说避免了)两个坑:
- 避免了重复代码的书写
- 提供了强异常安全的保证
逻辑上来讲, copy-and-swap
在内部复用了拷贝构造函数
去拷贝资源, 然后将拷贝好的资源副本, 通过一个swap
函数(注意, 这不是标准库的std::swap
模板函数), 将旧资源与拷贝好的资源副本进行交换. 然后复用析构函数将旧资源进行析构. 最终仅保留拷贝后的资源副本.
上面这段话你看起来可能很头晕, 这不要紧, 后面会进行详细说明.
copy-and-swap
套路的核心有三:
- 一个实现良好的
拷贝构造函数
- 一个实现良好的
析构函数
- 一个实现良好的
swap
函数.
所谓的swap
函数, 是指这样的函数:
- 不抛异常
- 交换两个对象的所有成员
- 不使用
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)
处, 需要首先判断=
操作符左右是不是同一个对象. 这个逻辑上来讲其实没什么问题. 但实际应用中,=
左右两边都是同一个对象的可能性非常低, 几乎为0. 但这种判断你又不得不做, 你做了就是拖慢了代码运行速度. 但坦白讲这并不是一个什么大问题. - 在
(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;
}
- 整个
=重载
的实现, 几乎就是抄了拷贝构造函数
中的代码(虽然在本例中不是很明显: 因为拷贝构造函数
中使用了成员初始化列表).
看到这里你可能觉得我在吹毛求疵, 但你稍微想一下, 如果我们要管理的资源的非常复杂的初始化步骤的话, 上面的写法其实就很恶心了. 首先是异常安全的保证就需要非常小心, 其次就是抄代码的情况就会非常明显: 同样的逻辑, 你要在拷贝构造函数
和=操作符重载
里, 写两遍!
那么一个正确的实现应当怎么写呢? 我们上面说过, 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
的写法, 比如:
- 为什么它非要写成
friend void swap
, 而不是写成一个普通函数 - 里面那句
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;
}
是的, 就这是么简洁. 你没有看错, 就是这么有魔力! 那么, 为什么说它规避了我们先前提到的三个缺陷呢? 它又是如何规避的呢?
首先再回顾一下, 我们实现=操作符重载
的逻辑思路:
- 在
fuck = shit
的内部, 我们先将shit
拷贝一份, 称其为shit2
好了 - 然后使用一个
swap
函数, 将fuck
与shit2
进行交换: 即交换两个对象的所有成员变量的值. 这样就达到了"把shit
的值赋给fuck
"的目的 - 第三步, 在
=
操作符实现的内部退栈的时候,shit2
会自动由于退栈而被析构.
整个过程没有风险, 没有异常, 很是流畅.
这里有几个点也需要额外说明一下:
- 首先, 这个
=操作符重载
的实现, 其参数是值类型, 而不是const dumb_array & other
. 这里面是有门道的, 如果采用引用类型, 如下所示:
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
从功能上看, 和先前的值引用版本没什么区别. 但内在上, 你实质上放弃了一个"让编译器自动优化代码"的契机. 这个细节展开来说也比较复杂, 具体缘由在这里 有详细解释, 但总结起来就是: 在C++中, 普通的拷贝操作(调用拷贝构造函数), 比起在函数传参时, 编译器在背后执行的拷贝操作(虽然从表面看它也是在调用拷贝构造函数), 效率要低, 并且还低得多!
- 使用值传递来自动使编译器在背后调用
拷贝构造函数
(实质上编译器会做一些优化, 但你可以这样理解), 保证了只要执行流程进入到了=操作符
的内部, 数据拷贝就已经完成了. 这暗地里还复用了拷贝构造函数
的代码. 所以, 代码重复的问题解决了. - 并且由于这样的写法, 只要函数调用这个动作被成功发起了, 就代表着数据拷贝已经成功: 这意味着拷贝过程中发生的内存分配等其它高危操作已经完成, 如果有异常, 应当在函数调用之前被扔出来, 而一旦代码执行进调用内部, 就不可能再抛异常了. 这解决了异常安全的问题
- 我们也规避了用以检查
=
左右两边是否为同一个对象的逻辑. 虽然如果这种情况发生, 这种写法会导致一次额外的数据拷贝与析构, 但这也是可以接受的, 毕竟, 如果出现了这种情况, 你应当反思的是为什么出现了自己 = 自己
这种奇怪的逻辑, 而不是去苛责自己 = 自己
执行的不够快.
至此, 就是copy-and-swap
套路的所有内容.
那么, 在C++11中, 事情发生了任何变化了吗? 我们在rule of three/five
这一小节说过, 由于C++11引入了右值引用
和移动语义
, 所以three
变five
: 你要新增一个移动构造函数
, 与右值引用版的=操作符重载
. 但实质上, 使用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 & that
是const引用
, 这代表着它可以指向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
之后, a
和x
分别持有两份数据, 后续再对x
做一些数据修改的操作, 不会影响到a
, 反之亦然. x
显然也是C++03标准中的左值.
而第二行和第三行的参数, 无论是x + y
还是some_function_returning_a_string()
, 显然都不能算是C++03中的左值, 显然它们都是右值. 因为这两个表达式的运算结果虽然确实是一个string
的实例, 但没有一个变量名去持有这些实例, 这些实例都是临时性的实例: 阅后即焚. 即在这个表达式之后, 你没有任何办法再去访问先前表达式指代的那个string
实例. 按照C++03的规范, 这种临时量占用的内存在下一个分号之后就可以被扔掉了(更精确一点的说: 在整个包含着这个右值的表达式单元执行完毕之后. 再精确一点: 编译器的实现是不确定的, 你应当假定在表达式执行完毕后这个对象就被析构了, 但编译器多数情况下只会在遇到下个}的时候才析构这种临时对象).
这就给了我们一个灵感: 既然在下个分号之后, 再也无法访问x + y
与some_function_returning_a_string()
这两个表达式指向的临时string
对象, 意味着我们可以在下个分号之前(换句话说, 在初始化b
和c
的过程中: 在拷贝构造函数中), 随意蹂躏这两个临时量! 反正蹂躏完了也不会产生任何额外副作用.
基于这种思路, 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 + y
与some_function_returning_a_string()
是两个显然的临时量右值, 所以对于b
和c
的初始化, 就会匹配到string(string&& that)
.
那么string(string&& that)
内部到底做了什么事情呢? 看上面的代码就很显然, 它并没有像string(const string& that)
那样去真正的拷贝一份数据, 而仅仅是把临时量内部持有的数据偷了过来, 用读书人的说法, 就叫移动
.
这里需要注意, 在string(string&& that)
执行结束之后, 临时量x + y
与some_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).
- 由于
string& operator=(string that)
是值类型参数, 所以在调用发生时, 参数的传递会使用x
先去初始化that
, 你可以理解为string that(x)
这种. 由于x
是一个左值. 所以that
的初始化使用的是string(const string& that)
这个构造函数: 即that
是x
的一个完整副本, 深度拷贝了x
的数据 - 在执行
std::swap(data, that.data)
的过程中,a
持有的数据与that
持有的数据相互交换. 至此,a
持有的数据其实就是x
数据的一个完整副本. - 在
return *this
执行之后,that
由于函数退栈, 被析构.that
中持有的数据(其实是原a
持有的数据)被析构函数安全释放
总结起来: a = x
内部, 将x
的数据完整的复制了一份给a
, 再把a
原持有的数据安全析构掉了.
我们再来分析第五行(Line 5)
- 由于
string& operator=(string that)
是值类型参数, 所以在调用发生时, 参数的传递会使用x + y
先去初始化that
, 你可以理解为string that(x)
这种. 由于x + y
是一个临时量右值, 所以that
的初始化使用的是string(string&& that)
这个构造函数, 在这个构造函数内部,that
偷掉了x + y
内部持有的数据, 并没有发生数据拷贝. - 在执行
std::swap(data, that.data)
的过程中,b
持有的数据与that
持有的数据相互交换. 至此,x + y
原持有的数据经过二次转手, 来到了b
的手上. 而b
原持有的数据, 则交换给了that
- 在
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
了
移动语义, 值类别, 右值引用, 将亡值等新概念的深入讨论
我们在这里, 再对移动语义
, 右值引用
等内容做一些补充
概览
移动语义
允许一个对象, 在一些受限的上下文中, 去夺取另外一个同类型对象的内部资源. 这有两个点:
- 它将C++03标准中, 代价昂贵的
拷贝
操作进行了优化. 但如果一个类类型, 内部并不掌管任何外部资源的话(无论是直接掌管, 还是由成员对象间接掌管),移动语义
是没有任何卵用的: 它实质上就是拷贝! 也就是说, 在这种情况下,移动
和拷贝
, 指的是同一件事. 比如下面这个POD类:
class cannot_benefit_from_move_semantics
{
int a; // 移动一个int, 其实就是拷贝
float b; // 移动一个float, 其实也是拷贝
double c; // 移动一个double, 其实还是拷贝
char d[64]; // 移动一个字节数组, 其实还他妈是拷贝
// ...
};
移动语义
的引入可以让程序员写出这样一种类: 它的对象仅能移动
, 而不能被拷贝
. 这种对象中或许掌管着诸如锁, 文件句柄, 智能指针这样的全局或局部单例资源.
移动的本质是什么?
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
对象. 这就是移动语义
的最初发源, 所以, 正确理解移动语义
需要理解下面两句话:
- 当我们讲, 将
a
移动到b
的时候.a
和b
其实还是相互独立的两个实例, 各自在内存中占用着各自的空间. 移动语义
中的移动
, 其实说的是a
将它"持有的资源"交给了b
, 这种资源
一般都是以指针
形式指向的动态资源.
移动
并不是说, 内存中的a
对象本身改名叫b
了, 并不是. a
和b
还是各自独立的两个对象, 分别有自己的内存. 这一点一定要正确理解.
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
, 自己一无所有了. 而工厂例子中, 每次狗都持有一个全新的资源.
但从另外一个角度来看问题: 两个例子中, a
和make_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
中的移动语义, 结合上面提到的"完犊子"与"工厂"两个鲜活的例子, 可以得出一个例子:
- 移动
左值
是一种危险的行为. 因为移动
代表着剥夺
, 但左值可能在内部资源被剥夺
之后, 错误的再去尝试使用内部资源 - 移动诸如
make_triangle()
这样的右值
则是一种安全的行为. 因为这种右值
本身就是一次性的, 阅后即焚的, 即便你不剥夺
它的内部资源, 它也会在下个;
后被析构.
或者形象一点, 左值
就像是一个正常的人, 能活到90岁(所属作用域终止), 你不能随便就把一个正常人的肾挖掉. 但右值
就像是一个被判决死刑立即执行的人一样, 我们可以心安理得的将死刑犯的肾挖掉. 反正下午就嗝屁了, 不如死前给和谐社会做一点贡献.
auto_ptr<Shape> c(make_triangle());
^ make_triangle()表达式的值所指向的Triangle对象, 活不过这个分号
其实左值
与右值
这个概念是从C语言一脉相承继承过来的. 左值可以出现在=左边, 右值只能出现在=右边
这句话在C语言范畴中, 是绝对正确的. 但在C++98或C++03中, 并不完全正确, 这里举几个反例:
- 数组变量, 或删除了
=操作符
的类变量, 是没法出现在=
左边的, 但它们都是货真价实的左值
- 如果一个类, 实现了
=操作符
, 但它的语义并不是赋值的话, 这种类的变量也有可能出现了=
的左边(这确实有点抬杠了, 但你不能说这不是一个反例)
在C++98与C++03中, 或许我们这样定义左值
和右值
可能会更精确一点:
- 无论
左值
还是右值
, 本质都是值
, 都是对象
, 都在内存中占用一块区域 左值
是有名字的值
, 像变量就是典型的左值(变量名, 或者引用名就是名字), 意味着这块内存区域在它的作用域范围内, 可以通过名字
被多次访问. 它的生命周期一般与作用域一致右值
则是没有名字的值
, 一般仅在表达式求值
的那一个时刻可以访问这块内存区域, 之后就没有办法再去访问这块内存了.
注意, 我们当前的讨论, 还没有超出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
类型, 这个过程中相当创建了两个临时对象:
std::string("shit")
创建了一个临时的std::string
对象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)
是不能通过编译的, 这是因为:
- 由于我们已经显式的定义了一个
移动构造函数
, 所以编译器不再提供默认的拷贝构造函数
的实现 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
中已经讲过了这种写法的缺陷, 上面的写法有两个特点:
- 它仅能实现资源的移动, 真是
移动赋值操作符
, 但并不能实现拷贝赋值
的语义. 即如果=
右边是一个左值, 会编译失败 - 这个实现只是很直观的在向你展示
移动赋值操作符
的语义
真正的良好实践, 如我们在copy-and-swap idiom
中讲的那样, 应当如下写:
unique_ptr & operator = (unique_ptr source) {
std::swap(_ptr, source.ptr);
return *this;
}
};
这个写法有两个特点:
- 是否需要实现
拷贝
语义, 要看拷贝构造函数
是否存在 - 避免了代码重复, 异常不安全等缺陷.
这些内容在copy-and-swap
我们已经讲过了, 这里就不再重复陈述了.
在左值上实施移动: 掠夺左值的内部资源
有时候我们确实想掠夺一个左值的资源, 并且我们确实明白这样做风险的话, C++11也为我们提供了一个途径: std::move
.
在继续讲之前我实再是忍不住要吐槽一下, 这就是C++吸引人的地方, 也是C++ Fucked up的地方: 真他妈是给你自由过了火, 你想怎么整都行, C++恨不得把挖祖坟的能力给你.
在头文件<utility>
中, C++11新提供了一个标准库设计:模板函数std::move
. 坦白讲这个模板函数的名字取的非常有误导性, 其实std::move
并不实施任何与移动
有关的操作, 它的功能仅是把一个左值
, 转换成一个右值
, 从而使得可以调用仅接受右值引用
的函数. 其实讲道理这个模板函数应该把名字取成std::cast_to_rvalue
或std::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甚至都没有关系, 这就是一个编译器的优化行为, 这个优化行为诡异的点在于:
- 按C++98或03标准的眼光去看,
c
的初始化应当调用拷贝构造函数. 但实际上, 并没有 - 按C++11标准的眼光去看,
c
的初始化应当调用移动构造函数. 但实际上, 也没有 - 既然既没调用拷贝构造函数, 也没调用移动构造函数, 好吧你编译器要搞黑魔法你去搞, 那我把拷贝构造函数和移动构造函数都声明为
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提供的所谓的右值引用
+移动语义
, 只能用在两个场合:
- 函数调用时的参数传递(通过
移动构造函数
) - 对象之间的相互拷贝(通过
移动赋值操作符
)
而关于函数返回这里, 从C语言一路沿袭下来的内存模型(函数退栈返回的时候, 返回值对象在内存或寄存器中, 是直接改名"移动"的, 而不是进行拷贝, 这是编译器的成果: 汇编层面的行为, 与程序员写的任何构造函数都没关系)决定了, 这和C++11中的右值引用
或移动语义
没有任何卵关系. 当然, 右值引用在一些很特殊的条件下, 可以作为函数的返回值, 但最佳实践的建议是:
- 不要这样做
- 如果你非要这样做, 不要用
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
作为一个形参, 被声明为右值引用
类型, 这包含了两个意思:
- 你只能用一个
右值引用
去初始化param
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标准, 这三个特殊的成员函数分别是:
X::X(const X&);
拷贝构造函数X& X::operator=(const X&);
拷贝赋值操作符X::~X();
析构函数
C++11标准由于右值引用
与移动语义
的引入, 追加了两个特殊的类内成员函数:
X::X(X&&);
移动构造函数X& X::operator=(X&&);
移动赋值操作符
对于特殊成员函数, 编译器在某些情况下会提供默认实现, 规则如下:
- 编译器仅在以上五个特殊的成员函数都没有声明实现的时候, 才会去默认给你生成一个
默认的移动构造函数
与默认的移动赋值操作符
. - 一旦你自己实现了
移动构造函数
, 或移动赋值操作符
. 编译器就不会给你生成拷贝构造函数
与拷贝赋值操作符
的默认实现
那么, 在日常实践中, 应当怎么做呢? 很简单:
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
脱掉引用, 再用&&
将其强转为右值引用.
对C++11中的`移动语义`与`右值引用`的介绍与讨论的更多相关文章
- move语义和右值引用
C++11支持move语义,用以避免非必要拷贝和临时对象. 具体内容见收藏中的“C++右值引用” .
- (原创)C++11改进我们的程序之右值引用
本次主要讲c++11中的右值引用,后面还会讲到右值引用如何结合std::move优化我们的程序. c++11增加了一个新的类型,称作右值引用(R-value reference),标记为T & ...
- C++11新特性(1) 右值引用
在C++中,左值(lvalue)是能够获取其地址的一个量.因为常常出如今赋值语句的左边.因此称之为左值.比如一个有名称的变量. 比如: int a=10; //a就是一个左值. 传统的C++引用,都是 ...
- c++ 11 移动语义、std::move 左值、右值、将亡值、纯右值、右值引用
为什么要用移动语义 先看看下面的代码 // rvalue_reference.cpp : 定义控制台应用程序的入口点. // #include "stdafx.h" #includ ...
- [c++11]右值引用、移动语义和完美转发
c++中引入了右值引用和移动语义,可以避免无谓的复制,提高程序性能.有点难理解,于是花时间整理一下自己的理解. 左值.右值 C++中所有的值都必然属于左值.右值二者之一.左值是指表达式结束后依然存在的 ...
- [转][c++11]我理解的右值引用、移动语义和完美转发
c++中引入了右值引用和移动语义,可以避免无谓的复制,提高程序性能.有点难理解,于是花时间整理一下自己的理解. 左值.右值 C++中所有的值都必然属于左值.右值二者之一.左值是指表达式结束后依然存在的 ...
- c++11——右值引用
1. 左值和右值 左值是表达式结束之后仍然存在的持久化对象,而右值是指表达式结束时就不再存在的临时对象. c++11中,右值分为两种类型:将亡值(xvalue, expiring value) ...
- C++11中的右值引用及move语义编程
C++0x中加入了右值引用,和move函数.右值引用出现之前我们只能用const引用来关联临时对象(右值)(造孽的VS可以用非const引用关联临时对象,请忽略VS),所以我们不能修临时对象的内容,右 ...
- 【转】C++11 标准新特性: 右值引用与转移语义
VS2013出来了,对于C++来说,最大的改变莫过于对于C++11新特性的支持,在网上搜了一下C++11的介绍,发现这篇文章非常不错,分享给大家同时自己作为存档. 原文地址:http://www.ib ...
随机推荐
- JavaIO学习:缓冲流
缓冲流 1.缓冲流涉及到的类 BufferedInputStream BufferedOutputStream BufferedReader BufferedWriter 2.作用 提升流的读取.写入 ...
- UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa1 in position 3: invalid start byte错误解决办法
这类错误的原因是编码造成的,通常情况下都是utf-8编码,这需要变换一下,改成encoding="ISO-8859-1"即可: file = pd.read_csv("/ ...
- 七雄Q传封包辅助技术探讨回忆贴
前言 网页游戏2013年左右最火的类型最烧钱游戏,当年的我也掉坑了.为了边玩还满足码农精神我奋力的学习如何来做外挂.2013年我工作的第二个年头.多一半…介绍下游戏<七雄Q传>是北京游戏谷 ...
- 一款神器,批量手机号码归属地查询软件,支持导出Excel表格
很多人查询手机号码归属地,还在一个一个百度去查太慢了,如果有几万个那么是不是要百度很久 有一款软件很多人都不知道,可以吧号码复制进去,即使里面有汉字也可以把手机号码挑出来,然后查询归属地,还具有号码去 ...
- C#自定义消息函数,需要一个TextBox,一个委托,直接上代码;
private delegate void de_OutputMessage(string str); public void OutputMessage(string str) { if (text ...
- 2 java并行基础
我们认真研究如何才能构建一个正确.健壮并且高效的并行系统. 进程与线程 进程(Process):是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础 ...
- 基于vue+springboot+docker网站搭建【五】部署vue前端项目
部署vue前端项目 一.下载项目到本地 https://github.com/macrozheng/mall-admin-web 二.npm install 三.修改api配置,改为你接下来要部 ...
- 将MySQL升级到8.0.x后的遇到到错误及解决
一,安装的时遇到的坑 我下的是Mysql 8.0.13 地址:https://dev.mysql.com/downloads/mysql/ 下的是解压版(个人能不用安装就不想用安装版的强迫症(/▽\) ...
- LINUX开启SAMBA服务
samba,用于网络文件共享,类似于nfs, samba多用于win和linux之间 linux之间多用nfs c/s架构 smb协议 samba主要是两个服务,核心启动服务SMB,监听139TCP端 ...
- SpringCloud学习第四章-Eureka创建
注:因为有了父项目,所以不需要引入boot的jar,项目都是maven构建 1.pom.xml <?xml version="1.0" encoding="UTF- ...