Skip to content

泛型 Lambda 与模板 Lambda ​

前两篇的主线里,咱们写的 lambda 大多把参数类型定死:int、uint32_t、const std::string&。第一篇后段咱们还见过一眼 auto 参数,它留下的内容正好由这一篇接着讲完。真到了项目里,很多 lambda 的逻辑其实对类型是中性的:排序的比较器只要求类型支持 <,累加器只要求类型支持 + 就行了。要是咱们为每种类型都各写一份 lambda,那就等于回到了 C++98 仿函数的老路上:同一段逻辑换了个类型就得再抄一遍,写出来的东西重复又冗余。C++14 给了 lambda 泛型的能力(auto 参数)。C++20 又往前走了一步,直接让 lambda 拥有了显式模板参数列表。咱们就从 C++14 的 auto 说起。


C++14 泛型 lambda——auto 参数 ​

您还记得第一篇见过的那个 add 吧:参数直接写了 auto。这样的 lambda 在标准里有个正式的名字,咱们叫它泛型 lambda(generic lambda)。auto 参数是 C++14 引入的写法,在咱们这些调用者看来,它的行为和模板函数一模一样:不同类型的参数进来,编译器就为每种类型各实例化了一份 operator()。

C++
// 泛型 lambda:接受任何支持 operator+ 的类型
auto add = [](auto a, auto b) {
    return a + b;
};

int xi = add(3, 4);                         // int
double xd = add(3.14, 2.72);                // double
std::string xs = add(std::string("hi "), std::string("there"));

您看上面三次调用:同一个 add,喂给它的整数、浮点、字符串全都接住了,咱们一行模板语法都没写。

咱们把一个 lambda 对象和 operator() 实例的对应关系画了出来:

底层实现:模板调用运算符 ​

那编译器在背后把它翻译成的闭包类型,长什么样呢?咱们看个简化版(闭包类型,就是编译器替每个 lambda 生成的那个类):

C++
// 你写的
auto add = [](auto a, auto b) { return a + b; };

// 编译器生成的(简化)
struct ClosureType {
    template<typename T1, typename T2>
    auto operator()(T1 a, T2 b) const {
        return a + b;
    }
};

咱们对着数一遍:每个 auto 参数都对应着闭包类型 operator() 上的一个模板参数。写了两个 auto,operator() 就成了一个双模板参数的成员函数模板。既然 operator() 的本质是模板,泛型 lambda 也就享有了模板的那套能力,比如 SFINAE(Substitution Failure Is Not An Error 的缩写,意思是替换失败了也不算编译错误,只是把不匹配的候选从重载集里筛掉),还有显式实例化之类的手段。

多种类型的 auto 参数 ​

还有一个细节值得咱们留意:每个 auto 都是独立的模板参数,各自的推导互不影响。

C++
auto multiply = [](auto a, auto b) {
    return a * b;
};

multiply(3, 4.5);    // int * double -> double
multiply(2.0f, 3);   // float * int -> float

如果您希望两个参数是同一个类型,C++14 里就得借助 std::common_type_t 求公共类型的技巧绕一下。到了 C++20 就省事了,咱们可以直接用模板参数表达,本篇后面咱们就会讲到。


if constexpr 在 lambda 中 ​

咱们离 C++20 还差一节。中间这版 C++17 倒是也没闲着,它带来的 if constexpr,可以在编译期根据类型信息选择不同的代码路径。而放进泛型 lambda 里,它就特别好用:咱们可以根据参数的类型特征,给不同类型挑不同的实现。

展开代码收起代码共 26 行
C++
#include <type_traits>
#include <iostream>
#include <vector>
#include <string>

auto process = [](auto& container) {
    using T = std::decay_t<decltype(container)>;

    if constexpr (std::is_same_v<T, std::string>) {
        std::cout << "Processing string: " << container << "\n";
    } else if constexpr (std::is_same_v<T, std::vector<int>>) {
        std::cout << "Processing int vector, size: " << container.size() << "\n";
    } else {
        std::cout << "Processing unknown type\n";
    }
};

void demo_if_constexpr() {
    std::string s = "hello";
    std::vector<int> v = {1, 2, 3};
    double d = 3.14;

    process(s);  // Processing string: hello
    process(v);  // Processing int vector, size: 3
    process(d);  // Processing unknown type
}

真正需要咱们打起精神的地方,是它的丢弃行为:不满足条件的分支会在编译期被丢弃(discarded),不参与最终的代码生成。于是咱们可以在不同分支里使用某种类型特有的操作(比如 container.size()),只要该分支在当前的实例化中不满足条件,编译器就不会检查它的语义正确性。不过有一点得当心:被丢弃的分支仍然要做基本的语法检查,也不能包含无法解析的模板依赖名称。

更实用的场景是处理不同的迭代器类型:随机访问迭代器可以用下标访问,前向迭代器可用的就只剩 ++ 了。而按类型挑实现这件事,正是 if constexpr 的本职,您以后写通用算法会反复用到它。


C++20 模板 lambda——显式模板参数 ​

泛型 lambda 的 auto 参数用起来确实方便,可 auto 也有几处别扭的地方:推导出来的具体类型叫什么名字,您没有一个直接写出来的办法。您没办法给模板参数施加约束,也没办法在 lambda 内部引用推导出来的类型去声明别的变量。C++20 干脆给 lambda 加上了显式模板参数列表,一举解决了这些问题:

C++
// C++20 模板 lambda:显式声明模板参数
auto add_explicit = []<typename T>(T a, T b) {
    return a + b;
};

add_explicit(3, 4);       // T = int
add_explicit(3.0, 4.0);   // T = double
// add_explicit(3, 4.0);  // 编译错误:T 不能同时是 int 和 double

咱们看方括号后面挂出来的那串 <typename T>:写法和普通模板的参数列表完全一致,平常怎么写模板函数,这里就怎么写。两个参数都声明成了 T,调用的时候就必须传同一类型。您看注释掉的那行 add_explicit(3, 4.0),它就是过不了编译的例子。而 C++14 的 auto 参数恰好就做不到同型的约束。

在 lambda 内部使用模板参数名 ​

咱们拿到模板参数名 T,在 lambda 体内就能放心使用了,比 auto 灵活多了:

展开代码收起代码共 21 行
C++
#include <vector>
#include <iostream>

// 用模板参数名创建同类型的容器或变量
auto transform_to_vector = []<typename T>(const std::vector<T>& input) {
    std::vector<T> result;
    result.reserve(input.size());
    for (const auto& elem : input) {
        result.push_back(elem * 2);
    }
    return result;
};

void demo_template_param_name() {
    std::vector<int> data = {1, 2, 3, 4, 5};
    auto doubled = transform_to_vector(data);
    for (int x : doubled) {
        std::cout << x << " ";   // 2 4 6 8 10
    }
    std::cout << "\n";
}

要是用 C++14 的 auto 参数,您拿到的是 const std::vector<int>&,但元素类型您在 lambda 内部并不知道,咱们得靠 decltype 去推。有了 C++20 的模板参数 T,一切都直截了当,std::vector<T> 的声明直接写就行。

配合 Concepts 进行约束 ​

C++20 的 Concepts(概念,给模板参数加编译期约束的机制)和模板 lambda 配合得正好。咱们可以用 requires 从句对模板参数施加约束,让 lambda 只接受满足特定概念的类型:

展开代码收起代码共 34 行
C++
#include <concepts>
#include <iostream>
#include <sstream>
#include <string>

// 只接受整数类型
auto int_only = []<std::integral T>(T a, T b) {
    return a + b;
};

// 只接受浮点类型
auto float_only = []<std::floating_point T>(T a, T b) {
    return a + b;
};

// 自定义概念:支持序列化的类型
template<typename T>
concept Serializable = requires(T t, std::ostream& os) {
    { serialize(t, os) } -> std::same_as<void>;
};

auto serialize_and_log = []<Serializable T>(const T& obj) {
    std::ostringstream oss;
    serialize(obj, oss);
    std::cout << "Serialized: " << oss.str() << "\n";
};

void demo_concepts() {
    int_only(1, 2);         // OK
    // int_only(1.0, 2.0); // 编译错误:double 不满足 std::integral

    float_only(1.0, 2.0);   // OK
    // float_only(1, 2);   // 编译错误:int 不满足 std::floating_point
}

Concepts 约束带来的不只是编译期类型安全,错误信息也比传统 SFINAE 的做法友好得多。您传错了类型,编译器会直接告诉您"约束不满足",还会指出具体是哪个概念失败了,而不是甩出一大堆模板实例化堆栈。这个对比咱们不用去本地开编译器。下面的 demo 把三种只允许整数的写法(concepts、static_assert、SFINAE)放在同一个文件里,默认是能编过的。您把 main 里被注释的错误调用一次放开一个去点“运行”,结果区给出的就是报错的原文,咱们把三种放在一起比:

Compiler Explorer

动手对比:三种约束写法的报错质量

默认编过,输出 3/7/11。放开被注释的错误调用(一次一个)再点「运行」:concepts 版直说 integral<T> 约束不满足、挂在哪一步推导上;static_assert 版给出您自己写的消息;SFINAE 版只说 no matching function,外加一串 enable_if 候选。

code/examples/vol2/56_concepts_sfinae_errors.cpp

调用模板 lambda 时显式指定模板参数 ​

有时候您不想让编译器推导模板参数,而是想自己显式指定。模板 lambda 其实也支持显式的调用写法,只是写出来的语法有点特殊:

C++
auto identity = []<typename T>(T x) { return x; };

// 正常调用,编译器推导 T = int
auto r1 = identity(42);

// 显式指定模板参数
auto r2 = identity.template operator()<int>(42);

咱们得承认,.template operator()<T>() 这套语法确实难看了点,不过您实际上很少需要显式调用它,大部分时候编译器的推导就够用了。咱们真需要显式指定的场合主要有两类:一类是您想强制某种转换(比如把 int 强制作为 double 处理),另一类是 lambda 内部用 if constexpr 根据模板参数选择不同的分支。


递归 Lambda ​

lambda 本身是匿名的,没有名字的它自然没办法在函数体里调用自己。可递归又是编程中很常见的需求,咱们写阶乘、写斐波那契,靠的全是递归。咱们有几种绕过去的办法。

方式 1:用 std::function 包装 ​

咱们最直观的做法,是把 lambda 存进 std::function(<functional> 里的通用可调用对象包装器)。存好了之后,lambda 就能靠这个变量名调用自己了:

C++
#include <functional>
#include <iostream>

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

    std::cout << factorial(5) << "\n";   // 120
}

笔者的实际测试(代码在 code/volumn_codes/vol2/ch03-lambda/test_recursive_lambda_performance.cpp)表明,在 -O2 的优化下,std::function 版本的递归调用比模板化实现慢约 75-145 倍,具体多少取决于递归深度和编译器的优化能力。慢在哪儿?std::function 的调用涉及类型擦除(type erasure,把具体类型藏到统一接口后面的技术),每一层递归都要经过虚函数表做一次间接的调用。性能敏感的代码里,这笔开销咱们得掂量掂量。

方式 2:泛型 lambda + auto&& 参数(Y 组合子思路) ​

更高效的方式,是利用泛型 lambda 的特性,把"自身引用"当作参数传了进去。这是 Y 组合子(Y combinator)思路的简化版。Y 组合子您可能头一回听说:它是 lambda 演算里的不动点组合子,出自数学家 Haskell Curry 的工作,专门让没有名字的函数也能递归。

展开代码收起代码共 27 行
C++
#include <iostream>

// Y 组合子辅助函数:接受一个高阶函数,返回它的不动点
template<typename F>
class YCombinator {
    F f_;
public:
    explicit YCombinator(F f) : f_(std::move(f)) {}

    template<typename... Args>
    decltype(auto) operator()(Args&&... args) {
        return f_(*this, std::forward<Args>(args)...);
    }
};

template<typename F>
YCombinator(F) -> YCombinator<F>;

void demo_y_combinator() {
    auto factorial = YCombinator([](auto&& self, int n) -> int {
        if (n <= 1) return 1;
        return n * self(n - 1);
    });

    std::cout << factorial(5) << "\n";   // 120
    std::cout << factorial(10) << "\n";  // 3628800
}

真正需要咱们看明白的,是第一个参数:泛型 lambda 的 auto&& self 接住的是 YCombinator 对象本身的引用,lambda 内部就靠 self(n - 1) 完成递归的调用。而 YCombinator::operator() 作为模板函数,编译器可以把整条调用链彻底地内联掉。

笔者拿同一份基准代码,在 g++ 15.2.1 -O2 下真跑了一轮(1,000,000 次 factorial(10) 调用),拿到的数据如下:

  • std::function 版本:~18,700 µs(类型擦除开销,难以优化)
  • Y Combinator 版本:~130-250 µs(模板化,可完全内联)
  • 性能提升:约 75-145 倍

咱们在实际项目里该怎么选?递归深度不大或调用频率不高的场合,std::function 的简洁性可能更重要。性能要紧的代码,则更适合 Y 组合子或直接传递自身引用的方式。

方式 3:C++14 泛型 lambda 直接传自身 ​

要是您不想写 Y 组合子辅助类,还有一个取巧的办法:给 lambda 加一个 auto&& 参数,调用的时候把 lambda 自己一并传进去:

C++
#include <iostream>

void demo_self_ref() {
    // fibonacci
    auto fib = [](auto&& self, int n) -> long long {
        if (n <= 1) return n;
        return self(self, n - 1) + self(self, n - 2);
    };

    std::cout << fib(fib, 10) << "\n";   // 55
}

代价也很直白:调用的人必须手动把 lambda 自身传进去,写的是 fib(fib, 10) 而不是 fib(10)。写法确实是有点怪的,不过在不需要封装到 API 的内部逻辑里,咱们是可以接受它的。

咱们把三种递归写法的调用链做成了动画,您可以逐步看:std::function 每层都要绕回包装器,Y 组合子把自身引用一路传了下去,自身传递写出来的是 fib(fib, 10)。

0.0s / 70.3s
STEP 01开场

通用示例 ​

通用比较器 ​

咱们从比较器说起。std::sort 要的只是一个能比较两个元素的函数,咱们按哪个字段比,就交给调用的人来决定。

展开代码收起代码共 37 行
C++
#include <algorithm>
#include <vector>
#include <string>

// 通用比较器:按任意字段排序
template<typename Projection>
auto make_comparator(Projection proj) {
    return [proj = std::move(proj)](const auto& a, const auto& b) {
        return proj(a) < proj(b);
    };
}

struct Employee {
    std::string name;
    int age;
    double salary;
};

void demo_generic_comparator() {
    std::vector<Employee> employees = {
        {"Alice", 30, 85000.0},
        {"Bob", 25, 72000.0},
        {"Charlie", 35, 92000.0},
    };

    // 按年龄排序
    std::sort(employees.begin(), employees.end(),
             make_comparator([](const auto& e) { return e.age; }));

    // 按薪资降序排序
    std::sort(employees.begin(), employees.end(),
             make_comparator([](const auto& e) { return -e.salary; }));

    // 按名字排序
    std::sort(employees.begin(), employees.end(),
             make_comparator([](const auto& e) -> const auto& { return e.name; }));
}

通用变换器 ​

变换器这边咱们走得更远:连"对容器做什么"都被做成了参数。

展开代码收起代码共 30 行
C++
#include <vector>
#include <algorithm>
#include <iterator>

// 通用变换:对容器中的每个元素应用变换函数
auto make_transformer = [](auto func) {
    return [f = std::move(func)](auto& container) {
        std::transform(container.begin(), container.end(),
                      container.begin(), f);
        return container;
    };
};

// 链式变换
auto make_pipeline = [](auto... transforms) {
    return [=](auto input) {
        auto current = std::move(input);
        // 依次应用每个变换(C++17 fold expression)
        ((current = transforms(current)), ...);
        return current;
    };
};

void demo_generic_transformer() {
    auto double_it = make_transformer([](int x) { return x * 2; });
    auto add_one = make_transformer([](int x) { return x + 1; });

    std::vector<int> data = {1, 2, 3, 4, 5};
    auto result = double_it(data);    // {2, 4, 6, 8, 10}
}

您对着 ((current = transforms(current)), ...) 这一行看,它就是 C++17 的折叠表达式(fold expression)。咱们可以把一包变换挨个地套到 current 上。

多态容器操作 ​

最后咱们看容器操作。有了泛型 lambda 配合模板函数,咱们可以写出不依赖具体容器类型的通用算法。下面这个例子用泛型 lambda 打印任意类型的容器,只要容器的元素支持 operator<<:

展开代码收起代码共 29 行
C++
#include <iostream>
#include <vector>
#include <list>
#include <array>
#include <set>

auto print_container = [](const auto& container) {
    using T = std::decay_t<decltype(container)>;
    std::cout << "[";
    bool first = true;
    for (const auto& elem : container) {
        if (!first) std::cout << ", ";
        std::cout << elem;
        first = false;
    }
    std::cout << "]\n";
};

void demo_polymorphic_container() {
    std::vector<int> v = {1, 2, 3};
    std::list<double> l = {1.1, 2.2, 3.3};
    std::array<std::string, 2> a = {"hello", "world"};
    std::set<int> s = {5, 3, 1, 4, 2};

    print_container(v);   // [1, 2, 3]
    print_container(l);   // [1.1, 2.2, 3.3]
    print_container(a);   // [hello, world]
    print_container(s);   // [1, 2, 3, 4, 5]
}

您看 demo_polymorphic_container 里那四行调用,同一个 print_container 把四种容器全接住了,注释里的四行输出一个都没有少。咱们拿 auto 参数配上范围 for 循环,一个 lambda 就吃下所有支持迭代的容器,也就用不着再为哪种容器补一份重载了。


参考资源 ​

验证代码 ​

本篇的性能对比和概念验证代码,您可以在 code/volumn_codes/vol2/ch03-lambda/ 找到:

  • test_recursive_lambda_performance.cpp:递归 lambda 不同实现的性能基准测试
  • test_concepts_error_messages.cpp:Concepts 与 SFINAE 错误信息质量对比

咱们用 CMake 编译运行:

bash
cd code/volumn_codes/vol2/ch03-lambda
cmake -B build
cmake --build build
./build/test_recursive_lambda_performance
./build/test_concepts_error_messages

pdf-latest-4-g85128cc · 85128cc · 2026-10-05