前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >C++拾趣——有趣的操作符重载

C++拾趣——有趣的操作符重载

作者头像
方亮
发布2019-01-16 17:09:10
7640
发布2019-01-16 17:09:10
举报
文章被收录于专栏:方亮方亮

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

为什么要使用操作符重载

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

代码语言:javascript
复制
#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 0
    return 0;
}

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

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

代码语言:javascript
复制
#include <iostream>

class Sample {
public:
    int value() {
        return _m;
    }
private:
    int _m = 0;
};

int main() {
    Sample sample;
    std::cout << sample.value() << std::endl; // output 0
    return 0;
}

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

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

代码语言:javascript
复制
#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 0
    return 0;
}

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

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

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

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

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

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

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

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

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

代码语言:javascript
复制
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两者写法,哪个执行更高效?

代码语言:javascript
复制
// A
for (int i = 0; i < 8; i++) {}

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

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

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

        问题来了:

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

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

代码语言:javascript
复制
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。

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

代码语言:javascript
复制
    sample.operator++();

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

代码语言:javascript
复制
    sample.operator++(10);

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

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

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

代码语言:javascript
复制
if (ptr && ptr->suc()) {
    // do somethind
}

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

代码语言:javascript
复制
if (ptr->value() > 10 || ptr->value() < -10) {
    // do something
}

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

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

代码语言:javascript
复制
#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;
}

        这个程序的执行结果是

代码语言:javascript
复制
postfix ++0
Segmentation fault

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

        再看看||的操作

代码语言:javascript
复制
Sample* sample = new Sample;
std::cout << "sample || (*sample) || (*sample)++ " << (sample || (*sample) || (*sample)++) << std::endl;

        它的输出是

代码语言:javascript
复制
postfix ++0
prefix ++
sample || (*sample) || (*sample)++ 1

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

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

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

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

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

代码语言:javascript
复制
#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对象。

代码语言:javascript
复制
Sample sample;
sample = sample + 2;

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

代码语言:javascript
复制
sample = 2 + sample;

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

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

代码语言:javascript
复制
Sample operator+(int n, const Sample& smpR) {
    return Sample(n + smpR._m);
}

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

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

函数对象

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

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

代码语言:javascript
复制
type a = some_method(arg1, arg2);

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

代码语言:javascript
复制
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行构造了一个临时对象——它没有名字,但是连续两个()让人感觉还是很“异类”。

        一种比较优雅的方式是:

代码语言:javascript
复制
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类型。

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

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2018年06月13日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 为什么要使用操作符重载
  • 重载操作符需要遵从“隐性共识”
  •  自增、自减的前置和后置
  • &&、||的短路求值特性
  • (非)成员函数和隐式构造
  • 函数对象
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档