C++中重载operator()的意义是什么?

294次阅读

C++中,操作符operator(),也就是小括号,与其他操作符一样,都可以被重载,并且operator()可以接收任意个参数。由于它的调用语法与函数调用完全一致,也与operator[]极为相似,因此operator()有两种常见的重载用法,Callable索引

Callable

Callable,也就是可调用对象,包括了函数指针、重载operator()的对象以及可隐式转化为前两者的对象。重载operator()的对象,也称Functor,中文翻译有时候叫做函子。在谈论为什么使用Functor之前,我们先来看看函子是什么,以及怎么用。

比如,我们这里有一个函数,叫做for_each,是std::for_each的简化版,它对于C数组中的每一个元素都进行一个处理。在函数中,Func类型定义了一个Callable的参数。

template <typename T, typename Func>
void for_each(T* begin, T* end, const Func& f)
{
    while (begin != end) f(*begin++);
}

现在我们定义一个函数print,它的功能是打印一个变量。我们把它作为函数for_each的第三个参数,以打印一个int数组。

template <typename T>
void print(const T& x)
{
    std::cout << x << " ";
}

int main()
{
    int arr[5] = { 1, 2, 3, 4, 5 };
    // 这里的print<int>自动decay为decltype(&print<int>)
    for_each(arr, arr + 5, print<int>);
    return 0;
}

我们可以再写一个Functor的版本。

template <typename T>
struct Print
{
    void operator()(const T& x) const
    {
        std::cout << x << " ";
    }
};

for_each(arr, arr + 5, Print<int>{});

当然,你也可以定义一个lambda,这个lambda函数本质上也是一个匿名Functor,C++标准中称之为闭包类型Closure。它与Functor没有什么区别,本质上是一个语法糖。

for_each(arr, arr + 5, [](auto&& x) { std::cout << x << " "; });

当然,如果你想要通过写一份难以理解但能用的代码来阻止其他人维护(可能为了报复不让你午休的CEO),你也可以定义一个能够转化为函数指针的类型,这就是Callable的第三种形态。应该注意到,这里本质上还是一个函数指针。

struct PrintForYuTangCEO
{
    typedef void(*Func_Type)(const int&);
    operator Func_Type() const { return &print<int>; }
};

for_each(arr, arr + 5, PrintForYuTangCEO{});

现在,你可能突然有了一个奇怪的需求,想要对打印的对象进行计数,你当然可以在函数print中加入一个static变量,但是这破坏了面向对象,也不利于使用(报复CEO除外)。

static int count = 0;

template <typename T>
void print(const T& x)
{
    std::cout << count << " : " << x << std::endl;
    count++;
}

count = 0;
for_each(arr, arr + 5, print<int>);

如果使用Functor来定义,则会方便得多。

template <typename T>
struct Print
{
    mutable int count = 0;

    void operator()(const T& x) const
    {
        std::cout << count << " : " << x << std::endl;
        count++;
    }
};

for_each(arr, arr + 5, Print<int>{});

当然,你也可以不重载operator(),而是采用一个普通的成员函数来print。但是这会让你的代码写的比较exciting!当然,这种用法在某些框架中随处可见,主要用于传入回调函数。

template <typename T, typename Func, typename... Args>
void for_each_Ex(T* begin, T* end, const Func& f, const Args&... args)
{
    while (begin != end) std::invoke(f, args..., *begin++);
}

template <typename T>
struct Print_Exciting
{
    mutable int count = 0;

    void print(const T& x) const
    {
        std::cout << count << " : " << x << std::endl;
        count++;
    }
};

for_each_Ex(arr, arr + 5, &Print_Exciting<int>::print, Print_Exciting<int>{});

这里有人会问,std::invoke是C++17才出现的函数,哪怕是变参模板也是C++11的语法,那么此前将类中普通成员函数作为参数该怎么写呢?这里就要涉及一个more exciting的写法了,成员函数指针及其调用。

// 这里C是Func函数所属的类别,这里的函数f是类C中除类限定外类型为Func的成员函数.
// 具体于本例,T = int, Func = void(const int&) const, C = Print_Exciting<int>
template <typename T, typename Func, typename C>
void for_each_More_Ex(T* begin, T* end, Func C::* f, const C& obj)
{
    while (begin != end) (obj.*(f))(*begin++);
}

// 应该注意到,for_each函数的调用方式没有任何变化
for_each_More_Ex(arr, arr + 5, &Print_Exciting<int>::print, Print_Exciting<int>{});

值得一提的是,由于我们只能够在定义形参时指定函数类型,而非带有函数名的签名,因此这两种普通成员函数指针做参数的写法,并不能够提供充足的信息以供编译器优化,因为你完全可以在同一个类中提供两个甚至更多具有相同类型,但名字不同函数,这使得函数的地址是运行时的,而非编译时的。这对于普通的函数指针同样成立。

而operator()在给定签名后,是可以在编译期唯一确定的,因此编译器可以对operator()的调用做出优化,例如将其内联。

当然,你也可以将函数指针作为模板的非类型模板参数而不是函数的形参,这使得函数指针成为了编译时常量,因此可允许编译器优化。但是由于非类型模板参数中的类型名只能引用模板形参列表中前面出现过的类型名,因此会你的函数写法极其exciting,需要手动指定模板实参以实例化(专用化),使用时极不方便,在语法上是一个灾难。例子如下。

template <typename Func, typename C, Func C::* f, typename T>
void for_each_More_More_Ex(T* begin, T* end, const C& obj)
{
    while (begin != end) (obj.*(f))(*begin++);
}

// 务必注意第一个函数类型中不可出现(*),如果出现会导致模板具体化时类型错误,
// 即f会成为一个诡异的类型,如果第一个实参为void (*)(const int&),则f为
// void (* Print_Exciting<int>::* )(const int&) const,无法实例化该模板
for_each_More_More_Ex<void(const int&) const, Print_Exciting<int>, &Print_Exciting<int>::print>
    (arr, arr + 5, Print_Exciting<int>{});

此外,Functor还有一个比较有趣的用法,是Functor重载。比如,你可以用同一个Functor来同时实现Hash和Equal,这会让你的类变得好用一点(也许吧)。

struct A
{
    int x;
};

struct A_Hash
{
    std::size_t operator() (const A& a) const
    { return std::hash<int>{}(a.x); }
};

struct A_Equal
{
    std::size_t operator() (const A& lhs, const A& rhs) const 
    { return lhs.x == rhs.x; }
};

template <typename... Ops>
struct AllOps : Ops...
{
    using Ops::operator()...;
};

using A_Hasher = AllOps<A_Hash, A_Equal>;

std::unordered_set<A, A_Hasher, A_Hasher> a_hashset;

举了这些例子之后,你现在可能已经理解了一些Functor的好处。

第一,Functor是C++风格的Callable,C++标准对Functor的支持要更为完善,提供了丰富的接口以及编译器优化,比如Functor会被编译器优化为内联函数。

第二,Functor可以方便的进行有状态操作。

第三,Functor是面向对象的,让你的代码更为抽象。

第四,你可以同时将多个函数聚集在同一个Functor上,实现重载。

索引

如果你用过Python,你一定会嫉妒numpy中的index,你可以用方括号的方式来索引多维数组。比如

import numpy as np
x = np.random.randn(10, 10)
x[5, 5] # correct

不幸的是,C++的operator[]只允许你声明一个参数,这就导致这个运算符对于多维数组而言无比鸡肋,然而这种需求对于矩阵等类型而言又是客观存在的。所以,你可能会见到一些库使用operator()实现这个操作。这里,我们就举一个矩阵的例子。

template <typename T, std::size_t ROWS, std::size_t COLS>
struct Matrix
{
    T data[ROWS][COLS];

    T operator() (int x, int y) const
    {
        return data[x][y];
    }

    T& operator() (int x, int y)
    {
        return data[x][y];
    }

    template <typename... Args>
    auto get(Args&&... args) const
    { return this->operator()(std::forward<Args>(args)...); };
};

Matrix<int, 10, 20> m;

m(5, 5) = 10;
std::cout << m.get(5, 5) << std::endl;

总结

C++中operator ()的重载主要用于实现Functor和索引,前者是对函数指针的面向对象化,具有诸多优点,后者则是为了弥补operator[]不能使用两个参数。

yiywain
版权声明:本文于2021-07-15转载自C++中重载operator()的意义是什么?,共计4608字。
转载提示:此文章非本站原创文章,若需转载请联系原作者获得转载授权。