5.5 类的拷贝控制操作
简介
当定义一个类时,我们显式或隐式地指定此类型的对象拷贝、移动、赋值和销毁时做什么,类通过五种特殊的成员函数来控制这些操作:
- 拷贝构造函数
copy constructor
- 拷贝赋值运算符
copy-assignment operator
- 移动构造函数
move contructor
- 移动赋值运算符
move-assignment operator
- 析构函数
destructor
其中拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么,拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么,析构函数定义了当此类型对象销毁时做什么。上述五个函数统称为拷贝控制操作。
拷贝构造函数
1. 定义
Tips:虽然我们可以定义一个接受非
const
引用的拷贝构造函数,但此参数几乎总是一个const
引用。拷贝构造函数在几种情况下都会被隐式地使用,因此拷贝构造函数通常不应该是explicit
的。
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
2. 合成拷贝构造函数
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同的是,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
一般情况下,合成的拷贝构造函数会将其参数的非static
成员逐个拷贝到正在创建的对象中:
- 类类型成员调用其拷贝构造函数来拷贝
- 内置类型直接拷贝
- 数组:逐元素拷贝一个数组类型的元素(数组元素是类类型时调用该元素的拷贝构造函数进行拷贝)
3. 调用拷贝构造函数的场景:拷贝初始化
Tips:拷贝初始化既有可能调用拷贝构造函数也有可能调用移动构造函数(左值被拷贝而右值被移动),不过这需要类存在对应的移动构造函数。
构造函数用于控制对象的初始化,那拷贝构造函数自然用于控制对象的拷贝初始化,拷贝初始化的场景包括:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
- 初始化标准库容器或者调用其
insert/push
成员时容器会对其元素进行拷贝初始化(emplace
成员创建的元素都进行直接初始化)
#include <vector>
#include <iostream>
struct Cat {
// 默认构造函数
Cat() {
printf("Cat()\n");
}
// 拷贝构造函数
Cat(const Cat &cat) {
printf("Cat(const Cat &cat)\n");
}
// 移动构造函数
Cat(Cat && cat) {
printf("Cat(Cat && cat)\n");
}
// 拷贝赋值运算符
Cat& operator=(const Cat &cat) {
printf("Cat& operator=(const Cat &cat)\n");
return *this;
}
};
int main(void) {
// 直接初始化: 调用默认构造函数
Cat cat1 = Cat();
// 拷贝初始化: 调用拷贝构造函数
Cat cat2(cat1);
// 拷贝初始化: 调用拷贝构造函数
Cat cat3 = cat2;
// 拷贝赋值: 调用拷贝赋值运算符
cat3 = cat1;
}
// 输出:
Cat()
Cat(const Cat &cat)
Cat(const Cat &cat)
Cat& operator=(const Cat &cat)
需要注意的是:
=
不一定都是赋值(调用拷贝赋值运算符),也可能是初始化(调用拷贝构造函数)- 拷贝初始化通常使用拷贝构造函数完成,但是有些情况下可能会使用移动构造函数来完成拷贝初始化
拷贝赋值运算符
1. 定义
与类控制其对象如何初始化一样,类也可以控制其对象如何赋值。
class Cat {
public:
// 拷贝赋值运算符
Cat& operator=(const Cat &cat);
// ...
};
// 定义c1和c2两个Cat类型的对象
Cat c1, c2;
// 调用Cat的拷贝赋值运算符
c1 = c2;
Tips:拷贝赋值运算符本质上是重载了赋值运算符,包括赋值运算符在内的一些运算符必须定义为成员函数。而且如果一个二元运算符是一个成员函数,其左侧运算对象就绑定到隐式的
this
参数,其右侧运算对象作为显式参数传递。赋值运算符通常应该返回一个指向其左侧运算对象的引用。
2. 合成拷贝赋值运算符
与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为其合成一个。它会将右侧运算对象的每个非static
成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员则逐个赋值数组元素。
3. 编码规范:令拷贝赋值运算符返回左侧运算对象的引用
Effective C++:Have assignment operators return a reference to *this.
- 令赋值操作符返回一个reference to *this。
C++中存在“连锁赋值”的特性:
int x, y, z;
x = y = z = 15; // 连锁赋值, 等价于 x = (y = (z = 15))
为了实现“连锁赋值”,赋值操作符必须返回一个左侧运算对象的引用:
class Foo {
public:
...
};
Foo& operator=(const Foo& rhs) { // 返回类型是一个当前对象类型的reference
...
return *this; // 返回左侧对象
}
这些协议不仅适用于以上的标准赋值形式=
,也适用于所有赋值相关运算,比如+=
、-=
和*=
等。
另外需要注意的是这只是一个约定俗成的标准而不具有强制性。如果不遵循它代码一样可以通过编译。然而这份协议被所有内置类型和标准库提供的类型共同遵守。因此除非你有一个标新立异的好理由,不然还是随众吧。
4. 编码规范:在拷贝复制运算符中处理自赋值问题
Effective C++:Handle assignment to self in operator=.
- 确保对象自我赋值时
operator=
有良好行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy-and-swap。- 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
4.1 自我赋值
“自我赋值”发生在对象被赋值给自己时:
class Foo { ... };
Foo f;
f = f;
上面的代码虽然没什么意义,但是其是合法的。有一些“自我赋值”行为更加隐蔽:
a[i] = a[j]; // i=j时是自我赋值
*px = *py; // px和py值一样时是自我赋值
4.2 自我赋值的问题
class Foo {
public:
// 不具备"自我赋值安全型"和"异常安全性"的拷贝赋值运算符
Foo& operator=(const Foo& rhs) {
delete pi_;
pi_ = new int(*rhs.pi_);
return *this;
}
private:
int* pi_;
};
int main() {
Foo f;
f = f; // 自我赋值: core dump with signal 11
return 0;
}
Foo类的拷贝赋值运算符看上去合理,但是不满足“自我赋值安全性”和“异常安全性”的问题。
在main函数中我们对Foo类操作了自我赋值,从Foo::operator=
实现上我们看到它释放了pi_
并再次解引用它,因此coredump。
为了阻止这种错误,一般是在operator=
最前面加上一个“证同测试”(identity test)达到“自我赋值”的检验目的:
Foo& operator=(const Foo& rhs) {
// 证同测试: 满足"自我赋值安全型"
if (this == &rhs) {
return *this;
}
delete pi_;
pi_ = new int(*rhs.pi_);
return *this;
}
4.3 异常安全性
“证同测试”可以让拷贝赋值运算符满足“自我赋值安全性”,但它依然不具备“异常安全性”。如果new int
出现异常,那么对象会持有一个指针指向一块被删除的int
,这样的指针是有害的。你无法地安全删除它们,也无法安全地读取它们。
让
operator=
具备“异常安全性”往往自动获得“自我赋值安全性”的回报。
通过对拷贝赋值运算符的精心设计,我们可以导出异常安全(以及自我赋值安全)的代码。例如下述代码,我们只需要注意在复制pi_
所指向内容前别删除pi_
即可:
// 具备"自我赋值安全型"和"异常安全性"的拷贝赋值运算符
Foo& operator=(const Foo& rhs) {
int* pi_orig = this->pi_;
pi_ = new int(*rhs.pi_);
delete pi_orig;
return *this;
}
如果new int
抛出异常,那么pi_
(以及它栖身的Foo对象)保持原状,因此可以满足“异常安全性”。
然而它并不是处理“自我赋值”的最高效方法(最高效的方法是“证同测试”),但是如果我们把“证同测试”放到函数起始处会导致代码(原始码和目标码)变大,并且增加一个新的控制流(control flow)分支,这两者都会降低执行速度。
4.4 copy and swap技术
copy and swap是一个常见的operator=
实现方法,它保证了“异常安全性”和“自我赋值安全性”:
class Foo {
public:
// 交换 *this 与 rhs的数据
void swap(Foo& rhs) {
std::swap(rhs.pi_, this->pi_);
}
// 为rhs制作一份副本, 将*this的数据与副本交换
Foo& operator=(const Foo& rhs) {
Foo temp(rhs);
swap(temp);
return *this;
}
private:
int* pi_;
};
copy-and-swap技术的另一种实现方式如下,它令拷贝赋值运算符被声明为“以by value方式接受实参”。尽管它牺牲了代码的清晰性,但其将“copying动作”从函数本体内移动到“函数参数构造阶段”从而令编译器生成更高效的代码。
// pass by value, 交换*this数据与实参副本的数据
Foo& operator=(Foo rhs) {
swap(rhs);
return *this;
}
析构函数
1. 定义
Tips:
- 析构函数不接受任何参数,因此无法被重载,一个给定类只会有唯一一个析构函数
- 构造函数中成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序初始化;析构函数中首先执行函数体然后按照成员初始化顺序的逆序销毁
- 隐式销毁一个内置指针类型的成员不会
delete
它所指向的对象(智能指针在析构阶段会自动销毁它指向的对象)
析构函数执行与构造函数相反的操作:
- 构造函数初始化对象的非
static
数据成员,还可能做一些其他工作 - 析构函数释放对象使用的资源,并销毁对象的非
static
数据成员
2. 调用析构函数的场景
Tips:当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
无论何时一个对象被销毁,就会调用其析构函数:
- 变量离开其作用域被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁
- 对于动态分配的对象,当对指向它的指针应用
delete
运算符被销毁 - 对于临时对象,当创建它的完整表达式结束时被销毁
3. 合成析构函数
当一个类未定义自己的析构函数,编译器会为它合成一个合成析构函数。类似拷贝构造函数和拷贝赋值运算符,对于某些类合成析构函数用于阻止该类型的对象被销毁(合成的析构函数可以是删除的)。如果不是这种情况,合成析构函数的函数体就为空。
4. 编码规范:为多态基类声明virtual析构函数
Effective C++:Declare destructors virtual in polymorphic base classes.
- 任何类只要带有virtual函数几乎确定也应该有一个virtual析构函数。
- 类的设计目的如果不是为了作为基类使用,或不是为了具备多态性,就不应该声明virtual析构函数。
C++中明白指出,当派生类对象经由一个基类指针delete且基类中带有non-virtual析构函数,其结果是未定义的(实际执行时通常是对象的derived成分没被销毁)。消除这个问题的做法很简单:给基类定义一个virtual析构函数。
另外如果一个类设计目的不是作为base class使用时,令其析构函数为virtual往往是一个馊主意。这是因为如果要实现virtual函数,对象必须携带某些信息(virtual table pointer,vptr)以用于在运行期决定哪一个virtual函数应该被调用,这会导致对象体积增加。
即使一个类完全不带virtual函数,但也可能遇到“non-virtual析构函数”问题。如果你企图继承一个STL容器或其他带有“non-virtual析构函数”的类,那么使用基类指针delete派生类对象时就可能出现资源泄漏等问题。
需要注意的是Uncopyable和标准库的input_iterator_tag
这些类的设计目的是作为基类使用,但是不是为了多态用途,因此它们不需要virtual析构函数。
5. 编码规范:别让异常逃离析构函数
Effective C++:Prevent exceptions from leaving destructors.
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后处理它们或者结束程序。
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。
C++并不禁止析构函数吐出异常,但是它不鼓励你这么做。从语法上来说,析构函数可以抛出异常,但从逻辑上和风险控制上,析构函数中不要抛出异常,因为栈展开容易导致资源泄露和程序崩溃,所以别让异常逃离析构函数。
原因在《More Effective C++》中提到两个:
- 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
- 通常异常发生时,c++的异常处理机制在异常的传播过程中会进行栈展开(stack-unwinding),因发生异常而逐步退出复合语句和函数定义的过程,被称为栈展开。在栈展开的过程中就会调用已经在栈构造好的对象的析构函数来释放资源,此时若其他析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃。
三/五法则
C++语言并不要求我们定义全部五种拷贝控制操作:可以只定义其中一个或两个,而不必定义所有。但是这些操作通常应该被看做一个整体,只需要其中一个操作而不需要定义所有操作的情况是很少见的。
- “三法则”:针对的是较旧的C++89标准,指的是自定义析构函数的类不可使用合成拷贝构函数和合成拷贝赋值运算符,必须自定义拷贝和赋值操作(哪怕是删除的)
- “五法则”针对的是较新的C++11标准,加入了移动构造函数和移动赋值运算符,虽然不提供移动构造函数和移动赋值运算符通常不是错误,但会失去优化的机会
1. 自定义析构函数的类也需要自定义拷贝和赋值操作
Tips:如果一个类需要自定义析构函数,我们几乎可以肯定它也需要自定义拷贝构造函数和拷贝赋值运算符。一个类需要自定义析构函数往往是因为合成析构函数不足以释放类所拥有的资源(最典型的就是指针成员),自定义拷贝构造函数和拷贝赋值运算符是为了防止指针类型成员的浅拷贝问题。
假设一个类HasPtr
在构造函数中分配动态内存,那么我们必须自定义一个析构函数来释放构造函数分配的内存(合成的析构函数不会delete
一个指针数据成员),此时使用合成版本的拷贝构造函数和拷贝运算符会引入一个严重的错误:拷贝操作简单拷贝指针成员,这意味着多个HasPtr
对象可能指向相同的内存。
#include <iostream>
#include <string>
class HasPtr {
public:
// 构造函数: 分配一个string的动态内存
explicit HasPtr(const std::string &s) : ps_(new std::string(s)) { }
// 自定义析构函数: 释放管理的string动态内存
~HasPtr() { delete ps_; }
private:
std::string *ps_;
};
int main(void) {
HasPtr hp1 = HasPtr("tomocat");
// hp2调用默认的拷贝构造函数: 简单拷贝ps_指针的值, 从而hp1和hp2的ps_指针指向同一块内存
HasPtr hp2 = hp1;
// main函数结束时调用hp1和hp2的析构函数, 导致此指针被delete两次, 这是未定义的行为
// 在我的Linux机器上发生了core dumped
}
2. 需要自定义拷贝操作的类也需要自定义赋值操作
有一些类只需要自定义拷贝或赋值操作,而不需要自定义析构函数。假设某个类需要拷贝构造函数为每个对象分配一个独一无二的序号,除此之外这个拷贝构造函数从给定对象拷贝所有其他数据成员。我们可以肯定的是这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。
=default使用合成的构造函数和拷贝控制成员
Tips:在类内用
=default
修饰成员的声明时,合成的函数将隐式地声明为内敛的。
我们可以使用default
来显式地要求编译器生成合成的构造函数和拷贝控制成员:
struct Cat {
// 合成的默认构造函数
Cat() = default;
// 合成的拷贝构造函数
Cat(const Cat &cat) = default;
// 合成的拷贝赋值运算符
Cat& operator=(const Cat &) = default;
// 合成的析构函数
~Cat() = default;
};
=delete删除默认构造函数和拷贝控制成员
Effective C++:Explicitly disallow the use of compiler-generated functions you do not want.
1. 简介
正常来说大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是显式地还是隐式地。但是对于某些类来说这些操作没有合理的意义,在这些情况下必须采用某种机制阻止拷贝或者赋值。例如iostream
阻止了拷贝以避免多个对象写入或者读取相同的IO缓冲。
2. =delete定义删除的函数
=delete
通知编译器我们不希望定义这些函数成员:
#include <vector>
#include <iostream>
struct Cat {
// 使用合成的默认构造函数
Cat() = default;
// 阻止拷贝构造函数
Cat(const Cat &cat) = delete;
// 阻止拷贝赋值运算符
Cat& operator=(const Cat &) = delete;
// 删除的析构函数: 不允许定义该类型的变量或创建该类型的临时变量
~Cat() = delete;
};
int main(void) {
// 报错: error: use of deleted function ‘Cat::~Cat()’
Cat cat;
return 0;
}
需要注意的是:
- 我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用
=default
,但是我们可以对任何函数指定=delete
,虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的 - 析构函数不能是删除的成员:对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或者创建该类型的临时对象,不过我们可以动态分配这种类型的对象(但是不能释放这些对象)
了解C++默认编写并调用哪些函数
Effective C++:Know what functions C++ sliently writes and calls.
1. 编译器默认创建的拷贝控制操作
如果你仅仅声明一个空类,编译器就会为它声明(编译器版本)的default构造函数、copy构造函数、copy assignment运算符和析构函数。所有这些函数都是public且inline的。
// 声明一个空类
class Empty {};
等价于如下代码:
class Empty {
public:
// 默认构造函数
Empty() {}
// 拷贝构造函数
Empty(const Empty& rhs) {}
// 析构函数, 是否该是virtual见稍后说明
~Empty() {}
// 拷贝赋值运算符
Empty& operator=(const Empty& rhs) {}
};
只有当这些函数被调用时,它们才会被编译器创建出来。例如下面的代码导致上述每一个函数被编译器产出:
// 默认构造函数 和 析构函数
Empty e1;
// 拷贝构造函数
Empty e2(e1);
// 拷贝赋值运算符
e2 = e1;
2. 注意事项
- 编译器创建的析构函数是non-virtual的,除非这个class的base class自身声明有virtual析构函数(这种情况下这个函数的虚属性「virtualness」主要来自于base class)。
- 编译器创建的拷贝构造函数和拷贝赋值运算符只是单纯地将来源对象的每一个non-static成员变量拷贝到目标对象。
- 编译器只有在类不包含任何构造函数的情况下才会替我们合成一个默认构造函数,一旦我们定义了其他的构造函数,那么除非我们再定义一个默认构造函数,否则类将没有默认构造函数。
- 如果一个class内包含reference成员或const成员,那么编译器不会为我们生成默认的拷贝构造函数和拷贝赋值运算符。
private拷贝控制成员(C++11新标准废弃)
Tips:在C++11新标准下,希望组织拷贝的类应该使用
=delete
来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private
的。
在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private
来阻止拷贝的:
class PrivateCopy {
private:
// 拷贝控制成员是private的, 因此普通用户代码无法访问
PrivateCopy(const PrivateCopy&);
PrivateCopy &operator=(const PrivateCopy&);
public:
PrivateCopy() = default;
~PrivateCopy();
};
需要注意的是:
- 拷贝构造函数和拷贝赋值运算符是
private
的,用户代码将不能拷贝这个类型的对象 - 友元和成员函数仍然可以拷贝对象,我们可以将这些拷贝控制成员声明为
private
但不定义他们就可以阻止友元和成员函数进行拷贝
Tips:声明但不定义一个成员函数是合法的,但试图访问一个未定义的成员将导致一个链接时错误。
拷贝控制成员与资源管理
通常管理类外资源的类需要通过析构函数来释放对象所分配的资源,根据“三/五原则”它也必须自定义拷贝构造函数和拷贝赋值运算符(delete
拷贝构造函数和拷贝赋值运算符也算自定义的一种)。
对于管理类外资源的类,根据如何拷贝指针成员我们可以大致分为如下三类:
- 既不像值也不像指针的类:
IO
类型和unique_ptr
这种不允许拷贝和赋值的类 - 行为像值的类:标准库容器和
string
类 - 行为像指针的类:
shared_ptr
1. 行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该有自己的一份拷贝。以管理string
资源的类HasPtr
的类而言:
- 拷贝构造函数:完成
string
的拷贝而不是拷贝指针 - 析构函数:释放
string
对象 - 拷贝赋值运算符:释放对象当前的
string
,并从右侧运算对象拷贝string
class HasPtr {
public:
// 构造函数: 分配string动态内存
explicit HasPtr(const std::string &s) : ps_(new std::string(s)) { }
// 拷贝构造函数
HasPtr(const HasPtr &p) : ps_(new std::string(*p.ps_)) { }
// 拷贝赋值运算符
HasPtr& operator=(const HasPtr &);
// 析构函数: 释放构造函数中分配的动态内存
~HasPtr() { delete ps_; }
// 类自定义的swap成员函数
friend void swap(HasPtr&, HasPtr&);
private:
std::string *ps_;
};
// 拷贝赋值运算符:
// 1) 组合了析构函数和拷贝构造函数: 先销毁左侧运算对象资源, 然后从右侧运算对象拷贝数据
// 2) 自赋值安全: 如果将一个对象赋予它自身, 赋值运算符必须能正确工作
// 3) 异常安全: 当异常发生时能将左侧运算对象置于一个有意义的状态
HasPtr& HasPtr::operator=(const HasPtr &rhs) {
auto newp = new std::string(*rhs.ps_); // 拷贝底层string
delete ps_; // 释放本对象的旧内存
ps_ = newp; // 从右侧运算对象拷贝数据到本对象
return *this;
}
2. 行为像指针的类
令一个类展现类似指针的行为的最好方法是使用shared_ptr
来管理类中的资源,拷贝(或赋值)一个shared_ptr
会拷贝(或赋值)shared_ptr
所指向的指针。shared_ptr
类会自己记录有多少用户共享它所指向的对象,当没有用户使用对象时,shared_ptr
类负责释放资源。
3. swap交换操作
Tips:管理动态资源的类通常除了自定义拷贝控制成员外,还需要定义一个名为
swap
的函数。如果一个类定义了自己的swap
成员函数,那么算法将使用类自定义版本,否则算法将使用标准库定义的swap
。
// 交换指针而非string数据, 提高性能
inline void swap(HasPtr &lhs, HasPtr &rhs) {
std::swap(lhs.ps_, rhs.ps_);
}
定义了swap
的类通常用swap
来定义它们的“拷贝并交换赋值运算符”,这些运算符使用了一种名为拷贝并交换copy and swap
的技术,将左侧运算对象与右侧运算对象的一个副本进行交换:
Tips:
- 这种技术天生是自赋值安全且异常安全的,一方面它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的安全性,另一方面代码唯一可能抛出异常的是拷贝构造函数中的
new
表达式,如果真的抛出异常也是在我们改变左侧运算对象之前发生- 由于接受的参数并不是一个引用,因此该参数需要进行拷贝初始化,既有可能调用拷贝构造函数(左值)也有可能调用移动构造函数(右值)
- 当类定义了移动构造函数时,拷贝并交换赋值运算符也会为该类实现一个移动赋值运算符
// 拷贝并交换赋值运算符既是移动赋值运算符也是拷贝赋值运算符:
// 1) 参数并不是一个引用: 调用拷贝/移动构造函数以值传递传入一个右侧运算对象的副本
// 2) 交换左侧运算对象与右侧运算对象的副本
HasPtr& HasPtr::operator=(HasPtr rhs) {
swap(*this, rhs); // rhs现在指向本对象曾经使用过的内存
return *this; // rhs销毁, 从而delete了rhs中的指针
}
移动构造函数与移动赋值运算符
1. 简介
新标准一个最主要的特性是可以移动而非拷贝对象的能力,在很多情况下对象拷贝后就立即被销毁了,在这些情况下移动而非拷贝对象会大幅度提升性能。使用移动而非拷贝的另一个原因源于IO
类或unique_ptr
这样的类,这些类都包含不能被共享的资源(如指针或IO
缓冲),因此这些类型的对象不能拷贝但可以移动。
2. 移动操作: 移动构造函数与移动赋值运算符
class HasPtr {
public:
explicit HasPtr(const std::string &s) : ps_(new std::string(s)) { }
HasPtr(const HasPtr &p) : ps_(new std::string(*p.ps_)) { }
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps_; }
// 移动构造函数
HasPtr(HasPtr&&) noexcept;
// 移动赋值运算符
HasPtr& operator=(HasPtr &&) noexcept;
private:
std::string *ps_;
};
// 移动构造函数:
// 1) 接管右侧运算对象中的资源
// 2) 令右侧运算对象进入析构安全的状态
HasPtr::HasPtr(HasPtr&& rhs) noexcept : ps_(rhs.ps_ ) {
// 将rhs置于可析构的状态
rhs.ps_ = nullptr;
}
// 移动赋值运算符:
// 1) 必须正确处理自赋值
// 2) 释放左侧运算对象的资源
// 3) 接管右侧运算对象中的资源
// 4) 令右侧运算对象进入析构安全的状态
HasPtr& HasPtr::operator=(HasPtr&& rhs) noexcept {
// 直接检测自赋值
if (this != &rhs) {
delete ps_;
ps_ = rhs.ps_;
// 将rhs置于可析构的状态
rhs.ps_ = nullptr;
}
return *this;
}
3. noexcept: 承诺移动操作不抛出异常
由于移动操作通常是“窃取”资源而不分配资源,因此移动操作不会抛出任何异常。当编写不抛出异常的移动构造函数和移动赋值运算符时,我们必须在类头文件的声明和定义中都指定为noexcept
来通知标准库我们的移动操作不会抛出异常,防止标准库为了处理抛出异常的可能性而做一些浪费性能的额外工作。
比如标准库vector
承诺如果我们调用push_back()
时发生异常,则vector
自身不会发生改变。假设push_back()
时触发了vector
扩容,此时vector
会将元素从旧的堆空间复制到新申请的堆空间,考虑移动构造函数和拷贝构造函数:
- 移动构造函数:假设移动构造函数未声明成
noexcept
的且移动部分而非全部元素后抛出了异常,此时使用旧空间中移后源对象的值是不安全的而新空间中未构造的元素还不存在,这种情况下不能满足vecotr
自身不变的要求 - 拷贝构造函数:假设
vector
使用拷贝构造函数且在拷贝部分元素后发生了异常,虽然新空间中未构造的元素还不存在但旧空间的元素保持不变,vector
可以释放新分配(但还未成功构造的)内存并返回
Tips:为了避免潜在的问题,诸如
push_back()
等的标准库函数除非知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存拷贝元素的过程中,它就必须使用拷贝构造函数而不是移动构造函数(这会造成一定的性能浪费)。如果希望在这些情况下对我们自定义类型对象进行移动而不是拷贝,就必须显式通过noexcept
声明告诉标准库我们的移动构造函数是异常安全的。
4. 移后源对象处于可析构但有效的状态
Tips:在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
从一个对象移动数据并不会销毁此对象,但是必须确保移后源对象进入一个析构安全且有效的状态:
- 析构安全:移动操作会“窃取”移后源对象的资源,析构移后源对象不应该影响其他对象数据的安全性
- 有效:移动操作必须确保移后源对象仍然是有效的(即我们可以安全地为其赋予新值或者安全地使用而不依赖当前值),需要注意的是移动操作对移后源对象中留下的值没有任何要求(移后源对象的旧值是不明确的)
5. 合成的移动操作
只有当一个没有自定义它任何版本的拷贝构造函数(拷贝构造函数、拷贝赋值运算符或析构函数三者之一)。且类的每个非static
数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
6. 移动操作与拷贝操作
如果一个类既提供移动操作(移动构造函数和移动赋值运算符)也提供拷贝操作(拷贝构造函数和拷贝赋值运算符),那么对于拷贝操作而言,它接受const
引用从而可以用于任何类型的实参。对于移动操作而言,它接受右值引用从而只能用于实参是非static
右值的情形。
我们需要注意:
- 同时提供移动操作和拷贝操作:根据精确匹配的原则,当接受右值引用时会使用移动操作而不是拷贝操作
- 只提供拷贝操作:即使接受右值引用也会调用拷贝操作
7. 移动操作与拷贝并交换赋值运算符
前面提到了“拷贝并交换赋值运算符”,当我们为HasPtr
类定义了一个移动构造函数和拷贝并交换赋值运算符时,它实际上也会获得一个移动赋值运算符,这意味着该运算符同时实现了移动赋值运算符和拷贝赋值运算符:
#include<string>
#include<iostream>
class HasPtr {
public:
// 构造函数
explicit HasPtr(const std::string &s) : ps_(new std::string(s)) { }
// 拷贝构造函数
HasPtr(const HasPtr &p) : ps_(new std::string(*p.ps_)) {
printf("copy constructor\n");
}
// 移动构造函数
HasPtr(HasPtr&& rhs) noexcept : ps_(rhs.ps_ ) {
rhs.ps_ = nullptr;
printf("move constructor\n");
}
// 析构函数
~HasPtr() { delete ps_; }
// 类自定义的swap成员函数
friend void swap(HasPtr& lhs, HasPtr& rhs) {
std::swap(lhs.ps_, rhs.ps_);
}
// 拷贝并交换运算符: 既是移动赋值运算符(需要定义移动构造函数)又是拷贝赋值运算符(需要定义拷贝构造函数)
HasPtr& operator=(HasPtr rhs) {
swap(*this, rhs);
return *this;
}
private:
std::string *ps_;
};
int main() {
HasPtr a("tomo"), b("cat");
a = b; // 调用拷贝赋值运算符
a = std::move(b); // 调用移动赋值运算符
return 0;
}
// 输出:
copy constructor
move constructor
“拷贝并交换运算符”有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数(左值被拷贝,右值被移动)。因此单一的“拷贝并交换运算符”就实现了拷贝赋值运算符和移动赋值运算符两种功能。