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可以访问同作用域里其他的局部变量或者全局变量,与函数传参类似,默认的捕获方式也分为两种:
-
复制捕获
在[]
添加=
,即可构成复制捕获,如同按值传递一样,在Lambda表达式内部将访问一个捕获变量的副本,对这个副本的任何操作都不会影响到原本的变量(在没有mutable
说明符的情况下,也不允许修改捕获的变量)int x = 1; auto f = [=] { std::cout << x << std::endl; } // 打印捕获到的变量x
-
引用捕获
引用捕获类似于复制捕获,将=
替换为&
即可,如同按引用传递一样,在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);
这里的逻辑就更为简单了,捕获count1
和count2
,当x
可以被3整除时,表达式x % 3 == 0
的值为true
,该值和整形相加时自动被转换为整数1,就实现了当x
可被3整除时,count1
加一的效果——这样在遍历完整个数组的同时,要统计的数据也就完整的得到了
结语
函数指针、函数对象、Lambda表达式都起到了传递一个“函数谓词”的作用,其实可以认为Lambda表达式是前二者的语法糖,但是即便如此,为了程序的可读性,应该少用Lambda表达式,除非是像文中这样表达某种“规则”的时候
虽然这只是C++中一个很小的特性,但是用在正确的地方,也许就有事半功倍的效果呢
下一篇该系列的文章也许会谈谈C++中深拷贝与浅拷贝问题