ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++运算符重载实战指南:设计、实现与避坑

C++运算符重载实战指南:设计、实现与避坑 1. 运算符重载到底解决什么问题写过一段时间 C 的人大概率都会碰到这样的场景手里有一个自定义类型比如复数、矩阵、日期、分数或者一个游戏里的向量代码里成天要写add(a, mul(b, c))这种套娃式的调用。能跑是能跑但是读起来累写起来也累稍不注意参数顺序还容易搞反。C 运算符重载就是冲着这类问题来的——它允许我们给自定义类型重新定义、-、、这些运算符的行为让代码看起来像在操作内置类型一样自然。我自己最早接触运算符重载是在做一个简易的向量计算库的时候。一开始老老实实写函数vector_add(v1, vector_scale(v2, 3.0))写了不到两百行就受不了了。后来改成重载直接写v1 v2 * 3.0整个表达式的可读性立刻就不一样了。但真正让我花时间研究的不是“怎么写”而是“什么时候该写、怎么写才不会踩坑”。这篇文章就是把我这些年摸出来的东西系统梳理一遍从设计思路到实操细节再到排错经验尽量讲透。这篇文章适合谁如果你已经会写类和成员函数但还没系统用过运算符重载那正合适如果你已经用过但总是在返回值、const、友元这些地方纠结也能在里面找到答案。全文会用大量的代码片段和实际踩坑记录尽量做到能直接抄、能直接跑。1.1 从一段拧巴的调用链说起我们先看一个没有运算符重载的世界。假设要做一个简单的分数类Fraction需要支持加减乘除和比较。如果全靠普通函数代码大概长这样Fraction a(1, 2), b(1, 3); Fraction c fraction_add(a, fraction_mul(b, Fraction(2, 1))); bool eq fraction_equal(c, Fraction(4, 3));这段代码的问题不在于“错”而在于它把数学表达式中天然的层次感给抹掉了。人脑读a b * 2是一瞬间的事读fraction_add(a, fraction_mul(b, ...))却需要一层层拆括号。运算符重载的意义就是把这种层次感还给代码。重载之后同样的逻辑可以写成Fraction c a b * 2; bool eq (c Fraction(4, 3));一眼就能看出运算的优先级和意图。这不是“炫技”而是让自定义类型在语法层面真正融入语言环境。这也是为什么标准库里的std::string、std::complex、各种迭代器都大量使用了运算符重载——它们想让自己的类型看起来就像语言原生的东西。但这里要泼一盆冷水运算符重载不是万能的语法糖它有个前提——被重载的运算符其含义必须和它在内置类型上的直觉保持一致。就该表示某种“合并”就该表示“相等判断”。如果你把定义成删除元素那代码看着就疯了维护的人会想打人。这是重载的第一条铁律语义一致性优先于语法便利。1.2 运算符重载的本质是一层语法映射很多人把运算符重载想得很玄乎其实剥开来看它就是把a b这种表达式翻译成一次函数调用。具体翻译成哪个函数取决于运算符和操作数的类型。对于二元运算符表达式a b会被编译器解释成两种可能的调用形式之一如果a是类类型优先查找a.operator(b)也就是成员函数版本否则或者成员版本不匹配时查找operator(a, b)也就是非成员函数版本。一元运算符类似a会查找a.operator()或operator(a)。正是因为这层翻译运算符重载必须遵守一些硬性规则不能改变运算符的优先级不能改变结合性不能改变操作数的个数也不能发明新的运算符。永远是二元的、左结合的你不能让a b c变成先算右边。理解这一点很重要因为它意味着重载只改变行为不改变语法规则。还有一个容易忽略的点有些运算符天生只能作为成员函数重载有些则只能作为非成员。比如、[]、()、-必须是成员函数而流运算符、通常必须写成非成员因为左操作数是std::ostream我们改不了标准库的类于是就用友元来访问私有成员。这个规则表后面会详细列。1.3 哪些能干哪些不能干——边界先摸清在动手之前先把“能重载的”和“不能重载的”分清楚省得写到一半发现方向错了。下面这张表是我自己总结的几乎覆盖了日常会碰到的全部运算符。运算符能否重载常见写法备注 - * / %能非成员返回新对象对称性要求高优先非成员 - * /能成员返回*this引用复合赋值一般做成成员 ! 能非成员或 defaultC20 可用自动生成 能非成员友元左操作数是流对象[]能成员必须成员常成对写 const 版()能成员函数对象仿函数靠它 --能成员前置/后置要区分能成员拷贝/移动赋值必须成员-能成员智能指针的核心new delete能静态成员谨慎重载容易出事:: . .* ?:不能—语言硬性禁止# ##预处理不能—那是预处理阶段的符号这张表里::、.、.*、?:、sizeof这几个是明确不能重载的。原因也简单它们要么和名字查找、类型系统深度绑定要么涉及编译期而非运行期的行为语言不给你改的机会。还有一类“能重载但强烈不建议”的比如、||、,逗号。为什么因为你一旦重载它们就会丢失短路求值语义。内置的是左边为假右边根本不求值重载后变成了函数调用两个操作数都会先算出来。这会让很多依赖副作用的代码出现隐蔽 bug。所以除非你非常清楚自己在干什么否则远离这几个。注意重载,逗号运算符几乎只在下标代理proxy这类高级技巧里才会用到日常业务里基本是混淆视听的元凶能不用就不用。2. 成员函数还是非成员函数先把这件事想明白搞清了边界下一个绕不开的问题就是这个运算符到底写成成员函数还是写成非成员函数这个问题看似小事实际上决定了你的类用起来顺不顺、能不能被隐式转换正确支持。我见过太多代码因为把operator写成了成员导致1 obj编不过只能写obj 1用的人一脸问号。2.1 两类写法的适用边界先给一个我总结的实用判断法遇到不确定的时候按这个顺序问自己如果运算符会修改左操作数本身比如、、写成成员函数。因为它天然需要访问this而且语义上就是“对自身做修改”。如果运算符需要对称支持两个操作数比如、-、优先写成非成员函数。因为只有当两个操作数都是函数参数时编译器才能对左右两边同时做隐式类型转换。如果左操作数不是你能改的类型比如std::ostream、std::istream必须写成非成员函数必要时加friend访问私有成员。如果运算符必须访问私有成员又不想暴露 getter那就用友元。关键点在于“对称性”这一条。举个例子假设Fraction的operator写成了成员函数class Fraction { public: Fraction(int n, int d) : num_(n), den_(d) {} Fraction operator(const Fraction rhs) const; // 成员版本 private: int num_, den_; };这时候a 2能编过因为2可以隐式转成Fraction作为rhs但是2 a编不过。为什么因为编译器会去找2.operator(a)而int根本没有这个成员函数。这个不对称在自定义数值类型上是致命的用户写2 a是很自然的诉求。改成非成员函数就没这个问题Fraction operator(const Fraction lhs, const Fraction rhs);现在a 2和2 a都能通过隐式转换工作因为两边都是参数机会平等。所以我的习惯是算术类、比较类运算符一律非成员赋值类、自增自减一律成员。这个划分在《C 编程规范》里也有类似建议是我用下来最省心的方式。2.2 返回值、参数与 const 的组合套路定好了成员还是非成员接下来是签名怎么设计。返回值和参数这块坑比想象的多。我把最常用的几种模式整理成下表你可以直接对照。运算符类型参数返回值理由 - * /const TT值结果是一个新对象返回引用会悬垂 -const TT*this引用支持链式调用且是原地修改 ! const Tbool判等/比较就是布尔输出ostream, const Tostream支持cout a b[]size_tT/const T返回引用以便读写元素前置无T先自增再返回自身引用后置int占位T值返回自增前的副本这里最容易被写错的就是返回值类型。很多人一看返回新对象觉得“会不会有拷贝开销”手一抖就写成返回引用。这就埋了个大雷函数返回后局部对象被销毁你拿到的引用指向一片已经被回收的内存运行起来表现可能就是“看着对、偶尔错”非常难查。关于这一点第 4 章会专门用一段讲。参数部分能用const T就用const T避免不必要的拷贝同时向调用者承诺“我不会改你传进来的东西”。只有在参数本身是小型内置类型如int、double、指针时才考虑按值传递因为引用反而多一层间接。2.3 友元不是必需品但有时真香“友元”这个词听上去像在破坏封装很多人本能地排斥。但在运算符重载的语境下友元往往是最干净的选择。原因在于像operator这种左操作数是被我们改不了的标准库类型右操作数才是我们的类想访问私有成员除了把它声明为友元就只剩暴露公有 getter 这一条路了。对比一下两种写法。用 getter 的方式class Fraction { public: int numerator() const { return num_; } int denominator() const { return den_; } private: int num_, den_; }; std::ostream operator(std::ostream os, const Fraction f) { os f.numerator() / f.denominator(); return os; }用友元的方式class Fraction { public: friend std::ostream operator(std::ostream os, const Fraction f); private: int num_, den_; }; std::ostream operator(std::ostream os, const Fraction f) { os f.num_ / f.den_ \n; return os; }两种都能跑。如果这个类本来就有合理的 getter比如矩阵的行列、复数的实部虚部本来就是对外的公开信息那用 getter 没问题还避免了友元。如果只是想“给流运算符开个后门”那友元更合适因为它把访问限制在一处不会顺带把别的接口也暴露出去。我的判断标准是这个数据本身是不是对外公开的语义。复数的实部虚部是对外公开的那就用 getter某个内部的缓存、状态位本就不该暴露那就只在流运算符这一个点上用友元。提示友元声明写在类内部但它本身不受public/private影响。为了可读性我习惯把友元声明统一放在类定义的开头集中写而不是散落在各个访问区里。3. 手把手把 Complex 类写完整理论讲完进入实操。这一章我打算从头到尾写一个比较完整的Complex复数类把日常最常用的一批运算符都实现一遍。为什么选复数因为它同时涉及算术、比较、输入输出、自增自减虽然复数自增意义不大但为了演示覆盖面足够广而且逻辑单纯不会让读者被业务细节干扰。3.1 类骨架与构造、访问接口先搭骨架。为了让2 a这种写法能工作构造函数不能标explicit否则隐式转换被禁掉非成员运算符的优势就发挥不出来了。#include iostream #include cmath class Complex { public: Complex(double re 0.0, double im 0.0) : re_(re), im_(im) {} double real() const { return re_; } double imag() const { return im_; } double modulus() const { return std::sqrt(re_ * re_ im_ * im_); } // 复合赋值成员 Complex operator(const Complex rhs); Complex operator-(const Complex rhs); Complex operator*(const Complex rhs); // 流运算符友元 friend std::ostream operator(std::ostream os, const Complex c); friend std::istream operator(std::istream is, Complex c); // 比较非成员或 default friend bool operator(const Complex lhs, const Complex rhs); private: double re_; double im_; };构造函数给了默认参数(0, 0)这样Complex z;会得到一个零复数符合直觉。实部虚部通过real()和imag()暴露出去这属于“本来就该公开”的语义所以走 getter 而非友元。这里有个小细节值得说成员函数的运算符重载编译器会隐式传入this所以二元运算符的成员版本参数只需要写右操作数。这也是为什么上面operator只声明了一个参数。3.2 算术运算符 - * / 的落地算术运算符一律写成非成员函数。先看加法。复数加法的公式很简单实部加实部虚部加虚部Complex operator(const Complex lhs, const Complex rhs) { return Complex(lhs.real() rhs.real(), lhs.imag() rhs.imag()); }这里返回的是值因为结果是一个全新的对象。有经验的读者可能会担心“返回Complex会不会多一次拷贝”其实不会——编译器会做返回值优化RVO / NRVO而且现代 C 有移动语义兜底这点开销可以忽略。减法同理Complex operator-(const Complex lhs, const Complex rhs) { return Complex(lhs.real() - rhs.real(), lhs.imag() - rhs.imag()); }乘法稍微复杂一点复数乘法的公式是(abi)(cdi) (ac-bd) (adbc)iComplex operator*(const Complex lhs, const Complex rhs) { double a lhs.real(), b lhs.imag(); double c rhs.real(), d rhs.imag(); return Complex(a * c - b * d, a * d b * c); }除法要处理分母为 0 的情况而且有个数值稳定性上的小技巧——用分子分母同乘共轭的方式并提前算好分母的模长平方Complex operator/(const Complex lhs, const Complex rhs) { double denom rhs.real() * rhs.real() rhs.imag() * rhs.imag(); if (denom 0.0) { throw std::runtime_error(Complex division by zero); } double a lhs.real(), b lhs.imag(); double c rhs.real(), d rhs.imag(); return Complex((a * c b * d) / denom, (b * c - a * d) / denom); }注意这里的denom 0.0是浮点判断严格来说不够严谨实际工程里更常用一个极小的 epsilon 来比较。演示代码图省事真实项目里建议换成std::abs(denom) 1e-12这种写法。operator/里抛出异常是一种设计选择。如果你不希望算术运算符抛异常很多数值库倾向于不抛那也可以返回一个 NaN 复数或者用专门的错误码机制。这是个权衡取决于你的库面向什么用户。3.3 复合赋值运算符与效率账复合赋值和上面的算术运算符是一对好搭档而且它们是算术运算符的基础实现。什么意思很多标准库的实现里operator其实是复用完成的Complex Complex::operator(const Complex rhs) { re_ rhs.re_; // 成员函数里可以直接访问另一个同类型对象的私有成员 im_ rhs.im_; return *this; } Complex operator(Complex lhs, const Complex rhs) { lhs rhs; // 复用 return lhs; }注意operator这里第一个参数按值传递Complex lhs这其实是一种常见写法反正要产生新对象不如直接拷贝一份左操作数到参数里然后就地 rhs最后返回。这样只需要一次拷贝构造比先算新对象再赋值更简洁。这个技巧叫“拷贝加赋值”在本类场景下很好用。*和/类似不过要注意复数乘法会同时用到原值的实部和虚部别算着算着把re_改了导致后面用错Complex Complex::operator*(const Complex rhs) { double a re_, b im_; double c rhs.re_, d rhs.im_; re_ a * c - b * d; im_ a * d b * c; return *this; }回到前面提的那个坑复合赋值返回的是T而不是T。原因是原地修改返回*this的引用才能支持a b c这种链式写法而且避免了额外拷贝。而普通算术运算符返回T因为它产生的是临时对象。3.4 比较运算符与 C20 的三路比较比较运算符在 C20 之前是个体力活、!、、、、六个得一个个写。如果两个复数要判等直接比实部虚部bool operator(const Complex lhs, const Complex rhs) { return lhs.re_ rhs.re_ lhs.im_ rhs.im_; } bool operator!(const Complex lhs, const Complex rhs) { return !(lhs rhs); }但复数其实没有自然的“大小”序关系复平面没法像实数轴那样线性排序所以、这些对复数本身意义不大。真要排序一般按模长排。这都是设计决策重载之前得想清楚“用户到底期待什么语义”。如果你用 C20那就可以省事很多。三路比较运算符常被念作 spaceship能一次性生成、、、配合生成!#include compare struct Vector3 { double x, y, z; // 让编译器自动逐成员比较 auto operator(const Vector3) const default; bool operator(const Vector3) const default; };这行 default会按成员声明顺序做字典序比较则逐成员判等。写起来极其省心是 C20 出来之后我最喜欢的特性之一。不过要注意如果你需要自定义比较逻辑比如按模长而不是按成员顺序就不能用 default得手写。double lhs std::sqrt(a.real() * a.real() a.imag() * a.imag()); double rhs std::sqrt(b.real() * b.real() b.imag() * b.imag()); if (lhs rhs) return std::strong_ordering::less; if (lhs rhs) return std::strong_ordering::greater; return std::strong_ordering::equal;返回类型这里也有讲究。std::strong_ordering表示严格全序相等即不可区分std::weak_ordering用于“可以相等但不等同”的场景比如大小写不敏感的字符串std::partial_ordering用于存在不可比较值的场景比如含 NaN 的浮点。用错类型语义用户在做相等判断时可能拿到反直觉的结果。3.5 输入输出运算符的友元写法流运算符是我个人认为最值得优先实现的一类重载。理由很实在调试时能不能直接cout obj看内容直接决定了排查效率。见过太多类调试只能一行行打印成员太痛苦了。输出运算符的签名规范几乎是固定的std::ostream operator(std::ostream os, const Complex c) { if (c.im_ 0.0) os c.re_ - std::abs(c.im_) i; else os c.re_ c.im_ i; return os; }这里有几个细节第一返回os的引用才能支持cout a b这种链式输出第二右操作数是const Complex因为我们只是读它第三符号处理上负数虚部要显示成-而不是 -所以单独判断了一下。输入运算符稍微麻烦些因为它要处理解析失败std::istream operator(std::istream is, Complex c) { double re 0, im 0; char plus 0, i 0; if (is re plus im i) { if (plus -) im -im; c.re_ re; c.im_ im; } else { is.setstate(std::ios::failbit); } return is; }关键点输入运算符要能优雅处理失败。遇到格式不对的输入应该设置流的 failbit 并返回这样调用者可以用if (cin c)判断是否解析成功。很多人写输入运算符时忽略这点一旦用户输入格式错了后面的读取就全乱套了。提示输入运算符的参数是Complex不能是const因为要往里写值。这也提醒我们任何需要修改对象的运算符参数不可能是 const 引用。3.6 下标、函数调用、自增自减这些“特殊成员”除了算术和流还有一批“特殊成员”运算符它们在某些场景下非常有用。下标运算符[]典型就是容器。写法上有个讲究要提供一个const版本和一个非const版本否则 const 对象无法读取元素。class Buffer { public: double operator[](std::size_t i) { return data_[i]; } const double operator[](std::size_t i) const { return data_[i]; } private: double* data_; };函数调用运算符()让对象变成“仿函数”可以像函数一样被调用在 STL 的谓词、策略类里用得很多struct Square { int operator()(int x) const { return x * x; } }; Square sq; int r sq(5); // r 25自增自减是最容易被写错的一组。前置和后置的区分标准是前置不接受参数、返回引用后置接受一个int占位参数仅用于区分重载不会被用到、返回值。class Counter { public: Counter operator() { // 前置 value_; return *this; } Counter operator(int) { // 后置 Counter old *this; value_; return old; } private: int value_ 0; };为什么要这么设计前置c的语义是“自增并返回自身”所以返回引用最自然后置c的语义是“返回自增前的旧值”所以必须先保存副本再自增最后返回那个副本。这也是为什么前置版本通常比后置快——后置多了一次拷贝构造。很多遍历代码for (auto it v.begin(); it ! v.end(); it)里的it其实应该改成it就是在省这一份拷贝。这三种“特殊成员”我个人的经验是[]和()值得掌握因为它们在容器和算法里的地位很核心自增自减的关键不是“会不会写”而是“别把前后置写反”。第 4 章会提到写反之后的表现有多迷惑。4. 常见问题与排查实录前面该讲的都讲了但真正让人抓狂的往往是那些“语法看起来对、运行起来不对”的问题。这一章我把这些年带人、被问、自己踩过的几种典型情况整理出来配上排查思路。4.1 为什么 cout obj 死活编不过这是新手最常遇到的编译错误之一。现象是std::cout myObj;报一堆 “no match for operator” 或者 “cannot be used with ‘std::ostream’”报错信息还特别长。排查步骤我一般这么走确认是不是忘了写运算符重载。内置类型不用管自定义类如果没有operator编译器找不到任何可用的重载自然报错。确认写的位置对不对。如果写成成员函数os.operator(...)那肯定不行因为左操作数是ostream得写成非成员。确认有没有被命名空间挡住。如果你把operator放在了自己的命名空间mylib里那么调用点如果在全局编译器通过 ADL参数依赖查找只有在参数类型属于mylib时才会找到它。如果你的类也在mylib里通常没问题但如果你把类声明在别处就可能找不到。确认是不是签名不对。比如返回值写成了void或者参数漏了引用都会导致匹配不上。标准签名就是std::ostream operator(std::ostream, const T)。这里有个小技巧编译器报错时重点看 “candidates are” 那一坨它会列出所有被考虑过的候选函数。如果候选里根本没有你的operator那多半是作用域或声明问题如果有但被打叉那就是签名不匹配对着改就是了。4.2 返回引用踩出的悬垂引用这是最阴险的一类 bug。前面反复强调过算术运算符要返回值但总有人图省性能写成返回引用// 错误示范 Complex operator(const Complex lhs, const Complex rhs) { Complex tmp(lhs.real() rhs.real(), lhs.imag() rhs.imag()); return tmp; // tmp 在函数返回时被销毁 }这段代码能编过编译器顶多给个 warning但运行起来就是未定义行为。典型表现是有时候结果看着对有时候是垃圾值加个打印或改动无关代码后结果又变了。这种“薛定谔的 bug”如果能定位到靠的基本是静态分析工具或者编译器 warning。我的应对方式有几条编译时把 warning 拉到最高-Wall -Wextra返回局部引用通常会有警告别忽略它。善用地址检查工具这类内存问题用地址检查工具跑一遍基本无所遁形。建立条件反射看到operator、operator-这类返回引用的先怀疑。这类运算符的返回值永远应该是值。那什么时候该返回引用复合赋值返回*this引用前自增返回*this引用[]返回元素引用——这些返回的都是“真实存在、生命周期超过函数调用”的对象没问题。判断标准一句话返回的对象在函数返回后是否还活着。4.3 隐式转换引发的二义性前面说非成员运算符能支持2 a靠的是构造函数的隐式转换。但这把双刃剑有时会带来二义性。假设我们同时给Complex提供了“和double相加”的重载又提供了“和Complex相加”的重载Complex operator(const Complex lhs, const Complex rhs); Complex operator(const Complex lhs, double rhs);那么2.0 a这个表达式编译器就有两条路把2.0转成Complex走第一个或者把a转成……等等a转不了double所以其实不冲突。但如果两个方向都能转就麻烦了。经典例子是比较运算符a 2和2 a如果都存在隐式转换编译器可能不知道该调哪个。排查这类问题编译器其实给的信息很明确——“ambiguous overload”或者 “call is ambiguous between ...”它会列出所有可行候选。解决办法通常是减少隐式转换路径把某些构造函数改成explicit或者干脆补一个精确匹配的重载。我的经验是构造函数默认加上explicit只在确实需要隐式转换的数值类型上放开。像Complex这种数值类型放开隐式转换是合理的但像File、Connection这类资源类构造函数应该是explicit避免发生莫名其妙的隐式构造。4.4 前后置自增写反导致的性能与语义坑前后置自增写反症状很特别。假设你把前置版本的参数写错了或者后置忘了那个占位int那么某一次调用可能匹配到错误的重载。更常见的是逻辑上认为写了前置实际调用的却是后置结果多了一次拷贝。这在普通场景下难以察觉但在循环里遍历大容器性能差异就出来了。还有一种语义坑前置应该返回*this引用后置应该返回旧值副本。如果后置错误地返回了*this那么x拿到的会是自增后的值和x行为一样把依赖旧值的逻辑搞坏。排查方法其实很直接把的两种重载都写出来单步调试看一眼调用的到底是哪个。另外养成习惯循环里统一用前置it除非确实需要旧值。现代编译器有些能优化掉后置的拷贝但别指望它自己写对最稳。4.5 常见问题速查表把上面这些高频问题和对应处理方式整理成表出问题时可以直接查问题现象可能原因排查/解决方式cout obj编译失败没定义operator/ 写成成员 / 签名不对定义为非成员返回ostream参数const T运算符结果随机、时对时错返回了局部对象的引用算术运算符一律返回值别返回引用报 ambiguous overload隐式转换路径过多构造函数加explicit或补精确重载a 2能过、2 a不过运算符写成了成员函数改成非成员函数循环里性能异常误用了后置改成前置ita b后调链式失败复合赋值返回了值而非引用返回T*thisC20 下比较运算符缺失未声明/加 default或手写对应三路比较这张表不建议死记建议在项目里放一份遇到了翻一翻用几次自然就记住了。5. 工程实践中的取舍与经验能写对运算符重载和“该不该重载”是两个层次的问题。我工作这些年见过太多次“为了重载而重载”最后类用起来反而别扭。这一章聊的都是取舍。5.1 什么时候不该重载运算符运算符重载最吸引人的地方是“语法简洁”但也正因如此滥用它的代价很高。以下几种情况我一般会克制语义不明朗时。如果一个运算符在你的类型上没有明确的、用户一眼能理解的语义就别重载。比如一个Person类p1 p2表示什么合并生小孩没人知道那就不如老老实实写merge(p1, p2)。运算符含义和日常直觉冲突时。有些库会把^重载成“幂运算”这在数学语境下勉强说得过去但和 C 内置的按位异或冲突容易误导。类似的还有把当作“插入”而不是“左移”——虽然流运算符就这么干了但那是约定俗成已经形成惯例新的类型模仿它没问题别的运算就别乱来。操作链过长导致优先级困惑时。如果你重载的运算符一起出现会让人搞不清结合顺序那就得加括号那就失去了简洁的意义。这种情况下具名函数反而清晰。一句话总结运算符重载服务于可读性如果它让代码更难读懂那就本末倒置了。5.2 与三/五法则和现代 C 的配合运算符重载不是孤立存在的它和类的特殊成员函数构造、拷贝、移动、析构是联动的。有个经验法则叫“三法则/五法则”如果一个类需要自定义析构、拷贝构造、拷贝赋值中的任何一个通常三个都需要C11 之后加上移动构造和移动赋值就是五法则。为什么提这个因为你重载了赋值运算符往往意味着你在管理资源那拷贝构造、析构也得一起考虑否则会出现浅拷贝、double free 这类经典问题。反过来如果你已经正确实现了拷贝/移动赋值那么用 default让编译器帮你生成operator之类就会安全得多。C20 之后我越来越倾向于“尽量让编译器代劳”。能用 default生成的比较运算符绝不手写能用成员函数自然表达的绝不上友元。手写的好处是可控坏处是容易出错。现代 C 的趋势是让编译器承担更多机械工作人只负责那些真正需要定制的逻辑。还有一个配套点如果你用了移动语义那么operator里return Complex(...)这种写法编译器会优先用移动而不是拷贝性能上基本不用担心。不要再为了“减少拷贝”而去写一些返回引用的危险代码了。5.3 几个调试与性能小技巧最后分享几个我在实际项目里常用的小技巧。第一优先把operator和operator写出来。这两个看似不起眼却是调试和测试的基石。有了日志、断言失败信息都能直接打印对象有了单元测试的断言可以写成EXPECT_EQ(a, b)这种直观形式失败了还能让测试框架打印两边的值。第二比较复杂对象时别急着手写六个比较运算符优先用 C20 的。但如果项目还在 C17 及以前那就老老实实写和!其余按需。写时想清楚是全序还是偏序。第三善用std::tie简化多成员判等C17 之前很实用。比如三个成员逐个比较可以写成bool operator(const Foo lhs, const Foo rhs) { return std::tie(lhs.a_, lhs.b_, lhs.c_) std::tie(rhs.a_, rhs.b_, rhs.c_); }这比手写一长串更不容易漏项也不容易写错。std::tie生成的是引用元组比较行为完全符合预期。第四警惕浮点比较的坑。如果你的类型内部有double成员直接用比较浮点可能因为精度问题判等失败。库级别的类型比如复数、向量通常会把判等实现成“近似相等”用一个 epsilon 阈值。这个选择会影响用户的使用方式最好在文档里明确说明。第五给自定义类型加上const正确的接口。这个和运算符重载关系密切如果operator[]只提供了非 const 版本const 对象就没法读元素如果operator的参数没用const就没法比较 const 对象。const 正确性是运算符重载里最容易忽略、又最容易在编译期暴露的问题——编译器会帮你抓但前提是你把接口写规范。说到个人体会我这些年最深的感受是运算符重载真正的难点从来不在语法而在“取舍”。写一个operator只要五分钟但决定“这个类型到底该不该有operator、该不该支持2 a、该不该抛异常”往往要花上半天。每次给一个类加运算符之前我都会先问自己这个运算符加进去之后用户第一次看到这个类型能不能凭直觉猜出它的行为如果答案是“能”那就加如果答案是“得查文档才知道”那八成是加错了。踩过的最大一个坑是早年写的一个矩阵库把*同时重载成了“矩阵乘法”和“逐元素相乘”结果用户完全分不清A * B到底走的哪条路只能靠参数类型区分一不小心就编译到错误的重载。后来我把逐元素相乘改成了具名函数elementwise_mul代码立刻就清晰了。这件事让我彻底明白了运算符越简单用户对它的期待就越“内置化”越不能有歧义。歧义一旦产生省下的那几个字符会以十倍的代价在调试里还回来。
返回列表