C++拷贝控制

1.拷贝控制核心

对类所管理的资源进行拷贝的过程时需要使用到拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符以及析构函数,其中关系如下:

左值 右值 操作 调用
当前类新对象 已初始化的对象 构造 拷贝构造或移动构造函数
已初始化对象 已初始化的对象 赋值 拷贝赋值或移动赋值运算符

其中拷贝构造和移动构造函数复制产生了新对象,赋值和移动赋值复制了右值对象并销毁了左值原对象。

可以把赋值的过程看成是拷贝和销毁的组合。如果有必要自己实现上述五种函数的任何一个,应该将其他4种也重写。

2.拷贝控制成员

2.1 拷贝构造函数

参数1必须为当前类的引用,因为如果是传值,此时又会发生拷贝,会造成死循环。

1
类名(const 类名& r);                // 规范写法

2.2 拷贝赋值运算符

1
2
3
类名& operator=(const 类名& r);     // 规范写法

类名& operator=(const 类名 r); // copy and swap优化

2.2.1 自赋值安全

示例代码:

1
2
3
4
5
6
// m_data是指向一块堆内存的指针
Foo& operator=(const Foo& r) { // r == this
delete m_data; // 先释放内存
m_data = new int[r.size];
memcpy(m_data, r.m_data, r.size); // 非法访问
}

此时如果发生自赋值,m_data指向的内存先被释放,再去调用memcpy拷贝会访问非法内存区域。

2.2.2 异常安全

示例代码:

1
2
3
4
5
6
// m_data是指向一块堆内存的指针
Foo& operator=(const Foo& r) {
delete m_data; // 释放内存
m_data = new int[r.size]; // 抛出异常
memcpy(m_data, r.m_data, r.size);
}

new运算可能会引用堆空间不足而抛出异常,会暂停当前函数执行,而this->m_data指向的内存区域被释放了,可能造成运行时错误。

2.2.3 安全写法

示例代码:

1
2
3
4
5
6
7
// m_data是指向一块堆内存的指针
Foo& operator=(const Foo& r) {
auto tmp = m_data; // 备份指针
m_data = new int[r.size];
memcpy(m_data, r.m_data, r.size);
delete tmp; // 释放内存
}

2.3 移动构造函数

1
类名(类名&& r) noexcept;

将右值对象的内容窃取后,需要将右值对象置于可以被正确析构的状态。

2.4 移动赋值运算符

1
类名& operator=(类名&& r) noexcept;

移动操作尽量保证不抛出异常,因为一旦出现了异常,很难恢复原来的状态。

2.5 copy/move and swap

2.5.1 原理

拷贝/移动赋值运算符 == 拷贝/移动构造函数 + 析构函数

拷贝/移动都需要将右值资源拷贝一份或直接占为己有,再将原左值资源销毁。

2.5.2 优点

  • 更多的去复用已存在的代码,增加可维护性
  • 天生自赋值、异常安全
  • 一份赋值运算符重载函数代码拷贝和移动均适用

2.5.3 例子

1
2
3
4
5
6
7
8
9
void Foo::swap(Foo& r) noexcept {
using std::swap;
swap(this->m_data, r.m_data);
}

Foo& Foo::operator=(Foo r) noexcept { // 这里不写引用,发生拷贝或移动构造
swap(r);
return *this; // 离开作用域,销毁左值对象资源
}

3.移动语义

3.1 存在的意义

1
2
3
4
5
6
std::string get_string() {
std::strring tmp(some_values());
return tmp; // 拷贝构造
}

std::string s = get_string(); // 拷贝构造

严格按照C++语法来分析,上述代码发生了两次拷贝构造,简化含义就是将局部对象tmp的资源交给对象s。两次拷贝实际上是多余的,严重影响效率。移动语义就是通过移动资源所有权,避免发生拷贝的方式来提升效率。

3.2 右值引用

为了使用移动语义的思路来提高效率,最简单的方式就是提供具有移动功能的拷贝控制成员函数,当待拷贝的对象为右值的时候,就调用带有移动语义的拷贝构造函数;当待赋值的对象为右值的时候,就调用带有移动语义的赋值运算符。而为了与原拷贝控制成员构成重载关系,需要在参数列表处作出区分,参数从左值引用类型变为右值引用类型。

1
2
3
4
5
int left = 10;
int& lref = left; // 左值引用绑定左值
int&& rref = 12; // 右值引用绑定右值

const int& clref = 12; // 常量左值引用可以绑定右值

右值引用延长了右值的生命期,也就是说右值引用类型实际上是左值,如下面代码:

1
2
3
4
5
6
7
8
9
class Widget {
std::string m_str;
public:
...
Widget& operator=(Widget&& other) { // 此时other是个左值
m_str = other.m_str; // 此时调用std::string的拷贝赋值运算符
m_str = std::move(other.m_str); // 此时调用std::string的移动赋值运算符
}
}

其中std::move函数将左值强制转换成右值,内部使用static_cast<&&>实现转换。C++11规定,返回值为右值引用的函数调用为右值。std::mov_if_noexcept()如果类移动操作不抛出异常则返回右值,否则返回左值。

3.3 新标准下的return

C++11新标准下,函数中返回参数对象或者是局部对象时,会发生两次重载决议,第一次将要返回的对象当作右值进行匹配,如果匹配失败,再按照左值去返回。

C++17新标准下,返回局部对象的过程直接被省略,如下代码:

1
2
3
4
5
6
Foo get_foo() {
Foo tmp(some_values());
return tmp;
}

Foo f(get_foo);

上述代码等价于:

1
Foo f(some_values());

3.补充关键字

3.1 delete关键字

有些对象不应该被拷贝,如IO对象,因为流是唯一的,无法进行同步。所以可以将不想被拷贝的类的拷贝控制成员函数指定为delete的。

1
2
Person(const Person& p) = delete;               
Person& operator=(const Person& p) = delete;

3.2 default关键字

让编译器合成相关的函数,只适合一些不进行资源管理的类。

3.3 explicit关键字

1
2
3
4
5
6
// 构造函数
explict Foo(std::size_t n);

// 定义对象
Foo a = 'c'; // 错误,不能发生隐式类型转化
Foo a(10); // 正确

在C++中,如果的构造函数只有一个参数时,那么在编译的时候就会有一个缺省的转换操作:将该构造函数对应数据类型的数据转换为该类对象。而explicit关键字就是禁止这一过程的发生。


C++拷贝控制
http://helloymf.github.io/2022/12/06/c-kao-bei-kong-zhi/
作者
JNZ
许可协议