复制赋值运算符(operator=)用于将值从一个对象复制到另一个已存在的对象。
相关内容
自 C++11 起,语言还支持“移动赋值”。我们将在课程《移动构造函数与移动赋值》中讨论移动赋值。
复制赋值 vs 复制构造函数
复制构造函数与复制赋值运算符的目的几乎相同——都是将一个对象复制到另一个对象。然而,复制构造函数用于初始化新对象,而赋值运算符则替换已存在对象的内容。
这一区别常常令新手困惑,实则并不复杂。总结如下:
- 若必须先创建新对象才能进行复制,则调用复制构造函数(注意:按值传递或返回对象时亦属此类情形)。
- 若无需先创建新对象即可进行复制,则调用赋值运算符。
重载赋值运算符
重载复制赋值运算符(operator=)较为直接,但有一处需要特别注意,后文将详述。复制赋值运算符必须以成员函数形式重载。
#include <cassert>
#include <iostream>
class Fraction
{
private:
int m_numerator{ 0 };
int m_denominator{ 1 };
public:
// 默认构造函数
Fraction(int numerator = 0, int denominator = 1)
: m_numerator{ numerator }, m_denominator{ denominator }
{
assert(denominator != 0);
}
// 复制构造函数
Fraction(const Fraction& copy)
: m_numerator{ copy.m_numerator }, m_denominator{ copy.m_denominator }
{
// 此处无需检查分母为 0,因 copy 必已是有效的 Fraction
std::cout << "Copy constructor called\n"; // 仅作演示
}
// 重载赋值运算符
Fraction& operator=(const Fraction& fraction);
friend std::ostream& operator<<(std::ostream& out, const Fraction& f1);
};
std::ostream& operator<<(std::ostream& out, const Fraction& f1)
{
out << f1.m_numerator << '/' << f1.m_denominator;
return out;
}
// 一种简单的 operator= 实现(见下文更优实现)
Fraction& Fraction::operator=(const Fraction& fraction)
{
// 执行复制
m_numerator = fraction.m_numerator;
m_denominator = fraction.m_denominator;
// 返回当前对象,以支持链式赋值
return *this;
}
int main()
{
Fraction fiveThirds{ 5, 3 };
Fraction f;
f = fiveThirds; // 调用重载的赋值运算符
std::cout << f;
return 0;
}
输出:
5/3
至此应已一目了然。重载的 operator= 返回 *this,使我们能够链式赋值:
int main()
{
Fraction f1{ 5, 3 };
Fraction f2{ 7, 2 };
Fraction f3{ 9, 5 };
f1 = f2 = f3; // 链式赋值
return 0;
}
自赋值引发的问题
此处开始变得有趣:C++ 允许自赋值:
int main()
{
Fraction f1{ 5, 3 };
f1 = f1; // 自赋值
return 0;
}
上述代码会调用 f1.operator=(f1)
,在简单实现下,各成员被赋给自身,除了浪费时间外并无实质影响。多数情况下,自赋值根本无需任何操作!
然而,若赋值运算符需要动态分配内存,自赋值可能变得危险:
#include <algorithm> // std::max、std::copy_n
#include <iostream>
class MyString
{
private:
char* m_data{};
int m_length{};
public:
MyString(const char* data = nullptr, int length = 0)
: m_length{ std::max(length, 0) }
{
if (length)
{
m_data = new char[static_cast<std::size_t>(length)];
std::copy_n(data, length, m_data); // 将 data 的 length 个元素复制到 m_data
}
}
~MyString()
{
delete[] m_data;
}
MyString(const MyString&) = default; // 某些编译器(如 GCC)在类含指针成员却未显式声明复制构造函数时会发出警告
// 重载赋值运算符
MyString& operator=(const MyString& str);
friend std::ostream& operator<<(std::ostream& out, const MyString& s);
};
std::ostream& operator<<(std::ostream& out, const MyString& s)
{
out << s.m_data;
return out;
}
// 一种简单的 operator= 实现(切勿直接使用)
MyString& MyString::operator=(const MyString& str)
{
// 若当前字符串已有数据,则先删除
if (m_data) delete[] m_data;
m_length = str.m_length;
m_data = nullptr;
// 分配适当长度的新数组
if (m_length)
m_data = new char[static_cast<std::size_t>(str.m_length)];
std::copy_n(str.m_data, m_length, m_data); // 将 str.m_data 的 m_length 个元素复制到 m_data
// 返回当前对象,以支持链式赋值
return *this;
}
int main()
{
MyString alex("Alex", 5); // 创建 Alex
MyString employee;
employee = alex; // Alex 成为新员工
std::cout << employee; // 输出员工姓名
return 0;
}
先按原样运行程序,会正确输出 “Alex”。
再运行以下代码:
int main()
{
MyString alex{ "Alex", 5 };
alex = alex; // Alex 赋给自己
std::cout << alex; // 输出 Alex 的姓名
return 0;
}
你很可能会得到乱码输出。发生了什么?
考虑重载 operator= 时,隐式对象与形参 str 皆为变量 alex 的情形。此时 m_data 与 str.m_data 指向同一块内存。函数首先检查隐式对象是否已含字符串,若有则将其删除,以避免内存泄漏。在此例中,m_data 已分配,于是函数 delete[] m_data。但因 str 与 *this 为同一对象,欲复制的字符串已被删除,m_data(及 str.m_data)变为悬垂指针。
随后,我们为 m_data(及 str.m_data)分配新内存。当尝试将 str.m_data 中的数据复制到 m_data 时,我们实际复制的是垃圾值,因为 str.m_data 从未被正确初始化。
检测并处理自赋值
好在我们可以检测自赋值。下面是 MyString 类重载 operator= 的更新实现:
MyString& MyString::operator=(const MyString& str)
{
// 自赋值检查
if (this == &str)
return *this;
// 若当前字符串已有数据,则先删除
if (m_data) delete[] m_data;
m_length = str.m_length;
m_data = nullptr;
// 分配适当长度的新数组
if (m_length)
m_data = new char[static_cast<std::size_t>(str.m_length)];
std::copy_n(str.m_data, m_length, m_data); // 将 str.m_data 的 m_length 个元素复制到 m_data
// 返回当前对象,以支持链式赋值
return *this;
}
通过比较隐式对象的地址与传入参数的地址,若相同,则赋值运算符立即返回,不再执行后续操作。
由于这只是指针比较,开销极小,且无需重载 operator==。
何时可不处理自赋值
通常,复制构造函数无需自赋值检查。因被复制构造的对象是新创建的,唯一可能与新对象相等的情形是用自身初始化新定义的对象:
someClass c{ c };
此时,编译器应会警告 c 为未初始化变量。
其次,若某类天然能妥善处理自赋值,亦可省略自赋值检查。考虑带自赋值守卫的 Fraction 类赋值运算符:
// operator= 的更佳实现
Fraction& Fraction::operator=(const Fraction& fraction)
{
// 自赋值守卫
if (this == &fraction)
return *this;
// 执行复制
m_numerator = fraction.m_numerator; // 可安全处理自赋值
m_denominator = fraction.m_denominator; // 可安全处理自赋值
// 返回当前对象,以支持链式赋值
return *this;
}
若移除自赋值守卫,该函数在自赋值时仍能正确运行(因为所有操作均可正确处理自赋值)。
鉴于自赋值极少发生,某些著名 C++ 专家建议在即使会受益的类中也省略自赋值守卫。我们不推荐此做法,认为更合理的实践是先防御式编码,再视情况优化。
复制并交换(copy and swap)惯用法
一种更优的自赋值处理方式是所谓的“复制并交换”惯用法。Stack Overflow 上有关于该惯用法的精彩阐述。
隐式复制赋值运算符
与其他运算符不同,若未提供用户定义的复制赋值运算符,编译器将为你的类生成一个隐式的 public 复制赋值运算符。该运算符执行按成员赋值(实质上与默认复制构造函数的按成员初始化相同)。
与其他构造函数和运算符一样,你可通过将复制赋值运算符设为 private 或使用 delete 关键字来阻止赋值:
#include <cassert>
#include <iostream>
class Fraction
{
private:
int m_numerator{ 0 };
int m_denominator{ 1 };
public:
// 默认构造函数
Fraction(int numerator = 0, int denominator = 1)
: m_numerator{ numerator }, m_denominator{ denominator }
{
assert(denominator != 0);
}
// 复制构造函数
Fraction(const Fraction& copy) = delete;
// 重载赋值运算符
Fraction& operator=(const Fraction& fraction) = delete; // 禁止通过赋值复制!
friend std::ostream& operator<<(std::ostream& out, const Fraction& f1);
};
std::ostream& operator<<(std::ostream& out, const Fraction& f1)
{
out << f1.m_numerator << '/' << f1.m_denominator;
return out;
}
int main()
{
Fraction fiveThirds{ 5, 3 };
Fraction f;
f = fiveThirds; // 编译错误:operator= 已被删除
std::cout << f;
return 0;
}
注意: 若类含有 const 成员,编译器会将隐式
operator=
定义为 deleted,因为 const 成员不可赋值,编译器据此推断该类不应可赋值。
若你希望含 const 成员的类仍可赋值(仅对非 const 成员赋值),则需显式重载 operator=
并手动赋值每个非 const 成员。