news 2026/10/26 9:12:09

运算符重载该写成成员还是非成员

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
运算符重载该写成成员还是非成员

运算符重载最容易被忽略的一个决策是:这个函数写在类里还是写在类外。看起来只是风格问题,实际上它直接决定了表达式的对称性:同一个+,写成成员时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 = 8

6. 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] = 5

8. 延伸阅读

  • 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 找到)。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 9:12:08

PostgreSQL 使用 psql `\do` 命令查询运算符所有重载版本

文档教程知识库 【免费下载链接】til :memo: Today I Learned 项目地址&#xff1a; https://gitcode.com/gh_mirrors/ti/til 点击查看 免费下载 > 是 PostgreSQL 中最常见的包含&#xff08;containment&#xff09;运算符之一&#xff0c;常用于数组与 JSONB 的包含判断。…

作者头像 李华
网站建设 2026/10/26 9:12:06

把电脑变成 AI 打工人!OpenClaw 本地部署图文全攻略

&#x1f4d6; 前言 本文面向 Windows 系统用户&#xff0c;系统梳理 OpenClaw 的标准化部署流程。整套安装过程完全依托可视化向导界面完成&#xff0c;全程无需输入任何命令行指令&#xff0c;即便是缺乏技术基础的用户&#xff0c;也能顺利完成整套环境部署。文中同时整理了…

作者头像 李华
网站建设 2026/10/26 9:12:08

Zotero安装中文翻译插件-保姆级教程

目录 一、下载翻译插件 二、安装Zotero翻译插件 三、配置翻译服务 四、如何使用Zotero翻译&#xff1f; 五、总结 对研究生来说&#xff0c;Zotero不仅可以用来管理论文&#xff0c;还可以通过安装翻译插件实现PDF划词翻译&#xff0c;不用再频繁复制英文到浏览器翻译&am…

作者头像 李华