对于 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+=。
