C++:类的拷贝和移动、初始化和赋值

C++:类的拷贝和移动、初始化和赋值

测试代码

《C++Primer》学到拷贝控制这一章开始有点犯晕,拷贝和移动的各种使用条件和限制很不好理解。同时,在使用类对象的时候,明显能够感觉到正如《C++Primer》中所写的,虽然初始化(尤其是拷贝初始化)和赋值看上去差不多,都使用=,但是二者区别实际上非常大。今天写了这样一段代码,结果很有意思:

//Message.h
#ifndef MESSAGE_H
#define MESSAGE_H
#include 

class Message {
public:
	Message() { std::cout << "normal construct: " << this << std::endl; }
	Message(const Message&) { std::cout << "copy construct: " << this << std::endl; };
	Message(Message&&) noexcept { std::cout << "move construct: " << this << std::endl; };
	~Message() { std::cout << "destruct: " << this << std::endl; };
	Message& operator=(const Message& m) {
		std::cout << "copy: " << this << " = " << &m << std::endl;
		return *this;
	}
	Message& operator=(Message&& m) noexcept
	{
		std::cout << "move: " << this << " = " << &m << std::endl;
		return *this;
	}
};
#endif


//main.cpp
#include "Message.h"
#include 

using namespace std;

Message getMes_a()
{
	Message a;
	return a;
}

Message getMes()
{
	return Message();
}

int main()
{
	cout << "======================test1======================" << endl;
	getMes_a();

	cout << endl << "======================test2======================" << endl;
	Message m1 = getMes_a();

	cout << endl << "======================test3======================" << endl;
	m1 = getMes_a();

	cout << endl << "======================test4======================" << endl;
	getMes();

	cout << endl << "======================test5======================" << endl;
	Message m2 = getMes();

	cout << endl << "======================test6======================" << endl;
	m2 = getMes();

	cout << endl << "=======================end=======================" << endl;
	return 0;
}

Message类在执行构造函数、拷贝构造函数、移动构造函数、拷贝赋值、移动赋值和析构函数时会分别打印不同的提示信息和调用者的this指针。运行上述代码,结果如下:
C++:类的拷贝和移动、初始化和赋值_第1张图片
由于定义了移动构造函数和移动赋值运算符,因此实际上根本没有用到拷贝相关的函数。注释掉移动构造函数和移动赋值运算符,再次运行上述代码,结果如下:
C++:类的拷贝和移动、初始化和赋值_第2张图片
除了把move换成copy及指针的差异,其他的都完全相同,也就是说,忽略拷贝和移动的差异,这两次运行程序时发生的事情是一样的,因此可以当成同一种情况来分析。结合书上所学内容,我们可以解释出现这种运行结果的原因,同时可以总结出一些编程时有用的小技巧。

函数返回类对象的情况

对比1-4、2-5、3-6可知,不在函数内使用局部对象而直接在return语句中构造类对象的话,可以省去一个局部对象的构造析构过程。函数内的局部对象存在于该函数的栈中,随函数运行结束而销毁。如果需要将其以返回值的形式送给函数调用者,则在执行return语句时,需要在函数调用者的栈里使用拷贝/移动构造的方式重新构造一个该局部对象的副本。但如果我们不使用局部对象而直接在return语句的表达式中构造类的话,则似乎可以直接在函数调用者的栈里按照所给的参数,选用合适的普通构造函数构造一个对象。但是仔细想想我们会发现问题:照理来说,这种方式也应该是由return语句后面的表达式先在函数的栈内部构造一个临时量右值,然后再使用拷贝/移动构造的方式送到外面,跟另外一种一样才对。为什么会出现这种情况呢?这就引出了下一个问题:绕过拷贝/移动构造函数。

绕过拷贝/移动构造函数

《C++Primer》中提到:“在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。”这是现在大多数编译器默认开启的功能,叫做返回值优化(RVO/NRVO, RVO, Return Value Optimization 返回值优化,或者NRVO,Named Return Value Optimization)。从上面结果的2和5,我们可以很直观地看到这一现象:按照语法应该执行拷贝/移动构造函数的语句,最后都并没有执行。

同时,上一节最后抛出的问题现在也可以解决了:因为编译器绕过了拷贝/移动构造函数。即是说,编译器把“先在函数栈内构造一个临时量,再将这个临时量拷贝/移动到调用者的栈内作为一个右值”的操作简化为了“直接在调用者的栈内构造一个右值”。而且进一步观察test5可以发现,编译器甚至可以连续绕过两次拷贝/移动构造函数:第一次在return Message();,把函数栈里的临时量右值在调用者栈的拷贝/移动构造被绕过;第二次在Message m2 = getMes();’,利用函数返回的右值进行m2的拷贝/移动构造被绕过。其最终效果是test5这个初始化过程最终由“调用三次构造函数两次析构函数”简化到“仅仅调用了一次普通构造函数”。

最后再对比1-2和4-5可发现,这两组的差异均出现在函数返回的右值的析构操作上,结合刚才绕过两次构造函数的test5,我们似乎可以不严谨地这么总结:绕过拷贝/移动构造函数的其中一个条件是,调用拷贝/移动构造函数时传入的实参为一个右值。这样操作的结果是,编译器似乎是直接给这些右值“赋予”了一个名字,使它成为了一个初始化完毕的左值。(这个条件并不是一个充分条件。比如对一个vector v调用v.push_back(getMes())时,虽然push_back调用了类Message的移动构造函数,而且实参为getMes()返回的右值,但是这次对移动构造函数的调用并不会被绕过。这应该是因为需要将对象移动到vector v所管理的堆空间中,所以必须进行移动而导致的。)

同时也如书上所说的,“即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/构造函数必须是存在且可访问的”。不仅仅需要存在且可访问,而且必须可以正确调用才行。即是说,即使程序运行时并不使用拷贝/移动构造函数,但这条使用拷贝/构造函数的“正常流程”也必须要能走通才行。举个例子,如果我们在第二个注释掉了移动构造函数和移动赋值运算符的代码中,再把拷贝构造函数Message(const Message&)修改为Message(Message&),那么使用右值进行拷贝初始化就不可行了,因为非常量引用的形参是无法绑定到一个右值的。这时候,虽然我们知道最后程序根本没有使用拷贝初始化,但是函数getMes()及test2、test5中的两个初始化语句都会在编译时直接报错。这也提示了我们把拷贝构造和拷贝赋值函数的形参写成const的原因:为了正常使用右值。

初始化和赋值的区别

观察上面的结果就可以知道,虽然(拷贝)初始化和赋值都用的是=,但是二者完全是不同的过程,绝不能混为一谈。绕过拷贝/构造函数是初始化独有的编译器优化操作,赋值没有也不可能有类似的功能。

你可能感兴趣的:(C++学习之路)