一、重载运算符的三个“坑”

运算符重载,说白了就是让自定义类型也能像内置类型一样用 +=[] 这些符号。听起来很爽对吧?但我在项目中见过太多人栽在这三个问题上:自赋值、返回引用还是值、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。养成习惯后,你会发现代码的健壮性提升一大截。

五、知识体系总览

下面这张图总结了本章的核心内容,帮你理清思路:

运算符重载三大陷阱 自赋值问题 返回引用还是值 const 正确性 身份检查 (if this != &rhs) 先拷贝后释放 copy-and-swap(推荐) 赋值/复合赋值 → 返回引用 算术运算符 → 返回值 前置++/-- → 引用,后置 → 值 下标/解引用 → 返回引用 不修改对象 → 加 const const 版本返回值,非 const 返回引用 比较/输出运算符必须 const 核心原则:让自定义类型的行为像内置类型一样自然

六、总结

好了,三个陷阱都讲完了。咱们回顾一下:

  • 自赋值问题:用 copy-and-swap 一劳永逸,别偷懒写身份检查
  • 返回引用还是值:赋值返回引用,算术返回值,别返回局部对象的引用
  • const 正确性:不修改就加 const,这是契约精神

这三个问题看似独立,其实都指向同一个核心:让你的自定义类型用起来像内置类型一样自然、安全。运算符重载不是炫技,是让代码更优雅。每次重载一个运算符,都问问自己:“用户会怎么用?会不会踩坑?”

我在项目中吃过不少亏,这些经验都是用 bug 换来的。希望你能少走弯路。

一句话记住:自赋值用 swap,返回看语义,const 别忘记。


公众号:蓝海资料掘金营,微信deep3321