Lambda表达式:C++ 多态背后的那个男人

0 阅读10分钟

Lambda 表达式(也称为匿名函数)是 C++11 引入的一项核心特性,它允许我们在函数内部定义临时的、可调用的对象,而无需显式定义一个仿函数类。

顺带着也极大地简化了回调、STL 算法谓词、并发任务等场景的代码编写,使代码更紧凑、更易读。

今天,我们就来细品这颗“语法糖”,看看它为什么这么甜,以及怎么吃才不会蛀牙(避免陷阱)。

函数指针

你有没有想过,在lambda表达式出现之前C++ 程序员都是用什么方法实现类似的效果呢?

没错,第一种方法就是使用传统的函数指针。

#include <iostream>

// 声明函数指针
int (*funcPtr) (intint);

int add(int a, int b)
{
    return a + b;
}

int main()
{
    funcPtr = &add; // 给函数指针赋值
    int res = funcPtr(35); // 调用
    std::cout << res << std::endl; // 输出:8

    return 0;
}

上述代码又是声明,又是赋值,又是调用的,使用不够灵活而且过于繁琐。

函数指针的局限性:

  • 无法捕获上下文变量(即不能有状态)。
  • 只能指向全局函数或静态成员函数。
  • 类型安全性较弱(函数指针类型需严格匹配)。
  • 不支持泛型算法中的内联优化(编译器难以内联)。

仿函数

仿函数(Functor),又称函数对象(Function Object),是指重载了 operator() 的类对象。

它可以像普通函数一样被调用,但比普通函数更灵活,因为它可以拥有自己的状态(成员变量),并且可以被内联优化,效率更高。

基本实现

定义一个类,重载 operator(),然后创建该类的对象,使用 对象名(参数) 的方式调用。

#include <iostream>

class Adder {
public:
    // 重载函数调用运算符
    int operator()(int a, int b) const 
    {
        return a + b;
    }
};

int main() {
    Adder add; // 创建一个仿函数对象
    int result = add(34); // 像函数一样使用
    std::cout << result << std::endl; // 输出 7
}

优势

可以携带状态

普通函数只能通过静态局部变量或全局变量来保存状态,容易引起线程安全问题。

而仿函数可以将状态封装在成员变量中,使其在调用时具备上下文信息。

class Accumulator {
public:
    Accumulator(int init = 0) : total(init) {}

    int operator()(int value) 
    {
        total += value;
        return total;
    }

    int getTotal() const return total; }

private:
    int total;
};

int main() {
    Accumulator acc(10);
    std::cout << acc(5) << std::endl;  // 输出 15
    std::cout << acc(3) << std::endl;  // 输出 18
}

我们可以看到通过存储total这个成员变量,每次调用不会被初始化,能够不断地累加下去。

可作为模版参数

仿函数通常与 STL 算法结合使用,作为模板参数传递,例如 std::sort 的第三个参数。

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

struct Greater 
{
    bool operator()(int a, int b) const 
    {
        return a > b;
    }
};

int main() {
    std::vector<int> v = { 31415 };
    std::sort(v.begin(), v.end(), Greater()); // 降序排序
    
    return 0;
}

支持内联优化

由于仿函数是一个具体的类型,编译器可以轻松地将 operator() 内联展开,避免了函数指针调用时的间接跳转开销。

缺点

  • 需要单独定义一个类,代码冗长。
  • 如果要在多个地方使用不同状态,需要定义多个仿函数类,或使用模板参数传入状态。
  • 捕获多个变量时,类的成员会增多,书写繁琐。

lambda表达式

通过以上的两种方法,我们可以看到它们虽然能够完成任务,但往往代码冗长、可读性差,且使用不够灵活。

而Lambda表达式整合了上述方法的优点,同时克服了它们的缺点:

  • 简洁的语法:捕获列表 -> 返回类型 { 函数体 },无需单独定义类。
  • 方便的变量捕获:按值、按引用捕获上下文变量,甚至能捕获 this。
  • 性能:编译器通常生成与手写仿函数同样高效的代码,且容易内联。
  • 与标准库无缝集成:所有接受可调用对象的算法都能直接使用 Lambda。

基本语法

[captures_list](parameters_list) -> return_type { function_body }
  • captures_list:捕获列表定义了如何访问其所在作用域中的局部变量(包括 this)。捕获方式分为值捕获、引用捕获和隐式捕获。
  • parameters_list:参数列表定义了lambda接受哪些参数。
  • return_type:返回类型,通常可以省略,由编译器根据自动推导。
  • function_body:函数体,包含了lambda执行的代码。

捕获方式

  • 值捕获:捕获外部变量的副本。
int x = 10;
auto lambda = [x]() ->void { 
    std::cout << x << std::endl; 
}; // 捕获x的副本
x = 20;
lambda(); // 输出:10,因为捕获的是x的副本
  • 引用捕获:捕获外部变量的引用。
int x = 10;
auto lambda = [&x]() ->void {
    std::cout << x << std::endl; 
}; // 捕获x的引用
x = 20;
lambda(); // 输出:20,引用了外部的 x
  • 隐式捕获:使用[=]或[&],表示按值或引用捕获所有外部变量的副本或引用。
    (不推荐使用隐式捕获,隐式捕获会造成不必要开销影响性能,并且代码可读性下降以及线程安全问题)
  • this捕获:在类的非静态成员函数中,可以使用[this] 捕获当前对象指针,从而访问成员变量和成员函数。
class A {
public:
    void func() {
        auto lambda = [this]() {
            std::cout << this->value << std::endl; 
        };
        lambda();
    }

private:
    int value = 10;
};

mutable关键字

默认情况下,lambda 的 operator() 是 const 的,意味着它不能修改按值捕获的变量。

若需要修改按值捕获的变量,我们可以使用 mutable 关键字。

int main() {
    int count = 0;

    auto lambda = [count]() mutable{
        ++count;
        std::cout << "count在lambda内部的值: " << count << std::endl;
    };

    lambda();
    lambda();

    std::cout << "count在lambda外部的值: " << count << std::endl;
    return 0;
}

程序输出:

count在lambda内部的值: 1
count在lambda内部的值: 2
count在lambda外部的值: 0

上述代码中我们调用了两次lambda()函数,可以看到通过mutable关键字成功修改了lambda捕获的count值,但外部的count值没有改变。

lambda与标准库算法

许多标准库算法接受可调用对象,可以直接传入 lambda,让代码更简洁和直观。

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

int main() {
    std::vector<int> v = { 1,3,5,2,7 };

    std::sort(v.begin(), v.end(), 
        [](int a, int b) {
            return a < b; // 升序
        }
    );

    for (auto v1 : v)
        std::cout << v1 << " ";

    std::cout << std::endl;
    return 0;
}

程序输出:

1 2 3 5 7

lambda与智能指针的结合

关于智能指针可以查看我的另一篇博客:智能指针

在现代 C++ 异步编程中,我们经常需要将回调函数(如 lambda)传递给异步任务(如线程、std::async、网络 I/O 回调等)。

这些回调往往在将来的某个时刻执行,而此时原始对象可能已经超出作用域被销毁,导致悬挂指针和未定义行为。

使用 std::shared_ptr 结合 lambda 可以优雅地解决这一问题:

通过将 shared_ptr 按值捕获到 lambda 中,增加对象的引用计数,从而延长其生命周期,直到 lambda 执行完毕。

我们先来看看使用原始指针时会出现的问题。

原始指针的危险

假设有一个类 Worker,它有一个成员函数 doWork()。

我们希望在另一个线程中异步执行这个函数,因此将 lambda 传递给 std::thread,并在 lambda 中捕获这个指针。

#include <iostream>
#include <thread>

class Worker {
public:
    ~Worker() {
        std::cout << "Worker destroyed" << std::endl;
    }
    void doWork() {
        std::cout << "Worker working..." << std::endl;
    }
};

int main() 
{
    {
        Worker w; // 局部对象

        std::thread t([&w]() {  // 引用捕获 w
            std::this_thread::sleep_for(std::chrono::seconds(1)); // 睡1秒
            w.doWork();
            }
        );

        t.detach(); // 线程分离,lambda在后台执行
    } // 离开作用域,w 被销毁
    std::cout << "Leaving the scope..." << std::endl;

    std::this_thread::sleep_for(std::chrono::seconds(2)); // 等待线程执行
    return 0;
}

程序输出:

Worker destroyed
Leaving the scope...
Worker working...

我们可以看到在作用域结束后,w 被立即销毁了。

后台线程在 1 秒后才执行 w.doWork(),但 w 已不存在,访问其成员函数将导致未定义行为。

想避免这类情况发生,需要我们手动确保对象在回调执行前存活。

所幸我们可以使用 std::shared_ptr 来延迟对象的生命周期。

使用 std::shared_ptr 的优势

std::shared_ptr 通过引用计数管理对象生命周期。

当我们将一个 shared_ptr 拷贝到 lambda 内部时,引用计数增加。

只要 lambda 还持有这个拷贝,对象就不会被销毁。

这完美解决了异步任务中对象提前销毁的问题。

我们将 Worker 改为由 shared_ptr 管理,并在 lambda 中按值捕获这个 shared_ptr:

#include <iostream>
#include <thread>
#include <memory>

/* ...Worker略... */

int main() 
{
    {
        auto work_ptr = std::make_shared<Worker>();

        std::thread t([work_ptr]() {
            std::this_thread::sleep_for(std::chrono::seconds(1)); // 睡1秒
            std::cout << "work_ptr use_count: " << work_ptr.use_count() << std::endl;
            work_ptr->doWork();
            }
        );

        t.detach(); // 线程分离,lambda在后台执行
        std::cout << "work_ptr before use_count: " << work_ptr.use_count() << std::endl;
    } // 离开作用域,w 被销毁
    std::cout << "Leaving the scope..." << std::endl;
    
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 等待线程执行
    return 0;
}

程序输出:

work_ptr before use_count: 2
Leaving the scope...
work_ptr use_count: 1
Worker working...
Worker destroyed

因为 lambda 内部持有一个 shared_ptr 拷贝,所以引用计数变为 2。
(worker 一份,lambda 一份)。

离开作用域之后,因为 lambda 手里有一份shared_ptr,引用计数为1。

并且只有等到 lambda 执行完毕,其内部的 shared_ptr 销毁,引用计数归零,对象才被释放。

这样我们就确保了 doWork 调用时对象一定存活。

捕获 this 的陷阱

在类的成员函数中启动异步任务,常常需要捕获 this 以访问成员。

直接捕获原始 this 指针会面临同样的问题——对象可能在异步任务执行前被销毁。

解决方案是继承 std::enable_shared_from_this,并通过 shared_from_this() 获得一个 shared_ptr 传给 lambda。

std::enable_shared_from_this 是一个辅助模板类:

允许一个被 std::shared_ptr 管理的对象安全地生成额外的 std::shared_ptr 实例指向自己。

class Worker : public std::enable_shared_from_this<Worker> {
public:
    void startAsync() {
        // 获取 shared_ptr<Worker>
        std::weak_ptr<Worker> weak_this = shared_from_this();

        std::thread t([weak_this]() {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            if (auto sp = weak_this.lock()) {   // 提升为 shared_ptr
                sp->doWork();
            }
            }
        );
        t.detach();
    }

    ~Worker() {
        std::cout << "Worker destroyed" << std::endl;
    }

    void doWork() {
        std::cout << "Worker working..." << std::endl;
    }
};

int main() {
    auto w = std::make_shared<Worker>();
    w->startAsync();
    std::cout << "Waiting for the thread to execute." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return 0;
}

程序输出:

Waiting for the thread to execute.
Worker working...
Worker destroyed

enable_shared_from_this 允许从 this 安全地获得一个 shared_ptr。

这里我们捕获 weak_ptr 而不是 shared_ptr,是为了避免循环引用:

如果 lambda 直接捕获 shared_from_this(),那么 lambda 内部持有了对象的 shared_ptr。

而对象又可能通过成员变量持有 lambda(例如将 lambda 存储为回调),就会形成循环。

结尾

Lambda 表达式是现代 C++ 编程的利器,它以简洁的语法提供了强大的函数式编程能力,同时保持了与底层仿函数相当的性能。

掌握其捕获规则、参数传递和适用场景,能够写出更安全、更清晰、更易维护的代码。

而lambda 与智能指针的结合,让我们能够以简洁、安全的方式管理异步任务中的资源。