对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类, 它有一些资源需要在析构的时候进行释放, 或者是内存, 或者是其它句柄

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/zzspdp.html