6. 运算符重载:让自定义类型像内置类型一样自然

运算符重载,说白了就是让咱们自己写的类也能用上 +-[] 这些运算符。我刚开始学的时候觉得这玩意儿挺花哨的,后来在项目里写了一个矩阵类,要是没有运算符重载,你想想看,每次做加法都得写 matrix.add(other),那代码得多难看。

嗯,这里要注意:运算符重载不是创造新的运算符,只是给已有的运算符赋予新的含义。而且,它必须作用于自定义类型上——你不能去改变 int+ 行为。

6.1 运算符重载的规则与语法

先说说规则。C++ 对运算符重载有一些硬性约束,我列几个关键的:

  • 不能改变优先级和结合性——你重载了 +,它还是比 * 低,别想改。
  • 不能改变操作数个数——一元运算符只能有一个参数,二元运算符只能有两个。
  • 不能发明新运算符——比如你不能造一个 ** 出来。
  • 不能重载的运算符::..*?:sizeoftypeid

语法其实就两种形式:

// 作为成员函数
class Complex {
public:
    Complex operator+(const Complex& rhs) const;
};

// 作为非成员函数(通常是友元)
Complex operator+(const Complex& lhs, const Complex& rhs);

我个人习惯是:一元运算符尽量做成成员函数,二元运算符尽量做成非成员函数。为什么?因为非成员函数能支持隐式类型转换——比如 3 + obj,如果 + 是成员函数,左边必须是你的类型,那就写不了。

核心原则:重载后的运算符应该保持语义自然。别把 + 重载成减法,那样写代码的人会骂你的。

6.2 一元运算符重载

一元运算符包括 ++--!~-(负号)等。这里有个坑:++-- 有前置和后置之分,怎么区分?

C++ 用了一个小技巧——后置版本加一个 int 占位参数:

class Counter {
    int value_;
public:
    // 前置 ++
    Counter& operator++() {
        ++value_;
        return *this;
    }
    
    // 后置 ++(注意那个 int)
    Counter operator++(int) {
        Counter tmp = *this;
        ++value_;
        return tmp;
    }
};

我在项目中遇到过有人把后置 ++ 返回了引用,结果出了诡异的 bug。记住:后置返回的是旧值,必须返回临时对象,不能返回引用。

性能小贴士:前置 ++ 效率更高,因为它不需要拷贝。迭代器遍历时,我习惯写 ++it 而不是 it++

6.3 二元运算符重载

二元运算符是最常用的,比如 +-*/==< 等。我建议把对称的运算符(比如 +==)写成非成员函数,把复合赋值运算符(比如 +=)写成成员函数。

class Vector2D {
    double x_, y_;
public:
    Vector2D(double x, double y) : x_(x), y_(y) {}
    
    // 复合赋值:成员函数
    Vector2D& operator+=(const Vector2D& rhs) {
        x_ += rhs.x_;
        y_ += rhs.y_;
        return *this;
    }
};

// 普通加法:非成员函数
Vector2D operator+(const Vector2D& lhs, const Vector2D& rhs) {
    Vector2D result = lhs;
    result += rhs;
    return result;
}

你看,+ 的实现复用了 +=,这样代码更干净。我一般都会让 + 调用 +=- 调用 -=,以此类推。

注意:比较运算符 ==!= 最好成对出现。我曾经只重载了 ==,结果别人用 != 时编译器报错,那场面挺尴尬的。

6.4 输入输出运算符重载

输入输出运算符 <<>> 比较特殊——它们必须是非成员函数,因为左操作数是 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;
}

这里有个细节:operator>> 的第二个参数不能是 const,因为你要修改它。另外,记得返回 ostream&istream&,这样才能链式调用——比如 cout << a << b

格式化技巧:输出时我习惯加上分隔符或括号,这样调试时一眼就能看出数据边界。比如 (1, 2)1 2 清晰多了。

6.5 类型转换运算符

类型转换运算符让你能控制自定义类型如何转换成其他类型。语法有点特别:

class Rational {
    int num_, den_;
public:
    // 转换成 double
    operator double() const {
        return static_cast<double>(num_) / den_;
    }
    
    // 转换成 bool(C++11 起可以用 explicit)
    explicit operator bool() const {
        return num_ != 0;
    }
};

嗯,这里要注意:类型转换运算符没有返回类型,因为返回类型就是你要转换的目标类型。而且它不能有参数。

我个人强烈建议:能用 explicit 就用 explicit。为什么?因为隐式转换经常带来意想不到的 bug。比如你写了个 if (r),如果 operator bool() 不是 explicit,那 r + 1 这种表达式也可能莫名其妙地编译通过——因为 r 先转成了 bool,再转成 int,最后加 1。这逻辑完全不对。

避坑指南:我曾经在项目里写了一个 operator int(),结果不小心把对象传给了需要 int 参数的函数,编译器一声不吭就转了。后来查了半天才发现是隐式转换搞的鬼。从那以后,所有单参数构造函数和类型转换运算符我都加 explicit

知识体系总览

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

运算符重载 基本规则 优先级/结合性不变 操作数个数不变 不能发明新运算符 一元运算符 ++ / --(前后置区分) ! / ~ / -(负号) 建议成员函数实现 二元运算符 + - * / == < 等 对称运算用非成员 复合赋值用成员 输入输出运算符 << 和 >> 必须是非成员函数 返回流引用 类型转换运算符 operator T() 无返回类型 建议加 explicit

这张图把五个核心知识点串起来了。你从上往下看:先搞清楚规则,再分别掌握一元、二元、IO 和类型转换。每个分支都有对应的实现建议,照着做基本不会踩坑。

运算符重载用好了,代码读起来就像在操作内置类型一样自然。但用不好——嗯,我见过有人把 + 重载成字符串拼接,把 - 重载成删除字符,结果代码维护起来简直噩梦。所以记住一句话:重载运算符,先想语义,再想语法


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