Lambda(匿名函数)简介

回顾课程 《标准库算法简介》中的示例代码:

#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::functionauto 进行类型推断后存储(即使 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(无捕获时亦可用函数指针)。

泛型 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; // 错误:类型与前句不符
  } };
}

若需返回不同类型,可:

  1. 显式强制转换使类型一致;或
  2. 显式指定 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

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"