C++11 lambda表达式与函数对象及绑定bind

C++ lambda表达式与函数对象

lambda表达式是C++11中引入的一项新技术,利用lambda表达式可以编写内嵌的匿名函数。从本质上来讲,lambda表达式只是一种语法糖,因为所有其能完成的工作都可以用其它稍微复杂的代码来实现。如果从广义上说,lambda表达式产生的是函数对象。在类中,可以重载函数调用运算符(),此时类的对象可以将具有类似函数的行为,我们称这些对象为函数对象(Function Object)或者仿函数(Functor)。

lambda表达式

// 定义简单的lambda表达式
auto basicLambda = [] { cout << "Hello, world!" << endl; };
// 调用
basicLambda(); // 输出:Hello, world!

如果需要参数,那么就要像函数那样,放在圆括号里面,如果有返回值,返回类型要放在->后面,即拖尾返回类型,当然你也可以忽略返回类型,lambda会帮你自动推断出返回类型:

// 指明返回类型
auto add = [](int a, int b) -> int { return a + b; };
// 自动推断返回类型
auto multiply = [](int a, int b) { return a * b; };

int sum = add(2, 5); // 输出:7
int product = multiply(2, 5); // 输出:10

方括号的意义何在?其实这是lambda表达式一个很要的功能,就是闭包。这里我们先讲一下lambda表达式的大致原理:每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了()运算符),我们称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,其实一个右值。闭包的一个强大之处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们又将其称为lambda捕捉块。

捕捉是什么?对于复制传值捕捉方式,类中会相应添加对应类型的非静态数据成员。在运行时,会用复制的值初始化这些成员变量,从而生成闭包。

class ClosureType
{
public:
    // ...
    ReturnType operator(params) const { body };
};

这意味着lambda表达式无法修改通过复制形式捕捉的变量,因为函数调用运算符的重载方法是const属性的。有时候,你想改动传值方式捕获的值,那么就要使用mutable(易变的

auto add_x = [x](int a) mutable { x *= 2; return a + x; }; // 复制捕捉x

这是为什么呢?因为你一旦将lambda表达式标记为mutable,那么实现的了函数调用运算符是非const属性的:

class ClosureType
{
public:
    // ...
    ReturnType operator(params) { body };
};

对于引用捕获方式,无论是否标记mutable,都可以在lambda表达式中修改捕获的值。至于闭包类中是否有对应成员,C++标准中给出的答案是:不清楚的,看来与具体实现有关。既然说到了深处,还有一点要注意:lambda表达式是不能被赋值的。

但是没有禁用复制构造函数,所以你仍然可以用一个lambda表达式去初始化另外一个lambda表达式而产生副本。并且lambda表达式也可以赋值给相对应的函数指针,这也使得你完全可以把lambda表达式看成对应函数类型的指针。

  • []:默认不捕获任何变量;
  • [=]:默认以值捕获所有变量;
  • [&]:默认以引用捕获所有变量;
  • [x]:仅以值捕获x,其它变量不捕获;
  • [&x]:仅以引用捕获x,其它变量不捕获;
  • [=, &x]:默认以值捕获所有变量,但是x是例外,通过引用捕获;
  • [&, x]:默认以引用捕获所有变量,但是x是例外,通过值捕获;
  • [this]:通过引用捕获当前对象(其实是复制指针);
  • [*this]:通过传值方式捕获当前对象;

在上面的捕获方式中,注意最好不要使用[=][&]默认捕获所有变量。首先说默认引用捕获所有变量,你有很大可能会出现悬挂引用(Dangling references),因为引用捕获不会延长引用的变量的声明周期:

std::function<int(int)> add_x(int x)
{
    return [&](int a) { return x + a; };
}

因为参数x仅是一个临时变量,函数调用后就被销毁,但是返回的lambda表达式却引用了该变量,但调用这个表达式时,引用的是一个垃圾值,所以会产生没有意义的结果。

采用默认值捕获所有变量仍然有风险:

class Filter
{
public:
    Filter(int divisorVal):divisor{divisorVal}
    {}
    std::function<bool(int)> getFilter()
    {
        return [=](int value) {return value % divisor == 0; };
    }
    
private:
    int divisor;
};

你可能认为这个lambda表达式也捕捉了divisor的一份副本,但是实际上大错特错。问题出现在哪里呢?因为数据成员divisorlambda表达式并不可见,你可以用下面的代码验证:

// 类的方法,下面无法编译,因为divisor并不在lambda捕捉的范围
std::function<bool(int)> getFilter()
{
    return [divisor](int value) {return value % divisor == 0; };
}

那么原来的代码为什么能够捕捉到呢?原来实际上捕捉的是this指针的副本,所以原来的代码等价于:

std::function<bool(int)> getFilter()
{
    return [this](int value) {return value % this->divisor == 0; };
}

尽管还是以值方式捕获,但是捕获的是指针,其实相当于以引用的方式捕获了当前类对象,所以lambda表达式的闭包与一个类对象绑定在一起了,这也很危险,因为你仍然有可能在类对象析构后使用这个lambda表达式,那么类似“悬挂引用”的问题也会产生。所以,采用默认值捕捉所有变量仍然是不安全的,主要是由于指针变量的复制,实际上还是按引用传值。

lambda表达式可以赋值给对应类型的函数指针。但是使用函数指针貌似并不是那么方便。所以STL定义在<functional>头文件提供了一个多态的函数对象封装std::function,其类似于函数指针。它可以绑定任何类函数对象:

std::function<bool(int, int)> wrapper = [](int x, int y) { return x < y; };

lambda表达式还用于对象的排序准则:

class Person
{
public:
    Person(const std::string& first, const std::string& last):
        firstName{first}, lastName{last}
    {}

    Person() = default;
    std::string first() const { return firstName; }
    std::string last() const { return lastName; }
private:
    std::string firstName;
    std::string lastName;
};

int main()
{
    std::vector<Person> vp;
    // 按照姓名排序
    std::sort(vp.begin(), vp.end(), [](const Person& p1, const Person& p2)
    { return p1.last() < p2.last() || (p1.last() == p2.last() && p1.first() < p2.first()); });

    return 0;
}

总之,对于大部分STL算法,可以非常灵活地搭配lambda表达式来实现想要的效果。

lambda表达式的完整语法:

// 完整语法
[capture-list](params)mutable(optional) constexpr(optional)(c++17)exception attribute->ret{body}

// 可选的简化语法
[ capture-list ] ( params ) -> ret { body }
[ capture-list ] ( params ) { body }
[ capture-list ] { body }

有一定的限制,比如你使用了拖尾返回类型,那么就不能省略参数列表,尽管其可能是空的。

  • capture-list:捕捉列表,记住它不能省略;
  • params:参数列表,可以省略(但是后面必须紧跟函数体);
  • mutable:可选,将lambda表达式标记为mutable后,函数体就可以修改传值方式捕获的变量;
  • constexpr:可选,C++17,可以指定lambda表达式是一个常量函数;
  • exception:可选,指定lambda表达式可以抛出的异常;
  • attribute:可选,指定lambda表达式的特性;
  • ret:可选,返回值类型;

lambda新特性

C++14中,lambda又得到了增强,一是泛型lambda表达式,一是lambda可以捕捉表达式。

lambda捕捉表达式

有时候,我们希望捕捉不在其作用域范围内的变量,希望捕捉右值。所以C++14中引入了表达式捕捉,其允许用任何类型的表达式初始化捕捉的变量。

// 利用表达式捕获,可以更灵活地处理作用域内的变量
int x = 4;
auto y = [&r = x, x = x + 1] { r += 2; return x * x; }();
// 此时 x 更新为6,y 为25

// 直接用字面值初始化变量
auto z = [str = "string"]{ return str; }();
// 此时z是const char* 类型,存储字符串 string

可以用std::move初始化变量。这对不能复制只能移动的对象很重要,比如std::unique_ptr,因为其不支持复制操作。

泛型lambda表达式

其参数可以使用自动推断类型的功能,这就如同函数模板一样,参数要使用类型自动推断功能,只需要将其类型指定为auto,类型推断规则与函数模板一样。

auto add = [](auto x, auto y) { return x + y; };
int x = add(2, 3); // 5
double y = add(2.5, 3.5); // 6.0

函数对象

函数对象是一个广泛的概念,因为所有具有函数行为的对象都可以称为函数对象。所谓的函数行为是指可以使用()调用并传递参数。这样来说,lambda表达式也是一个函数对象。

这样,我们可以使用这个类的对象,并把它当做函数来使用:

X f;
f(arg1, arg2); // 等价于 f.operator()(arg1, arg2);
// T需要支持输出流运算符
template <typename T>
class Print
{
public:
    void operator()(T elem) const
    {
        std::cout << elem << ' ' ;
    }
};

int main()
{
    std::vector<int> v(10);
    int init = 0;
    std::generate(v.begin(), v.end(), [&init] { return init++; });
    
    // 使用for_each输出各个元素(送入一个Print实例)
    std::for_each(v.begin(), v.end(), Print<int>{});
    // 输出:0, 1, 2, 3, 4, 5, 6, 7, 8, 9
    return 0;
}

可以看到Print<int>的实例可以传入std::for_each,其表现可以像函数一样,因此我们称这个实例为函数对象。

// for_each的类似实现
namespace std
{
    template <typename Iterator, typename Operation>
    Operation for_each(Iterator act, Iterator end, Operation op)
    {
        while (act != end)
        {
            op(*act);
            ++act;
        }
        return op;
    }
}

泛型提供了高级抽象,不论是lambda表达式、函数对象,还是函数指针,都可以传入for_each算法中。

本质上,函数对象是类对象,这也使得函数对象相比普通函数有自己的独特优势:

  • 函数对象带有状态:因为函数对象除了提供函数调用符方法,还可以拥有其他方法和数据成员。而且函数对象是可以在运行时创建。
  • 每个函数对象有自己的类型:对于普通函数来说,只要签名一致,其类型就是相同的。函数对象的类型是其类的类型。这意味着函数对象可以用于模板参数,这对泛型编程有很大提升。
  • 函数对象一般快于普通函数:因为函数对象一般用于模板参数,模板一般会在编译时会做一些优化。

这里我们看一个可以拥有状态的函数对象,其用于生成连续序列:

class IntSequence
{
public:
    IntSequence(int initVal) : value{ initVal } {}
    
    int operator()() { return ++value; }
private:
    int value;
};

int main()
{
    vector<int> v(10);
    std::generate(v.begin(), v.end(), IntSequence{ 0 });
}

头文件<functional>中预定义了一些函数对象,如算术函数对象,比较函数对象,逻辑运算函数对象及按位函数对象,我们可以在需要时使用它们。比如less<>STL排序算法中的默认比较函数对象,所以默认的排序结果是升序,但是如果你想降序排列,你可以使用greater<>函数对象

std::sort(v.begin(), v.end(), std::greater<int>{}); // output: 9, 5, 4, 3, 2

函数适配器

从设计模式来说,函数适配器是一种特殊的函数对象,是将函数对象与其它函数对象,或者特定的值,或者特定的函数相互组合的产物。头文件<functional>定义了几种函数适配器:

  • std::bind(op, args...):将函数对象op的参数绑定到特定的值args
  • std::mem_fn(op):将类的成员函数转化为一个函数对象
  • std::not1(op), std::not2(op):一元取反器和二元取反器

绑定器(binder)(functional头文件)

3. 使用std::bind要注意的地方
(1)bind预先绑定的参数需要传具体的变量或值进去,对于预先绑定的参数,是pass-by-value的。除非该参数被std::ref或者std::cref包装,才pass-by-reference。
(2)对于不事先绑定的参数,需要传std::placeholders进去,从_1开始,依次递增。placeholder是pass-by-reference的;
(3)bind的返回值是可调用实体,可以直接赋给std::function对象; 
(4)对于绑定的指针、引用类型的参数,使用者需要保证在可调用实体调用之前,这些参数是可用的;
(5)类的this可以通过对象或者指针来绑定

绑定器std::bind是最常用的函数适配器,std::placeholers::_1, std::placeholers::_2等标记。

auto minus10 = std::bind(std::minus<int>{}, std::placeholders::_1, 10);
std::cout << minus10(20) << std::endl; // 输出10

注意绑定器默认是以传值绑定参数,如果需要引用绑定,那么要使用std::refstd::cref函数,分别代表普通引用和const引用:

void f(int& n1, int& n2, const int& n3)
{
    std::cout << "In function: " << n1 << ' ' << n2 << ' ' << n3 << '\n';
    ++n1;
    ++n2;
    // ++n3; //无法编译
}

int main()
{
    int n1 = 1, n2 = 2, n3 = 3;
    auto boundf = std::bind(f, n1, std::ref(n2), std::cref(n3));
    n1 = 10;
    n2 = 11;
    n3 = 12;
    std::cout << "Before function: " << n1 << ' ' << n2 << ' ' << n3 << '\n';
    boundf();
    std::cout << "After function: " << n1 << ' ' << n2 << ' ' << n3 << '\n';

//    Before function: 10 11 12
//    In function: 1 11 12
//    After function: 10 12 12
    return 0;
}

绑定器可以用于调用类中的成员函数:

class Person
{
public:
    Person(const std::string& n) : name{ n } {}
    void print() const { std::cout << name << std::endl; }
    void print2(const std::string& prefix) { std::cout << prefix << name << std::endl; }
private:
    std::string name;
};

int main()
{
    std::vector<Person> p{ Person{"Tick"}, Person{"Trick"} };
    // 调用成员函数print
    std::for_each(p.begin(), p.end(), std::bind(&Person::print, std::placeholders::_1));
    // 输出:Tick Trick
    std::for_each(p.begin(), p.end(), std::bind(&Person::print2, std::placeholders::_1,"Person: "));
    // 输出:Person: Tick Person: Trick

    return 0;
}

而且绑定器对虚函数也有效,你可以自己做一下测试。

前面说过,C++11lambda表达式无法实现移动捕捉变量,但是使用绑定器可以实现类似的功能:

vector<int> data{ 1, 2, 3, 4 };
auto func = std::bind([](const vector<int>& data) { cout << data.size() << endl; },
    std::move(data));
func(); // 4
cout << data.size() << endl; // 0

可以看到绑定器可以实现移动语义,这是因为对于左值参数,绑定对象是复制构造的,但是对右值参数,绑定对象是移动构造的。

std::mem_fn()适配器

当想调用成员函数时,你还可以使用std::mem_fn函数,此时你可以省略掉用于调用对象的占位符:

vector<Person> p{ Person{ "Tick" }, Person{ "Trick" } };
std::for_each(p.begin(), p.end(), std::mem_fn(&Person::print));
// 输出: Tick Trick
Person n{ "Bob" };
std::mem_fn(&Person::print2)(n, "Person: ");
// 输出:Person: Bob

std;:mem_fn还可以调用成员变量:

class Foo
{
public:
    int data = 7;
    void display_greeting() { std::cout << "Hello, world.\n"; }
    void display_number(int i) { std::cout << "number: " << i << '\n'; }
};

int main()
{
    Foo f;
    // 调用成员函数
    std::mem_fn(&Foo::display_greeting)(f); // Hello, world.
    std::mem_fn(&Foo::display_number)(f, 20); // number: 20
    // 调用数据成员
    std::cout << std::mem_fn(&Foo::data)(f) << std::endl; // 7

    return 0;
}

取反器std::not1std::not2很简单,就是取函数对象的反结果,不过在C++17两者被弃用了,所以就不讲了。

需要注意的一点是,boost::bind里的参数个数一定要与被bind的函数相同,否则这个函数对象就无法生成了,编译器会抱怨一堆信息,如果你仔细看的话,它是在告诉你,没有这样的函数,你实际的函数是....,这是使用c++很杯具的一点,当遇到模板时,一旦报错,那些信息直接可以令人崩溃。

在asio中,boost::bind被大量使用

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值