拷贝赋值与移动赋值函数
-
拷贝赋值函数(Copy Assignment Operator):类的特殊成员函数,用于将一个已有对象的内容复制到另一个已有对象中。它通常以 operator= 的形式重载,返回类型为当前类的引用(ClassName&),参数为常量引用(const ClassName&)。
ClassName& operator=(const ClassName& other);
拷贝赋值涉及深拷贝和浅拷贝(取决于实现方式),会复制源对象的状态到目标对象中,通常需要考虑资源管理(如动态内存分配)。
-
移动赋值函数(Move Assignment Operator):移动赋值函数是C++11 引入的特殊成员函数,用于将一个已有对象的资源“移动”到另一个已有的对象中,而不是复制。它通常以 operator= 的形式重载,返回类型为当前类的引用(ClassName&),参数为右值引用(ClassName&&)。
ClassName& operator=(ClassName&& other) noexcept;
移动赋值不是简单复制数据,而是将资源的所有权从源对象转移到目标对象,源对象通常被置于“有效但未定义的空状态”(如指针置为nullptr)。它旨在提高性能,避免不必要的深拷贝。
为什么需要拷贝赋值和移动赋值函数?
-
拷贝赋值函数
如果类没有定义拷贝赋值函数,编译器会自动为该类提供默认的拷贝赋值函数,进行逐成员拷贝(浅拷贝)。对于包含指针成员或动态分配资源的类,浅拷贝会导致两个对象指向同一块内存,后续释放可能引发双重释放或未定义行为。需要深拷贝或特殊逻辑来正确管理资源时,必须显示定义拷贝赋值函数。
-
移动赋值函数
拷贝赋值涉及深拷贝,开销较大,尤其对于大对象(如容器)。移动赋值通过“转移”资源(如指针)避免深拷贝,提高效率。
临时对象处理:对于右值(如函数返回的临时对象),移动赋值可以高效接管其资源,避免无意义的复制。
C++11 引入右值引用后,移动语义成为标准库(如 std::vector)高效运行的基础,自定义类也需要支持移动赋值以融入生态。
如何使用拷贝赋值和移动赋值函数
拷贝赋值 obj1 = obj2,其中 obj2 是一个已有的对象。
一个良好的拷贝赋值函数应该:
- 检查自赋值(self-assignment)
- 释放当前对象的资源
- 拷贝另一个对象的资源
- 返回当前对象的引用
在实现上:
ClassName& ClassName::operator=(const ClassName& other) {
// 检查自赋值
if (this == &other)
return *this;
// 释放当前对象的资源
delete[] data;
// 拷贝另一个对象的资源
size = other.size;
data = new int[size];
for (size_t i = 0; i < size; i++) {
data[i] = other.data[i];
}
// 返回当前对象的引用
return *this;
}
移动赋值 obj1 = std::move(obj2) 或者赋值给右值(如函数返回值)。
一个良好的移动赋值函数应该:
- 检查自赋值(self-assignment)
- 释放当前对象的资源
- 从源对象浅拷贝资源
- 将源对象置于有效但为空的状态
- 标记为 noexcept 以提高性能和安全性
- 返回 *this 引用
在实现上:
ClassName& ClassName::operator=(ClassName&& other) noexcept {
// 检查自赋值
if (this == &other)
return *this;
// 释放当前对象资源
delete[] data;
// 窃取资源(移动语义)
data = other.data;
size = other.size;
// 将源对象置于有效但为空的状态
other.data = nullptr;
other.size = 0;
// 返回当前对象的引用
return *this;
}
从内存层面理解拷贝赋值和移动赋值函数
拷贝赋值执行以下操作:
- 保留目标对象的内存位置:赋值操作不改变目标对象在内存中的位置
- 释放目标对象的资源:调用适当的析构函数或释放函数(如 delete)清理目标对象当前拥有的资源。
- 分配新内存:为需要复制的资源分配新的内存空间。
- 复制数据:将源对象的数据逐字节地复制到新分配的内存中。
- 更新指针:更新目标对象内的指针,使其指向新分配的资源。
移动赋值执行以下操作:
- 保留目标对象的内存位置:同样,赋值操作不改变目标对象在内存中的位置。
- 释放目标对象的资源:释放目标对象当前拥有的资源。
- 窃取指针:直接复制源对象的指针值,而不是复制指针指向的数据。
- 避免额外内存分配:不需要为资源分配新的内存。
- 重置源对象:将源对象的指针设置为 nullptr 或其他安全值,防止析构时释放被窃取的资源。
为何不能使用初始化列表?
赋值函数与构造函数有本质区别:
- 构造函数用于初始化新的对象,可以使用初始化列表
- 赋值函数用于重载赋值操作符,用于修改已存在对象的状态,此时对象的成员已经被构造
因此,赋值函数中不能使用初始化列表,只能在函数体内执行赋值操作。初始化列表只能在构造阶段使用,而赋值函数操作的对象已经完成了初始化的阶段。
赋值函数为何返回引用?
赋值函数通常返回当前对象的引用(ClassName&),这样做的好处包括:
- 支持链式赋值表达式,比如 a = b = c
- 避免不必要的对象复制
链式赋值从右到左求值,例如 a = b = c 首先执行 b = c,然后执行 a = (b = c)。此处 b = c 需要返回引用才能继续用于后续的赋值。
处理自赋值的情况
自赋值是指对象赋值给自身的情况(例如 a = a),如果不正确处理,可能导致严重问题,比如,如果先释放自身资源,在复制,而源数据已经被销毁,可能导致内存泄漏或访问无效内存。
// 自赋值检查
if (this == &other) {
return *this; // 直接返回,不做任何操作
}
编译器合成的赋值函数
在某些情况下,编译器会自动合成赋值函数:
默认拷贝赋值函数:如果类没有自定义拷贝赋值函数,编译器会生成一个执行成员逐个浅拷贝的默认版本
默认移动赋值函数:如果类满足以下所有条件,编译器会生成移动赋值函数
- 没有用户定义的拷贝构造函数
- 没有用户定义的移动构造函数
- 没有用户定义的拷贝赋值函数
- 没有用户定义的移动赋值函数
- 没有用户定义的析构函数
当类中有指针成员或管理其他资源时,编译器合成的赋值运算符通常是不够的,因为它们只执行浅拷贝。