CPU眼里的:运算符重载和函数对象(运算符重载的调用)

C++跟C的重大区别之一:“运算符重载”。它让C++显得另类了许多,但在这套复杂语法的背后,又藏着什么样的秘密呢?

喜欢看视频的同学,请点击视频链接:CPU眼里的:运算符重载 | 函数对象 | 仿函数


01

提出问题

在编程的过程中,我们经常会对数据,作加、减、乘、除等数学运算,CPU也有专门的指令,进行这些数学运算,如图所示:

但如果问题稍微复杂一点,例如,我们要做复数、或者向量、矩阵的加、减、乘、除运算,CPU应该如何应对呢?我们在编程的过程中,还能用+、-、*、/这些简单的关键字来编写相应的代码吗?

答案是肯定的,C++可以通过重载运算符做到这一点,那它又是如何实现的呢?就让我们用CPU的视角一探究竟。


02

运算符重载

让我们定义一个简单的复数类Complex:

#include <iostream>
#include <vector>
#include <algorithm>

class Complex {
public:
    int r;
    int i;
    Complex(int r, int i) : r(r), i(i){}
    Complex funcAdd(Complex const &a)
    {
        Complex result(0, 0);
        result.r = r + a.r;
        result.i = i + a.i;
        return result;
    }
};

int main() 
{
    Complex aa(0, 0), bb(1, 1);
    aa.funcAdd(bb);
}

根据复数的定义,它有一个实部变量r和一个虚部变量i;其中构造函数,用来作成员变量的初始化;下面的加法函数funcAdd,用来作和参数a的加法运算,根据复数的加法运算规则,就是把两个复数的实部变量r和虚部变量i分别相加,这样,我们就可以在main函数中,通过调用函数funcAdd,来完成两个复数对象的加法运算。也就是作复数对象aa和bb的加法运算。

当然,虽然加法的功能实现了,但这比直接使用 + 繁琐了许多,为了能直接使用 + ,我们只需要重载一下 + 运算符:

class Complex {
public:
    int r;
    int i;
    Complex(int r, int i) : r(r), i(i){}
    Complex operator+(Complex const &a)
    {
        Complex result(0, 0);
        result.r = r + a.r;
        result.i = i + a.i;
        return result;
    }
};
int main() 
{
    Complex aa(0, 0), bb(1, 1);
    aa + bb;
}

这里需要用到一个关键字 operator,除此之外,代码部分跟刚才的函数funcAdd完全一致;这时,我们再作aa和bb的加法运算时,就可以通过一个熟悉、简洁的 + 完成!

让我们对比一下两种实现方式的CPU指令,如图所示:

如你所见,CPU指令完全相同!原来运算符重载的底层实现,还是类的成员函数。

再看看调用部分:

aa + bb;

其中“+”前的aa,是函数operator+的调用方,而“+”后的bb,则充当函数operator+的参数。我们也可以把它写成普通成员函数的调用形式:

aa.operator+(bb);

不仅如此,三种函数的调用方法,所对应的CPU指令也完全相同,如图所示:

这样看来operator的背后,还是熟悉的配方、熟悉的操作,所以也有人说operator是语法糖。

当然,可以重载的运算符还非常多,这里有一个列表,便于大家参考:


03

函数对象

当然,除了这些常规的运算符,还有些运算符的使用场景就相对复杂,例如:函数调用运算符。让我们重载一个函数调用运算符opeartor(),用来作实部变量r的加法运算:

class Complex {
public:
    int r;
    int i;
    Complex(int r, int i) : r(r), i(i){}
    int operator()(int a)
    {
        return r + a;
    }
    int functor(int a)
    {
        return r + a;
    }
};
int main() 
{
    Complex aa(0, 0), bb(1, 1);
    aa(10);
    aa.functor(10);
}

这里为了便于比较,我们也写一个同样功能的成员函数functor,对比一下二者的CPU指令,如图所示:

如你所见,它们的CPU指令也是完全相同的!它们的实现细节是一模一样的。再看看调用部分,如图所示:

这里我们把对象aa,当作函数名,并在后面的括号中添加参数,就可以完成这个函数对象的调用了;如你所见,这跟对象aa,直接调用函数functor对应的CPU指令完全一致!

同时,由于类Complex重载了这个函数调用运算符,我也称这个类的实例,也就是类对象aa为:函数对象,也叫:仿函数。

这种方式,看起来有些奇怪,既有对象实体的特性,也有函数的特性。那问题来了,直接调用普通的成员函数functor不行吗?为什么一定要使用这个看上去非常奇怪的函数对象呢?

至少从这个实例上看,这个函数调用运算符,并非不可或缺;实际上,在编程实践中,我们也很少这样使用它。更多的时候,我们会结合STL,也就是C++标准库。

例如,STL中非常常用的for_each函数,它能帮助我们遍历各种容器。让我们定义一个复数的数组array,然后用for_each作一下遍历,此时编译器会提示我们调用方式不匹配,如图所示:

此时,我们需要定义一个缺失的运算符operator(),其具体功能也非常简单,就是打印一下这个复数,接着就是新建一个函数对象Complex(0, 0),传递给for_each函数,如图所示:

如你所见,我们不仅可以通过编译,也可以顺利的打印出array中的所有元素。另外,我们也可以用lambda函数或者普通函数print来替换这个函数对象:

void print(const Complex& a)
{
    std::cout << "{" << a.r << ", " << a.i << "} ";
}
int main() 
{
    std::vector<Complex> array {Complex(1, 0), Complex(3, 0), Complex(2, 0)};
    std::for_each(begin(array), end(array), print);
}

是不是感觉此时的函数对象,特别像函数指针呢?至于lambda函数的实现细节,也可以参看“CPU眼里的Lambda”

但跟普通函数或lambda函数不同的是:函数对象可以拥有自己的成员变量和成员函数,所以,我们也可以在函数调用运算符中夹带一些“私货”。例如,我们可以跟成员变量、成员函数一起,做一些更加复杂的工作,这在一定程度上,可以提高程序的扩展性。


04

总结

1. 在C++中运算符也是函数,在实现上跟普通的成员函数没有区别。它可以让类对象延用“+、-、*、/、[]”等标准运算符,并重新定义这些运算符的行为,这在一定程度上,帮助我们简化了代码,扩展了功能,是一个不错的语法糖。

但过度使用、或者滥用,也会让代码变得难以理解,所以,在你重载运算符的时候,一定需要一个非常明确、合理的理由。

当然也不是所有的运算符都允许重载,例如“::” 和“.”;同时也要注意重载一些逻辑运算符(“&&” 、“||”),可能带来的副作用。


2. 函数对象,在实现上跟普通的类对象、或结构体没有区别,因此,它可以拥有自己的成员变量和成员函数,可以从事更加复杂的操作。通过重载相关的运算符,可以使函数对象跟STL搭配使用,产生出更加简洁、稳定的上层代码,这也是现代C++的重要特征之一。

但另一方面,它也让C++的语法规则变得复杂、代码量也有所增加。这对小型程序的开发帮助不大;对于小型程序,直接调用普通函数、lambda函数,或者传递函数指针,会更加直观、方便。

最后,在有STL加持下的C++语言,在解决LeetCode问题上,往往有事半功倍的效果。


05

更多知识

如果喜欢阿布这种解读方式,希望更加系统学习这些编程知识的话,也可以考虑看看由阿布亲自编写,并由多位微软大佬联袂推荐的新书《CPU眼里的C/C++》

【京东热卖】好评度:> 98%

【微信读书】推荐度:> 82%

<script type="text/javascript" src="//mp.toutiao.com/mp/agw/mass_profit/pc_product_promotions_js?item_id=7496730335439258149"></script>
原文链接:,转发请注明来源!