
运算符重载operator overloading听起来是个高级特性拆开看却只有一句话a b就是a.operator(b)的另一种写法。既然它本质上是函数问题就变成了两个哪些名字能被占用写出来的函数该遵守什么约定这篇把清单、约定和六个常用运算符一次讲清每个示例都在 C17 下真跑过。1. 引子先看一段最小代码把「语法糖」这层皮掀掉#includeiostreamclassVec2{doublex_{0.0};doubley_{0.0};public:Vec2(doublex,doubley):x_{x},y_{y}{}Vec2operator(constVec2rhs)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 cab;// 编译器和下面一行完全等价constVec2 da.operator(b);// 显式调用语法合法std::coutc (c.x(), c.y())\n;std::coutd (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比较要能返回booloperator最容易被写成返回int为了模仿 C 的strcmp风格这在 C 里是坏味道if (a b)会静默通过但a b c这类笔误再也拦不住。正确写法是返回bool!直接借取反保证两者永不打架。#includeiostreamclassVec2{doublex_{0.0};doubley_{0.0};public:Vec2(doublex,doubley):x_{x},y_{y}{}booloperator(constVec2r)const{returnx_r.x_y_r.y_;}booloperator!(constVec2r)const{return!(*thisr);// 只写一次比较逻辑避免两处不同步}};intmain(){constVec2 a{1.0,2.0};constVec2 b{1.0,2.0};constVec2 c{3.0,4.0};std::coutstd::boolalpha;std::couta b ? (ab)\n;std::couta ! b ? (a!b)\n;std::couta c ? (ac)\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 只读#includeiostream#includeutility#includevector#includecstddefclassScores{std::vectorintv_;public:explicitScores(std::vectorintv):v_{std::move(v)}{}intoperator[](std::size_t i){returnv_[i];}// 非 const可写constintoperator[](std::size_t i)const{returnv_[i];}// const只读std::size_tsize()const{returnv_.size();}};voidprint(constScoress){// 形参是 const只能走 const 版本for(std::size_t i0;is.size();i)std::couts[i] ;std::cout\n;}intmain(){Scores s{{10,20,30}};print(s);s[0]99;// 走非 const 版本写入成功constScorescss;// 同一个对象但是 const 视角std::coutcs[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_ | ------------- ------------------ | | 无状态共享 每次调用累积状态 想带状态就得用全局变量 状态天然跟着对象走#includeiostreamclassRunningSum{inttotal_{0};// 状态存在对象里public:intoperator()(intx){total_x;returntotal_;}// 像函数一样被调用intvalue()const{returntotal_;}};intmain(){RunningSum acc;std::cout1 - acc(1)\n;std::cout2 - acc(2)\n;std::cout3 - acc(3)\n;std::cout最终累计 acc.value()\n;}1 - 1 2 - 3 3 - 6 最终累计 64.4operator为什么要返回引用复合赋值compound assignment的约定是「改我、然后把我交出去」所以返回*this的引用。返回引用而不是值直接决定了能不能链式写#includeiostreamclassCounter{intn_{0};public:Counteroperator(intx){n_x;return*this;}// 返回 *this 引用intvalue()const{returnn_;}};intmain(){Counter c;c5;// 语句形式(c1)2;// 链式先 c 1 得到 c 本身再 2std::coutc 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#includeiostreamclassPoint{intx_,y_;public:Point(intx,inty):x_{x},y_{y}{}friendstd::ostreamoperator(std::ostreamos,constPointp);};std::ostreamoperator(std::ostreamos,constPointp){returnos(p.x_, p.y_);// 返回 os 引用支持链式}intmain(){constPoint p{3,4};std::coutp 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结果跟着变而且性能上根本不需要这样省C17 的**保证拷贝消除guaranteed copy elision**让return Vec2{...}直接在调用方预留的存储上构造通常连一次拷贝都不会发生。省拷贝要靠返回值优化不是靠返回引用。官方文档cppreference · Copy elision —— C17 起return T{...}的拷贝消除是标准强制要求不是编译器的额外恩惠。5. 完整示例把前面六种运算符收进一个可整体编译的程序覆盖算术、比较、下标、函数调用、复合赋值和流输出#includeiostream#includecstddefclassVec2{doublex_{0.0};doubley_{0.0};public:Vec2()default;Vec2(doublex,doubley):x_{x},y_{y}{}Vec2operator(constVec2r)const{returnVec2{x_r.x_,y_r.y_};}booloperator(constVec2r)const{returnx_r.x_y_r.y_;}booloperator!(constVec2r)const{return!(*thisr);}doubleoperator()(std::size_t i)const{returni0?x_:y_;}// 按分量取值friendVec2operator(Vec2lhs,constVec2rhs){// 就地修改返回引用lhs.x_rhs.x_;lhs.y_rhs.y_;returnlhs;}friendstd::ostreamoperator(std::ostreamos,constVec2v){returnos(v.x_, v.y_);}};intmain(){Vec2 a{1.0,2.0};constVec2 b{3.0,4.0};std::couta a, b b\n;std::couta b (ab)\n;ab;// 返回引用可继续链式运算std::couta b 后 a a\n;std::couta(0) a(0), a(1) a(1)\n;std::coutstd::boolalpha;std::couta b ? (ab), 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两个版本就能避开九成的坑。