让你的类型像内置类型一样好用
你已经知道 std::string 可以用 a + b 拼接、用 cout << s 打印。这些并不是编译器的特殊技巧,而是名字有点奇怪的普通函数。运算符重载这项功能让你自己的类也能使用同样的语法,于是 Vector2 或 Money 类型就能像 int 一样相加、比较和打印。
一旦看明白,它的机制就很简单:a + b 这样的表达式只是一种简写。编译器会把它改写成对名为 operator+ 的函数的调用,并寻找与操作数类型匹配的版本。为你的类定义这个函数,a + b 就能正常工作了。这其实是函数重载的一种特殊形式,那里的名字解析规则同样适用,只是函数名长得像运算符。
注意这个函数通过 const& 接收两个操作数:算术运算不应该修改输入,而引用可以避免复制。它按值返回一个新的 Vector2:p + q 必须产生一个全新的结果,而不改变 p 或 q,就像 2 + 3 不会改变 2 一样。
成员函数与非成员函数
定义运算符有两个位置:作为类的成员,或者作为自由(非成员)函数。作为成员时,左操作数是隐含的 this,所以二元运算符只需要一个显式参数:
参数列表后面的 const 很重要:a + b 不应该修改 a,所以这个成员函数被标记为 const。对于本质上与左操作数绑定、且不需要对左操作数进行类型转换的运算符,使用成员形式:+=、[]、()、->,以及 -x、++x 这样的一元运算符。
成员形式的问题在于:左操作数不能被转换。使用上面的成员 operator+,a + 50 可以工作(右边的 50 会被转换为 Money),但 50 + a 无法编译:左操作数 50 是 int,而你无法给 int 添加成员函数。非成员运算符解决了这个问题,因为两个操作数都是显式参数,都可以被转换:
经验法则:对称的二元运算符(+、==、*)写成非成员,这样两边都能进行转换;必须修改左操作数或与左操作数绑定的运算符(+=、[]、=)写成成员。
重载流运算符
最常被重载的运算符是用于打印的 <<。你不能把它写成类的成员,因为它的左操作数是 std::ostream(比如 cout),而不是你的类型,而 ostream 也不归你所有。所以它总是一个非成员函数,以非 const 引用接收流,并返回这个流:
有两个细节让它能够正常工作。流是按引用(ostream&)传入和返回的:流不能被复制,而返回同一个流,才让你能链式调用 cout << "p = " << p << "\n"。每个 << 都返回流,下一个 << 才有对象可以绑定。忘了 return os;,链式调用就会失效。
比较运算符
要用 ==、< 等比较你的对象,就需要重载比较运算符。在 C++20 之前,你得逐个手写;关键的陷阱在于 operator< 必须返回 bool,并且定义一个一致的顺序:
手写全部六个比较运算符(==、!=、<、<=、>、>=)既繁琐又容易出错。C++20 新增了三路比较运算符 <=>(俗称“宇宙飞船运算符”)。把它和 == 设为默认实现,编译器就会为你生成所有比较:
= default 告诉编译器按声明顺序比较各个成员,这正是你手写时会用的字典序。在现代编译器上,优先使用这种写法。
赋值运算符及其陷阱
operator=(复制赋值)比较特殊:编译器会为你生成一个,对于简单的类,这个默认版本就是正确的。只有当你的类管理着某种资源,比如原始内存或文件句柄,逐个成员复制会出错时,才需要自己编写。标准的签名会按引用返回 *this,这样赋值就能链式进行(a = b = c):
这个短短的函数里藏着两个陷阱。第一,自赋值检查 if (this == &other):如果没有它,a = a 会先 delete[] data,然后从刚刚释放的 other.data 中读取,这是未定义行为。第二,顺序很重要:在手写的版本中,你不能在安全地复制新数据之前就删除旧的缓冲区(真正的实现通常会先分配内存,或者使用 copy-and-swap 惯用法,这样即使分配失败,对象也能保持完好)。
还有一个更普遍的陷阱:不要以出人意料的方式重载运算符。偷偷修改左操作数的 operator+,或者不对称的 operator==,会让每个读代码的人困惑,还会破坏那些依赖常规含义的标准库代码。只有当某个操作对你的类型来说确实“像加法”或“像相等比较”时,才去重载对应的运算符。
下一步:访问说明符
注意,每个例子都把数据成员设为 private,只通过一小组公有接口(构造函数、运算符和几个方法)对外提供行为。哪些对外部可见、哪些隐藏在类的内部,这条边界由访问说明符控制:public、private 和 protected。接下来我们会具体看看每一种允许什么,为什么“私有数据加公有方法”是良好封装的默认做法,以及 protected 在继承中扮演什么角色。
常见问题
C++ 中的运算符重载是什么?
运算符重载让你为自己的类型定义 +、==、<< 等内置运算符的含义。你编写一个名字特殊的函数,比如 operator+、operator== 等,只要运算符的操作数是你的类,编译器就会调用它。string + string 能拼接字符串、cout << obj 能打印自定义对象,靠的都是它。
C++ 中运算符应该写成成员函数,还是非成员(友元)函数?
当左操作数是你自己的类、并且不需要类型转换时(例如 +=、[]、()),使用成员函数。当左操作数可能是内置类型,或者你希望两边都能对称地进行类型转换时,使用非成员函数(通常是 friend)。operator<< 必须这样写,因为它的左操作数是 std::ostream,而不是你的类。
C++ 中哪些运算符不能重载?
不能重载的有 ::(作用域解析)、.(成员访问)、.*(成员指针访问)、?:(三元运算符)和 sizeof。你也不能发明全新的运算符,或者改变运算符的操作数个数和优先级:无论是对 int 还是对你的 Vector2 做加法,+ 永远是二元运算符,优先级也不变。