Modern C++ 学习笔记 —— lambda表达式篇

学习笔记 专栏收录该内容
10 篇文章 0 订阅

往期精彩:

Modern C++ 学习笔记 —— lambda表达式篇

关键字:lambda表达式、闭包、泛型lambda

函数对象

首先解释一下函数对象——任何定义了函数调用操作符的对象都是函数对象[1]。而lambda表达式也称为匿名函数对象。这也是在解释lambda之间需要理解清楚函数对象的原因。
函数对象自C++98开始就已经标准化了。从概念上来说,函数对象是一个可以被当做函数来用的对象。举例来说明:
下面的代码分别定义一个简单的加n的函数对象类和一个用于过滤是否是学生的函数对象类:

struct adder {
   adder(int n) : n_(n) {}
   int operator()(int x) const
   {
       return x + n_;
   }
private:
   int n_;
};

class students {
public:
    students() 
    {
        names.insert("abc");
        names.insert("John");
    }    
    bool operator()(std::string name) {
        return names.find(name) == names.end();
    }
private:
    set<std::string> names;
};

共同特点是定义了一个operator(),这样我们就可以像调用函数一样使用小括号的语法。

adder add2(2); // C++98
auto add_2 = adder(2); // C++ 11

这样add2 和 add_2都可以当作一个函数来用了:

cout << add2(10); // 12

lambda表达式

引入lambda表达式之后,我们在上面写到的对象不再需要,我们只需将其变为:

auto add2 = [](int x) {
	return x + 2;
}

理解可能需要注意几点:

  • Lambda表达式以一对中括号开始。
  • 与函数定义一样,有参数列表
  • 与正常函数定义一样,有一个函数体,里面有return语句。
  • lambda表达式一般不需要说明返回值,相当于auto,当然也可以使用箭头语法:[](int x) -> int {…}
  • 每个lambda表达式都有一个全局唯一的类型,要精确捕捉lambda表达式到一个变量中,只能通过auto声明的方式。
    为了帮助你的理解,你可以将lambda表达式理解为编译生成了一个匿名的struct,并且实现了operator(),反过来也说明了上述所说的,只有通过auto声明的方式来捕捉lambda表达式。
    不仅如此,你还可以定义一个更加通用的addr:
auto addr = [](int n) {
	return [n](int x) {
		return x + n;
	};
};
cout << addr(10)(1); // 11

C++11引入lambda表达式之后就不用再像C++98中一样,可以使用匿名函数得到更简洁可读的代码:

struct Student {
    string name;
    int math;
    int english;
    int sum;
};
vector<Student> class1;
sort(class1.begin(), class1.end(), [](Student& lhs, Student& rhs) {
      return lhs.sum > rhs.sum;
    });

除此之外,lambda表达式还有一个特点就是可以立即求值,比如:

[](int x) { return x * x; }(3)

可以看到汇编代码,这个表达式的结果就是3的平方9,免去了我们定义一个 constexpr 函数的必要。只要能满足 constexpr 函数的条件,一个 lambda 表达式默认就是 constexpr 函数。
在这里插入图片描述
另外一种用途是解决多重初始化路径的问题。假如有如下代码:

Obj obj;
switch (init_mode) {
case mode1:
	obj = Obj(...);
	break;
case mode2:
	obj = Obj(...);
	break;
}

这样的代码实际是调用了默认构造、带参数的构造和移动赋值函数。既有性能方面的损失。也对Obj提出了有默认构造函数的要求。对于这样的代码可以使用lambda表达式来进行改造,既可以提升性能(不需要默认函数或拷贝 / 移动),又让初始化部分显得更清晰。当然更传统的重构做法是把这样的代码分离成独立的函数。

auto obj = [init_mode]() {
	switch (init_mode) {
	case mode1:
		return Obj(...);
		break;
	case mode2:
		return Obj(...);
		break;
	}
};

lambda,闭包和闭包类

Lambda 表达式是纯右值表达式,其类型是独有的无名非联合非聚合类类型,被称为闭包类型(closure type),它声明于含有该 lambda 表达式的最小块作用域、类作用域或命名空间作用域。这可能会有些令人迷惑,举例来说明:

  • 一个lambda表达式就仅仅是一个表达式。比如说:
    std::find_if(container.begin(), container.end(),
    [](int val) { return 0 < val && val < 10; });
    粗体部分就是lambda表达式
  • 闭包是有lambda表达式创建的运行时对象。
  • 闭包类是实例化闭包的类,有编译器生成,每个lambda会导致编译器生成一个独一无二的闭包类。

通常来说,模糊lambda、闭包、闭包类之间的界限是完全可以接受的。但是在本文中,区分什么 是在 编译期间存在(lambda 和闭包类),什么是在运行时存在(闭包)通常会帮助更好的理解。

变量捕获

C++11中有两种默认捕获模式:按引用捕获模式和按值捕获模式,当出现任一默认捕获符时,都能隐式捕获当前对象(*this)。当它被隐式捕获时,始终被以引用捕获,即使默认捕获符是 = 也是如此。(C++20 起,当默认捕获符为 = 时,*this 的隐式捕获被弃用。)[2]
从工程的角度,大部分情况不推荐使用默认捕获符。更一般化的一条工程原则是:显示的代码比隐式的代码更容易维护。当然,如果保证这条原则是需要视情况权衡的,没人愿意写非常啰嗦的代码。对于lambda表达式的默认捕获方式(隐式)稍微不注意的确会引入问题,在《Effective Modern C++》也给出了相应的要点:

  • 默认按引用捕获可能导致引用悬挂。
  • 默认按值捕获容易受到野指针影响(特别是this指针),并且会误导我们,认为lambda是自给自足的。

默认按引用捕获可能导致引用悬挂

按引用捕获会导致运行期间有lambda表达式生成的闭包包含局部变量或参数的引用,这些局部变量或参数是定义在lambda所在定义域中并且是可用的。如果闭包的声明周期超过局部变量或参数的生命周期,那么在闭包引用就会悬挂,从而发生未定义行为。也就是说,C++闭包并不延长被捕获的引用的生命周期。
例如如下代码,在执行run_fun(func)时临时变量a已不存在,但是闭包中仍然保存对a的引用,在使用时就会产生未定义行为。

using Func = std::function<void()>;
Func GetFunc() {
    int a = 1;
    return [&](){cout << "& a:" << a << endl;};
}
void run_fun(Func f){
    if(f)
    {
        f();
    }
}
int main()
{
    std::function<void()> func = GetFunc();
    run_fun(func);
    return 0;
}

再如,假设我们有一个过滤容器,每个函数接受一个int并返回bool,指示传入的值是否满足过滤条件。

int GetDivisor();
using FilterContainer = vector<function<bool(int)>>;
FilterContainer filters;
void AddFilter() {
    // ...
    int divisor = GetDivisor();
    filters.emplace_back(
        [&](int n) { return n % divisor == 0; }
        );
}

lambda指向局部变量divisor,但是当AddFilter返回之后,这个变量就不存在了,也就是意味着添加到filters中的函数基本上就是挂了,几乎从它创建的那刻起,使用该过滤器就会产生未定义行为。
就算是使用显示指定divisor按引用捕获,也存在同样的问题。但是通过显示捕获更容易看出lambda的生存是依赖于divisor的生命周期。长期来看,明确地列出lambda依赖的局部变量和参数,是更好的软件工程。

默认按值捕获容易受到野指针影响

对于上述按引用捕获导致的悬挂引用问题,一种解决方法就是采用默认按值捕获。但是,默认按值捕获并非反悬挂的圣水。如果你按值捕获指针,即将指针拷贝到有lambda产生的闭包中,但你无法阻止lambda之外的代码delete指针。
假设Widght可以做到向容器中添加过滤器:

class Widght {
public:
    void AddFilter() const {
        filters.emplace_back(
            [=](int n) { return n % divisor == 0; }
            );
    }
private:
    int divisor;
};

千万不要以为这样就可以保证安全了,大错特错!假设如下调用:

void CrearWidght() {
	Widght widght;
	...
	widght.AddFilter();
	...
}

首先要明白捕获仅引用于创建lambda的作用域内可见的非静态局部变量。在Widhgt::AddFilter内部,divisor不是局部变量,而是Widght类的数据成员。如果将代码改为如下方式,代码都无法编译:

void AddFilter() const { // error
        filters.emplace_back(
            [](int n) { return n % divisor == 0; }
            );
    }

void AddFilter() const { // error
        filters.emplace_back(
            [divisor](int n) { return n % divisor == 0; }
            );
    }

或许你会纠结为何默认按值捕获缺没有问题?这其实也在前面提到了,默认捕获的时候都会隐式捕获当前对象(*this)。且当它被隐式捕获时始终以引用捕获(即便默认捕获符是=)。这样就意味着这这个lambda表达式产生的闭包的生命周期是与Widget的this指针紧密联系的。
在上述CrearWidght例子中,对象widght随着CrearWidght的结束而消亡,而filters中就包含了一个带有野指针的入口。

泛型lambda表达式

泛型lambda是C++14版本开始引入,解决了C++11版本中lambda表达式中存在的缺点。

初始化捕获

还记得在之前右值移动篇中提到的,右值和移动的引入是modern C++新特性中最重要的之一。但是根据上面的介绍你是否注意到在C++11中,lambda表达式却对于move-only对象无法捕获进闭包。同样,假如你有个对象,它的拷贝成本很高,但是移动成本很低(比如标准库中大多数容器),希望将该对象移动捕获进闭包,C++11同样没辙。
缺少移动捕获是C++11的一个公认的缺点。但在C++14中给出了一个方式来解决——初始化捕获(移动捕获只是它的技巧之一)。

// unique_ptr是move only的类型
auto u = make_unique<some_type>(
  some, parameters
);
// 将unique_ptr的所有权移入lambda
auto func = [u=std::move(u)] {
  do_something_with( u ); };

如上例所示,使用初始化捕获可以指定

  1. 由lambda生成的闭包类中的数据成员的名称。
  2. 初始化该数据成员的表达式。

上述代码中,在“=”左边为闭包类中数据成员的名称,右边为初始化表达式。左边的作用域是闭包类的作用域,右边的作用域与定义lambda的作用域相同。这就出现一个有意思的现象,“=”左右两边的作用域不同。u=std::move(u)意味着在闭包中创建一个数据成员u,并用std::move局部变量u的结果初始化该数据成员,因此u指的是闭包类的数据成员。
显而易见的是,C++14的“捕获”概念是对C++11相当地泛化。因此,初始化捕获的另一个名称是泛型lambda捕获。

由上述的讨论可以得到一个基本点,在C++14之前想要将对象移动到C++11闭包是不可能的。但是在C++11有没有办法做到类似效果呢?答案是可以的,将对象移动到bind对象,然后通过引用将对象传递给lambda,从而模拟移动捕获。这部分内容在后续会进行介绍,接下来再看一个需要引起注意的问题。

利用decltype来完美转发

在C++14泛型lambda表达式的形参列表中,auto可以作为形参的类型。不由得想到在右值移动篇提到的完美转发。
而想要达成完美转发,不仅要使用通用引用,还要是使用std::forward进行传递,而传什么类型给std::forward确是一个实现上的问题。这儿直接给出答案,正如小标题说到的,利用decltype来进行完美转发。例如:

auto f = [](auto&& param) {
	return func(std::forward<decltype(param)>(param)); };

C++14的lambda表达式还支持可变参数,所以接受任意数量参数的完美转发lambda就成了下面这样:

auto f = [](auto&&... param) {
	return func(std::forward<decltype(param)>(param)...); };

如果你对完美转发或者decltype推导还是不太了解,强烈建议再回头读一读右值移动篇和易用性改进篇中的相关内容。

参考资料

[1] cppreference.com
https://zh.cppreference.com/w/cpp/utility/functional

[2] cppreference.com
https://zh.cppreference.com/w/cpp/language/lambda

  • 1
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 代码科技 设计师:Amelia_0503 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值