递归Lambda:std::function实现递归、Y组合子简介

递归,说白了就是函数调用自己。这在普通函数里很自然,但到了Lambda表达式这儿,就有点绕了。为什么?因为Lambda是个匿名对象,它没有名字,怎么在自己体内调用自己?

我刚开始接触这个问题时,也卡了好一会儿。后来发现,其实有两条路可以走:一条是用std::function这个“容器”把Lambda装起来,另一条是更纯粹的Y组合子。今天咱们就把这两条路都走一遍。

为什么Lambda不能直接递归?

先看个简单的例子。你想写个Lambda计算阶乘:

auto factorial = [](int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);  // 编译错误!
};

这段代码编译不过。原因很简单:在Lambda内部,factorial这个变量还没定义完呢。你想想看,编译器解析Lambda体时,factorial还在构造中,它根本不知道factorial是什么东西。

嗯,这里要注意:Lambda的捕获列表是在定义时确定的,而不是在调用时。所以你不能在Lambda体内引用它自己——除非你用了某种“间接”的方式。

方法一:std::function + 引用捕获

最常见的做法,就是用std::function包装一下,然后通过引用捕获自己。我个人习惯用这种方式,因为它直观、好理解。

#include <iostream>
#include <functional>

int main() {
    std::function<int(int)> factorial;
    factorial = [&](int n) {
        return n <= 1 ? 1 : n * factorial(n - 1);
    };

    std::cout << factorial(5) << std::endl;  // 输出 120
    return 0;
}

关键点在于:[&]捕获了外部所有变量的引用,包括factorial本身。当Lambda体执行到factorial(n - 1)时,factorial已经是一个完整的std::function对象了,所以可以正常调用。

注意:这里必须用引用捕获,不能用值捕获。如果用[=],会拷贝一份factorial,但此时factorial还没赋值完成,拷贝的是空对象,运行时会崩溃。

我曾经在项目里犯过这个错。当时写了一个递归解析JSON的Lambda,用了值捕获,结果一运行就段错误。查了半天才发现是捕获方式的问题。从那以后,我写递归Lambda时都会多看一眼捕获列表。

方法二:通过参数传递自身

还有一种技巧,就是把Lambda自己作为参数传进去。这有点像函数式编程里的“不动点”思想。

auto factorial = [](auto&& self, int n) {
    return n <= 1 ? 1 : n * self(self, n - 1);
};

// 调用时要把自己传进去
std::cout << factorial(factorial, 5) << std::endl;  // 输出 120

这种写法不需要std::function,也没有额外的开销。但调用方式有点别扭——每次都要把Lambda自己作为第一个参数传进去。你想想看,这多麻烦。

不过,我们可以用一个小技巧包装一下:

auto factorial = [](int n) {
    auto impl = [](auto&& self, int n) {
        return n <= 1 ? 1 : n * self(self, n - 1);
    };
    return impl(impl, n);
};

std::cout << factorial(5) << std::endl;  // 输出 120

这样外部调用者就不需要关心内部实现了。嗯,这其实已经有点Y组合子的雏形了。

Y组合子简介

Y组合子,说白了就是一个“递归生成器”。它接受一个“不完整的函数”(即需要递归调用的函数),然后返回一个完整的递归函数。

在C++里实现Y组合子,需要用到模板和Lambda的泛型特性。我直接上代码:

#include <iostream>

template <typename F>
auto Y(F f) {
    return [f](auto... args) {
        return f(f, args...);
    };
}

int main() {
    auto factorial = Y([](auto&& self, int n) {
        return n <= 1 ? 1 : n * self(self, n - 1);
    });

    std::cout << factorial(5) << std::endl;  // 输出 120
    return 0;
}

这个Y函数接收一个Lambda,这个Lambda的第一个参数是“自己”,后面的参数才是真正的参数。然后Y返回一个新的Lambda,这个新Lambda隐藏了“把自己传给自己”的细节。

小提示:Y组合子的核心思想是“把函数作为参数传给自己”。你不需要记住复杂的数学推导,只要理解这个模式就行。在实际项目中,我很少直接用Y组合子,但理解它对掌握函数式编程很有帮助。

三种方式的对比

方法 优点 缺点 适用场景
std::function + 引用捕获 直观、易读、调用自然 有运行时开销(虚函数调用) 大多数日常场景
通过参数传递自身 无额外开销、纯Lambda 调用方式别扭、需要包装 对性能有极致要求的场景
Y组合子 纯函数式、无状态、可复用 理解门槛高、模板代码多 函数式编程风格的项目

避坑指南

我曾经在项目里用递归Lambda解析一个深层嵌套的JSON结构。一开始用的std::function方式,跑得挺好。后来数据量大了,发现性能有点慢。换成“参数传递自身”的方式后,速度提升了不少。

但要注意:递归深度太深会导致栈溢出。Lambda递归和普通函数递归一样,都有栈深度限制。如果你要处理的数据层级很深(比如超过1000层),建议改用循环或尾递归优化。

另外,std::function方式有个隐藏的坑:如果你在Lambda内部修改了捕获的std::function对象(比如重新赋值),会导致未定义行为。因为Lambda体执行时,std::function正在被调用,修改它相当于“一边开车一边换轮胎”。

知识体系图

下面这张图总结了递归Lambda的三种实现方式及其关系:

递归Lambda std::function 引用捕获自身 参数传递自身 泛型Lambda + 包装 Y组合子 纯函数式实现 直观但有运行时开销 适合日常使用 无额外开销 调用方式别扭 纯函数式、可复用 理解门槛高 选择哪种方式?看场景、看团队、看性能要求

总结

递归Lambda其实不复杂。核心就一句话:让Lambda能访问到自己。用std::function是最直接的方式,用参数传递是更纯粹的方式,Y组合子则是函数式编程的终极解法。

我个人建议:日常项目用std::function就够了。如果你在写一个对性能敏感的基础库,可以考虑参数传递的方式。至于Y组合子,理解它的思想就好,不用强求在代码里用上。

嗯,递归Lambda就聊到这儿。记住一点:能用循环解决的问题,尽量别用递归。递归虽好,但栈空间有限,别把自己绕进去了。


公众号:蓝海资料掘金营,微信deep3321