一、重载运算符的三个“坑”
运算符重载,说白了就是让自定义类型也能像内置类型一样用 +、=、[] 这些符号。听起来很爽对吧?但我在项目中见过太多人栽在这三个问题上:自赋值、返回引用还是值、const 正确性。今天咱们一个一个掰开揉碎了讲。
核心原则:运算符重载不是炫技,是让代码更自然。如果重载后反而让人困惑,那不如不重载。
二、自赋值问题:你以为不会发生,但它就是发生了
2.1 什么是自赋值?
自赋值就是对象给自己赋值:
MyClass obj;
obj = obj; // 自赋值
你可能会说:“谁会这么写代码?” 嗯,确实没人直接这么写。但你看这个:
MyClass a, b;
MyClass* p1 = &a;
MyClass* p2 = &a;
*p1 = *p2; // 自赋值!
或者更隐蔽的:
void swap(MyClass& x, MyClass& y) {
MyClass tmp = x;
x = y; // 如果 x 和 y 是同一个对象?
y = tmp;
}
我在项目中就遇到过这种场景——两个引用指向同一个对象,结果赋值操作符把资源释放了又试图拷贝,直接崩溃。
2.2 传统写法的问题
很多人写赋值操作符是这样的:
MyClass& operator=(const MyClass& rhs) {
delete[] data_; // 先释放自己的资源
data_ = new char[rhs.size_];
memcpy(data_, rhs.data_, rhs.size_);
return *this;
}
有什么问题?如果 this == &rhs,那 delete[] data_ 就把自己的数据删了,然后 rhs.data_ 指向的也是同一块内存——已经释放了!这就是典型的“先杀自己再复制”的悲剧。
2.3 三种解决方案
| 方案 | 做法 | 优缺点 |
|---|---|---|
| 身份检查 | 先判断 if (this != &rhs) |
简单直观,但每次赋值都多一次比较 |
| 先拷贝后释放 | 先拷贝 rhs 的数据,再释放自己的 | 异常安全,但需要临时变量 |
| copy-and-swap | 用拷贝构造 + swap | 最优雅,异常安全,代码简洁 |
我个人最推荐 copy-and-swap:
MyClass& operator=(MyClass rhs) { // 注意:传值,不是传引用
swap(rhs); // 交换当前对象和临时对象
return *this;
}
为什么好?因为 rhs 是传值进来的,拷贝构造已经完成了。如果拷贝失败,异常抛出,当前对象纹丝不动。交换后,临时对象析构时会释放原来的资源。完美!
我的习惯:只要自定义类型涉及动态资源,赋值操作符一律用 copy-and-swap。省心,安全,不容易出 bug。
三、返回引用还是值?这是个好问题
3.1 基本原则
你想想看,a = b = c 这种链式赋值,b = c 返回的是什么?如果是 void,那 a = (void) 就编译不过了。所以赋值操作符必须返回 *this 的引用。
但并不是所有运算符都返回引用。咱们分个类:
| 运算符 | 返回类型 | 原因 |
|---|---|---|
=, +=, -= 等复合赋值 |
引用 | 支持链式调用,避免拷贝 |
+, -, * 等算术运算符 |
值 | 返回的是新对象,不是修改自身 |
++, -- 前置 |
引用 | 返回递增后的自身 |
++, -- 后置 |
值 | 返回递增前的副本 |
[], *, -> |
引用 | 允许修改容器元素 |
3.2 一个常见的错误
我曾经看到有人这样写 operator+:
MyClass operator+(const MyClass& lhs, const MyClass& rhs) {
MyClass result(lhs);
result += rhs;
return result; // 返回局部对象,没问题,NRVO 会优化
}
但如果他写成返回引用:
MyClass& operator+(const MyClass& lhs, const MyClass& rhs) {
MyClass result(lhs);
result += rhs;
return result; // 返回局部对象的引用!悬垂引用!
}
嗯,这就是典型的 返回局部对象引用,函数结束 result 就析构了,外面拿到的引用指向一块被释放的内存。这种 bug 很难查,因为它可能偶尔能跑对,但随时会崩。
避坑指南:永远不要返回局部对象的引用或指针。如果返回的是新创建的对象,用值返回。编译器会帮你做 RVO/NRVO 优化,性能不差。
四、const 正确性:你的代码有“契约精神”吗?
4.1 为什么 const 这么重要?
const 正确性说白了就是:不修改对象的成员函数应该标记为 const。这不仅是语法问题,更是一种契约——告诉调用者“调用这个函数不会改变对象状态”。
我见过一个项目,整个代码库没有几个 const 成员函数。结果呢?想传个 const 引用都传不了,到处是 const_cast,代码像筛子一样漏洞百出。
4.2 运算符重载中的 const
来看几个典型场景:
class MyString {
public:
// 下标操作符:读操作应该 const
char operator[](size_t pos) const {
return data_[pos];
}
// 下标操作符:写操作不能 const
char& operator[](size_t pos) {
return data_[pos];
}
// 比较操作符:不修改对象,必须 const
bool operator==(const MyString& rhs) const {
return compare(rhs) == 0;
}
// 输出操作符:不修改对象,必须 const
friend std::ostream& operator<<(std::ostream& os, const MyString& str) {
os << str.data_;
return os;
}
};
注意 operator[] 的两个版本:const 版本返回 char(值),非 const 版本返回 char&(引用)。为什么?因为 const 对象不能返回可修改的引用,这是 const 正确性的基本要求。
4.3 一个让我头疼过的例子
我曾经写过一个矩阵类,重载了 operator() 来访问元素:
double& operator()(int row, int col) {
return data_[row * cols_ + col];
}
然后我想打印一个 const 矩阵:
void print(const Matrix& mat) {
for (int i = 0; i < mat.rows(); ++i) {
for (int j = 0; j < mat.cols(); ++j) {
std::cout << mat(i, j) << " "; // 编译错误!
}
}
}
为什么?因为 mat 是 const,但 operator() 没有 const 版本。加上 const 重载就解决了:
double operator()(int row, int col) const {
return data_[row * cols_ + col];
}
我的建议:写每个成员函数前先问自己:“这个函数会修改对象吗?” 不会?那就加 const。养成习惯后,你会发现代码的健壮性提升一大截。
五、知识体系总览
下面这张图总结了本章的核心内容,帮你理清思路:
六、总结
好了,三个陷阱都讲完了。咱们回顾一下:
- 自赋值问题:用 copy-and-swap 一劳永逸,别偷懒写身份检查
- 返回引用还是值:赋值返回引用,算术返回值,别返回局部对象的引用
- const 正确性:不修改就加 const,这是契约精神
这三个问题看似独立,其实都指向同一个核心:让你的自定义类型用起来像内置类型一样自然、安全。运算符重载不是炫技,是让代码更优雅。每次重载一个运算符,都问问自己:“用户会怎么用?会不会踩坑?”
我在项目中吃过不少亏,这些经验都是用 bug 换来的。希望你能少走弯路。
一句话记住:自赋值用 swap,返回看语义,const 别忘记。
公众号:蓝海资料掘金营,微信deep3321