运算符重载最容易被忽略的一个决策是:这个函数写在类里还是写在类外。看起来只是风格问题,实际上它直接决定了表达式的对称性:同一个+,写成成员时2.0 + v编译不过,写成非成员就两边都能用。这篇把「谁该写在哪」讲到底,并用编译期检测把差异真跑出来给你看。
1. 引子
先看一段注定编译不过的代码:
// 反例,不要这么写:把对称运算符写成成员,破坏了左右对称(违反 Core Guidelines C.161)classVec2Bad{doublex_{0.0};public:explicitVec2Bad(doublex):x_{x}{}Vec2Badoperator+(doublerhs)const{returnVec2Bad{x_+rhs};}};// 使用侧(纯展示,编译不过所以不运行):// Vec2Bad v{1.0};// Vec2Bad ok = v + 2.0; // OK:左操作数 v 有 operator+ 这个成员// Vec2Bad bad = 2.0 + v; // 编译错误:no match for 'operator+'(double 没有这个成员)原因很直白:成员运算符的接收者(receiver)永远是左操作数。v + 2.0会去找v的成员operator+,2.0 + v会去找double的成员。内置类型没有成员函数,于是这一侧直接判负。
成员版 a.operator+(b) 非成员版 operator+(a, b) --------------------- ---------------------- a 必须是本类对象(接收者) a、b 地位平等,都只是普通参数 → 2.0 + v 无解 → 2.0 + v 与 v + 2.0 都是候选 形态: [本类] OP [任意] 形态: [任意] OP [任意]2. 判断规则:对称的运算就用非成员
C++ Core Guidelines 把这条写得非常直接:
C.161: Use nonmember functions for symmetric operators
理由原文:If you use member functions, you need two.—— 写成成员的话,你得为每一种「另一侧类型在前」的情形再补一个重载;而且除非用非成员,a == b和b == a会有微妙差别。
官方文档:C++ Core Guidelines · C.161
配套还有两条值得一起记住:
- C.162:形式相当的运算应当用同一个名字重载(
add_int/add_double这种命名是把类型信息塞进函数名,阻碍泛型代码)。 - C.168:把重载运算符定义在操作数所在的命名空间里(否则依赖参数查找 ADL 找不到它)。
3. 真跑一次:成员版与非成员版的差别
「编译不过」这件事没法直接贴运行输出,但可以用**检测惯用法(detection idiom)**把它变成编译期的true/false打印出来。std::void_t是 C++17 的设施,这段代码在 C++17 下就能跑:
#include<iostream>#include<type_traits>#include<utility>// 检测:表达式 L + R 是否合法template<typenameL,typenameR,typename=void>structcan_add:std::false_type{};template<typenameL,typenameR>structcan_add<L,R,std::void_t<decltype(std::declval<L>()+std::declval<R>())>>:std::true_type{};// ① 成员版:接收者必须是本类对象,天然不对称classMemberVec{doublex_{0.0};public:explicitMemberVec(doublex):x_{x}{}MemberVecoperator+(doublerhs)const{returnMemberVec{x_+rhs};}doublevalue()const{returnx_;}};// ② 非成员版:左右各给一个重载,天然对称classFreeVec{doublex_{0.0};public:explicitFreeVec(doublex):x_{x}{}doublevalue()const{returnx_;}friendFreeVecoperator+(constFreeVec&a,doubleb){returnFreeVec{a.x_+b};}friendFreeVecoperator+(doublea,constFreeVec&b){returnFreeVec{a+b.x_};}};intmain(){std::cout<<std::boolalpha;std::cout<<"MemberVec + double 合法 ? "<<can_add<MemberVec,double>::value<<'\n';std::cout<<"double + MemberVec 合法 ? "<<can_add<double,MemberVec>::value<<'\n';std::cout<<"FreeVec + double 合法 ? "<<can_add<FreeVec,double>::value<<'\n';std::cout<<"double + FreeVec 合法 ? "<<can_add<double,FreeVec>::value<<'\n';constMemberVec m{1.0};constFreeVec f{1.0};std::cout<<"m + 2.0 = "<<(m+2.0).value()<<'\n';std::cout<<"2.0 + f = "<<(2.0+f).value()<<'\n';// 成员版在这里就写不出来}MemberVec + double 合法 ? true double + MemberVec 合法 ? false FreeVec + double 合法 ? true double + FreeVec 合法 ? true m + 2.0 = 3 2.0 + f = 3这张输出就是 C.161 的实测证据:成员版的两种写法一真一假,非成员版两种都成立。
顺便注意,非成员版这里用了「隐藏友元(hidden friend)」:把friend函数就地定义在类里,它只通过 ADL 被找到,不会污染全局重载集合:这是 C.168 推荐的写法。
官方文档:cppreference · Argument-dependent lookup —— 隐藏友元能被找到,全靠 ADL。
4.<</>>为什么必须非成员
std::cout << obj的左操作数是std::ostream,而std::ostream是标准库的类,你加不了成员。所以流运算符只能是非成员:这不是风格偏好,是唯一可行解。返回std::ostream&才能支持连续输出:
#include<iostream>classMoney{longcents_;public:explicitMoney(longcents):cents_{cents}{}friendstd::ostream&operator<<(std::ostream&os,Money m){returnos<<(m.cents_/100)<<'.'<<(m.cents_%100)<<" 元";}};classRatio{doublev_;public:explicitRatio(doublev):v_{v}{}friendstd::ostream&operator<<(std::ostream&os,Ratio r){returnos<<r.v_<<'x';}};intmain(){constMoney m{1234};constRatio r{2.5};std::cout<<m<<" / "<<r<<" 链式输出正常\n";// 两种自定义类型混着链式输出}12.34 元 / 2.5x 链式输出正常std::cout << m能工作的机制是:m的类型Money触发了 ADL,编译器在Money的命名空间里找到了那个隐藏友元。
官方文档:cppreference · operator_arithmetic —— 内置运算符的语义,写重载时对照「模仿常规用法」(C.160)。
5.+=为什么写成成员
先澄清一个常见误解:复合赋值运算符语言并不强制成员(真正被强制的只有=、[]、()、->和类型转换运算符)。但约定和 C.161 的精神都指向成员,原因还是对称性。+=本来就是不对称的,它就地修改左操作数:
| 维度 | 写成成员 | 写成非成员 |
|---|---|---|
| 左操作数 | 必须是本类对象 | 可以任意,容易误用 |
| 语义表达 | 「就改我自己」,一眼看出不对称 | 需要显式声明T& operator+=(T&, U),读起来绕 |
| 访问私有 | 天然可用 | 还得补一个 friend |
| 主流做法 | 标准库全部如此(std::string、std::vector) | 极少 |
#include<iostream>classCounter{intn_{0};public:Counter&operator+=(intx){n_+=x;return*this;}// 成员:改自身,返回引用intvalue()const{returnn_;}};intmain(){Counter c;c+=5;(c+=1)+=2;// 返回引用才能链式std::cout<<"c = "<<c.value()<<'\n';}c = 86. friend 在这里的角色
非成员运算符是自己写的普通函数,碰不到类的私有成员。而它偏偏又常需要读内部数据(比如operator+要拿两个坐标)。这时就补一个friend:
| 需求 | 做法 |
|---|---|
| 非成员 + 需要访问私有 | 在类里写friend T operator+(...) |
| 不想把函数名写两遍 | 直接把函数体写在类里(隐藏友元) |
| 不想让它进全局重载集合 | 隐藏友元天然如此:只能靠 ADL 找到 |
有三点要澄清:
friend只授予访问权,不改变「它是非成员」这个事实——调用形式、重载决议、对称性都按非成员算。friend写在public/protected/private哪一段都不影响权限,它在类里就是一张通行证。- 隐藏友元还有个附带好处:它只能通过实参类型(ADL)被找到,不会参与无关类型的重载决议,
operator+再多也不会互相抢位置。
反过来也成立:如果运算符不需要碰私有,就别加friend。能用公开接口写出来,恰恰说明这个运算本来就不该依赖内部表示:那才是更干净的封装。
7. 完整决策表
把「必须 / 建议」「成员 / 非成员」一次列全,写重载时照表查:
| 运算符 | 该写成 | 依据 |
|---|---|---|
=(拷贝 / 移动赋值) | 必须成员 | 语言强制 |
[] | 必须成员 | 语言强制 |
() | 必须成员 | 语言强制 |
-> | 必须成员 | 语言强制 |
类型转换operator T() | 必须成员 | 语言强制,且不能写返回类型 |
流插入 / 提取<<>> | 必须非成员 | 左操作数是std::ostream/std::istream |
二元算术+-*/% | 建议非成员 | 左右对称,C.161;2.0 + v也要能写 |
比较==!=<<=>>= | 建议非成员 | 对称;两侧都可能发生隐式转换 |
| 逻辑与 / 逻辑或 | 建议非成员,但别重载 | 重载会丢掉短路求值 |
复合赋值(+=-=*=等) | 建议成员 | 就地改左操作数,本就不对称 |
++-- | 建议成员 | 改自身 |
,、取地址&、解引用*、->* | 慎重重载 | 取地址&只在智能指针 / 代理系统内重载(C.166) |
一句话记住这张表:语言强制的四种(=[]()->)照办;<<>>因为左操作数不是本类,只能非成员;其余按「对称性」分:对称的写非成员,就地改左操作数的写成员。
7. 完整示例
下面这个程序把成员与非成员各归其位:+=与[]是成员,+、==、!=、<<是非成员(隐藏友元),整体可编译运行:
#include<iostream>#include<cstddef>classVec2{doublex_{0.0};doubley_{0.0};public:Vec2()=default;Vec2(doublex,doubley):x_{x},y_{y}{}// —— 成员:修改左操作数 / 语言强制成员 ——Vec2&operator+=(constVec2&r){// 就地修改,返回引用x_+=r.x_;y_+=r.y_;return*this;}doubleoperator[](std::size_t i)const{returni==0?x_:y_;}// —— 非成员(隐藏友元):对称运算 + 流输出 ——friendVec2operator+(constVec2&a,constVec2&b){returnVec2{a.x_+b.x_,a.y_+b.y_};}friendbooloperator==(constVec2&a,constVec2&b){returna.x_==b.x_&&a.y_==b.y_;}friendbooloperator!=(constVec2&a,constVec2&b){return!(a==b);}friendstd::ostream&operator<<(std::ostream&os,constVec2&v){returnos<<'('<<v.x_<<", "<<v.y_<<')';}};intmain(){Vec2 a{1.0,2.0};constVec2 b{3.0,5.0};std::cout<<"a = "<<a<<", b = "<<b<<'\n';std::cout<<"a + b = "<<(a+b)<<'\n';// 非成员 +:左右对称std::cout<<"b + a = "<<(b+a)<<'\n';// 交换顺序同样成立a+=b;// 成员 +=:就地修改std::cout<<"a += b 后 a = "<<a<<'\n';std::cout<<std::boolalpha;std::cout<<"a == b ? "<<(a==b)<<", a != b ? "<<(a!=b)<<'\n';std::cout<<"a[0] = "<<a[0]<<", b[1] = "<<b[1]<<'\n';}a = (1, 2), b = (3, 5) a + b = (4, 7) b + a = (4, 7) a += b 后 a = (4, 7) a == b ? false, a != b ? true a[0] = 4, b[1] = 58. 延伸阅读
- C++ Core Guidelines · C.161 —— 对称运算符用非成员,短短两行理由说透。
- cppreference · Operator overloading —— 「只能作为成员」的官方清单,别凭记忆。
- cppreference · Argument-dependent lookup —— 隐藏友元能工作的底层机制。
- isocpp · Operator Overloading FAQ —— 官方 FAQ 里专门的「成员 vs 非成员」问答。
本知识库内的相关篇目:
- 《运算符重载入门:算术、比较、下标、函数调用》 —— 运算符重载(operator overloading)只是给函数调用换了个语法糖。
- 《friend 友元:什么时候值得破坏封装》 —— 友元(friend)是 C++ 里唯一能绕过 private 访问控制的受控后门。
- 《构造函数详解:默认、委托、转换与 explicit 到底写在哪》 —— 把构造函数拆成四件事讲透——默认构造的隐式生成、= default 与 = delete 的用法、委托构
10. 一句话总结
对称的运算写非成员(+、==、<<),就地改左操作数的写成员(+=、++),=[]()->语言强制成员,<<>>因为左操作数是ostream/istream只能非成员——判断标准不是习惯,而是「这个运算对左右操作数是否平等」;非成员又要碰私有,就加friend(就地定义的隐藏友元还能靠 ADL 找到)。