《C++ Primer Plus 6th.ed》读书笔记之五:浅谈Lambda表达式和函数对象

Preface

所谓函数对象,是指重载了operator()的类型,在行为上这种类型可以当做函数调用,如下例所示:

class Base
{
public:
	// 重载的括号运算符
	void operator()(void) { std::cout << "This is a Base class!" << std::endl; }
};

int main()
{
	// 调用括号运算符
	Base x;
	x();
}

但是这种调用必须先实例化一个类,而Lambda表达式则免去了定义和实例化的繁琐:

int main()
{
	// 定义一个Lambda
	auto f = []() { std::cout << "I'm a Lambda!" << std::endl; };
	// 调用它
	f();
}

注意上面这段代码,首先f只是Lambda表达式的别名,它并不是表达式本体;观察定义式赋值的右边,[]取代了函数名,使之成为了一个不具名函数,()是形参列表,这里使用了一个空的形参列表,最后{}中是函数体,决定了该不具名函数的具体实现和功能——这就是一个完整的Lambda表达式,或者说叫做匿名函数
在定义Lambda表达式的别名时,一定注意使用auto关键字做自动类型推导,因为在定义表达式的作用域中,每个表达式的类型都是独一无二的,也无法使用基本类型进行描述
当然,从本质上说,Lambda表达式就是一个不具名的函数对象,因此Lambda表达式可以作为函数参数传入——传入一个函数作为参数,在C语言中也有这样的语法,虽然只能传递函数指针
下面笔者将从C语言的函数指针开始,详细描述一下函数指针、函数对象和Lambda的特性和使用

从C语言的函数指针说开去……

对大部分人来说,C语言的指针简直就是一个噩梦,即便是熟悉指针语法的开发者,偶尔也会因为野指针的问题而规避指针的使用——在这样的大环境下,函数指针就无可避免的成为了C语言中一个较为小众的语法……
我们已经知道在C语言中,各种变量(包括由register关键字修饰的)都有自己的地址——譬如,一个指针指向某一个地址,它自己也被存储在一个地址中——函数在编译后产生的二进制指令在执行时必然要放在内存中某处,那么是否可以用一个指针指向此处?答案很明确,可以
可以说,在C语言中,任何可调用对象,包括变量和函数,都拥有自己的地址,即便变量被register关键字修饰——该关键字只是建议编译器将变量放入寄存器中,实际上该变量仍然有可能被存放在内存中,只是无法取地址而已——只要有地址,即可表示为指针……下面是一个简单的例子:

int func(int x, int y) { return x+y; }	// 函数定义
int (*f)(int, int) = func;				// 函数指针定义
int z = f(1, 2);						// 调用函数指针,z=3

注意在声明函数指针时,必须使用()*和指针名结合在一起,利用()的优先级声明这个变量是指向某处的指针而非返回指针的函数;其次函数指针的类型就是要指向的函数的返回类型;除此之外,函数指针必须拥有和要指向的函数一致的形参列表
函数指针可以在声明时完成初始化,如上例;也可以先声明,在合适的时机再完成初始化,初始化的时候在赋值运算符两侧均可省略返回类型和形参列表
到这里可能有读者要问了,这么搞岂不是多此一举?当然不是,考虑这么一个场景:设计一个对数组进行排序的函数,要求可以实现升序/降序两种排序方式
对这个需求进行分析,排序无非是由比较和交换两种操作组合成的——显然,升序还是降序这个问题肯定是由比较操作说了算——但是升序和降序是互斥的,C语言没有函数重载特性,不可能说一个函数名实现两种比较方式,这个时候,某位开发者灵机一动,为什么不让用户决定排序的方式呢?允许用户传入一个比较函数作为参数,似乎是一种更省力的方式……
那么基于这种想法,可以给出这个排序函数一种可能的声明(只针对整形):

void qsort(
			int* base, 
			size_t _sizeofElements, 
			bool (*cmp)(const void* a, const void* b)	// 函数指针做形参
);

显然这个函数有三个参数,第一参数是指定开始排序的地址,第二参数是指定参与排序的元素的数目,第三参数就是由用户决定的比较函数的指针,这样传入不同的比较函数,那么排序的结果也不相同
实际上,C语言标准库中的qsort()函数就是使用类似的方法声明和实现的,截止到C11标准,想在函数中应用某种“规则”,依然只能依赖函数指针的方式
不过在介绍向函数中传入函数对象或者Lambda之前,先要介绍一下Lambda中的捕获(Capture)

Lambda表达式的具体语法

在开篇中已经介绍过Lambda的语法,这里对一些特殊语法进行补充说明

空的形参列表

如果Lambda函数具有一个空的形参列表,那么这个形参列表可以省略:

auto f = [] { std::cout << "Lambda without ()!" << std::endl; };
返回类型

如果定义的Lambda函数拥有返回类型,返回类型需要附在形参列表后,以如下的方式声明返回类型:

auto f = [](const int&x , const int& y) -> bool { return x > y; };	// 定义一个比较两数大小的函数
bool _flag = f(2, 3);	// false

如果返回类型是基本类型,则可以省略

捕获

Lambda表达式最重要的功能就是捕获了,通过捕获Lambda可以访问同作用域里其他的局部变量或者全局变量,与函数传参类似,默认的捕获方式也分为两种:

  1. 复制捕获
    []添加=,即可构成复制捕获,如同按值传递一样,在Lambda表达式内部将访问一个捕获变量的副本,对这个副本的任何操作都不会影响到原本的变量(在没有mutable说明符的情况下,也不允许修改捕获的变量)

    int x = 1;
    auto f = [=] { std::cout << x << std::endl; }	// 打印捕获到的变量x
    
  2. 引用捕获
    引用捕获类似于复制捕获,将=替换为&即可,如同按引用传递一样,在Lambda内部可以任意修改被捕获的变量,且修改会影响到变量本身的值:

    int x = 1;				// x = 1
    [&]() { x = 2; }();		// x = 2
    

    如上,两种默认捕获方式可以捕获同一作用域中所有的变量,也可以在[]中声明要捕获的变量以及捕获方式,写成捕获列表的形式:

    // 0. 仅按值捕获变量x
    [x] {};
    // 1. 仅按引用捕获变量x
    [&x] {};
    // 2. 按值捕获变量x,对其他变量按引用捕获
    [&, x] {};
    // 3. 按引用捕获变量x,按值捕获其他变量
    [=, &x] {};
    

    当然捕获列表可以写很长,但是无一例外都存在着三条规则:

    • 当默认捕获符为=时,后续对特定变量的捕获必须带有&
    • 当默认捕获符为&时,后续对特定变量的捕获不能出现&;
    • 一个捕获列表中不能出现相同的捕获符,同一个变量名也不能出现两次
说明符

在形参列表和返回值中间,可以添加说明符,在C++11中只有一个可用的说明符,mutable——该说明符意味着允许修改使用复制捕获获取的变量,事实上对于完全按引用捕获的Lambda表达式,这个说明符是没有意义的
具体一点讲,mutable允许在Lambda表达式的函数体内对按值捕获的变量进行修改,但是这种修改不会影响到函数体外的那个变量;但是若无该说明符,那么在函数体内对按值捕获的变量进行复制操作是不能通过编译的,如下例:

// 假设作用域中存在变量x = 0
auto f1 = [=] { x = 1; };			// Error: 不能通过编译
auto f2 = [=] mutable { x = 1; };	// Correct: x = 0
auto f3 = [&] { x = 2; };			// Correct: x = 2
auto f4 = [&] mutable { x = 0; };	// Correct: x = 0
重载operator()

相对于其他可重载运算符的参数类型和个数都有限制,operator()可谓是非常自由——返回类型不固定,接收参数的类型不固定,接收参数的个数也不确定——当然这些在重载运算符时都要确定下来,对该运算符重载的唯一限制就是必须以成员函数的形式重载它

向函数中传入函数对象或者Lambda

这里通过一个简单的例子展示一下函数对象和Lambda的使用
使用随机数函数产生一个随机序列,并统计其中可以被3和13整除的数字的个数,使用STL产生这个序列的代码如下:

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

...;

std::vector<int> nums(1000);
std::generate(nums.begin(), nums.end(), std::rand);

使用count_if函数进行计数,该函数需要传入一个规则或者条件,在满足该条件时计数加一,首先尝试使用函数对象表示此规则:

class f_mod
{
private: 
	int dv;		// 除数
public:
	f_mod(int d = 1) : dv(d) {}
	bool operator()(int x) { return x % dv == 0; }
}

int count1 = count_if(nums.begin(), nums.end(), f_mod(3));
int count2 = count_if(nums.begin(), nums.end(), f_mod(13));

可以看到f_mod类重载了小括号运算符,接受一个整形参数,返回一个布尔类型,表示该参数是否可以被dv整除
下面是使用不带捕获的Lambda表达式定义规则的情形:

auto f1 = [](int x) -> bool { return x % 3 == 0; };
auto f2 = [](int x) -> bool { return x % 13 == 0;};

int count1 = count_if(nums.begin(), nums.end(), f1);
int count2 = count_if(nums.begin(), nums.end(), f2);

如果使用带捕获的Lambda表达式,我们甚至可以抛弃count_if函数:

int count1 = 0, count2 = 0;
auto f = [&](int x) { count1 += (x % 3 == 0); count2 += (x % 13 == 0); }

std::for_each(nums.begin(), nums.end(), f);

这里的逻辑就更为简单了,捕获count1count2,当x可以被3整除时,表达式x % 3 == 0的值为true,该值和整形相加时自动被转换为整数1,就实现了当x可被3整除时,count1加一的效果——这样在遍历完整个数组的同时,要统计的数据也就完整的得到了

结语

函数指针、函数对象、Lambda表达式都起到了传递一个“函数谓词”的作用,其实可以认为Lambda表达式是前二者的语法糖,但是即便如此,为了程序的可读性,应该少用Lambda表达式,除非是像文中这样表达某种“规则”的时候
虽然这只是C++中一个很小的特性,但是用在正确的地方,也许就有事半功倍的效果呢


下一篇该系列的文章也许会谈谈C++中深拷贝与浅拷贝问题

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值