21-12 重载赋值运算符

复制赋值运算符copy assignment operator(operator=)用于将值从一个对象复制到另一个已存在的对象。

相关内容:
自C++11起,C++也支持“移动赋值”。我们在第22.3节——移动构造函数与移动赋值中讨论移动赋值。


复制赋值与复制构造函数

复制构造函数与复制赋值运算符的目的几乎等同——二者均将一个对象复制到另一个对象。然而,复制构造函数用于初始化新对象,而赋值运算符则用于替换现有对象的内容。

两者的区别常令新手困惑,实则并不复杂。总结如下:

  • 若复制前必须创建新对象(包括按值传递或返回对象),则使用复制构造函数。
  • 若复制前无需创建新对象,则使用赋值运算符。

重载赋值运算符

重载复制赋值运算符(operator=)相当简单,但有一个特定注意事项我们稍后会说明。复制赋值运算符必须作为成员函数进行重载。

#include <cassert>
#include <iostream>

class Fraction
{
private:
	int m_numerator { 0 };
	int m_denominator { 1 };

public:
	// Default constructor
	Fraction(int numerator = 0, int denominator = 1 )
		: m_numerator { numerator }, m_denominator { denominator }
	{
		assert(denominator != 0);
	}

	// Copy constructor
	Fraction(const Fraction& copy)
		: m_numerator { copy.m_numerator }, m_denominator { copy.m_denominator }
	{
		// no need to check for a denominator of 0 here since copy must already be a valid Fraction
		std::cout << "Copy constructor called\n"; // just to prove it works
	}

	// Overloaded assignment
	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;
}

// A simplistic implementation of operator= (see better implementation below)
Fraction& Fraction::operator= (const Fraction& fraction)
{
    // do the copy
    m_numerator = fraction.m_numerator;
    m_denominator = fraction.m_denominator;

    // return the existing object so we can chain this operator
    return *this;
}

int main()
{
    Fraction fiveThirds { 5, 3 };
    Fraction f;
    f = fiveThirds; // calls overloaded assignment
    std::cout << f;

    return 0;
}

这将输出:

image

现在这些应该都很直观了。我们的重载赋值运算符返回*this,这样就能将多个赋值操作串联起来:

int main()
{
    Fraction f1 { 5, 3 };
    Fraction f2 { 7, 2 };
    Fraction f3 { 9, 5 };

    f1 = f2 = f3; // chained assignment

    return 0;
}

自赋值引发的问题

事情开始变得更有趣了。C++ 允许自赋值:

int main()
{
    Fraction f1 { 5, 3 };
    f1 = f1; // self assignment

    return 0;
}

这将调用 f1.operator=(f1),在上述简单实现中,所有成员都会被赋值给自己。在这个特定示例中,自赋操作导致每个成员都被赋值给自己,除了浪费时间外,整体上没有任何影响。在大多数情况下,自赋操作根本不需要做任何事情!

然而,当赋值运算符需要动态分配内存时,自赋操作可能带来实际风险:

#include <algorithm> // for std::max and 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); // copy length elements of data into m_data
		}
	}
	~MyString()
	{
		delete[] m_data;
	}

	MyString(const MyString&) = default; // some compilers (gcc) warn if you have pointer members but no declared copy constructor

	// Overloaded assignment
	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;
}

// A simplistic implementation of operator= (do not use)
MyString& MyString::operator= (const MyString& str)
{
	// if data exists in the current string, delete it
	if (m_data) delete[] m_data;

	m_length = str.m_length;
	m_data = nullptr;

	// allocate a new array of the appropriate length
	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); // copies m_length elements of str.m_data into m_data

	// return the existing object so we can chain this operator
	return *this;
}

int main()
{
	MyString alex("Alex", 5); // Meet Alex
	MyString employee;
	employee = alex; // Alex is our newest employee
	std::cout << employee; // Say your name, employee

	return 0;
}

image

首先,直接运行该程序。你会看到程序如预期般输出“Alex”。

现在运行以下程序:

int main()
{
    MyString alex { "Alex", 5 }; // Meet Alex
    alex = alex; // Alex is himself
    std::cout << alex; // Say your name, Alex

    return 0;
}

image
image

你可能会得到垃圾输出。这是怎么回事?

考虑重载的 operator= 中,当隐式对象和传入参数(str)都是变量 alex 时的情况。此时 m_data 与 str.m_data 相同。首先函数会检查隐式对象是否已有字符串,若有则需删除它以避免内存泄漏。此时 m_data 已被分配,函数便会删除 m_data。但由于 str 等同于 *this,本应复制的字符串已被删除,导致 m_data(及 str.m_data)成为悬空指针。

随后我们为m_data(及str.m_data)分配了新内存。因此当后续将str.m_data的数据复制到m_data时,实际复制的是垃圾数据——因为str.m_data从未被初始化。


检测和处理自我赋值

幸运的是,我们可以检测到自我赋值的发生。以下是为 MyString 类更新的重载赋值运算符实现:

MyString& MyString::operator= (const MyString& str)
{
	// self-assignment check
	if (this == &str)
		return *this;

	// if data exists in the current string, delete it
	if (m_data) delete[] m_data;

	m_length = str.m_length;
	m_data = nullptr;

	// allocate a new array of the appropriate length
	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); // copies m_length elements of str.m_data into m_data

	// return the existing object so we can chain this operator
	return *this;
}

通过检查隐式对象的地址是否与作为参数传递的对象地址相同,我们的赋值运算符即可立即返回而不执行其他操作。

由于这只是指针比较,因此速度很快,且无需重载运算符==。


何时不处理自赋值

通常情况下,复制构造函数会跳过自赋值检查。由于被复制构造的对象是新创建的,新创建的对象仅在尝试用自身初始化新定义对象时才可能与被复制对象相等:

someClass c { c };

在这种情况下,编译器应提示 c 是未初始化的变量。
其次,对于能自然处理自赋值的类,可省略自赋值检查。请看这个带有自赋值保护的 Fraction 类赋值运算符:

// A better implementation of operator=
Fraction& Fraction::operator= (const Fraction& fraction)
{
    // self-assignment guard
    if (this == &fraction)
        return *this;

    // do the copy
    m_numerator = fraction.m_numerator; // can handle self-assignment
    m_denominator = fraction.m_denominator; // can handle self-assignment

    // return the existing object so we can chain this operator
    return *this;
}

如果没有自赋值保护机制,该函数在自赋值操作中仍能正常运行(因为函数执行的所有操作都能正确处理自赋值)。

由于自赋值是罕见事件,部分知名C++专家建议即使在能受益于自赋值保护的类中也应省略该机制。我们不推荐此做法,因为我们认为更优的实践是先采用防御性编程,再根据需要进行优化。


复制与交换模式

处理自赋值问题的更佳方案是采用所谓的复制与交换模式。Stack Overflow上有一篇关于该模式运作原理的精彩解析。


何时不处理自赋值

通常情况下,复制构造函数会跳过自赋值检查。由于被复制构造的对象是新创建的,新创建的对象仅在尝试用自身初始化新定义对象时才可能与被复制对象相等:

someClass c { c };

在这种情况下,编译器应提示 c 是未初始化的变量。

其次,对于能自然处理自赋值的类,可省略自赋值检查。请看这个带有自赋值保护的 Fraction 类赋值运算符:

// A better implementation of operator=
Fraction& Fraction::operator= (const Fraction& fraction)
{
    // self-assignment guard
    if (this == &fraction)
        return *this;

    // do the copy
    m_numerator = fraction.m_numerator; // can handle self-assignment
    m_denominator = fraction.m_denominator; // can handle self-assignment

    // return the existing object so we can chain this operator
    return *this;
}

如果没有自赋值保护机制,该函数在自赋值操作中仍能正常运行(因为函数执行的所有操作都能正确处理自赋值)。

由于自赋值是罕见事件,部分知名C++专家建议即使在能受益于自赋值保护的类中也应省略该机制。我们不推荐此做法,因为我们认为更优的实践是先采用防御性编程,再根据需要进行优化。


复制与交换模式

处理自赋值问题的更佳方案是采用所谓的复制与交换模式。Stack Overflow上有一篇关于该模式运作原理的精彩解析。


隐式复制赋值运算符

与其他运算符不同,若未提供用户定义的赋值运算符,编译器将为类提供隐式公共复制赋值运算符。该赋值运算符执行成员逐项赋值(本质上与默认复制构造函数的成员逐项初始化相同)。

与其他构造函数和运算符类似,可通过将复制赋值运算符设为私有或使用delete关键字来阻止赋值操作:

#include <cassert>
#include <iostream>

class Fraction
{
private:
	int m_numerator { 0 };
	int m_denominator { 1 };

public:
    // Default constructor
    Fraction(int numerator = 0, int denominator = 1)
        : m_numerator { numerator }, m_denominator { denominator }
    {
        assert(denominator != 0);
    }

	// Copy constructor
	Fraction(const Fraction &copy) = delete;

	// Overloaded assignment
	Fraction& operator= (const Fraction& fraction) = delete; // no copies through assignment!

	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; // compile error, operator= has been deleted
    std::cout << f;

    return 0;
}

请注意,如果类包含 const 成员,编译器会将隐式定义的 operator= 标记为 deleted。这是因为 const 成员不可赋值,因此编译器会默认该类不可赋值。

若需使含 const 成员的类可赋值(针对所有非 const 成员),则必须显式重载 operator= 并手动为每个非 const 成员赋值。

posted @ 2026-01-25 22:27  游翔  阅读(1)  评论(0)    收藏  举报