类型与对象(三)

1.5函数对象

  一个对象只要能够像函数一样进行调用,那么这个对象就是函数对象,他与普通函数相比更加通用,同时函数对象还可以拥有状态。标准库<functional>里提供了一些常用的函数对象,并且算法部分<algorithm>大多要求以更加通用的函数对象形式提供。而不仅仅局限于普通函数(函数指针)。

  函数对象有个好处就是可以作为参数传递给其它函数,这种函数被称为高阶函数。在C++中不允许直接传递函数,虽然可以通过函数指针的形式来传递,但与函数对象相比会产生间接调用的开销,不利于编译器优化。

  典型使用函数对象的场景是编写回掉函数,它可以轻松的携带一些状态。

>>1.5.1 定义函数对象

  C++支持操作符重载,而类能够存储私有状态,这两个特性组合起来,便能定义一个函数对象,在C++98时代通常采用这种做法,据此我们可以定义一些简单的函数对象。

struct plus{
    int operator()(int x,int y){
        return x+y; }
};

cout<< plus{}(2,3);  //5

  首先定义一个名为plus的类,其成员函数只有一个operator()操作符,该函数实现了对两个int类型的数值进行相加的操作,而plus{}实例化了一个临时的函数对象,因此可以使用成员函数调用动作plus{}(2,3),从而得到5.

  熟悉设计模式的读者会发现这其实是一个命令模式,将行为包裹于函数对象中并传递给用户供后续操作。

 

  更进一步可以将该函数对象泛化,使其支持除int以外的其它数值类型,我们可以将该类重构为模板类。

template<typename T> struct plus{
    T operator ()(T x,T y){return x+y;}
};

cout<< plus<double>{}(2.2,3.3); //5.5

  除了添加模板参数上的差异,实例化函数对象时需要显式指明类的模板参数plus<double>{}.如果需要为该函数对象添加状态,可以通过成员变量来存储状态,考虑实现为任意数+N的函数对象。

struct plusN {
    plusN(int N) : N(N){}
    int operator()(int x) { return x + N; }
private:
    int N;
};

auto plus5 = plusN(5);
cout << plus5(2) << endl;  // 7
cout << plus5(3) << endl;  //8

  上述代码添加了一个成员变量N用于存储加数,而这时的成员函数只需要接受一个参数,便能够和已有的加数进行相加,在实例化对象的时候通过构造函数传递加数5,后续调用得到加5的结果。

  观察从plus到plusN的过程,其实函数的行为都一定,唯一不同的是后者的状态确定,更确切的是函数的两个参数中的一个参数确定了,也就是说其中一个参数被绑定了,程序员每次使用时无需传递被绑定的参数。

  为了避免每次都要为参数绑定动作实现一个对应的类与构造函数,标准库提供了高阶函数bind来简化这个过程,

using namespace std::placeholders;
auto plus5 = std::bind(plus<int>{},5,_1);

  bind接收一个函数对象,这里需要对plus<int>{}函数对象进行参数绑定,后续参数是待绑定的参数5,还有未提供的参数_1,这是一个来自于std::placeholders名称空间的占位符。表示将来调用时才对这个参数进行绑定,bind最终生成一个只接受一个参数的函数对象plus5.当使用plus5(2)进行调用时,第一个参数2将被_1绑定,从而得到最终的结果7.

  函数对象和普通类对象一样,可以赋给一个变量进行传递,配合bind等高阶函数,能够将一系列函数对象灵活组合成更高级的功能,考虑如下将所有大于4的数打印出来的代码。

std::vector nums = { 5,3,2,5,6,1,7,4 };
    std::copy_if(nums.begin(), nums.end(),
        std::ostream_iterator<int>(std::cout, ","),
        std::bind(std::greater<int>{}, std::placeholders::_1, 4));

  copy_if接受一个输入迭代器区间和一个输出迭代器,接受一个单参的谓词函数,对输入迭代器区间的每个元素进行谓词调用,若为真则把这个元素复制到输出迭代器上。greater<int>函数对象接受两个参数,判断这两个参数是否满足大于关系,通过bind将第二个参数绑定为4得到一个只接受一个参数的谓词函数,从而实现了对每个元素进行是否大于4的判断。

>> 1.5.2 lambda表达式

  C++11起提供了lambda特性,简化程序员定义函数对象,而无需定义对应的函数对象类,如下是lambda的语法:

  [捕获](形参列表)->后置返回类型{函数体}

  其中后置返回类型可以省略,编译器会自动推导出lambda的返回类型,形参列表也是可选的,因此最简单的lambda定义为[ ]{ }。考虑如下代码:

constexpr auto add = [](auto a,auto b){return a+b;};

  我们声明了一个constexpr的编译时常量add来存储这个lambda对象,从C++17起lambda默认为constexpr,因此能够被一个constexpr常量所储存,而在这之前只能将常量声明为const。值得注意的是,从C++14起lambda的形参支持auto,表明类型由编译器根据实际调用传递的实参进行推断,即泛型lambda。

  add是一个值,那么它所属的类型到底是什么呢?它其实是编译器生成的一个匿名类,如下代码成为一种可能:

struct _lambda_7_26{
    template<tyepanem t0,tyepanem t1>
    constexpr auto operator()(t0 a,t1 b)const{return a+b;}
};

constexpr const _lambda_7_26 add  = _lambda_7_26{};

  从上可看出lambda背后其实是一个匿名类与一个成员操作符operator()。如果lambda包含在捕获列表内,那么捕获将在对应的匿名类中生成成员变量与构造函数来存储捕获;对于无捕获的lambda而言,其生成的匿名类中拥有一个非虚的函数指针类型转换操作符,能够将lambda转换为函数指针。这个不难理解,因为无状态的lambda表达式可以赋给无状态的函数指针,据此如下代码合法:

using IntAddFunc = int(*)(int,int);
constexpr IntAddFunc iadd = [](auto a,auto b){return a+b;};

  这段代码将无捕获的泛型lambda赋给一个函数指针,编译器会把匿名类中的模板函数实例化成int版本,然后通过类型转换操作符转换成具体的函数指针。

  泛型lambda与模板函数有什么区别呢?考虑同样实现的模板函数:

template<typename T> T add(T a, T b){return a+b;}

  区别在于add模板函数隐含着要求两个形参类型一致,而泛型lambda版本由于两个形参声明为auto,表明两个类型之间没有严格的一致性。在C++20中泛型lambda也支持以模板参数形式提供,这样能保证两个形参类型一致。

constexpr auto add = []<typename T>(T a,T b){return a+b;};

  另一个重要的区别在于模板函数只有实例化之后才能传递,而泛型lambda是个对象,因此可以按值传递,在调用时根据实际传参进行实例化模板函数operator(),从而延迟了实例化的时机,大大提高了灵活性。标准库的一些算法通常要求对函数对象进行组合,此时泛型lambda将能通过编译,而模板函数不行,

  在前一节中提到通过bind操作为函数兑现绑定部分参数,lambda作为函数对象自然也能通过这种方式来做,但在C++扩展了lambda之后,仅通过lambda也能实现同样的功能。

// auto plus5 = std::bind(plus<int>{},5,_1);
constexpr auto add5 = [add](auto x){return add(x,5);};
cout<<add5(2); //7

  add5的实现捕获了一个函数对象add,同时接受一个参数x,返回add(x,5)的结果。捕获add对应于bind的第一个参数,形参x对应于占位符_1。

  对于bind而言虽然它提供了一种比较简洁的表现形式来对函数对象的参数进行部分绑定、重排,但也有对应的代价。由于它是库特性,编译器生成绑定版本的函数对象需要进行复杂的编译时计算来生成代码,运行结果也不是constexpr,这不利于编译器的优化,而lambda是语言的核心特性,这意味着编译器能够很简单的优化它们。

  另外使用bind与其他高阶函数组合时,需要思考bind生成的函数对象与高阶函数要求的函数签名是否一致。例如copy_if高阶函数需要接受一个单参的谓词,从而bind无法直观看出来。考虑前一节的将所有大于4的数打印出来的代码,lambda可读性更加。

copy_if(/*......*/,bind(greater<int>{}(_1,4));
copy_if(/*......*/,[](int x){return x>4;});

  lambda的捕获既可以通过值语义也可以通过引用语义传递,而bind的参数绑定为值语义,若需要传引用,需通过std::ref等函数将引用包裹为值语义进行再传递,考虑如下函数对象assign,将第二个参数赋给第一个参数。

constexpr auto assign = [](int& x,int y){x=y;};
int x = 0;
auto assignX = [assign,&x](int y){assign(x,y);};
assignX(5);  // x==5

  assignX将按引用捕获x,因此调用将正确的赋给x。而通过bind的方式若不注意则会产生意外的结果。

int x = 0;
auto assignX = std::bind(assign,x,_1);
assignX(5);  // x==0,不符合预期

  若通过std::ref将引用以值语义形式传递给bind,从而得出正确结果:bind(assign,ref(x),_1)。

>> 1.5.3函数适配器

  前文介绍的函数对象背后都有对应的类型:在C++11前需要定义一个类并实例化来表达函数对象;在C++11后使用lambda来表达函数对象,编译器自动生成一个匿名类型并实例化对象,C++11起标准库引入了std::function函数模板作为适配器,它能够存储任何可调用的对象:

  * 普通函数(函数指针)

  * lambda表达式

  * bind表达式

  * 函数适配器

  * 其它函数对象,以及指向成员函数的指针。

  这意味着不论函数对象的类型如何,都可以被对应原型的std::function所存储,即拥有统一类型,可以在运行时绑定,进而实现值语义运行时多态,考虑如下二元函数的工厂代码。

enum class Op
{
    ADD,MUL
};

std::function<int(int, int)> OperationFactory(Op op) {
    switch (op)
    {
    case Op::ADD:
        return [](int a, int b) {return a + b; };
    case Op::MUL:
        return std::multiplies<int>{};
    }
}

  由于这个函数需要在运行根据传递Op类型的值来创建不同函数类型的对象,因此需要一个统一的返回类型,即函数适配器std::function<int(int,int)>,它能够动态绑定两个入参类型为int与返回类型也为int的函数对象。

-----------本节完-------------

 

posted @ 2023-06-30 17:49  饼干`  阅读(6)  评论(0编辑  收藏  举报