运算符重载(operator overloading)听起来是个高级特性,拆开看却只有一句话:a + b就是a.operator+(b)的另一种写法。既然它本质上是函数,问题就变成了两个:哪些名字能被占用?写出来的函数该遵守什么约定?这篇把清单、约定和六个常用运算符一次讲清,每个示例都在 C++17 下真跑过。
1. 引子
先看一段最小代码,把「语法糖」这层皮掀掉:
#include<iostream>classVec2{doublex_{0.0};doubley_{0.0};public:Vec2(doublex,doubley):x_{x},y_{y}{}Vec2operator+(constVec2&rhs)const{// 成员形式的重载returnVec2{x_+rhs.x_,y_+rhs.y_};}doublex()const{returnx_;}doubley()const{returny_;}};intmain(){constVec2 a{1.0,2.0};constVec2 b{3.0,4.0};constVec2 c=a+b;// 编译器和下面一行完全等价constVec2 d=a.operator+(b);// 显式调用,语法合法std::cout<<"c = ("<<c.x()<<", "<<c.y()<<")\n";std::cout<<"d = ("<<d.x()<<", "<<d.y()<<")\n";}c = (4, 6) d = (4, 6)a + b和a.operator+(b)产生同样结果,这就说明重载运算符没有魔法:它只是一个名字特殊的函数。而「名字特殊」带来了全部限制。
官方文档:cppreference · Operator overloading —— 重载规则总入口,可重载清单和「必须成员」清单都在这里。
2. 哪些能重载,哪些不能
结论先给:除极少数,几乎都能重载。不能重载的清单如下,漏了它们会在脑子里留下假想的入口:
| 运算符 | 为什么不能重载 |
|---|---|
:: | 作用域解析,编译期语法结构 |
.* | 成员指针解引用,.的伴生形式 |
. | 成员访问——不允许你给类造一个operator.代理 |
?: | 三目运算符要求短路求值,函数调用做不到 |
sizeof、alignof | 编译期求值,没有运行期函数可替代 |
typeid | 运行时类型信息,不能改语义 |
static_cast/dynamic_cast/const_cast/reinterpret_cast | 这四个命名转换形式不可重载(operator T()这种自定义转换可以) |
#、## | 属于预处理器,压根不是语言运算符 |
比清单更重要的是四条边界,它们决定了你「能怎么改」:
- 不能发明新运算符:没有
**,想幂运算只能写函数。 - 不能改变优先级和结合性:
a + b * c在任何重载下都还是a + (b * c),<<的左结合性也不会变。 - 不能改变操作数个数:
operator+永远是二元,operator!永远是一元。 - 至少有一个操作数必须是类类型或枚举类型:
int operator+(int, int)想都别想。否则整段代码的可读性就没了。
第 4 条最值得记住,它意味着「运算符重载永远是给自定义类型开的门」。
把常见写法翻译成函数调用,这张图建议背下来:
源码写法 编译器看到的调用 备注 -------- ------------------ ---- a + b a.operator+(b) 二元,左操作数是接收者 -a a.operator-() 一元 a == b a.operator==(b) 比较 a[i] a.operator[](i) 只能成员 a(i, j) a.operator()(i, j) 函数对象,只能成员 std::cout << a operator<<(std::cout, a) 非成员(左操作数不是本类) ++a a.operator++() 前置,返回引用 a++ a.operator++(int) 后置,int 是哑参数3. 语义约定:模仿常规用法
重载最大的坑不是「写不出来」,而是写得能编译但语义诡异,比如让operator+偷偷修改左操作数。C++ Core Guidelines 的C.160「Define operators primarily to mimic conventional usage」说的就是这件事:运算符的行为要符合它在内置类型上的既有直觉,读者才不会踩坑。
为什么必须守约定?因为调用方看到的只是a + b,看不到你的实现。一旦+有副作用,所有「先算后看」的心智模型全部失效。
下面是速查表,写重载前对着核一遍:
| 运算符 | 返回类型 | 语义 | 关键点 |
|---|---|---|---|
+ - * / | 新对象(按值) | 不修改任何操作数 | 返回引用是经典错误:会悬垂 |
+= -= *= /= | *this的引用 | 就地修改左操作数 | 返回引用才能链式(a += b) += c |
== != | bool | 相等性,应可传递 | 别塞副作用 |
< <= > >= | bool | 排序关系 | 必须与==逻辑一致 |
[] | 元素引用(T&) | 可读可写 | 必须同时给const版 |
() | 任意 | 函数对象 | 参数个数不限 |
<<>> | std::ostream&/std::istream& | 流插入/提取 | 返回引用才能a << b << c |
++ -- | 前置返引用、后置返回值 | 自增自减 | 后置带int哑参数 |
官方文档:C++ Core Guidelines(见 C.160 一节)——「模仿常规用法」是整套运算符建议的地基。
4. 逐个示例
4.1operator==:比较要能返回bool
operator==最容易被写成返回int(为了模仿 C 的strcmp风格),这在 C++ 里是坏味道:if (a == b)会静默通过,但a == b == c这类笔误再也拦不住。正确写法是返回bool,!=直接借==取反,保证两者永不打架。
#include<iostream>classVec2{doublex_{0.0};doubley_{0.0};public:Vec2(doublex,doubley):x_{x},y_{y}{}booloperator==(constVec2&r)const{returnx_==r.x_&&y_==r.y_;}booloperator!=(constVec2&r)const{return!(*this==r);// 只写一次比较逻辑,避免两处不同步}};intmain(){constVec2 a{1.0,2.0};constVec2 b{1.0,2.0};constVec2 c{3.0,4.0};std::cout<<std::boolalpha;std::cout<<"a == b ? "<<(a==b)<<'\n';std::cout<<"a != b ? "<<(a!=b)<<'\n';std::cout<<"a == c ? "<<(a==c)<<'\n';}a == b ? true a != b ? false a == c ? false4.2operator[]:必须同时给const和非const两个版本
这是全篇最实用的一条。只写非const版本,const对象就下标不了;只写const版本,就再也不能s[0] = 99。两个版本都要写,它们的差别只在返回类型上:
调用点 重载决议选中 返回类型 能不能写 --------------------- ---------------------- ------------- -------- s[0] (s 非 const) int& operator[](size_t) int& 能写 cs[0] (cs 是 const) const int& operator[]() const int& 只读#include<iostream>#include<utility>#include<vector>#include<cstddef>classScores{std::vector<int>v_;public:explicitScores(std::vector<int>v):v_{std::move(v)}{}int&operator[](std::size_t i){returnv_[i];}// 非 const:可写constint&operator[](std::size_t i)const{returnv_[i];}// const:只读std::size_tsize()const{returnv_.size();}};voidprint(constScores&s){// 形参是 const&,只能走 const 版本for(std::size_t i=0;i<s.size();++i)std::cout<<s[i]<<' ';std::cout<<'\n';}intmain(){Scores s{{10,20,30}};print(s);s[0]=99;// 走非 const 版本,写入成功constScores&cs=s;// 同一个对象,但是 const 视角std::cout<<"cs[1] = "<<cs[1]<<'\n';// 走 const 版本,能读print(s);// cs[1] = 5;// 编译错误:const 版本返回 const int&,不可写。// 这正是「const 正确性」的价值 —— 编译期就拦住越权写入。}10 20 30 cs[1] = 20 99 20 30官方文档:cppreference · operator_member_access ——
[]的语义和「只能作为成员」的说明。
4.3operator():函数对象(function object)
重载()之后,对象本身就能像函数一样被调用,这类对象叫函数对象(functor)。它相较于普通函数的最大优势是可以携带状态。状态存在成员变量里,每次调用都在它之上累积:
函数指针 函数对象 +-------------+ +------------------+ | 只有入口地址 | | 成员状态 total_ | +-------------+ +------------------+ | | 无状态共享 每次调用累积状态 (想带状态就得用全局变量) (状态天然跟着对象走)#include<iostream>classRunningSum{inttotal_{0};// 状态存在对象里public:intoperator()(intx){total_+=x;returntotal_;}// 像函数一样被调用intvalue()const{returntotal_;}};intmain(){RunningSum acc;std::cout<<"1 -> "<<acc(1)<<'\n';std::cout<<"2 -> "<<acc(2)<<'\n';std::cout<<"3 -> "<<acc(3)<<'\n';std::cout<<"最终累计 = "<<acc.value()<<'\n';}1 -> 1 2 -> 3 3 -> 6 最终累计 = 64.4operator+=为什么要返回引用
复合赋值(compound assignment)的约定是「改我、然后把我交出去」,所以返回*this的引用。返回引用而不是值,直接决定了能不能链式写:
#include<iostream>classCounter{intn_{0};public:Counter&operator+=(intx){n_+=x;return*this;}// 返回 *this 引用intvalue()const{returnn_;}};intmain(){Counter c;c+=5;// 语句形式(c+=1)+=2;// 链式:先 c += 1 得到 c 本身,再 += 2std::cout<<"c = "<<c.value()<<'\n';}c = 8如果operator+=返回的是值(拷贝),(c += 1) += 2会修改到一个临时副本上,最终c还是 6 —— 这就是 Core Guidelines 强调「返回类型要匹配语义」的具体后果。
4.5operator<<只能是非成员
std::cout << p要求左操作数是std::ostream,而std::ostream是标准库的类,你改不了它的成员列表,所以operator<<只能写成非成员函数。写成非成员又要读Point的私有坐标,就得挂上friend:
#include<iostream>classPoint{intx_,y_;public:Point(intx,inty):x_{x},y_{y}{}friendstd::ostream&operator<<(std::ostream&os,constPoint&p);};std::ostream&operator<<(std::ostream&os,constPoint&p){returnos<<'('<<p.x_<<", "<<p.y_<<')';// 返回 os 引用,支持链式}intmain(){constPoint p{3,4};std::cout<<"p = "<<p<<'\n';// 链式:先输出 "p = ",再输出 p}p = (3, 4)官方文档:isocpp · Operator Overloading FAQ —— 官方 FAQ 对「哪些该写成成员、哪些不该」的问答,第 5 节会展开。
4.6 算术运算符为什么必须返回值
一个高频疑问:operator+每次都要把结果拷出去,能不能返回引用省掉这一次拷贝?
不能,而且两种「省法」都是 bug:
| 返回类型 | 看起来的好处 | 实际后果 |
|---|---|---|
Vec2(按值) | —— | 正确:结果是独立的新对象 |
Vec2&指向函数内的局部对象 | 省一次拷贝 | 悬垂引用(dangling reference):返回后局部对象已销毁,未定义行为 |
Vec2&/const Vec2&指向某个操作数 | 省一次拷贝 | 别名错乱:a + b和a成了同一个对象,改a结果跟着变 |
而且性能上根本不需要这样省:C++17 的**保证拷贝消除(guaranteed copy elision)**让return Vec2{...}直接在调用方预留的存储上构造,通常连一次拷贝都不会发生。省拷贝要靠返回值优化,不是靠返回引用。
官方文档:cppreference · Copy elision —— C++17 起
return T{...}的拷贝消除是标准强制要求,不是编译器的额外恩惠。
5. 完整示例
把前面六种运算符收进一个可整体编译的程序,覆盖算术、比较、下标、函数调用、复合赋值和流输出:
#include<iostream>#include<cstddef>classVec2{doublex_{0.0};doubley_{0.0};public:Vec2()=default;Vec2(doublex,doubley):x_{x},y_{y}{}Vec2operator+(constVec2&r)const{returnVec2{x_+r.x_,y_+r.y_};}booloperator==(constVec2&r)const{returnx_==r.x_&&y_==r.y_;}booloperator!=(constVec2&r)const{return!(*this==r);}doubleoperator()(std::size_t i)const{returni==0?x_:y_;}// 按分量取值friendVec2&operator+=(Vec2&lhs,constVec2&rhs){// 就地修改,返回引用lhs.x_+=rhs.x_;lhs.y_+=rhs.y_;returnlhs;}friendstd::ostream&operator<<(std::ostream&os,constVec2&v){returnos<<'('<<v.x_<<", "<<v.y_<<')';}};intmain(){Vec2 a{1.0,2.0};constVec2 b{3.0,4.0};std::cout<<"a = "<<a<<", b = "<<b<<'\n';std::cout<<"a + b = "<<(a+b)<<'\n';a+=b;// 返回引用,可继续链式运算std::cout<<"a += b 后 a = "<<a<<'\n';std::cout<<"a(0) = "<<a(0)<<", a(1) = "<<a(1)<<'\n';std::cout<<std::boolalpha;std::cout<<"a == b ? "<<(a==b)<<", a != b ? "<<(a!=b)<<'\n';}a = (1, 2), b = (3, 4) a + b = (4, 6) a += b 后 a = (4, 6) a(0) = 4, a(1) = 6 a == b ? false, a != b ? true6. 延伸阅读
- cppreference · Operator overloading —— 可重载清单、必须成员的清单、名字查找规则,写之前先对照。
- cppreference · Operator precedence —— 为什么重载改不了优先级,看这张表就明白了。
- cppreference · operator_member_access ——
[]、->的语义细节。 - isocpp · Operator Overloading FAQ —— 官方对「成员还是非成员」「要不要重载」的权威回答。
本知识库内的相关篇目:
- 《运算符重载该写成成员还是非成员》 —— 运算符重载写成成员还是非成员,直接决定表达式能不能对称。
- 《friend 友元:什么时候值得破坏封装》 —— 友元(friend)是 C++ 里唯一能绕过 private 访问控制的受控后门。
- 《构造函数详解:默认、委托、转换与 explicit 到底写在哪》 —— 把构造函数拆成四件事讲透——默认构造的隐式生成、= default 与 = delete 的用法、委托构
7. 一句话总结
运算符重载只是给函数取了特殊名字:能重载的很多,不能重载的很少(::.*.?:sizeoftypeid),改不了优先级、结合性和操作数个数,且至少一个操作数必须是自定义类型;写之前对照速查表守语义约定——算术返回新对象、复合赋值返回引用、比较返回bool、下标给const与非const两个版本,就能避开九成的坑。