深入理解cpp-拷贝和移动
摘要
本文将详细介绍C++中的拷贝构造函数、移动构造函数、拷贝赋值函数和移动赋值函数。
前言
其实,简单的理解,构造函数就是通过这个函数构造一个类的实例对象,即提供参数进行直接初始化。而拷贝构造函数是通过拷贝的方式,提供一个存在的对象拷贝一份进行拷贝初始化。而移动构造函数是利用移动构造函数来获取另一个对象的所有权,而不进行拷贝进行移动初始化。
explicit
首先先来了解explicit关键字。
explicit翻译过来就是显示的。explicit用于修饰构造函数,表明构造函数只能用于显式构造,而不能用于隐式转换。
例如下面的例子:
1 | class A{ |
而如果类A的构造函数加了explicit,则不能使用隐式转换:
1 | class A{ |
特别的,对于单参数的构造函数,最好是声明为explicit,减少隐式转换。
拷贝
拷贝构造函数
直接看例子:
1 | class sta{ |
通常来说,拷贝构造函数的第一个参数必须是引用类型。倘若不是引用类型,则在调用拷贝构造函数之前,就需要先在传参的过程中,执行拷贝的操作。这个时候又需要拷贝构造函数了,因为你没有定义如何拷贝一个对象的操作。这个时候就造成了死循环。
换句话说,拷贝构造函数定义了类对象间如何进行拷贝,所以需要用引用完成拷贝的详细过程。
同时,拷贝构造函数通常都是const类型的,这是因为一般不会修改拷贝的对象。
同时,拷贝构造函数几乎都会被隐式使用,所以不应该设置为explicit类型,例如:
1 | std::string str = "123456"; //拷贝初始化 |
拷贝赋值运算符
通过拷贝赋值运算符,即对赋值运算符进行重载,可以实现对类进行赋值。
例如:
1 | class A{ |
需要使用拷贝构造函数的类型,也必然需要一个拷贝赋值运算符
内联函数inline
内敛函数的目的是为了提高函数的执行效率。内联函数会在函数调用点展开函数,而不是压栈进行调用,提高性能。
这是一个编译器关键字,编译器可能还会根据函数的大小和其他因素来决定是否将函数内联。
default和delete
首先,如果没有定义拷贝构造函数和拷贝赋值运算符,编译器会生成默认的拷贝构造函数和拷贝赋值运算符,也叫做合成拷贝构造函数和合成拷贝赋值运算符。
而通过default关键字,可以用来显示地让编译器直接生成合成拷贝构造函数和合成拷贝赋值运算符,例如:
1 | class sta{ |
在类内直接定义default的成员函数是inline函数,而如果不想定义为inline函数,则可以在类外定义default,例如:
1 | class sta{ |
而通过delete关键字,可以阻止类进行拷贝和赋值。例如:
1 | class sta{ |
当类中的一些成员无法被默认构造、拷贝或者无法被析构的时候,那么对应的成员函数将被定义为删除的
移动
左值和右值
最简单的理解就是,表达式左边的值就是左值,表达式右边的值是右值。
本质上,左值是持久的,而右值是短暂的,将要被销毁的。
通常,引用一般值得是左值引用,同样,右值也有引用,右值引用就是右值的别名,例如:
1 | int i = 1; //i是左值 |
std::move
std::move的作用就是将一个左值转换为一个右值。
例如:
1 | int r = 1; |
左值和右值以及move的提出,就是为了移动语义。
正常来说,左值赋值给左值会发生拷贝,移后源对象仍然存在。但是当我们使用move使得一个左值转成了一个右值,那么赋值过程中就不存在拷贝了,而是获得其所有权。
移动构造函数
移动构造函数类似于拷贝构造函数,第一个参数必须是该类型的一个引用,并且是一个右值引用。并且任何额外的参数都必须有默认实参。
例如:
1 | class sta{ |
移动构造函数不会分配任何新的内存,只是获得st的所有权,获得所有权后,就需要把其中的指针设置为nullptr,在移动后,原来的对象就会被销毁,而我们的新的对象就获得了原来的对象。
注意到,移动构造函数被声明为noexcept了。我们必须在类的声明和定义中,都同时指定noexcept。不同于拷贝,拷贝的过程中,如果发生了异常,则原有的对象不发生改变,而新的需要构造的内存释放掉即可。但是移动构造函数中,移动的过程中如果出现了异常,则原来的对象元素已经发生了改变,会导致原对象的缺失。
移动赋值函数
移动赋值函数要正确处理好自赋值的情况,要保证在字符值的情况下,不会把自己给销毁掉。例如:
1 | class sta{ |
如果一个类,定义了拷贝操作,而没有定义移动操作,编译器不会为类自动成生成合成的移动操作。 此时,即使传参为一个右值,初始化的过程仍然是通过拷贝构造函数完成的。
一类特殊的赋值运算符
例如:
1 | class ptr{ |
对于上面的赋值运算符,其参数是一个非引用类型。所以当调用赋值运算符的时候,首先会发生初始化,即将实参构造为形参。这里就有两种选择,是调用拷贝构造函数还是调用移动构造函数,这取决于传入的实参。
1 | p1 = p2; |
当赋值的右侧是一个左值时,此时会调用拷贝构造函数来完成初始化。而当赋值的右侧是一个右值时,会调用移动构造函数来讲p2的所有权转移到形参p,完成初始化。
移动迭代器
make_move_iterator可以将一个左值迭代器,转换为右值迭代器。
例如:
1 | auto last = uninitialized_copy(make_move_iterator(begin()), |
重载与右值引用
通常,类的成员函数可以提供相同版本的右值引用和左值引用的函数重载。这样可以同时精确匹配左值和右值。例如标准库的容器定义了push_back的两个版本:
1 | void push_back(const X&); |
一般来说,左值引用带有const,这是因为进行拷贝的过程我们不应该改变这个对象。而右值引用通常不带有const,是因为右值进行移动时,运行对右值进行修改,而不会影响其他变量,所以通常不带有const。
引用函数
类似于const限定符,&限定符叫做引用限定符,可用于重载成员函数。即带有引用限定符的成员函数优先匹配对应引用对象的调用,例如:
1 | class sta{ |
对右值进行排序时,由于对象是右值,所以没有其他用户拥有其所有权,可以直接原地排序。
而对左值对象进行排序时,我们需要拷贝一份,再进行排序。
1 | retL().sorted(); //retL()返回左值,则调用sta sorted() const & |