16. 运算符重载:重载规则、重载算术运算符、重载输入输出运算符、重载赋值运算符、重载下标运算符
运算符重载,说白了就是让 C++ 的运算符能「认识」你自定义的类型。比如你写了个复数类,想让 + 号直接做复数加法,而不是写一堆 c1.add(c2) 的调用。嗯,这就是运算符重载的初衷——让代码读起来更自然。
我个人习惯是:能用运算符表达清晰语义的,就重载;否则别硬来。你想想看,要是把 + 重载成减法,那代码维护的人非骂你不可。
核心原则:运算符重载不改变运算符的优先级和结合性,也不创造新的运算符。你只能重载已有的那几十个。
16.1 重载规则
先说说哪些运算符不能重载:.、.*、::、?:、sizeof、typeid。剩下的基本都可以。
重载有两种形式:
- 成员函数形式:左操作数必须是当前类的对象
- 非成员函数形式(通常是友元):可以处理左右操作数类型不同的情况
举个例子,二元运算符 +:
// 成员函数形式
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 重载输入输出运算符
输入输出运算符 << 和 >> 必须重载为非成员函数,因为左操作数是 ostream 或 istream,不是你的类对象。
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 出来的内存),就必须自己重载。否则默认的浅拷贝会让你吃大亏。
我总结了一个「四步法」:
- 检查自赋值(
if (this == &rhs) return *this;) - 释放当前资源
- 分配新资源并拷贝数据
- 返回
*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 对象也能安全地读取元素。
知识体系总览
下面这张图把运算符重载的核心脉络梳理了一下,你可以对照着看:
运算符重载是 C++ 的一大特色,用好了能让代码优雅不少。但别滥用——我见过有人把 & 重载成逻辑与,结果排查 bug 排查了一整天。记住:重载的语义要和运算符原本的直觉一致,这才是正道。