你是否想过,std::vector 为什么能用 v[3] 取元素、std::cout << s 为什么能打印字符串?答案就是运算符重载:库作者把 [] 和 << 变成了"函数"。今天我们就用最经典的复数类,把运算符重载讲透,让你写的类也能拥有这种语法特权。

1. 为什么要重载运算符

先看一个痛点。假设你写了一个复数类,想计算两个复数的和。没有运算符重载时,你得写 a.add(b) 或者 add(a, b),读起来别扭;有了 operator+ 之后,直接写 a + b,和数学公式一模一样。同样的道理,自定义的矩阵类可以用 m1 * m2 做乘法,自定义的时间类可以用 t1 < t2 比先后——运算符重载让自定义类型在语法上"长得像内置类型",这是它最大的价值。

运算符重载的本质是函数重载的特例:运算符只是名字特殊的函数。a + b 等价于 a.operator+(b)(成员函数形式)或 operator+(a, b)(非成员函数形式)。C++ 允许重载绝大多数运算符,但有几条硬性规则:

日常用得最多的是这几组:算术运算符(+ - * / %)、比较运算符(== != < >)、赋值(= +=)、流式读写(<< >>)、下标 [] 和调用 ()。下面从最简单的版本开始。

其实你天天都在用运算符重载:std::string 的 + 拼接、== 比较,std::vector 的 [],std::map 的 [],迭代器的 ++ 和 *……标准库本身就是最大的重载示例库。学习重载最好的方式,就是去读标准库头文件里的声明,看库作者是怎么组织这些函数的。

2. 第一个例子:复数类的 operator+

先实现成员函数形式的加法:

#include <iostream>

class Complex {
public:
    Complex(double r = 0, double i = 0) : re_(r), im_(i) {}

    // 成员函数形式:this 是左操作数,rhs 是右操作数
    Complex operator+(const Complex& rhs) const {
        return Complex(re_ + rhs.re_, im_ + rhs.im_);
    }

    void print() const {
        std::cout << re_ << " + " << im_ << "i\n";
    }

private:
    double re_, im_;
};

int main() {
    Complex a(1, 2), b(3, 4);
    Complex c = a + b;          // 等价于 a.operator+(b)
    c.print();                  // 输出:4 + 6i
    return 0;
}

关键点:operator+ 是 const 成员函数,因为加法不该修改操作数;它返回一个新对象而不是引用,因为结果是临时值,返回引用就会得到悬垂引用。传参用 const Complex&,避免不必要的拷贝;编译器通常会用返回值优化消除这次临时对象拷贝,所以不用担心性能。运行这段代码,输出 4 + 6i。注意:这里 a + b 中的 a 必须是 Complex 类型,如果左操作数不是本类对象,成员函数形式就无能为力了,这正是下一节要解决的问题。

3. 输出与比较:为什么需要非成员函数

接下来实现 operator<< 和 operator==。注意:<< 的左操作数是 std::ostream,不是我们的类,没法写成成员函数,所以要用友元函数:

#include <iostream>

class Complex {
    // 友元可以访问私有成员
    friend std::ostream& operator<<(std::ostream& os, const Complex& c);
    friend bool operator==(const Complex& a, const Complex& b);

public:
    Complex(double r = 0, double i = 0) : re_(r), im_(i) {}

    Complex operator+(const Complex& rhs) const {
        return Complex(re_ + rhs.re_, im_ + rhs.im_);
    }

private:
    double re_, im_;
};

std::ostream& operator<<(std::ostream& os, const Complex& c) {
    os << c.re_ << (c.im_ >= 0 ? "+" : "") << c.im_ << "i";
    return os;   // 返回 os 本身,支持 cout << a << b 连续输出
}

bool operator==(const Complex& a, const Complex& b) {
    return a.re_ == b.re_ && a.im_ == b.im_;
}

int main() {
    Complex a(1, 2), b(3, 4);
    std::cout << a + b << "\n";                     // 输出:4+6i
    std::cout << (a == Complex(1, 2)) << "\n";      // 输出:1
    return 0;
}

两个细节值得记住:第一,operator<< 必须返回 std::ostream&,这样 cout << a << b 才能链式输出;第二,operator== 写成非成员函数,当左操作数发生隐式转换时也能生效(比如 Complex(1, 0) == 1)。如果不希望隐式转换参与比较,可以给构造函数加 explicit 关键字挡住它。

4. 复合赋值、下标与自增自减

复合赋值与下标

工程里经常需要 +=、-= 这类复合赋值。它们要修改左操作数,所以是非 const 成员函数,且返回引用以便链式调用。再结合 operator[],实现一个极简的"整数数组"类:

#include <iostream>
#include <vector>

class IntVec {
public:
    explicit IntVec(std::size_t n = 0) : data_(n, 0) {}

    int& operator[](std::size_t i) { return data_[i]; }
    const int& operator[](std::size_t i) const { return data_[i]; }

    IntVec& operator+=(int v) {
        for (auto& x : data_) x += v;
        return *this;
    }

    std::size_t size() const { return data_.size(); }

private:
    std::vector<int> data_;
};

int main() {
    IntVec v(3);
    v[0] = 10; v[1] = 20; v[2] = 30;   // 通过重载的 [] 写入
    v += 5;                             // 每个元素加 5
    for (std::size_t i = 0; i < v.size(); ++i)
        std::cout << v[i] << " ";       // 输出:15 20 35
    std::cout << "\n";
    return 0;
}

这里展示了重载的"两个版本"套路:const 版本给常量对象用,非 const 版本返回可变引用,这样 v[0] = 10 能写、const IntVec cv; cv[0] 也能读。复合赋值返回 *this 的引用,支持 v += 1; v += 2; 连写。

前缀与后缀自增

++ 是最容易写错的重载:前缀返回新值,后缀返回旧值。区分它们靠一个"哑参数" int:

#include <iostream>

class Counter {
public:
    explicit Counter(int v = 0) : value_(v) {}

    // 前缀 ++:先自增,返回自增后的对象(引用)
    Counter& operator++() {
        ++value_;
        return *this;
    }
    // 后缀 ++:返回自增前的副本,形参 int 只是区分标记
    Counter operator++(int) {
        Counter old = *this;
        ++value_;
        return old;
    }
    int get() const { return value_; }

private:
    int value_;
};

int main() {
    Counter c(10);
    std::cout << (++c).get() << " ";   // 前缀:先加后取,11
    std::cout << (c++).get() << " ";   // 后缀:先取后加,还是 11
    std::cout << c.get() << "\n";      // 此时已是 12
    return 0;
}

前缀版本返回 Counter&,后缀版本返回 Counter 副本——后缀要构造临时对象,效率略低,所以循环里能写 ++it 就别写 it++。这个约定(前缀返回引用、后缀返回副本)是 C++ 的行业标准,std::vector 的迭代器就是这么实现的。

5. 函数调用运算符:让对象"可调用"

operator() 可以让对象像函数一样被调用,这样的对象叫仿函数。它最大的价值是可以携带状态,比如一个累加器:

#include <iostream>

class Accumulator {
public:
    explicit Accumulator(int start = 0) : sum_(start) {}
    int operator()(int x) {
        sum_ += x;
        return sum_;
    }
    int total() const { return sum_; }

private:
    int sum_;
};

int main() {
    Accumulator acc(100);
    std::cout << acc(1) << " ";    // 输出:101
    std::cout << acc(5) << " ";    // 输出:106
    std::cout << acc(20) << "\n";  // 输出:126
    return 0;
}

每次调用 acc(x) 都在内部状态上累加。在 C++11 之前,仿函数是给算法传"函数"的主要手段;现在虽然 lambda 更常用,但理解 operator() 仍是读懂老代码的必备技能——lambda 的底层实现就是一个匿名仿函数类。

6. 完整复数类与工程建议

把前面几节的技巧组合起来,得到完整版复数类:

#include <iostream>

class Complex {
    friend std::ostream& operator<<(std::ostream&, const Complex&);

public:
    Complex(double r = 0, double i = 0) : re_(r), im_(i) {}

    Complex operator+(const Complex& rhs) const {
        return Complex(re_ + rhs.re_, im_ + rhs.im_);
    }
    Complex& operator+=(const Complex& rhs) {
        re_ += rhs.re_; im_ += rhs.im_;
        return *this;
    }
    bool operator==(const Complex& rhs) const {
        return re_ == rhs.re_ && im_ == rhs.im_;
    }
    bool operator!=(const Complex& rhs) const { return !(*this == rhs); }

private:
    double re_, im_;
};

std::ostream& operator<<(std::ostream& os, const Complex& c) {
    return os << c.re_ << " + " << c.im_ << "i";
}

int main() {
    Complex a(1, 2), b(3, 4);
    a += b;
    std::cout << a << "\n";                    // 4 + 6i
    std::cout << (a == Complex(4, 6)) << "\n"; // 1
    std::cout << (a != b) << "\n";             // 1
    return 0;
}

还有一个绕不开的运算符 operator=(赋值)。它和 + 不同:赋值是修改现有对象,所以返回 Complex&。编译器会自动生成默认赋值运算符(逐成员拷贝),这对含指针的类是危险的——两个对象会指向同一块内存,析构时双重释放。解决之道要么自己写深拷贝,要么用智能指针管理成员,这正是 C++ "三/五法则"的由来。

工程建议:实现了 + 就顺手实现 +=,实现了 == 就顺手实现 !=,保持一致;重载的语义要和内置类型对齐,别把 + 重载成"减法",那会让所有读代码的人崩溃。另外,operator+ 通常可以基于 operator+= 实现(先拷贝再 +=),避免两处重复逻辑。

7. 陷阱:短路与优先级

最后看一个经典陷阱。C++ 允许重载 && 和 ||,但重载之后短路求值就消失了,两个操作数都会被求值:

#include <iostream>

struct Flag {
    bool on;
    Flag operator&&(const Flag& rhs) const { return Flag{on && rhs.on}; }
};

Flag make_flag(bool v, const char* name) {
    std::cout << "求值 " << name << "\n";
    return Flag{v};
}

int main() {
    Flag a = make_flag(false, "a") && make_flag(true, "b");
    // 输出两行:说明 b 也被求值了,短路失效
    return 0;
}

内置 && 在左边为假时根本不会碰右边,而这里两行都打印了。所以除非有充分理由,否则不要重载 &&、|| 和逗号运算符。同理,重载 new/delete 属于高级内存管理话题,入门阶段避开。另一个常见误解是"重载运算符能改变优先级"——不能,a + b * c 永远是先乘后加。

8. 总结与练习

回顾一下:运算符重载让自定义类型拥有和内置类型一样的语法;成员函数适合修改自身的运算符(+=),非成员/友元函数适合左操作数不是本类的运算符(<<、==);[] 要成对提供 const 与非 const 版本;++ 前缀返回引用、后缀返回副本;() 让对象可调用;永远保持语义一致、别破坏短路。

练习题:

💡 判断该不该重载一个运算符,问自己:这个写法像不像数学或内置类型的直觉?如果答案是"像",就重载;如果只是图省事,不如写个有名字的函数。