回顾课程 《标准库算法简介》中的示例代码:
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
// 若元素包含目标子串,则返回 true
bool containsNut(std::string_view str)
{
// std::string_view::find 若未找到子串,则返回 std::string_view::npos
// 否则返回子串在 str 中的起始索引
return str.find("nut") != std::string_view::npos;
}
int main()
{
constexpr std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };
// 遍历数组,查找首个包含 "nut" 子串的元素
auto found{ std::find_if(arr.begin(), arr.end(), containsNut) };
if (found == arr.end())
{
std::cout << "No nuts\n";
}
else
{
std::cout << "Found " << *found << '\n';
}
return 0;
}
该程序在字符串数组中查找首个包含子串 “nut” 的元素,输出:
Found walnut
尽管代码可行,但仍有改进空间。
问题在于 std::find_if
要求传入函数指针。因此,我们被迫定义了一个仅使用一次的函数,必须为其命名,并置于全局作用域(因函数无法嵌套!)。该函数短小至极,其单行实现比函数名与注释更易理解。
Lambda 表达式(匿名函数)
Lambda 表达式(亦称 lambda 或闭包)允许在函数内部定义匿名函数。嵌套能力尤为重要,它既可避免命名空间污染,又能把函数定义置于其调用点之近旁,从而提供更多上下文。
Lambda 的语法在 C++ 中颇为独特,需稍加熟悉。其基本形式如下:
[ captureClause ] ( parameters ) -> returnType
{
statements;
}
- 若无需捕获,捕获子句可为空。
- 若无需形参,形参表可留空;若未指定返回类型,亦可省略形参表。
- 返回类型为可选项;若省略,则假定为
auto
,由编译器根据返回语句推断。此前我们曾建议避免对函数返回类型使用类型推断,但在此处(函数极短)则无妨。 - Lambda 匿名,故无需提供名称。
顺便一提…
最简单的 lambda 可写为:
#include <iostream>
int main()
{
[] {}; // 无捕获、无形参、返回类型省略的 lambda
return 0;
}
以下用 lambda 重写前述示例:
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
int main()
{
constexpr std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };
// 在使用处直接定义函数
auto found{ std::find_if(arr.begin(), arr.end(),
[](std::string_view str) // 此处为 lambda,无捕获子句
{
return str.find("nut") != std::string_view::npos;
}) };
if (found == arr.end())
{
std::cout << "No nuts\n";
}
else
{
std::cout << "Found " << *found << '\n';
}
return 0;
}
运行结果与函数指针版本完全一致:
Found walnut
可见 lambda 与 containsNut
函数在形参和函数体上完全相同。该 lambda 无需捕获子句(下一课将解释其含义)。我们省略了尾置返回类型,但因 operator!=
返回 bool
,故 lambda 的返回类型亦为 bool
。
最佳实践
遵循“在最小作用域且最靠近首次使用处定义”的最佳实践时,若仅需一个简单的一次性函数用作实参,应优先选用 lambda,而非普通函数。
Lambda 的类型
在前例中,我们把 lambda 直接写在使用处,这种用法有时称为函数字面量。
然而,把 lambda 与使用写在同一行有时会降低可读性。正如我们可以用字面量或函数指针初始化变量以备后用,我们亦可将 lambda 存入变量再延后使用。具名 lambda 配合恰当函数名,可提升代码可读性。
例如,下例用 std::all_of
检查数组元素是否全为偶数:
// 不佳:需要阅读 lambda 才能理解意图
return std::all_of(array.begin(), array.end(), [](int i){ return ((i % 2) == 0); });
可改写为:
// 较好:将 lambda 存入具名变量再传递
auto isEven{
[](int i)
{
return (i % 2) == 0;
}
};
return std::all_of(array.begin(), array.end(), isEven);
注意最后一行读起来自然:“返回数组中是否所有元素均为偶数”。
关键洞见
把 lambda 存入变量,一方面可为其赋予有意义的名称以增强可读性;另一方面亦允许 lambda 被多次使用。
那么 isEven
的类型是什么?
实际上,lambda 并无我们可以直接使用的类型。写下 lambda 时,编译器会为其生成一个独一无二的、未暴露给我们的类型。
进阶阅读
实际上,lambda 并非函数(这正是其绕过 C++ 不支持嵌套函数限制的原因),而是一种称为“函子”(functor)的特殊对象。函子内部含有重载的 operator()
,使其可像函数一样被调用。
尽管不知 lambda 的确切类型,仍有多种方式在定义后存储 lambda:
- 若 lambda 无捕获(
[]
为空),可用普通函数指针存储; - 亦可用
std::function
或auto
进行类型推断后存储(即使 lambda 有捕获亦可)。
#include <functional>
int main()
{
// 1. 普通函数指针,仅适用于无捕获的 lambda
double (*addNumbers1)(double, double){
[](double a, double b) {
return a + b;
}
};
addNumbers1(1, 2);
// 2. 使用 std::function,支持含捕获的 lambda
std::function addNumbers2{ // 注意:C++17 前须写 std::function<double(double, double)>
[](double a, double b) {
return a + b;
}
};
addNumbers2(3, 4);
// 3. 使用 auto,按实际类型存储
auto addNumbers3{
[](double a, double b) {
return a + b;
}
};
addNumbers3(5, 6);
return 0;
}
唯有 auto
能直接获得 lambda 的实际类型,且相较于 std::function
无任何运行时开销。
若需将 lambda 作为函数形参,有四种可选方案:
#include <functional>
#include <iostream>
// 方案 1:使用 std::function
void repeat1(int repetitions, const std::function<void(int)>& fn)
{
for (int i{ 0 }; i < repetitions; ++i)
fn(i);
}
// 方案 2:函数模板 + 类型模板形参
template <typename T>
void repeat2(int repetitions, const T& fn)
{
for (int i{ 0 }; i < repetitions; ++i)
fn(i);
}
// 方案 3:C++20 简写函数模板
void repeat3(int repetitions, const auto& fn)
{
for (int i{ 0 }; i < repetitions; ++i)
fn(i);
}
// 方案 4:函数指针(仅适用于无捕获 lambda)
void repeat4(int repetitions, void (*fn)(int))
{
for (int i{ 0 }; i < repetitions; ++i)
fn(i);
}
int main()
{
auto lambda = [](int i)
{
std::cout << i << '\n';
};
repeat1(3, lambda);
repeat2(3, lambda);
repeat3(3, lambda);
repeat4(3, lambda);
return 0;
}
- 方案 1 形参为
std::function
,可显式看出参数与返回类型,但每次调用需隐式转换,略有开销。此写法可分离声明(头文件)与定义(.cpp)。 - 方案 2 使用函数模板,编译器针对 lambda 的实际类型实例化函数,效率更高,但形参类型不显。
- 方案 3 利用 C++20 简写函数模板,效果同方案 2。
- 方案 4 形参为函数指针,仅接受无捕获的 lambda,因其可隐式转换为函数指针。
最佳实践
- 将 lambda 存入变量时,变量类型请使用
auto
。 - 将 lambda 作为函数实参时:
- 若编译器支持 C++20,优先使用
auto
作为形参类型; - 否则,使用函数模板形参或
std::function
(无捕获时亦可用函数指针)。
- 若编译器支持 C++20,优先使用
泛型 Lambda
Lambda 形参遵循与普通函数形参相同的大多数规则。
自 C++14 起,允许在 lambda 形参中使用 auto
(C++20 起,普通函数亦可)。若 lambda 含一个或多个 auto
形参,编译器将根据调用点推断所需形参类型。此类 lambda 称为泛型 lambda。
进阶阅读
在 lambda 语境中,auto
实为模板形参的简写。
示例:
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
int main()
{
constexpr std::array months{
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
};
// 寻找连续两月名称首字母相同
const auto sameLetter{ std::adjacent_find(months.begin(), months.end(),
[](const auto& a, const auto& b) {
return a[0] == b[0];
}) };
if (sameLetter != months.end())
{
std::cout << *sameLetter << " and " << *std::next(sameLetter)
<< " start with the same letter\n";
}
return 0;
}
输出:
June and July start with the same letter
通过 auto
形参以 const auto&
捕获字符串,使得 lambda 可接受 std::string
、C 风格字符串等任意类型。若后续修改 months
的类型,无需重写 lambda。
然而,auto
并非万能。例如:
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
int main()
{
constexpr std::array months{
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
};
// 统计长度为 5 的月份
const auto fiveLetterMonths{ std::count_if(months.begin(), months.end(),
[](std::string_view str) {
return str.length() == 5;
}) };
std::cout << "There are " << fiveLetterMonths << " months with 5 letters\n";
return 0;
}
此处若用 auto
会推断为 const char*
,C 风格字符串操作不便。显式指定 std::string_view
更便于使用(如可直接取长度)。
Constexpr Lambda
自 C++17 起,若满足常量表达式要求,lambda 隐式为 constexpr
。通常需满足:
- Lambda 无捕获,或所有捕获值均为
constexpr
; - Lambda 调用的函数均为
constexpr
(许多标准库算法与数学函数直至 C++20/C++23 才 constexpr 化)。
上例中,C++17 下 lambda 不会隐式 constexpr
,而 C++20 中因 std::count_if
已 constexpr,故可写:
constexpr auto fiveLetterMonths{ std::count_if(months.begin(), months.end(),
[](std::string_view str) {
return str.length() == 5;
}) };
泛型 Lambda 与静态变量
在《函数模板实例化》中,我们指出:若函数模板含静态局部变量,则每实例化一函数,该变量独立。泛型 lambda 亦如此:每遇不同 auto
推断类型,会生成唯一 lambda。
示例展示同一泛型 lambda 生成两份不同实例:
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
int main()
{
auto print{
[](auto value) {
static int callCount{ 0 };
std::cout << callCount++ << ": " << value << '\n';
}
};
print("hello"); // 0: hello
print("world"); // 1: world
print(1); // 0: 1
print(2); // 1: 2
print("ding dong"); // 2: ding dong
}
输出:
0: hello
1: world
0: 1
1: 2
2: ding dong
由于生成了两份 lambda,每份拥有独立 callCount
。如需共享计数,须定义全局或 lambda 外静态变量(二者均易引发问题)。下一课将讨论 lambda 捕获,届时可避免此类变量。
返回类型推断与尾置返回类型
若使用返回类型推断,lambda 的返回类型由内部所有 return
语句共同决定,且所有语句返回类型必须一致,否则报错。
#include <iostream>
int main()
{
auto divide{ [](int x, int y, bool intDivision) {
if (intDivision)
return x / y; // 返回类型 int
else
return static_cast<double>(x) / y; // 错误:类型与前句不符
} };
}
若需返回不同类型,可:
- 显式强制转换使类型一致;或
- 显式指定 lambda 返回类型,由编译器做隐式转换。后者更佳:
#include <iostream>
int main()
{
auto divide{ [](int x, int y, bool intDivision) -> double {
if (intDivision)
return x / y; // 隐式转为 double
else
return static_cast<double>(x) / y;
} };
}
如此,若日后修改返回类型,仅需改动 lambda 返回类型,而无需改动函数体。
标准库函数对象
对于常见操作(如加法、取反、比较等),无需自行编写 lambda,标准库已提供诸多基本可调用对象,位于 <functional>
头文件。
示例:
#include <algorithm>
#include <array>
#include <iostream>
bool greater(int a, int b)
{
return a > b;
}
int main()
{
std::array arr{ 13, 90, 99, 5, 40, 80 };
std::sort(arr.begin(), arr.end(), greater);
for (int i : arr)
std::cout << i << ' ';
std::cout << '\n';
}
输出:
99 90 80 40 13 5
与其将 greater
改写为 lambda,不如直接使用 std::greater
:
#include <algorithm>
#include <array>
#include <iostream>
#include <functional>
int main()
{
std::array arr{ 13, 90, 99, 5, 40, 80 };
std::sort(arr.begin(), arr.end(), std::greater{}); // 注意需花括号实例化
for (int i : arr)
std::cout << i << ' ';
std::cout << '\n';
}
输出同上。
结论
相较于手写循环,lambda 与算法库的组合初看繁复,却能在寥寥数行内完成强大操作,且可读性更高。此外,算法库支持便捷而高效的并行化,循环则难望项背。升级使用库函数的代码,亦远易于升级手写循环。
Lambda 虽好,却非万能。对于复杂且需复用的场景,仍应优先使用普通函数。
测验时间
问题 1
创建结构体 Student
,存储学生姓名与分数;创建学生数组,使用 std::max_element
找出分数最高者并输出其姓名。std::max_element
接收区间首尾迭代器,以及一个二元谓词函数,若第一实参小于第二实参则返回 true
。
给定数组
std::array<Student, 8> arr{
{ { "Albert", 3 },
{ "Ben", 5 },
{ "Christine", 2 },
{ "Dan", 8 },
{ "Enchilada", 4 },
{ "Francis", 1 },
{ "Greg", 3 },
{ "Hagrid", 5 } }
};
程序应输出:
Dan is the best student
问题 2
使用 std::sort
与 lambda 对下列代码中的季节按平均气温升序排序:
#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
struct Season
{
std::string_view name{};
double averageTemperature{};
};
int main()
{
std::array<Season, 4> seasons{
{ { "Spring", 285.0 },
{ "Summer", 296.0 },
{ "Fall", 288.0 },
{ "Winter", 263.0 } }
};
/*
* 在此处使用 std::sort
*/
for (const auto& season : seasons)
{
std::cout << season.name << '\n';
}
return 0;
}
程序应输出:
Winter
Spring
Fall
Summer