C++ 运算符重载

对于 int、double 这些基本数据类型,我们可以直接使用 +、-、== 等符号计算或比较大小。但如果是自定义的类(如表示距离的 Distance 类),直接写成 d1 + d2 就会编译报错,因为编译器不知道两个 Distance 对象相加应该怎么算。

虽然我们可以在类中定义 add() 成员函数,通过 d1.add(d2) 来计算总距离,但直接写 d1 + d2 显然直观得多。在 C++ 中,我们可以为自定义类型重新规定已有运算符的计算规则,这种机制就叫做 “运算符重载(Operator Overloading)”。

简单来说,运算符重载并不是发明新的符号,而是编写一个名为 operator运算符 的特殊函数。你可以把它理解为给编译器立下一条规矩:当 +、==、<< 等符号遇到我们自己的类对象时,应该执行哪一段代码。

使用成员函数重载运算符

在 C++ 中,运算符重载本质上就是一个函数,只是它的函数名必须由关键字 operator 加上要重载的运算符符号组成。比如重载加号的函数名是 operator+,重载相等比较符的函数名是 operator==。在实际开发中,最常见的做法是把运算符重载函数写成类的成员函数。

语法:

class 类名 {
public:
    返回值类型 operator运算符(参数列表) {
        // 自定义运算规则
    }
};

说明:

像 +、-、== 这样需要左右两个操作数的运算符称为 “双目运算符”。当我们把双目运算符重载为类的成员函数时,左边的操作数就是正在调用该函数的 “当前对象”(由隐含的 this 指针指向),因此参数列表中只需要接收 1 个参数,用来代表右边的操作数。

也就是说,当我们在代码中写下 d1 + d2 时,C++ 编译器在背后会自动把它转换成成员函数调用。对于这个例子来说,下面 2 种方式是等价的。

// 方式 1
Distance total = d1 + d2;

// 方式 2
Distance total = d1.operator+(d2);

看懂了这层转换关系,为什么参数列表里只需要写 1 个参数来接收右边的 d2,一下子就清楚了。下面我们通过两个简单的例子,来看看如何重载常用的 + 运算符和 == 运算符。

示例 1:重载 + 运算符

#include <iostream>

class Distance {
private:
    int meters;                  // 距离(米)

public:
    Distance(int m) : meters(m) {}

    // 重载 + 运算符
    Distance operator+(const Distance& other) const {
        return Distance(meters + other.meters);
    }

    int getMeters() const {
        return meters;
    }
};

int main() {
    Distance d1(5);
    Distance d2(7);

    Distance total = d1 + d2;    // 等价于 d1.operator+(d2)
    std::cout << "总距离:" << total.getMeters() << " 米\n";
    return 0;
}

运行结果如下。

总距离:12 米

分析:

在这个例子中,执行 Distance total = d1 + d2; 时,左边的 d1 是当前对象,右边的 d2 作为实参传给形参 other。随后 return Distance(meters + other.meters); 直接调用构造函数创建出米数为 12 的新对象,并将其返回赋给 total。

观察函数头 Distance operator+(const Distance& other) const,里面出现了两个 const:括号里的 const Distance& 用来修饰形参,既省去复制开销,又防止修改右边的 d2。括号后面的 const 则表示这是一个 const 成员函数,保证不修改左边的当前对象 d1。

这里大家还要特别注意返回值类型:两数相加算出的是一个全新的结果对象,而不是修改原来的 d1。因此返回值类型必须按值写成 Distance,千万不能写成引用 Distance&,否则返回局部对象的引用会导致悬空引用问题。

可能有些小伙伴会问:“meters 明明是 private 私有成员,为什么函数里能直接读取 other.meters 呢?” 其实 C++ 的访问权限是以 “类” 为单位的,当前代码处在 Distance 类的成员函数内部,自然可以直接访问其他同类对象的私有成员。

示例 2:重载 == 运算符

#include <iostream>

class Distance {
private:
    int meters;

public:
    Distance(int m) : meters(m) {}

    // 重载 == 运算符
    bool operator==(const Distance& other) const {
        return meters == other.meters;
    }
};

int main() {
    Distance d1(10);
    Distance d2(10);
    Distance d3(20);

    if (d1 == d2) {
        std::cout << "d1 和 d2 的距离相等\n";
    }

    if (d1 == d3) {
        std::cout << "d1 和 d3 的距离相等\n";
    } else {
        std::cout << "d1 和 d3 的距离不相等\n";
    }
    return 0;
}

运行结果如下。

d1 和 d2 的距离相等
d1 和 d3 的距离不相等

分析:

operator== 成员函数通过比较 meters == other.meters,直接返回布尔值 true 或 false。这样我们就可以在 if 语句中直接写出 d1 == d2,底层等价于调用 d1.operator==(d2),代码读起来非常直观。

使用非成员函数重载运算符

除了写成类的成员函数之外,我们也可以把运算符重载为类外的普通函数,也就是 “非成员函数”。当这个非成员函数需要直接读取类内部的私有成员时,我们通常会配合上一节学过的友元函数,在类的内部使用 friend 给它授权。

语法:

返回值类型 operator运算符(参数1, 参数2) {
    // 自定义运算规则
}

说明:

因为非成员函数不属于类,内部没有 this 指针,所以双目运算符的左、右两个操作数都必须通过参数列表传进来,也就是需要接收 2 个参数。如果函数体内需要直接访问类的私有成员,我们只需在类内部补上一行 friend 友元声明即可。

那什么时候必须使用非成员函数来重载运算符呢?最常见的情况就是:运算符左边的操作数不是当前类的对象。比如我们希望直接写 std::cout << d1; 来输出 Distance 对象,此时输出运算符 << 左边的操作数是 std::cout,右边才是 Distance 对象 d1。

如果我们把 operator<< 写成 Distance 的成员函数,左边的操作数就必须是 Distance 对象,输出时就得反过来写成古怪的 d1 << std::cout;,完全不符合日常习惯。因此,我们必须把 operator<< 写成类外的非成员函数,此时下面 2 种方式是等价的。

// 方式 1
std::cout << d1;

// 方式 2
operator<<(std::cout, d1);

编写 operator<< 时会用到 <iostream> 中的 std::ostream 类型,它是 “output stream(输出流)” 的缩写,正是 std::cout 所属的类型。

由于标准库的输出流对象不允许被复制,而且连续输出时需要一直操作同一个流,因此第 1 个参数和返回值类型都必须写成引用形式 std::ostream&。请看下面例子。

示例 3:重载 << 输出运算符

#include <iostream>

class Distance {
private:
    int meters;

public:
    Distance(int m) : meters(m) {}

    // 声明 operator<< 为友元函数
    friend std::ostream& operator<<(std::ostream& os, const Distance& d);
};

// 类外定义 operator<<
std::ostream& operator<<(std::ostream& os, const Distance& d) {
    os << d.meters << " 米";
    return os;    // 返回输出流引用
}

int main() {
    Distance d1(15);
    Distance d2(25);

    std::cout << "d1 = " << d1 << ",d2 = " << d2 << '\n';
    return 0;
}

运行结果如下。

d1 = 15 米,d2 = 25 米

分析:

初学时看到 operator<< 的函数头可能会觉得有点长,其实拆开看非常简单。当我们执行 std::cout << d1 时,左边的 std::cout 会传给第 1 个形参 os,右边的 d1 则会传给第 2 个形参 d。

因为 Distance 类把这个函数声明成了 friend 友元函数,所以在函数体内可以直接读取私有成员 d.meters,并通过 os << d.meters << " 米"; 把内容输送到输出流中。

至于函数末尾写 return os; 并返回引用 std::ostream&,原理和前面通过 this 指针实现链式调用时返回 *this 完全一样。每次输出完一个 Distance 对象后,把同一个输出流 os 原路返回出来,后面才能接着写 << 实现连续输出。

提示 小伙伴们不要把 “非成员运算符重载” 和 friend 当成固定绑定的语法。如果非成员函数直接调用公开的 getMeters() 就能拿到所需数据,那就不需要加 friend。只有当它确实需要直接访问类的私有成员时,我们才需要将其声明为友元。

C++ 运算符重载的规则

运算符重载虽然能让自定义类型的表达式更直观,但也必须遵循 C++ 的语法边界。在 C++ 中,绝大多数已有运算符都可以重载,只有少数涉及底层语法的运算符禁止重载,如下表所示。

运算符重载范围
分类 包含的运算符 说明
常见可重载运算符 + - * / == != < > = += << >> [] () 可根据类的实际需求自定义运算规则
禁止重载的运算符 . :: ? : sizeof 涉及底层基础语法,C++ 禁止重载

在编写运算符重载函数时,我们需要遵守以下 4 条基本原则:

  • 不能创造新的运算符:我们只能重载 C++ 原本就已经存在的运算符,不能自己发明新的运算符号(如 @ 或 $),也不能重载上表中的 .、::、? : 和 sizeof。
  • 不能改变原有的优先级与操作数个数:重载之后,符号原本的运算符优先级和结合方向都保持不变,比如 * 依然比 + 先计算。同时操作数的个数也是固定的,双目运算符不能改成接收 3 个操作数。
  • 参数中至少包含一个自定义类型:重载运算符的参数中,至少要有一个操作数是我们自定义的类类型或枚举类型,C++ 不允许修改 int、double 等内置类型的默认运算规则。
  • 重载后的功能应该符合直觉:看到 + 默认是做 “相加或拼接”,看到 == 默认是 “判断是否相等”。如果某个操作的含义不够明确,我们应该优先使用名字清晰的普通成员函数。

对于第 3 点,我们可以对比一下下面这两种非成员运算符重载声明。

// 正确方式
Distance operator+(const Distance& d1, const Distance& d2);

// 错误方式
int operator+(int a, int b);

在上面的错误方式中,两个参数全都是内置的 int 类型,相当于尝试修改整数加法的默认规则,编译时就会直接报错。

常见问题

1. 重载运算符时,应该选成员函数还是非成员函数?

在实际开发中,我们可以按以下 3 条规律来判断:

左操作数不是当前类对象:比如重载 << 输出运算符或 >> 输入运算符,左边是标准库的流对象,我们必须将其写成非成员函数,需要访问私有成员时再配合 friend 声明为友元。

语法要求或会修改左操作数:对于赋值 =、下标 []、函数调用 () 等符号,C++ 规定必须重载为成员函数,其中赋值 = 会在后面的 “拷贝赋值运算符” 一节专门介绍。像 += 这类会修改当前对象的运算符,通常也写成成员函数。

普通的双目算术或比较运算符:比如 +、==,它们不会修改操作数本身,在单类运算场景下我们直接写成 const 成员函数最为简单直观。

2. 重载了 + 运算符之后,对象就能自动使用 += 了吗?

不能。在 C++ 中,+ 和 += 是两个彼此独立的运算符。重载了 operator+ 只表示支持 d1 + d2,编译器并不会自动生成 operator+=。如果我们希望对象同时支持 d1 += d2,还需要单独重载 operator+=。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号