16. 运算符重载:重载规则、重载算术运算符、重载输入输出运算符、重载赋值运算符、重载下标运算符

运算符重载,说白了就是让 C++ 的运算符能「认识」你自定义的类型。比如你写了个复数类,想让 + 号直接做复数加法,而不是写一堆 c1.add(c2) 的调用。嗯,这就是运算符重载的初衷——让代码读起来更自然。

我个人习惯是:能用运算符表达清晰语义的,就重载;否则别硬来。你想想看,要是把 + 重载成减法,那代码维护的人非骂你不可。

核心原则:运算符重载不改变运算符的优先级和结合性,也不创造新的运算符。你只能重载已有的那几十个。

16.1 重载规则

先说说哪些运算符不能重载:..*::?:sizeoftypeid。剩下的基本都可以。

重载有两种形式:

  • 成员函数形式:左操作数必须是当前类的对象
  • 非成员函数形式(通常是友元):可以处理左右操作数类型不同的情况

举个例子,二元运算符 +

// 成员函数形式
class Complex {
public:
    Complex operator+(const Complex& rhs) const {
        return Complex(real + rhs.real, imag + rhs.imag);
    }
};

// 非成员函数形式(友元)
class Complex {
    friend Complex operator+(const Complex& lhs, const Complex& rhs);
};

Complex operator+(const Complex& lhs, const Complex& rhs) {
    return Complex(lhs.real + rhs.real, lhs.imag + rhs.imag);
}

我的建议:如果运算符需要修改左操作数(比如 +=),用成员函数;如果左右操作数类型可能不同(比如 int + Complex),用非成员函数。

16.2 重载算术运算符

算术运算符包括 +-*/% 等。我一般会同时重载复合赋值版本(+=-= 等),然后用复合赋值来实现普通算术运算符。这样代码复用率高,也容易维护。

class Vector2D {
    double x, y;
public:
    Vector2D& operator+=(const Vector2D& rhs) {
        x += rhs.x;
        y += rhs.y;
        return *this;
    }

    // 用 += 实现 +
    Vector2D operator+(const Vector2D& rhs) const {
        Vector2D temp = *this;
        temp += rhs;
        return temp;
    }
};

我在项目中遇到过一个问题:重载 * 时,既要支持向量乘标量,又要支持标量乘向量。这时候非成员函数就派上用场了:

Vector2D operator*(const Vector2D& v, double s) {
    return Vector2D(v.x * s, v.y * s);
}

Vector2D operator*(double s, const Vector2D& v) {
    return v * s;  // 复用上面的实现
}

16.3 重载输入输出运算符

输入输出运算符 <<>> 必须重载为非成员函数,因为左操作数是 ostreamistream,不是你的类对象。

class Point {
    int x, y;
public:
    friend ostream& operator<<(ostream& os, const Point& p);
    friend istream& operator>>(istream& is, Point& p);
};

ostream& operator<<(ostream& os, const Point& p) {
    os << "(" << p.x << ", " << p.y << ")";
    return os;
}

istream& operator>>(istream& is, Point& p) {
    is >> p.x >> p.y;
    return is;
}

注意:我曾经在重载 >> 时忘记处理输入失败的情况。结果用户输入了非数字,程序直接崩了。记得检查流状态:

istream& operator>>(istream& is, Point& p) {
    if (is >> p.x >> p.y) {
        // 成功读取
    } else {
        p = Point();  // 重置为默认值
    }
    return is;
}

16.4 重载赋值运算符

赋值运算符 = 是每个类默认就有的,但如果你有动态分配的资源(比如 new 出来的内存),就必须自己重载。否则默认的浅拷贝会让你吃大亏。

我总结了一个「四步法」:

  1. 检查自赋值(if (this == &rhs) return *this;
  2. 释放当前资源
  3. 分配新资源并拷贝数据
  4. 返回 *this
class String {
    char* data;
public:
    String& operator=(const String& rhs) {
        if (this == &rhs) return *this;  // 自赋值检查

        delete[] data;  // 释放旧资源

        data = new char[strlen(rhs.data) + 1];
        strcpy(data, rhs.data);

        return *this;
    }
};

小技巧:用「拷贝并交换」惯用法可以让赋值运算符更安全、更简洁:

void swap(String& other) noexcept {
    std::swap(data, other.data);
}

String& operator=(String rhs) {  // 传值,自动拷贝
    swap(rhs);  // 交换资源
    return *this;
}

这样写,自赋值安全、异常安全,而且代码量少。我个人非常推荐。

16.5 重载下标运算符

下标运算符 [] 通常用于容器类,比如你自己写的数组、矩阵等。它必须返回引用,这样才能支持 obj[i] = value 这种写法。

class IntArray {
    int* arr;
    size_t size;
public:
    int& operator[](size_t index) {
        if (index >= size) {
            throw std::out_of_range("Index out of range");
        }
        return arr[index];
    }

    // const 版本,用于只读访问
    const int& operator[](size_t index) const {
        if (index >= size) {
            throw std::out_of_range("Index out of range");
        }
        return arr[index];
    }
};

我在项目中遇到过一个问题:用户传了负数下标。嗯,size_t 是无符号的,负数会变成很大的正数,直接越界。所以我建议下标检查一定要做,哪怕只是 assert 也行。

关键点:下标运算符通常需要两个版本——普通版本返回 int&,const 版本返回 const int&。这样 const 对象也能安全地读取元素。

知识体系总览

下面这张图把运算符重载的核心脉络梳理了一下,你可以对照着看:

运算符重载 重载规则 不能重载: . .* :: ?: sizeof 成员函数 vs 非成员函数 常见重载分类 算术运算符 输入输出 赋值运算符 下标运算符 实践要点 复合赋值复用 自赋值检查 拷贝并交换惯用法 让自定义类型像内置类型一样自然 可读性 · 一致性 · 安全性

运算符重载是 C++ 的一大特色,用好了能让代码优雅不少。但别滥用——我见过有人把 & 重载成逻辑与,结果排查 bug 排查了一整天。记住:重载的语义要和运算符原本的直觉一致,这才是正道。


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