C++ 中,常规的函数语法通过“可调用对象”的概念得到了扩展 —— 可调用对象简称 callable,指的是可以像函数一样被调用的实体。一些可调用对象的例子包括函数、函数指针,以及重载了 operator() 的对象,也被称为函数对象:
void f(int i); struct G {
void operator()(int i);
};
f(5); // 函数
G g; g(5); // 函数对象
局部上下文中,就在使用位置附近定义一个可调用实体通常非常有用。例如,为了对一组对象进行排序,可能需要定义一个自定义的比较函数,可以使用一个普通的函数来实现:
bool compare(int i, int j) { return i < j; }
void do_work() {
std::vector<int> v;
.....
std::sort(v.begin(), v.end(), compare);
}
然而,函数不能在其他函数内部定义,因此 compare() 函数需要在离使用位置较远的地方定义。如果这是一个仅使用一次的比较函数,这种分离就会带来不便,并降低代码的可读性和可维护性。
有一种方法可以绕过这个限制 —— 虽然不能在函数内部声明函数,但可以在函数内部声明类,而类可以是可调用的:
void do_work() {
std::vector<int> v;
.....
struct compare {
bool operator()(int i, int j) const { return i < j; }
};
std::sort(v.begin(), v.end(), compare());
}
这种方式虽然紧凑且作用域局部,但显得相当冗长。实际上并不需要给这个类命名,而且通常只需要这个类的一个实例。在 C++11 中,有了一个更好的选择,即 Lambda 表达式:
void do_work() {
std::vector<int> v;
.....
auto compare = [](int i, int j) { return i < j; };
std::sort(v.begin(), v.end(), compare);
}
如果仅在一次 std::sort 调用中使用此比较函数,甚至无需为其命名,可以直接在调用内部进行定义:
std::sort(v.begin(), v.end(),
[](int i, int j) { return i < j; });
这已经是最简洁的写法了。Lambda 表达式的返回类型可以指定,但通常可以由编译器自动推导。Lambda 表达式会创建一个对象,因此其具有类型,但该类型由编译器生成,并且对象声明必须使用 auto。
Lambda 表达式是对象,可以拥有数据成员。当然,局部的可调用类也可以拥有数据成员。通常,这些数据成员会从外围作用域的局部变量进行初始化:
// Example 11
void do_work() {
std::vector<double> v;
.....
struct compare_with_tolerance {
const double tolerance;
explicit compare_with_tolerance(double tol) :
tolerance(tol) {}
bool operator()(double x, double y) const {
return x < y && std::abs(x - y) > tolerance;
}
};
double tolerance = 0.01;
std::sort(v.begin(), v.end(),
compare_with_tolerance(tolerance));
}
再次,这是一种实现简单功能却非常冗长的方式。必须提及容差变量三次 —— 作为数据成员、构造函数参数以及在成员初始化列表中。而 Lambda 表达式也能简化此代码,其可以捕获局部变量。在局部类中,不允许直接引用外围作用域的变量,除非通过构造函数参数传递;但对于 Lambda 表达式,编译器会自动生成一个构造函数,以捕获表达式主体中提到的所有局部变量:
void do_work() {
std::vector<double> v;
.....
double tolerance = 0.01;
auto compare_with_tolerance = [=](auto x, auto y) {
return x < y && std::abs(x - y) > tolerance;
};
std::sort(v.begin(), v.end(), compare_with_tolerance);
}
Lambda 表达式内部的名称 tolerance 指的是具有相同名称的局部变量。该变量通过值捕获,这在 Lambda 表达式的捕获子句 [=] 中指定,也可以使用 [&] 按引用捕获:
auto compare_with_tolerance = [&](auto x, auto y) {
return x < y && std::abs(x - y) > tolerance;
}
按值捕获时,会在 Lambda 对象构造时在其内部创建捕获变量的副本。这个局部副本默认是 const 的,可以将 Lambda 声明为 mutable,从而允许修改捕获的值:
double tolerance = 0.01;
size_t count = 0; // 第2行
auto compare_with_tolerance = [=](auto x, auto y) mutable {
std::cout << "called " << ++count << " times\n";
return x < y && std::abs(x - y) > tolerance;
};
std::vector<double> v;
... 在 v 中存储值 ...
// 统计调用次数,但不会改变第2行的值
std::sort(v.begin(), v.end(), compare_with_tolerance);
另一方面,通过引用捕获外层作用域的变量,会使 Lambda 内部对该变量的每次引用都指向原始变量。通过引用捕获的值可以进行修改:
double tolerance = 0.01;
size_t count = 0;
auto compare_with_tolerance = [&](auto x, auto y) mutable {
++count; // 修改上方的 count 变量
return x < y && std::abs(x - y) > tolerance;
};
std::vector<double> v;
... 在 v 中存储值 ...
std::sort(v.begin(), v.end(), compare_with_tolerance);
std::cout << "lambda called " << count << " times\n";
也可以显式地按值或按引用捕获某些变量;例如,捕获子句 [=, &count] 表示除 count 按引用捕获外,其他所有变量均按值捕获。
可以将前面示例中的 Lambda 表达式参数从 int 声明为 auto,这实际上使 Lambda 表达式的 operator() 成为一个模板(这是 C++14 的特性)。
Lambda 表达式最常被用作局部函数,但其实际上并不是函数;而是是可调用对象,因此缺少函数拥有的一项功能 —— 重载能力。本节最后要介绍的技巧是如何绕过这一限制,使用 Lambda 表达式创建一个重载集。
首先,核心思想是:确实无法重载可调用对象。另一方面,在同一个对象中重载多个 operator() 方法却非常容易 —— 方法的重载方式与其他函数相同。当然,Lambda 表达式对象的 operator() 是由编译器生成的,而非手动声明,因此无法强制编译器在同一个 Lambda 表达式中生成多个 operator()。但类有自己的优势,最主要的是可以进行继承。
Lambda 表达式是对象 —— 它们的类型是类,也可以对其进行继承。如果一个类公开继承自基类,那么基类的所有公共方法都将成为派生类的公共方法。如果一个类公开继承自多个基类(多重继承),其公共接口由所有基类的公共方法组成。如果此集合中存在多个同名方法,它们将形成重载,通常的重载解析规则将适用(特别是,可能创建一组有歧义的重载,此时程序将无法编译)。
因此,我们需要创建一个类,其能自动继承任意数量的基类。我们刚刚看到了实现此目的的合适工具 —— 可变参数模板。正如我们在上一节所了解的,遍历可变参数模板参数包中任意数量项的常用方法是通过递归:
// Example 12a
template <typename ... F> struct overload_set;
template <typename F1>
struct overload_set<F1> : public F1 {
overload_set(F1&& f1) : F1(std::move(f1)) {}
overload_set(const F1& f1) : F1(f1) {}
using F1::operator();
};
template <typename F1, typename ... F>
struct overload_set<F1, F ...> : public F1, public overload_set<F ...> {
overload_set(F1&& f1, F&& ... f) :
F1(std::move(f1)),
overload_set<F ...>(std::forward<F>(f) ...) {}
overload_set(const F1& f1, F&& ... f) :
F1(f1), overload_set<F ...>(std::forward<F>(f) ...) {}
using F1::operator();
using overload_set<F ...>::operator();
};
template <typename ... F> auto overload(F&& ... f) {
return overload_set<F ...>(std::forward<F>(f) ...);
}
overload_set 是一个可变参数类模板。特化之前,必须先声明通用模板,但它没有定义。第一个定义针对只有一个 Lambda 表达式的特殊情况 —— overload_set 类继承自该 Lambda 表达式,并将其 operator() 添加到自己的公共接口中。针对 N 个 Lambda 表达式(N>1)的特化版本,继承自第一个 Lambda 表达式以及由剩余 N-1 个 Lambda 表达式构造的 overload_set。最后,这里有一个辅助函数,可以从任意数量的 Lambda 表达式构造出重载集 —— 这不仅是便利,更是必需,因为无法显式指定 Lambda 表达式的类型,而必须让函数模板进行推导。现在,可以从任意数量的 Lambda 表达式构造一个重载集:
int i = 5;
double d = 7.3;
auto l = overload(
[](int* i) { std::cout << "i=" << *i << std::endl; },
[](double* d) { std::cout << "d=" << *d << std::endl; }
);
l(&i); // i=5
l(&d); // d=5.3
这种解决方案并不完美,其不能很好地处理有歧义的重载。C++17 中,可以做得更好,这也给了我们一个机会来展示一种使用参数包的替代方法,该方法无需递归。以下是 C++17 的版本:
// Example 12b
template <typename ... F>
struct overload_set : public F ... {
overload_set(F&& ... f) : F(std::forward<F>(f)) ... {}
using F::operator() ...; // C++17
};
template <typename ... F> auto overload(F&& ... f) {
return overload_set<F ...>(std::forward<F>(f) ...);
}
可变参数模板不再依赖于偏特化,其直接从参数包继承(实现的这一部分在 C++14 中也可行,但 using 声明需要 C++17)。模板辅助函数保持不变 —— 推导出所有 Lambda 表达式的类型,并使用这些类型实例化的 overload_set 来构造一个对象。Lambda 表达式本身通过完美转发传递给基类,在那里用于初始化 overload_set 对象的所有基类对象(Lambda 表达式是可移动的)。由于不再需要递归或偏特化,这个模板变得更为紧凑和直接。其使用方式与之前的 overload_set 版本相同,但能更好地处理接近歧义的重载。
甚至可以去掉模板函数,而使用模板推导指引:
// Example 12c
template <typename ... F>
struct overload : public F ... {
using F::operator() ...;
};
template <typename ... F> // Deduction guide
overload(F&& ... ) -> overload<F ...>;
overload 模板的使用基本保持不变,这里使用了花括号来构造对象:
int i = 5;
double d = 7.3;
auto l = overload{
[](int* i) { std::cout << "i=" << *i << std::endl; },
[](double* d) { std::cout << "d=" << *d << std::endl; },
};
l(&i); // i=5
l(&d); // d=5.3
本书后面的章节中,将大量使用 Lambda 表达式。
接下来,我们将介绍一项新的 C++ 特性,从某种意义上说,它与迄今为止试图实现的目标恰恰相反:它使模板变得更不通用,使用模板很容易过度承诺:可以定义一些模板,但其定义在某些情况下无法编译。更好的做法是将对模板参数的限制作为声明的一部分明确表达出来。接下来,来看看如何实现。