函数对象与lambda

前言

前一篇文章:# lambda简介和使用

在前面的文章中,我们对lambda的定义以及使用有了一个非常清晰的认识,它是一个可调用的对象,定义很像函数指针,包括参数、返回值和函数体3大要素,不同之处是lambda可以定义在函数内,可以通过捕获来获取外部函数中的局部变量。

同时,我们结合sortfind_iffor_each与lambda的使用,更可以加深理解谓词的含义,以及lambda的捕获功能所带来的便捷性,可以在不传递参数的情况下,使用外部变量。

这个非常好用的lambda究竟是什么呢?编程没有魔法,本篇文章来重点探究一下。

正文

想要完全理解lambda,我们不能把lambda看成一个匿名函数,至于为什么,我们可以简单想一下为什么会把lambda看成函数。

lambda定义有参数列表、返回值和函数体,这完全就是一个函数的定义,但是没有名字,所以我们可以看成是匿名函数。同时,在函数体内部,可以使用外部的局部变量,有一种可以实现,那就是内联,相当于lambda代码块是函数的子作用域,所以我们可以看成是匿名的内联函数。

但是有一点不好解释,那就是捕获的对象,lambda可以对捕获的对象进行值捕获(拷贝)、引用捕获,单纯是函数的话,非常不好实现。所以当定义一个lambda时,编译器生成一个与lambda对应的类类型,当给函数传递一个lambda对象时,传递的参数本质是该类类型的未命名对象

至于该类类型结构是什么样子的,如何调用,我们一步一步来分析。

函数调用运算符

如果类重载了函数调用运算符 ,则我们可以像使用函数一样使用该类的对象 。同时因为类可以存储状态,所以比普通函数更加灵活。这两句话需要完全理解,举个例子:

c++ 复制代码
#include <iostream>

struct absInt {
        int operator()(int val) const {
                return val < 0 ? -val : val;
        }
};

int main() {
        int i = -42;
        absInt absObj;
        int absi = absObj(i);
        std::cout << absi << std::endl;
}

在上面代码中,使用operator重载了函数调用运算符() ,它负责接受一个int类型的实参,然后返回绝对值。在absObj(i)的使用中,我们可以调用该对象,就像absObj是一个函数一样。

如果类定义了调用运算符,则把该类的对象称为函数对象(function object),原因非常简单,因为可以调用这种对象,也可以说这种对象的行为像函数一样。

含有状态的函数对象类

搞清楚了重载函数运算符,我们现在探究前面说的它可以做到行为类似函数,但是比函数更灵活的特性。

因为重载运算符的函数必须是类的成员函数,而类的成员函数可以调用其他成员,所以给调用该重载运算符函数提供了更多的操作。我们直接来看个例子:

C++ 复制代码
#include <iostream>
#include <string>

using namespace std;

class PrintString {
        public:
                PrintString(ostream &o = cout, char c = ' '):
                        os(o), sep(c) { }
                void operator() (const string &s) const { os << s << sep; }
        private:
                ostream &os;
                char sep;
};

int main() {
        PrintString printer;
        printer("hello");
        PrintString errors(cerr, '\n');
        errors("error");
}

在代码中,我们定义了一个用于自定义打印字符串的PrintString类,默认使用std::cout和空格符号来打印字符串,同时重载了调用运算符,传入一个待打印的字符串。

在使用中,对于对象printer,可以直接把它当作函数来使用,而对于对象errors,将会使用std::cerr打印,并且后面跟一个换行符。

可以看到,当构建不同的对象时,调用它的效果是不一样的,这就是所体现的灵活性

平时对于这种需求,我们一般都是习惯用函数重载来实现,现在使用重载运算符的方式,可以说是更加灵活。

lambda是函数对象

铺垫了这么多,终于讲到了重点,当我们编写一个lambda时,编译器会将该表达式翻译成一个未命名类的未命名对象。比如上一篇文章中的排序例子:

C++ 复制代码
sort(words.begin(), words.end(), [](const string &a, const string &b){
    return a.size() < b.size();
});

其行为就类似于下面类的一个对象:

C++ 复制代码
class ShorterString {
public: 
    bool operator() (const string &a, const string &b) const {
        return a.size() < b.size();
    }
}

产生的类,只有一个重载了的函数调用运算符成员,参数和lambda完全一样。上一篇文章我们说过,标准库中传递给算法的谓词是一个可调用对象 ,所以前面我们演示了可以传递给sort分别为函数和lambda,现在我们可以传递给它一个类对象

C++ 复制代码
sort(words.begin(), words.end(), ShorterString());

sort内部的代码中,每次比较两个string时,都会调用这个对象。

表示捕获行为的类

既然搞清楚lambda的本质其实就是函数对象后,捕获特性就非常容易理解了。当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时的对象确实存在,因此,编译器可以直接使用该引用而无需在lambda产生的类中将其存储为数据成员。

相反,通过值捕获的变量会被拷贝到lambda中,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。

还是上一篇文章中的例子,我们写了一个lambda传递给find_if用来查询第一个满足条件的元素:

C++ 复制代码
auto wc = find_if(words.begin(), words.end(), [sz](const std::string &s){ 
    return s.size() >= sz; 
});

该lambda使用了值捕获,所以它产生的类将如下:

C++ 复制代码
class SizeComp {
public:
    SizeComp(size_t n): sz(n) { }
    bool operator() (const string &s) const {
        return s.size() >= sz;
    }
private:
    size_t sz;
}

在该类中,直接多了构造函数和用来保存捕获的值的数据成员,上面lambda的写法就和下面效果一样:

C++ 复制代码
auto wc = find_if(words.begin(), words.end(), SizeComp(sz));

这么一看,我们就可以发现lambda其实并没有神奇之处,关于捕获的最特别特性在了解其本质后,也非常容易理解。

总结

本篇文章以重载函数调用运算符开始,逐步揭开lambda的本质,可以看出lambda其实就是一个语法糖,是函数对象的简写方式而已,由编译器为我们做了后续的工作。

当我们能在大脑中为每个lambda都可以自动转换为一个相应的未命名类时,其中的捕获和参数列表也就容易理解,我们使用lambda和阅读lambda将不再困难。

相关推荐
字节跳动数据库19 分钟前
文章分享——相似函数处理方法
人工智能·后端·程序员
云技纵横19 分钟前
@Transactional 失效的 7 种场景:第 5 种最难排查
后端
用户67570498850236 分钟前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
程序员cxuan1 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构
用户6757049885021 小时前
面试官问“装饰器模式”,这样回答薪资多要 3000!
后端
tntxia1 小时前
Geo Scene域名修改引起的一些问题
后端
用户298698530141 小时前
Java 实现 Word 文档加密与权限解除
java·后端
vanuan1 小时前
给你的A2A-Agent加把锁-认证鉴权实战指南
后端
用户805533698032 小时前
不止三件套:QObject 属性系统全关键字与运行时反射!
c++·qt
Yeats_Liao2 小时前
14:Servlet中的页面跳转-Java Web
java·后端·架构