操作符重载是C++语言中一个非常有用的特性。它可以让我们比较优雅的简化代码,从而更加方便的编写逻辑。

为什么要使用操作符重载

一种常见的用法是重载<<运算符,让标准输出可以输出自定义的类型。比如

#include <iostream>class Sample {friend std::ostream& operator<<(std::ostream &out, const Sample& smp);
private:int _m = 0;
};std::ostream& operator << (std::ostream &out, const Sample& smp) {out << smp._m;return out;
}int main() {Sample sample;std::cout << sample << std::endl; // output 0return 0;
}

Sample是非标准类型,编译器并不知道怎么使用标准输出输出它——是输出它的内存结构还是输出它的一些变量呢?这个时候Sample类的作者通过重载<<运算符,告知编译器“我”是想输出它的某个成员变量。(转载请指明出于breaksoftware的csdn博客)

如果我们不进行重载,则可能像如下代码实现功能

#include <iostream>class Sample {
public:int value() {return _m;}
private:int _m = 0;
};int main() {Sample sample;std::cout << sample.value() << std::endl; // output 0return 0;
}

这种写法,需要对每个需要输出的成员变量定义一个“访问器”,“访问器”的个数随着需要输出的成员变量个数线性增加。假如“访问器”只有在标准输出时才使用,且不存在需要单独输出某些变量的场景,这种实现就显得不那么“智慧”——大量“访问器”函数的重用次数太低了。

有人会提出可以定义一个类似print的函数来实现

#include <iostream>class Sample {
public:void print() {std::cout << _m;}
private:int _m = 0;
};int main() {Sample sample;sample.print();std::cout << std::endl; // output 0return 0;
}

这种写法亦不那么“智慧”。这给试图输出组合信息的使用者带来麻烦。本来一行可以输出类的信息和换行符,在上例中就需要写两行。这种逻辑的“割裂”是不优雅的。

可能有人会说:虽然我认同操作符重载是优雅的,但是这样的“教学例子”仍然让我无法感知到它的重要性。是的,因为例子太简单。以一个工作中的场景为例:

工作中经常会用到Json或者XML等格式的数据,一般情况下,我们都需要将这些格式转换成一个对象来访问。假如我们不太清楚该格式的具体组织形式以及字段名称或者值类型,难道我们要一个个遍历整个对象么?这个时候,以“肉眼”可以看懂的格式输出该对象就显得非常必要了。可能有人会说Json和XML的内容是可以肉眼识别的。的确,但是如果该数据是一种二进制的结构呢?

重载操作符需要遵从“隐性共识”

C++给了程序员很多自由,但是自由永远都是相对的。因为重载操作符是存在一些隐性的共识,这些共识是我们要遵从的,否则将失去操作符重载的意义,甚至会给使用者带来极大的困扰。

隐性的共识包含几个部分:

  • 符合自然语义。比如我们重载操作符=,隐性的共识是该操作符将会产生赋值行为。而如果我们什么都不去实现,则违反了共识。再比如,我们重载++操作符,其隐性的共识是需要对关键信息进行自增。如果我们实现时,让关键信息自减了,那也是明显违反共识的。
  • 操作符存在关联性。关联性又分为:对等性和复合性。下面我们将针对这两个特性进行讨论。

自增和自减操作符是对等,它们都会对对象的关键信息作出修改。但是对等性要求我们,如果自增是对关键信息增加1,那么自减就是对该信息减少1。不可以产生一次自增,需要几次自减才会恢复到原始值的现象。

复合性是指:+操作和+=操作,*操作和*=操作……这种存在组合关联的操作符。比如我们实现了+操作符的重载,也就需要实现+=的重载。因为我们无法保证别人不去使用+=去进行“加”和“赋值”的操作。对于一对操作符,一般来说,我们让两者实现存在协同关系,即+使用+=实现,或者+=使用+和=实现。看个例子

class Sample {
public:Sample& operator=(const Sample& smp) {_m = smp._m;return *this;}   Sample operator+(const Sample& smp) {Sample tmp(*this);tmp += smp;return tmp;}   Sample& operator+=(const Sample& smp) {_m += smp._m;return *this;}
private:int _m = 0;
}

上例中我们使用+=实现了+操作。这儿一个有趣的点是第4行,我们直接使用了smp._m——_m可是私有变量啊。其实不用担心,因为smp也是Sample对象,且这个重载是Sample类的成员函数,所以在语法上是合法的。

自增、自减的前置和后置

自增(++)和自减(--)是非常独特的单目运算符,它可以出现在操作数的前面或者后面。如果出现在前面,则隐性共识是:自增(减)关键信息,并返回自身;如果出现在后面,则隐性共识是:自增(减)关键信息,返回自增(减)之前的自身。其一般实现是:构造一个和自身相同的临时对象,自增(减)关键信息,返回临时对象。

之前有一种与此相关的面试题。面试官会:A和B两者写法,哪个执行更高效?

// A
for (int i = 0; i < 8; i++) {}// B
for (int i = 0; i < 8; ++i) {}

这个问题就是考察后置自增(减)会构造临时对象的知识点。但是就此例子来看,这个问题构造的并不好。因为现在的编译器已经比较智能了,它会识别该场景不需要构造临时变量,于是A编译出的指令和B编译出的指令是一致的,执行效果也是一样的。

由于自增和自减是对等的,简单起见,之后的讨论我只以自增为例。

问题来了:

  • 前置和后置是否需要分开实现?由于两者执行逻辑不同,我们不可能通过重载一个操作符实现另外一个功能,所以这个答案是“是”。
  • 是否只需要重载前置或者后置?如果我只重载前置,那么使用者只能在使用前置操作符时才能产生正确的行为,但是使用者不知道后置是不能使用的。这种不对等的行为也是违反“隐性共识”的。所以这个问题的答案是“否”。
  • 前置和后置是同一个操作符,如何在重载声明上表现出区别?这个问题的答案就是C++的一个语法糖,也是本文标题中“有趣”的一个点。

C++使用了一种语法糖来区分前置和后置——前置重载无参数,后置重载有一个int型参数。看个例子

class Sample {
public:Sample& operator++() {std::cout << "prefix ++" << std::endl;;++_m;return *this;}   Sample operator++(int n) {std::cout << "postfix ++" << n << std::endl;Sample tmp(*this);++*this;return tmp;}
private:int _m = 0;
}

第3行是前置实现,它只是简单的对成员变量进行了自增,然后返回对象本身。第9行是后置实现,它在自增前使用了拷贝构造函数构造了一个和当前对象保存一样信息的临时对象,然后自增当前对象,最后返回了临时对象。

在进行后置操作符调用时,如果没有指定参数,系统会默认传入0。所以第9行,n的值默认是0。

介于这种语法,我们还可以如下调用前置操作

    sample.operator++();

或者这样调用后置操作。然传入的是10,系统也的确把10传入了重载函数,但是我们不应该去使用它。因为这只是C++的一个无可奈何的语法糖。

    sample.operator++(10);

再回到之前的面试题,如果面试官询问++sample和sample++哪个效率高些时,你则可以告知是前置高些,因为后置方式使用了拷贝构造函数构造了一个临时对象。

&&、||的短路求值特性

除了自增、自减具有“前置”或者“后置”区别外,还有一组操作符——&&和||具有特殊的属性——短路求值。假如我们重载&&或者||操作符,则没法保证该特性,而它却是“隐性共识”。

if (ptr && ptr->suc()) {// do somethind
}

上例中,我们希望先检测ptr是否为空,然后再调用suc方法。因为默认的&&支持短路求值,所以如果ptr为空,则整个判断结果为假,那么suc函数不会被执行。

if (ptr->value() > 10 || ptr->value() < -10) {// do something
}

||操作的短路求值是:从左向右,只要遇到一个条件为真的,则整个判断为真。之后的检测不用执行了。所以如果ptr->value()值是20,那么只会判断20是否大于10(因为已经为真),而不会去判断是否小于-10。

但是重载这两个操作符就会破坏短路求值特性。比如

#include <iostream>class Sample {friend std::ostream& operator<<(std::ostream &out, const Sample& smp);friend bool operator&&(bool pre, const Sample& smp);friend bool operator||(bool pre, const Sample& smp);
public:Sample& operator++() {std::cout << "prefix ++" << std::endl;;++_m;return *this;}Sample operator++(int n) {std::cout << "postfix ++" << n << std::endl;Sample tmp(*this);++*this;return tmp;}bool operator&&(const Sample& smp) {return _m && smp._m;}bool operator||(const Sample& smp) {return _m || smp._m;}private:int _m = 0;
};std::ostream& operator << (std::ostream &out, const Sample& smp) {out << smp._m;return out;
}bool operator&&(bool pre, const Sample& smp) {return pre && smp._m;
}bool operator||(bool pre, const Sample& smp) {return pre || smp._m;
}int main() {Sample* sample = NULL;std::cout << "sample && (*sample) && (*sample)++ " << (sample && (*sample) && (*sample)++) << std::endl;return 0;
}

这个程序的执行结果是

postfix ++0
Segmentation fault

最后它崩了。如果按照短路求值特性,由于sample为空,则整个运算结果为假。但是重载&&操作符后,(*sample)++被执行,从而将导致违例。

再看看||的操作

Sample* sample = new Sample;
std::cout << "sample || (*sample) || (*sample)++ " << (sample || (*sample) || (*sample)++) << std::endl;

它的输出是

postfix ++0
prefix ++
sample || (*sample) || (*sample)++ 1

如果按照短路求值,由于sample不为空,则整个运算结果为真。但是重载了||操作符后,短路求值特性丢失,于是要把所有||的操作都执行一遍(最后执行了自增操作)。

(非)成员函数和隐式构造

操作符重载可以定义为外部函数(因为可能会访问私有变量,所以常常被声明为友元),也可以定义为成员函数。

以二目操作符为例。如果操作符重载被定义为成员函数,则重载函数的参数(如果有的话)是操作符右侧值。因为成员函数隐藏了this指针,所以操作符左侧值就是this指针指向的对象。

如果定义为外部函数,则函数的两个参数分别为操作符的左、右值。

#include <iostream>class Sample {friend std::ostream& operator<<(std::ostream &out, const Sample& smp);friend Sample operator+(const Sample& smpL, const Sample& smpR);
public:Sample() {}Sample(int n) : _m(n) {}Sample operator+(const Sample& smpR) {Sample tmp(*this);tmp += smpR;return tmp;}Sample& operator+=(const Sample& smp) {_m += smp._m;return *this;}
private:int _m = 0;
};std::ostream& operator << (std::ostream &out, const Sample& smp) {out << smp._m;return out;
}Sample operator+(const Sample& smpL, const Sample& smpR) {return Sample(smpL._m + smpR._m);
}

上面例子第14行是加法的成员函数式的重载,第33行是友元式的重载。

这两种实现是有区别的,区别就是对隐式构造函数的处理。

如果只有成员函数式重载,则下面的调用方式可以工作。因为操作符左侧值是Sample对象。

Sample sample;
sample = sample + 2;

但是下面的代码不能编译通过,因为左侧值是个整型。

sample = 2 + sample;

如果想解决这个问题,就可以将加法重载设置为外部形式。这样编译器会将2隐式构造成一个Sample临时对象(调用Sample(int n)构造函数)。

但是如果隐式构造成本比较大,比较建议的方案是明确化,比如

Sample operator+(int n, const Sample& smpR) {return Sample(n + smpR._m);
}

但是不是所有重载都可以设置为成员函数形式,比如上面例子中频繁出现的<<重载。因为它用于支持标准输出,于是操作符左侧值是std::ostream对象,这样它就不能声明为成员函数了。

也不是所有重载都可以设置为外部函数形式,比如赋值(=)、下标([])、调用(())等。

函数对象

函数很容易理解,但是函数对象是什么?

下面是一般函数调用,函数名是some_method,它有两个参数,返回了一个type类型数据。

type a = some_method(arg1, arg2);

我们将注意力移到括号(())上,它是一个操作符。因为C++提供了“操作符重载”这样的武器,我们是不是可以将some_method想象成某个类?一种方式是

class Method {
public:int operator ()(int n, int m) const {return n * m;}
};int main() {Method m;std::cout << m(3, 4) << std::endl;std::cout << Method()(4, 5) << std::endl;return 0;
}

相较于第10行和第11行,第10行的调用方式更像普通的函数调用,但是它有一个缺点:需要显式的申明一个函数对象。第11行构造了一个临时对象——它没有名字,但是连续两个()让人感觉还是很“异类”。

一种比较优雅的方式是:

class Method {
public:Method(int n, int m) : _n(n), _m(m) {}operator int() const {return _n * _m;}private:Method() {}private:int _n = 0;int _m = 0;
};int main() {std::cout << Method(2, 3) << std::endl;return 0;
}

这儿用到了转换操作符的概念。我们使用“operator 类型()”的形式定义一个转换操作,这样该类对象可以直接转换成type类型。

“操作符重载”给我们提供了强大的工具,使我们可以编写出便于使用的类。但是它也藏着各种语法糖,通过本文,希望朋友们可以了解到它一些好玩的“糖”。

C++拾趣——有趣的操作符重载相关推荐

  1. delphi 操作符重载_Delphi XE2中的运算符重载示例

    delphi 操作符重载 In my programming career I have only very rarely run into situations where operator ove ...

  2. C++拾趣——STL容器的插入、删除、遍历和查找操作性能对比(Windows VirtualStudio)——遍历和删除

    相关环境和说明在<C++拾趣--STL容器的插入.删除.遍历和查找操作性能对比(Windows VirtualStudio)--插入>已给出.本文将分析各个容器中遍历和查找的性能.(转载请 ...

  3. C++拾趣——STL容器的插入、删除、遍历和查找操作性能对比(Windows VirtualStudio)——删除

    相关环境和说明在<C++拾趣--STL容器的插入.删除.遍历和查找操作性能对比(Windows VirtualStudio)--插入>已给出.本文将分析从头部.中间和尾部对各个容器进行删除 ...

  4. C++拾趣——STL容器的插入、删除、遍历和查找操作性能对比(ubuntu g++)——遍历和查找

    相关环境和说明在<C++拾趣--STL容器的插入.删除.遍历和查找操作性能对比(ubuntu g++)--插入>已给出.本文将分析各个容器中遍历和查找的性能.(转载请指明出于breakso ...

  5. C++拾趣——STL容器的插入、删除、遍历和查找操作性能对比(ubuntu g++)——删除

    相关环境和说明在<C++拾趣--STL容器的插入.删除.遍历和查找操作性能对比(ubuntu g++)--插入>已给出.本文将分析从头部.中间和尾部对各个容器进行删除的性能.(转载请指明出 ...

  6. C++拾趣——C++11的语法糖auto

    C++是一种强类型的语言,比如变量a,如果声明它是整型,则之后只能将它作为整型来用.这和其他弱类型的语言有很大的区别,比如python中,我们可以让a在第一行是个整型,第三行是一个字符串.(转载请指明 ...

  7. C++拾趣——类构造函数的隐式转换

    之前看过一些批判C++的文章,大致意思是它包含了太多的"奇技淫巧",并不是一门好的语言.我对这个"奇技淫巧"的描述颇感兴趣,因为按照批判者的说法,C++的一些特 ...

  8. 操作符重载——C/C++学习笔记

    此篇文章来自于网上,作为自己学习中的笔记,若有侵权行为,请告之,24小时之内必删除!下面就转入正题吧! 一.什么是操作符重载? 一看到重载,很容易就让人联想到成员函数重载,函数重载可以使名称相同的函数 ...

  9. C++——构造函数(拷贝构造,拷贝复制),析构函数,操作符重载

    C++--构造函数(拷贝构造,拷贝复制),析构函数,操作符重载 构造函数与析构函数:: 涉及构造函数还可以看这篇文章C++搞懂深拷贝初始化=与赋值=的区别 1.声明和定义构造函数和析构函数 构造函数在 ...

最新文章

  1. 双显示器设置:如何设置一台电脑两个显示器
  2. java如何在指定索引位置插入新元素
  3. Flink java wordcount案例(批处理、流处理)
  4. 21天学通java 3_《21天学通Java》PDF 下载
  5. Killing Parallel Query Session
  6. 给iphone或touch加桌面图标
  7. 求合体电脑版_网红界宋祖儿找了个年轻版言承旭当男友,一张合照就收割百万cp粉...
  8. 使用最小二乘法拟合曲线
  9. 大地高和正常高、正高的详细说明
  10. 求矩形槽内电位分布matlab,MATLAB超松弛迭代法求解接地金属槽内电位分布
  11. 用Rstudio写.Sh/Shell文件
  12. 好莱坞十大经典***电影
  13. 网管型工业交换机冗余功能介绍
  14. 新书推荐 |《Linux系统安全:纵深防御、安全扫描与入侵检测》
  15. 学习笔记(抽样技术)
  16. 我要偷偷的学Python,然后惊呆所有人(第七天)
  17. uniCloud云开发入门:了解云函数、云数据库、云存储的基本概念
  18. 解压系统镜像文件img并查看内容
  19. 如何使用PCL将XYZRGB点云转换为彩色mesh模型
  20. GraphDTA论文阅读小白笔记(附代码注释和复现流程)

热门文章

  1. java最全人名数组_java 里有两个方法 第一个方法定义一个数组 每个数组都是一个学生类 每个学生有姓名 学号 年龄...
  2. 排序 时间倒序_经典排序算法之冒泡排序(Bubble Sort)
  3. MySQL面试题 | 附答案解析(七)
  4. 齐次坐标的理解(1)
  5. Python:python中的可变类型和不可变类型
  6. 1. CVPR2021-Papers-with-Code-Demo(CVPR2021论文下载)
  7. 【camera】2.相机成像原理和数学模型
  8. jetson nano 用 tensorrt 运行 nanodet(kitti数据集)
  9. 16 分频 32 分频是啥意思_Verilog中任意分频的实现
  10. python二级多少分过_python考级有几个级别