1. 从一次双重释放事故讲起
先把话说在前头:赋值运算符重载,是C++类设计里最容易被轻视、又最容易炸出内存问题的环节。我见过太多写了三五年C++的人,遇到std::string、std::vector用得飞起,但自己手写一个带指针成员的类时,照样在operator=上翻车。最典型的场景就是:类里有一个char*或者int*成员,编译器默认生成的赋值运算符做了逐成员拷贝,两个对象指向同一块堆内存,析构的时候各自delete一次,程序直接崩溃。你查半天才发现,问题压根不在析构函数,而在赋值那一步。
这篇文章要讲的,就是围绕“C++类和对象”中赋值运算符重载的完整细节。我会从默认赋值行为为什么会出问题讲起,一路深入到返回值类型、自我赋值处理、异常安全、移动语义配合,最后给出一个可以直接抄走的完整实现范式。适合的对象是:正在学C++的初学者(尤其是被double free折磨过的人),以及写了好几年C++但没系统梳理过operator=编写规范的从业者。
需要提前说明的是,这篇文章里的所有实现思路都是基于C++11及之后的标准。如果你还在用C++98,部分代码需要微调,我会在对应位置标注。整个阅读过程大概十五分钟,但我建议你打开编译器跟着敲一遍,赋值运算符这东西,光看不练是记不住的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 赋值运算符为什么难写:三条底层逻辑
2.1 默认赋值是“值拷贝”,但指针成员要的是“深拷贝”
编译器会为每个类自动生成赋值运算符,前提是你没有自己声明任何一个operator=版本。它做的事情很简单:对每个非静态成员,逐个调用该成员的赋值运算符。对于int、double这种内置类型,赋值就是直接拷值;但对于指针成员,赋值拷贝的是指针本身——也就是地址。
这就引出第一个核心问题:地址拷贝之后,两个对象的指针成员指向同一块内存。假如类里还有析构函数负责释放这块内存,那么第一个对象析构时把内存释放了,第二个对象析构时再次释放同一块内存——这就是臭名昭著的double free。更隐蔽的是,如果你通过第二个对象修改了指针指向的内容,第一个对象看到的数据也跟着变了,因为本来就是同一块内存,这叫“浅拷贝副作用”。你可以把这种情况类比成:两个人共用一个银行账户,一个人取钱,另一个人余额就少了;一个人注销账户,另一个人手里的卡就成废卡了。
默认赋值运算符不会报错,它只是在你不知道的时候,埋下一颗定时炸弹。等你真正踩到的时候,往往已经离出事点很远了,排查起来极其痛苦。
2.2 拷贝构造与赋值运算符的“分工差异”
很多人把拷贝构造函数和赋值运算符混在一起记,觉得都是“把一个对象复制给另一个对象”,但它们的触发时机完全不同:
- 拷贝构造:创建一个新对象时,用已有对象初始化它。
- 赋值运算符:两个对象都已存在,把右侧对象的值赋给左侧对象。
代码上区分的话:
cpp复制MyClass a(10);
MyClass b = a; // 拷贝构造,因为 b 是刚创建的
MyClass c;
c = a; // 赋值运算符,因为 c 已经存在了
这里有个常见坑:MyClass b = a;看起来像赋值,实际上走的是拷贝构造。如果你在拷贝构造里写了正确的深拷贝,但赋值运算符没重写,依然会踩浅拷贝的坑。所以写类的时候,拷贝构造和赋值运算符必须成对考虑,只修一个不够。这也是后面要讲的“三法则”和“五法则”存在的意义。
2.3 三法则:什么时候必须自己写赋值运算符
所谓“三法则”,指的是:**如果类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么通常三个都需要自定义。**理由是这三个成员函数处理的都是同一个东西——资源的获取与释放。只要类里有堆内存、文件句柄、互斥锁、数据库连接等资源,默认的逐成员拷贝几乎必然出错。
举个例子,一个类只有int成员,析构函数什么都不用做,那拷贝构造和赋值运算符也用不着自己写。但一旦加了int*成员、并且析构函数里需要delete,三法则就生效了。判断标准很简单:**你的复制操作是要“共享资源”还是“独立拥有资源”?**独立拥有就得自己写深拷贝。这个判断一定要养成条件反射,面试里问三法则、算法题里写链表类,都靠它。
C++11之后,三法则升级成了五法则——多了移动构造函数和移动赋值运算符。我后面单独讲,先说清楚基础的复制版本。
3. 赋值运算符重载的完整实现与逐步拆解
3.1 标准签名:为什么返回值必须是引用
规范的赋值运算符声明长这样:
cpp复制class MyClass
{
public:
MyClass& operator=(const MyClass& other);
};
有四个细节需要说清楚:
-
返回类型是
MyClass&:为了支持链式赋值,比如a = b = c;。b = c返回b的引用,然后a = b的引用才能继续赋值。如果返回void,链式赋值直接编译报错。如果返回对象本身(也就是MyClass而不是MyClass&),每赋值一次就多一次临时对象的拷贝构造和析构,性能白白损耗。所以返回引用是最优解,既能链式,又不产生额外临时对象。 -
参数是
const MyClass&:传引用避免调用时的拷
