我们将在本章介绍以下食谱:
- 使用 lambda 表达式在运行时定义函数
- 通过在
std::function
中包裹羔羊肉来增加多晶型 - 通过串联组合函数
- 使用逻辑连接创建复杂谓词
- 用同一个输入调用多个函数
- 使用
std::accumulate
和 lambdas 实现transform_if
- 在编译时生成任何输入的笛卡尔乘积对
C++ 11 的一个重要新特性是lambda 表达式。在 C++ 14 和 C++ 17 中,lambda 表达式得到了一些新的增加,这使得它们更加强大。但是首先,是什么一个 lambda 表达式?
Lambda 表达式或 lambda 函数构造闭包。闭包是未命名对象的一个非常通用的术语,它可以像函数一样被称为。为了在 C++ 中提供这样的能力,这样的对象必须实现()
函数调用运算符,无论有无参数。在 C++ 11 之前构建这样一个没有 lambda 表达式的对象可能仍然如下所示:
#include <iostream>
#include <string>
int main() {
struct name_greeter {
std::string name;
void operator()() {
std::cout << "Hello, " << name << 'n';
}
};
name_greeter greet_john_doe {"John Doe"};
greet_john_doe();
}
name_greeter
结构的实例显然带有一个字符串。请注意,这个结构类型和实例都不是未命名的,但是 lambda 表达式可以,正如我们将看到的。就闭包而言,我们会说它们捕获了一个字符串。当示例实例像没有参数的函数一样被调用时,它会打印"Hello, John Doe"
,因为我们用这个名称构造了它。
自从 C++ 11 以来,创建这样的闭包变得更加容易:
#include <iostream>
int main() {
auto greet_john_doe ([] {
std::cout << "Hello, John Doen";
});
greet_john_doe();
}
就这样。整个结构name_greeter
被一个小的[] { /* do something */ }
结构代替,一开始看起来可能有点像魔法,但是本章的第一节将在所有可能的变体中彻底解释它。
Lambda 表达式对使代码通用和整洁有很大帮助。它们可以用作非常通用的算法的参数,以便在处理特定的用户定义类型时专门化这些算法。它们还可以用来将工作包和数据包装在一起,以便在线程中运行,或者只是为了保存工作并推迟实际执行。自从 C++ 11 问世以来,越来越多的库使用 lambda 表达式,因为它们已经成为 C++ 中非常自然的事情。另一个用例是元编程,因为 lambda 表达式也可以在编译时计算。然而,我们并没有深入到那个方向,因为这将很快冲击这本书的范围。
这一章确实严重依赖一些函数式编程模式,对于已经有经验但不熟悉这些模式的新手或程序员来说,这可能看起来很奇怪。如果您在即将到来的返回 lambda 表达式的食谱中看到 lambda 表达式,这些表达式再次返回 lambda 表达式,请不要太快感到沮丧或困惑。我们有点突破界限,以便为现代 C++ 做好准备,在现代 c++ 中,函数式编程模式越来越有规律地出现。如果下面食谱中的一些代码看起来有点太复杂,请慢慢理解。一旦你解决了这个问题,现实项目中复杂的 lambda 表达式就不会再让你困惑了。
有了 lambda 表达式,我们可以封装代码以便以后调用它,这也可能在其他地方,因为我们可以复制它们。我们也可以封装代码,用稍微不同的参数多次调用它,而不必为该任务实现一个全新的函数类。
lambda 表达式的语法在 C++ 11 中确实是新的,在 C++ 17 之前,它随着接下来的两个标准版本略有发展。在本节中,我们将看到 lambda 表达式可能是什么样子以及它们的含义。
我们将编写一个小程序,在其中使用 lambda 表达式,以便获得对它们的感觉:
- Lambda 表达式不需要任何库支持,但是我们将向终端写入消息并使用字符串,因此我们需要这样的头:
#include <iostream>
#include <string>
- 这次一切都发生在主函数中。我们定义了两个不带参数的函数对象,并用值
1
和2
返回整数常数。请注意,return 语句被花括号{}
包围,就像在普通函数中一样,表示无参数函数的()
括号是*可选的,*我们不在第二个 lambda 表达式中提供它们。但是[]
括号必须存在:
int main()
{
auto just_one ( [](){ return 1; } );
auto just_two ( [] { return 2; } );
- 现在,我们可以通过写下保存变量的名称并附加括号来调用这两个函数对象。在这一行中,对于读者来说,它们与正常功能没有区别:
std::cout << just_one() << ", " << just_two() << 'n';
- 现在让我们忘掉这些,定义另一个函数对象,它被称为
plus
,因为它接受两个参数并返回它们的和:
auto plus ( [](auto l, auto r) { return l + r; } );
- 这也很容易使用,就像任何其他二进制函数一样。因为我们将其参数定义为
auto
类型,所以它将与定义加号运算符+
的任何东西一起工作,就像字符串一样:
std::cout << plus(1, 2) << 'n';
std::cout << plus(std::string{"a"}, "b") << 'n';
- 我们不需要在变量中存储 lambda 表达式来使用它。我们也可以在适当的地方定义它*,然后将参数写在它后面的括号中
(1, 2)
:*
std::cout
<< [](auto l, auto r){ return l + r; }(1, 2)
<< 'n';
- 接下来,我们将定义一个带有整数计数器值的闭包。每当我们调用它时,它都会增加计数器值并返回新值。为了告诉它有一个内部计数器变量,我们在括号内写
count = 0
告诉它有一个初始化为整数值0
的变量count
。为了允许它修改自己捕获的变量,我们使用mutable
关键字,因为编译器不允许这样做:
auto counter (
[count = 0] () mutable { return ++ count; }
);
- 现在,让我们调用函数对象五次,并打印它返回的值,这样我们就可以在后面看到不断增加的数值:
for (size_t i {0}; i < 5; ++ i) {
std::cout << counter() << ", ";
}
std::cout << 'n';
- 我们也可以通过引用来获取现有变量,而不是给闭包一个自己的值副本。这样,捕获的变量可以通过闭包来增加,但是它仍然可以在外部访问。为了做到这一点,我们在括号之间写下
&a
,其中&
表示我们只存储变量的引用,而不是副本:
int a {0};
auto incrementer ( [&a] { ++ a; } );
- 如果这样的话,那么我们应该可以多次调用这个函数对象,然后观察它是否真的改变了变量
a
的值:
incrementer();
incrementer();
incrementer();
std::cout
<< "Value of 'a' after 3 incrementer() calls: "
<< a << 'n';
- 最后一个例子是巴结。Currying 是指我们取一个可以接受某些参数的函数,存储在另一个函数对象中,这个函数对象接受的参数比参数少。在这种情况下,我们存储
plus
功能,只接受一个参数,我们将其转发给plus
功能。另一个参数是值10
,我们保存在函数对象中。这样,我们得到一个函数,我们称之为plus_ten
,因为它可以将该值添加到它接受的单个参数中:
auto plus_ten ( [=] (int x) { return plus(10, x); } );
std::cout << plus_ten(5) << 'n';
}
- 在编译和运行程序之前,再次检查代码,并尝试预见它将打印到终端上的内容。然后运行它,并检查实际输出:
1, 2
3
ab
3
1, 2, 3, 4, 5,
Value of a after 3 incrementer() calls: 3
15
我们刚才做的事情并不太复杂——我们添加了数字,然后递增并打印出来。我们甚至用一个函数对象来连接字符串,这个函数对象被实现来累加数字。但是对于那些还不知道 lambda 表达式语法的人来说,它可能看起来很混乱。
那么,让我们先来看看 lambda 表达式的所有特性:
在一般情况下,我们通常可以省略其中的大部分,这样可以节省一些打字时间。最短的 lambda 表达式可能是[]{}
。它不接受任何参数,不捕捉任何东西,本质上什么也不做。
那么剩下的是什么意思呢?
指定我们是否捕获以及捕获什么。这样做有几种形式。有两种懒惰的变体:
- 如果我们写
[=] () {...}
,我们通过值从外部捕获闭包引用的每个变量,这意味着值是复制的 - 写
[&] () {...}
意味着闭包引用外部的所有东西都只被引用捕获,而不会导致复制。
当然,我们可以为每个变量单独设置捕获设置。写[a, &b] () {...}
意味着,我们通过值捕捉变量a
,通过引用捕捉b
。这是更多的打字工作,但通常这样冗长会更安全,因为我们不能意外地从外部捕获我们不想捕获的东西。
在配方中,我们定义了一个 lambda 表达式,如下所示:[count=0] () {...}
。在这种特殊情况下,我们没有从外部捕获任何变量,而是定义了一个新的变量count
。它的类型是从我们初始化它的值推导出来的,即0
,所以它是一个int
。
也可以通过值捕获一些变量,通过引用捕获一些变量,如:
[a, &b] () {...}
:通过复制捕捉a
,通过引用捕捉b
。[&, a] () {...}
:通过复制捕获a
,通过引用捕获任何其他使用的变量。[=, &b, i{22}, this] () {...}
:通过引用捕获b
,通过复制捕获this
,用值22
初始化新变量i
,通过复制捕获任何其他使用的变量。
If you try to capture a member variable of an object, you cannot do this directly using [member_a] () {...}
. Instead, you have to capture either this
or *this
.
如果函数对象应该能够修改通过复制* ( [=]
)捕捉到的*变量,则必须定义mutable
。这包括调用捕获对象的非常数方法。
如果我们将 lambda 表达式显式标记为constexpr
,如果它不满足constexpr
函数的标准,编译器将会出错出来。constexpr
函数和 lambda 表达式的优点是,如果用编译时常量参数调用它们,编译器可以在编译时评估它们的结果。这导致二进制文件中的代码较少。
如果我们没有显式地将 lambda 表达式声明为constexpr
但是它符合这个要求,那么无论如何它都将隐式地为constexpr
。如果我们希望 lambda 表达式成为 T2 表达式,显式是有帮助的,因为如果我们做错了 T7,编译器会通过出错来帮助我们。
*# 异常属性(可选)
这是指定函数对象在被调用并遇到错误情况时是否可以抛出异常的地方。
如果我们想最终控制返回类型,我们可能不希望编译器自动为我们推导它。在这种情况下,我们可以直接写[] () -> Foo {}
,告诉编译器我们真的会一直返回Foo
类型。
假设我们想为某种值编写一个观察者函数,它有时可能会改变,然后通知其他对象;比如气压指示器,或者股票价格,或者类似的东西。每当值改变时,应该调用一个观察者对象列表,然后这些对象做出反应。
为了实现这一点,我们可以在一个向量中存储一系列的观测器函数对象,这些对象都接受一个int
变量作为参数,它代表观测值。我们不知道这些函数对象在用新值调用时具体会做什么,但我们也不在乎。
函数对象的向量是什么类型的?如果我们用签名如void f(int);
捕获指向函数的指针,那么std::vector<void (*)(int)>
类型将是正确的。这确实也适用于任何不捕捉任何变量的 lambda 表达式,例如[](int x) {...}
。但是一个能够捕捉某些东西的 lambda 表达式实际上是一个与普通函数完全不同的类型,因为它不仅仅是一个函数指针。是一个对象把一定量的数据和一个函数耦合起来!想想 C++ 之前的 11 次,那时没有 lambdas。类和结构是将数据与函数耦合的自然方式,如果更改类的数据成员类型,就会得到完全不同的类类型。只是自然一个向量不能用同一个类型名存储完全不同的类型。
告诉用户只能保存不捕获任何东西的观察者函数对象是不好的,因为这极大地限制了用例的数量。我们如何允许用户存储任何类型的函数对象,只约束到调用接口,该接口接受一组特定的参数,这些参数代表应该观察的值?
本节展示了如何使用std::function
解决这个问题,它可以充当任何 lambda 表达式的多态包装器,无论它是否捕获了什么。
在这一节中,我们将创建几个 lambda 表达式,它们捕获的变量类型完全不同,但具有相同的函数调用签名。这些将使用std::function
保存在一个向量中:
- 让我们首先做一些必要的包括:
#include <iostream>
#include <deque>
#include <list>
#include <vector>
#include <functional>
- 我们实现了一个返回 lambda 表达式的小函数。它接受一个容器,并返回一个通过引用捕获该容器的函数对象。函数对象本身接受一个整数参数。每当该函数对象被输入一个整数时,它会将该整数追加到它捕获的容器中:
static auto consumer (auto &container){
return [&] (auto value) {
container.push_back(value);
};
}
- 另一个小助手函数将打印我们作为参数提供的任何容器实例:
static void print (const auto &c)
{
for (auto i : c) {
std::cout << i << ", ";
}
std::cout << 'n';
}
- 在主函数中,我们首先实例化一个
deque
、一个list
和一个vector
,它们都存储整数:
int main()
{
std::deque<int> d;
std::list<int> l;
std::vector<int> v;
- 现在我们将
consumer
函数用于我们的容器实例,d
、l
和v
:我们为这些容器生成消费者函数对象,并将它们全部存储在一个vector
实例中。然后我们有一个存储三个函数对象的向量。这些函数对象各自捕获对其中一个容器对象的引用。这些容器对象是完全不同的类型,函数对象也是如此。然而,向量包含std::function<void(int)>
的实例。所有的函数对象都被隐式包装成这样的std::function
对象,然后存储在向量中:
const std::vector<std::function<void(int)>> consumers
{consumer(d), consumer(l), consumer(v)};
- 现在,我们向所有数据结构提供 10 个整数值,方法是循环这些值,然后循环消费者函数对象,我们用这些值调用这些对象:
for (size_t i {0}; i < 10; ++ i) {
for (auto &&consume : consumers) {
consume(i);
}
}
- 所有三个容器现在应该包含相同的 10 个数值。让我们打印他们的内容:
print(d);
print(l);
print(v);
}
- 编译和运行程序会产生以下输出,这正是我们所期望的:
$ ./std_function
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
这个食谱的复杂部分是下面一行:
const std::vector<std::function<void(int)>> consumers
{consumer(d), consumer(l), consumer(v)};
物体d
、l
、v
各自包裹成一个consumer(...)
呼叫。该调用返回函数对象,然后每个对象捕获对d
、l
和v
之一的引用。虽然这些函数对象都接受int
值作为参数,但是它们完全捕获不同变量的事实也使得它们完全不同类型。这就像试图将类型为A
、B
和C
的变量填充到一个向量中,尽管这些类型没有任何共同之处*。*
为了解决这个问题,我们需要找到一个通用的类型,可以存储非常不同的功能对象,也就是std::function
。一个std::function<void(int)>
对象可以存储任何函数对象或传统函数,接受一个整数参数,不返回任何内容。它使用多态将它的类型从底层函数对象类型中分离出来。假设我们写了这样的东西:
std::function<void(int)> f (
[&vector](int x) { vector.push_back(x); });
在这里,由 lambda 表达式构造的函数对象被包装成一个std::function
对象,每当我们调用f(123)
时,这就导致了一个虚拟函数调用,它被重定向到里面的实际函数对象。
在存储函数对象时,std::function
实例应用了一些智能。如果我们在 lambda 表达式中捕获越来越多的变量,它一定会变大。如果它的大小不是太大,std::function
可以把它储存在自己里面。如果存储的函数对象太大,std::function
将在堆上分配一大块内存,然后将大的函数对象存储在那里。这不会影响我们代码的功能,但是我们应该知道这一点,因为这会影响我们代码的性能。
A lot of novice programmers think or hope that std::function<...>
actually expresses the type of a lambda expression. No, it doesn't. It is a polymorphic library helper, which is useful for wrapping lambda expressions and erasing their type differences.
很多任务真的不值得用完全定制的代码来实现。例如,让我们看一下程序员如何使用编程语言 Haskell 解决找出一个文本包含多少个唯一单词的任务。第一行定义了一个函数unique_words
,第二行用一个示例字符串演示了它的用法:
哇,真短!在不过多解释 Haskell 语法的情况下,让我们看看代码是做什么的。它定义了一个名为unique_words
的函数,该函数对其输入应用了一系列函数。它首先用map toLower
将输入的所有字符映射为小写。这样,像FOO
和foo
这样的词就可以看作是同一个词。然后,words
功能将一个句子拆分成单独的单词,如从"foo bar baz"
到["foo", "bar", "baz"]
。下一步是整理新的单词列表。这样,像["a", "b", "a"]
这样的单词序列就变成了["a", "a", "b"]
。现在,group
功能接管。它将连续相等的单词分组到分组列表中,因此["a", "a", "b"]
变成了[ ["a", "a"], ["b"] ]
。这项工作现在几乎完成了,因为我们现在只需要计算有多少组相等的单词,这正是length
函数所做的。
这是一种奇妙的风格的编程,因为我们可以阅读从右到左发生了什么,因为我们只是,某种程度上,描述了一个转换管道。我们不需要关心各个部分是如何实现的(除非它们很慢或者有问题)。
然而,我们在这里不是为了表扬 Haskell,而是为了提高我们的 C++ 技能。在 C++ 中也可以这样工作。我们不会完全达到 Haskell 示例的优雅,但我们仍然拥有最快的编程语言。这个例子解释了如何用 lambda 表达式模仿 C++ 中的函数串联。
在这一节中,我们定义了一些简单的玩具函数对象,连接它们,所以我们得到了一个单一的函数,它将简单的玩具函数一个接一个地应用到我们给它的输入中。为此,我们编写了自己的串联帮助函数:
- 首先,我们需要一些包括:
#include <iostream>
#include <functional>
- 然后,我们实现助手函数
concat
,它任意取多个参数。这些参数将是函数,如f
、g
、h
,结果将是对任何输入应用f(g(h(...)))
的另一个函数对象:
template <typename T, typename ...Ts>
auto concat(T t, Ts ...ts)
{
- 现在,事情变得有点复杂。当用户提供功能
f
、g
、h
时,我们将此评估为f( concat(g, h) )
,再次扩展为f( g( concat(h) ) )
,递归中止,得到f( g( h(...) ) )
。这个表示这些用户函数串联的函数调用链由一个 lambda 表达式捕获,该表达式稍后可以获取一些参数p
,然后将它们转发给f(g(h(p)))
。这个 lambda 表达式就是我们返回的。if constexpr
构造检查我们是否在递归步骤中,有多个函数要连接左边:
if constexpr (sizeof...(ts) > 0) {
return [=](auto ...parameters) {
return t(concat(ts...)(parameters...));
};
}
- 如果我们在递归的端,编译器会选择
if constexpr
构造的另一个分支。在这种情况下,我们只返回函数t
,因为它是唯一剩下的参数:
else {
return t;
}
}
- 现在,让我们使用我们很酷的新函数连接帮助器来连接一些我们想要看到的函数。让我们从
main
函数开始,在这里我们定义了两个便宜的简单函数对象:
int main()
{
auto twice ([] (int i) { return i * 2; });
auto thrice ([] (int i) { return i * 3; });
- 现在让我们串联起来。我们用 STL 函数
std::plus<int>
连接我们的两个乘数函数对象,该函数接受两个参数并简单地返回它们的和。这样,我们得到了一个函数,它执行twice(thrice(plus( a, b )))
。
auto combined (
concat(twice, thrice, std::plus<int>{})
);
- 现在让我们使用它。
combined
函数现在看起来像一个普通的函数,编译器也能够连接这些函数,而没有任何不必要的开销:
std::cout << combined(2, 3) << 'n';
}
- 编译并运行我们的程序会产生以下输出,这也是我们所期望的,因为
2 * 3 * (2 + 3)
就是30
:
$ ./concatenation
30
这部分比较复杂的是concat
功能。它看起来非常复杂,因为它将参数包ts
解包为另一个 lambda 表达式,该表达式再次递归调用concat
,参数较少:
template <typename T, typename ...Ts>
auto concat(T t, Ts ...ts)
{
if constexpr (sizeof...(ts) > 0) {
return [=](auto ...parameters) {
return t(concat(ts...)(parameters...));
};
} else {
return [=](auto ...parameters) {
return t(parameters...);
};
}
}
让我们编写一个更简单的版本,它将三个函数串联起来:
template <typename F, typename G, typename H>
auto concat(F f, G g, H h)
{
return [=](auto ... params) {
return f( g( h( params... ) ) );
};
}
这看起来已经很相似了,但没那么复杂。我们返回一个 lambda 表达式,它捕获f
、g
和h
。这个 lambda 表达式任意接受许多参数,并将它们转发给f
、g
和h
的调用链。当我们写auto combined (concat(f, g, h))
,然后用两个参数调用那个函数对象,比如combined(2, 3)
,那么2, 3
就由前面concat
函数的params
包来表示。
再看复杂得多、通用的concat
功能;我们唯一真正不同的是f ( g( h( params... ) ) )
串联。取而代之的是,我们编写f( concat(g, h) )(params...)
,它在下一次递归调用中计算为f( g( concat(h) ) )(params...)
,然后最终得到f( g( h( params... ) ) )
。
当用泛型代码过滤数据时,我们最终定义了谓词,它们告诉我们想要什么数据,以及我们不想要什么数据。有时候谓词是不同谓词的组合。
例如,当过滤字符串时,我们可以实现一个谓词,如果它的输入字符串以"foo"
开始,则该谓词返回true
。如果另一个谓词的输入字符串以"bar"
结束,则该谓词可能返回 true。
我们可以通过组合来重用谓词,而不是一直编写自定义谓词。如果我们想过滤以"foo"
开头、以"bar"
结尾的字符串,我们可以选择我们的现有的谓词和谓词,并用一个逻辑和组合它们。在本节中,我们将使用 lambda 表达式,以便找到一种舒适的方法来实现这一点。
我们将实现非常简单的字符串过滤器谓词,然后我们将它们与一个小的助手函数组合,该函数以通用的方式为我们完成组合。
- 一如既往,我们将首先包含一些标题:
#include <iostream>
#include <functional>
#include <string>
#include <iterator>
#include <algorithm>
- 因为我们稍后会用到它们,所以我们实现了两个简单的谓词函数。第一个指示字符串是否以字符
'a'
开头,第二个指示字符串是否以字符'b'
结尾:
static bool begins_with_a (const std::string &s)
{
return s.find("a") == 0;
}
static bool ends_with_b (const std::string &s)
{
return s.rfind("b") == s.length() - 1;
}
- 现在,让我们实现一个助手函数,我们称之为
combine
。它以二进制函数作为第一个参数,例如,它可以是逻辑AND
函数或逻辑OR
函数。然后,它需要另外两个参数,这两个参数应该是两个谓词函数,然后进行组合:
template <typename A, typename B, typename F>
auto combine(F binary_func, A a, B b)
{
- 我们只需返回一个 lambda 表达式,该表达式捕获新的谓词组合。它将一个参数转发给两个谓词,然后将两个谓词的结果放入二元函数并返回其结果:
return [=](auto param) {
return binary_func(a(param), b(param));
};
}
- 让我们声明我们使用
std
名称空间来节省我们在main
函数中的一些输入:
using namespace std;
- 现在,让我们将两个谓词函数合并到另一个谓词函数中,它告诉给定的字符串是否以
a
*开头,*以b
结尾,就像"ab"
或"axxxb"
一样。作为二元函数,我们选择std::logical_and
。它是一个需要实例化的模板类,所以我们用花括号来实例化它。请注意,我们不提供模板参数,因为对于这个类,它默认为void
。该类的这种专门化自动推导出所有参数类型:
int main()
{
auto a_xxx_b (combine(
logical_and<>{},
begins_with_a, ends_with_b));
- 我们迭代标准输入并将所有单词打印回终端,这满足了我们的断言:
copy_if(istream_iterator<string>{cin}, {},
ostream_iterator<string>{cout, ", "},
a_xxx_b);
cout << 'n';
}
- 编译并运行程序会产生以下输出。我们用四个词来填充程序,但是只有两个词满足谓词标准:
$ echo "ac cb ab axxxb" | ./combine
ab, axxxb,
STL 已经提供了一堆有用的功能对象,如std::logical_and
、std::logical_or
,以及许多其他对象,因此我们不需要在每个项目中重新实现它们。看看 C++ 参考资料,探索一下已经有的东西是个好主意:
http://en.cppreference.com/w/cpp/utility/functional
任务很多,导致代码重复。使用 lambda 表达式可以很容易地消除大量重复的代码,并且可以非常快速地创建一个包装这些重复任务的 lambda 表达式助手。
在这一节中,我们将使用 lambda 表达式将一个带有所有参数的调用转发给多个接收者。这将在中间没有任何数据结构的情况下发生,因此编译器有一个简单的任务来生成二进制文件而没有开销。
我们将编写一个 lambda 表达式助手,它将单个调用转发给多个对象,以及另一个 lambda 表达式助手,它将单个调用转发给其他函数的多个调用。在我们的示例中,我们将使用不同的打印机功能打印一封邮件:
- 让我们首先包含打印所需的 STL 标题:
#include <iostream>
- 首先,我们实现
multicall
功能,这是这个食谱的核心。它接受任意数量的函数作为参数,并返回一个接受一个参数的 lambda 表达式。它将此参数转发给之前提供的所有函数。这样,我们可以定义auto call_all (multicall(f, g, h))
,然后,call_all(123)
引出一系列的调用,f(123); g(123); h(123);
。这个函数看起来非常复杂,因为我们需要一个语法技巧,通过使用std::initializer_list
构造函数将参数包functions
扩展成一系列调用:
static auto multicall (auto ...functions)
{
return [=](auto x) {
(void)std::initializer_list<int>{
((void)functions(x), 0)...
};
};
}
- 下一个助手接受一个函数
f
和一组参数xs
。它用这些参数中的每一个来称呼f
。这样,一个for_each(f, 1, 2, 3)
呼叫会导致一系列呼叫:f(1); f(2); f(3);
。该函数本质上使用相同的语法技巧将参数包xs
扩展为一系列函数调用,就像前面的其他函数一样:
static auto for_each (auto f, auto ...xs) {
(void)std::initializer_list<int>{
((void)f(xs), 0)...
};
}
brace_print
函数接受两个字符,并返回一个新的函数对象,该对象接受一个参数x
。它将打印出来,周围是我们之前刚刚捕捉到的两个大字:
static auto brace_print (char a, char b) {
return [=] (auto x) {
std::cout << a << x << b << ", ";
};
}
- 现在,我们终于可以在
main
功能中使用一切了。首先,我们定义函数f
、g
和h
。它们表示接受值的打印函数,并且用不同的大括号/圆括号将它们打印出来。nl
函数接受任何参数,只打印一个换行符:
int main()
{
auto f (brace_print('(', ')'));
auto g (brace_print('[', ']'));
auto h (brace_print('{', '}'));
auto nl ([](auto) { std::cout << 'n'; });
- 让我们使用我们的
multicall
助手将它们组合起来:
auto call_fgh (multicall(f, g, h, nl));
- 对于我们提供的每个数字,我们希望看到它们被不同对的大括号/圆括号分别打印三次。这样,我们可以进行一次函数调用,并以对多功能的五次调用结束,多功能又对
f
、g
、h
和nl
进行了四次调用。
for_each(call_fgh, 1, 2, 3, 4, 5);
}
- 在编译和运行之前,考虑预期的输出:
$ ./multicaller
(1), [1], {1},
(2), [2], {2},
(3), [3], {3},
(4), [4], {4},
(5), [5], {5},
我们刚刚实现的助手看起来非常复杂。这是因为我们用std::initializer_list
展开参数包。我们为什么要使用这种数据结构?我们再来看看for_each
:
auto for_each ([](auto f, auto ...xs) {
(void)std::initializer_list<int>{
((void)f(xs), 0)...
};
});
这个函数的核心是f(xs)
表达式。xs
是一个参数包,我们需要将其解包,以便从中获取个体值,并将其馈送给个体f
调用。不幸的是,我们不能仅仅用我们已经知道的...
符号来写f(xs)...
。
我们可以做的是使用std::initializer_list
构造一个值列表,它有一个变量构造器。像return std::initializer_list<int>{f(xs)...};
这样的表达是有用的,但是它也有的缺点。让我们看一下for_each
的一个实现,它就是这样做的,所以它看起来比我们现有的更简单:
auto for_each ([](auto f, auto ...xs) {
return std::initializer_list<int>{f(xs)...};
});
这很容易理解,但它的缺点如下:
- 它从所有的
f
调用中构造一个返回值的实际初始化列表。此时,我们不关心返回值。 - 它返回那个初始化列表,虽然我们想要一个*“火了就忘了”函数,它什么也不返回。*
** 有可能
f
是一个函数,它甚至不返回任何东西,在这种情况下,它甚至不会编译。*
*更复杂的for_each
函数解决了所有这些问题。它做以下事情来实现这一点:
- 它不返回初始化列表,但是它使用
(void)std::initializer_list<int>{...}
将整个表达式转换为void
。 - 在初始化式表达式中,它将
f(xs)...
包装成一个(f(xs), 0)...
表达式。这导致返回值被扔掉,而0
被放入初始化列表。 (f(xs), 0)...
表达式中的f(xs)
再次被强制转换为void
,所以如果有返回值的话,真的不会在的任何地方进行处理。
不幸的是,把所有这些放在一起会导致一个丑陋的构造,但是它确实工作正常,并且用各种各样的函数对象编译,不管它们是否返回任何东西或者返回什么。
这项技术的一个很好的细节是,函数调用的应用顺序保证是按照严格的顺序进行的。
Casting anything using the old C-style notation (void)expression
is advised against because C++ has its own cast operators. We should have used reinterpret_cast<void>(expression)
instead, but this would have decreased the readability of the code further.
大部分用过std::copy_if
、std::transform
的开发者可能已经问过自己,为什么没有std::transform_if
。std::copy_if
功能将项目从源范围复制到目标范围,但跳过用户定义的谓词功能未选择的项目。std::transform
无条件地将所有项目从源范围复制到目标范围,但在两者之间进行转换。转换是由用户定义的函数提供的,它可以做一些简单的事情,比如将数字相乘或将项目转换成完全不同的类型。
这样的功能现在已经有很长时间了,但是还有还是没有std::transform_if
功能。在本节中,我们将实现这个函数。只需实现一个在范围内迭代的函数,同时复制谓词函数选择的所有项,并在它们之间进行转换,就可以很容易地做到这一点。然而,我们将利用这个机会深入研究 lambda 表达式。
我们将构建自己的transform_if
函数,通过为std::accumulate
提供正确的函数对象来工作:
- 我们需要像往常一样包括一些标题:
#include <iostream>
#include <iterator>
#include <numeric>
- 首先,我们将实现一个名为
map
的函数。它接受输入转换函数作为参数,并返回一个函数对象,与std::accumulate
配合使用效果很好:
template <typename T>
auto map(T fn)
{
- 我们返回的是一个接受约简函数的函数对象。当用这样的 reduce 函数调用该对象时,它返回另一个函数对象,该对象接受一个累加器和一个输入参数。它调用这个累加器和
fn
转换后的输入变量的减少函数。如果这看起来很复杂,不要担心,我们稍后会把它放在一起,看看它是如何工作的:
return [=] (auto reduce_fn) {
return [=] (auto accum, auto input) {
return reduce_fn(accum, fn(input));
};
};
}
- 现在我们实现一个叫做
filter
的函数。它的工作方式与map
功能完全相同,但输入保持不变,而map
功能使用变换功能对其进行变换。相反,我们接受一个谓词函数并且跳过输入变量而不减少它们,以防它们不被谓词函数接受:
template <typename T>
auto filter(T predicate)
{
- 这两个 lambda 表达式与
map
函数中的表达式具有完全相同的函数签名。唯一的区别是input
参数保持不变。谓词函数用于区分我们是在输入上调用reduce_fn
函数,还是在没有任何变化的情况下向前到达累加器:
return [=] (auto reduce_fn) {
return [=] (auto accum, auto input) {
if (predicate(input)) {
return reduce_fn(accum, input);
} else {
return accum;
}
};
};
}
- 现在让我们最终使用那些助手。我们实例化迭代器,让我们从标准输入中读取整数值:
int main()
{
std::istream_iterator<int> it {std::cin};
std::istream_iterator<int> end_it;
- 然后我们定义一个谓词函数
even
,如果我们有一个偶数,它就返回true
。变换函数twice
将其整数参数乘以因子2
:
auto even ([](int i) { return i % 2 == 0; });
auto twice ([](int i) { return i * 2; });
std::accumulate
函数取一系列值,累加它们。在默认情况下,累加意味着用+
运算符将和的值相加。我们想提供自己的积累功能。这样,我们就不会维护值的和。我们所做的是将范围的每个值分配给解引用的迭代器it
,然后在进一步推进之后返回这个迭代器:
auto copy_and_advance ([](auto it, auto input) {
*it = input;
return ++ it;
});
- 现在我们终于把碎片拼在一起了。我们迭代标准输入并提供输出
ostream_iterator
,输出将打印到终端。copy_and_advance
函数对象通过向输出迭代器分配用户输入整数来处理输出迭代器。向输出迭代器分配有效的打印分配的项目。但是我们只想要用户输入的甚至数字,我们想要乘以。为此,我们将copy_and_advance
功能包装到even
过滤器中,然后包装到twice
映射器中:
std::accumulate(it, end_it,
std::ostream_iterator<int>{std::cout, ", "},
filter(even)(
map(twice)(
copy_and_advance
)
));
std::cout << 'n';
}
- 编译和运行程序会产生以下输出。数值
1
、3
、5
因为不均匀而下降,数值2
、4
、6
翻倍后打印:
$ echo "1 2 3 4 5 6" | ./transform_if
4, 8, 12,
这个配方看起来非常复杂,因为我们正在大量嵌套 lambda 表达式。为了了解这是如何工作的,我们先来看看std::accumulate
的内部工作原理。这是它在典型的 STL 实现中的样子:
template <typename T, typename F>
T accumulate(InputIterator first, InputIterator last, T init, F f)
{
for (; first != last; ++ first) {
init = f(init, *first);
}
return init;
}
函数参数f
在这里做主要工作,而循环在用户提供的init
变量中收集其结果。在一个常见的例子中,迭代器范围可以表示一个数字向量,例如0, 1, 2, 3, 4
,而init
值是0
。f
函数是一个二元函数,可以使用+
运算符计算两个项目的和。
在这个例子中,循环只是将所有的项目加到init
变量中,比如在init = (((0 + 1) + 2) + 3) + 4
中。这样写下来很明显std::accumulate
只是一个通用的折叠功能。折叠一个范围意味着对一个累加器变量应用一个二进制运算,并逐步处理包含在该范围内的每一项(每个运算的结果就是下一项的累加器值)。由于这个功能如此通用,我们可以用它做各种事情,就像实现std::transform_if
一样!f
功能也称为减少功能。
transform_if
的一个非常直接的实现如下:
template <typename InputIterator, typename OutputIterator,
typename P, typename Transform>
OutputIterator transform_if(InputIterator first, InputIterator last,
OutputIterator out,
P predicate, Transform trans)
{
for (; first != last; ++ first) {
if (predicate(*first)) {
*out = trans(*first);
++ out;
}
}
return out;
}
这看起来很类似于std::accumulate
,如果我们将参数out
视为init
变量,不知何故得到函数f
来替代 if-construct 及其 body!
我们真的做到了。我们用我们作为参数提供给std::accumulate
的二元函数对象构造了 if-construct 及其主体:
auto copy_and_advance ([](auto it, auto input) {
*it = input;
return ++ it;
});
std::accumulate
函数将init
变量放入二元函数的it
参数中。第二个参数是每个循环迭代步骤的源范围的当前值。我们提供了一个输出迭代器作为std::accumulate.
的init
参数。这样,std::accumulate
不计算总和,而是将迭代的项目转发到另一个范围。这意味着我们只是重新实现了std::copy
而没有任何谓词和转换。
使用谓词的过滤是我们通过将copy_and_advance
函数对象包装到另一个函数对象中而添加的,该对象使用谓词函数:
template <typename T>
auto filter(T predicate)
{
return [=] (auto reduce_fn) {
return [=] (auto accum, auto input) {
if (predicate(input)) {
return reduce_fn(accum, input);
} else {
return accum;
}
};
};
}
这个构造刚开始看起来不太简单,但是看看if
构造。如果predicate
函数返回true
,它会将参数转发给reduce_fn
函数,在我们的例子中是copy_and_advance
。如果谓词返回false
,则accum
变量,也就是std::accumulate
的init
变量,只是不变地返回。这实现了过滤操作的跳过部分。if
构造位于内部 lambda 表达式中,该表达式具有与copy_and_advance
函数相同的二进制函数签名,这使其成为合适的替代。
现在我们能够过滤,但仍然没有改造。这是通过map
功能助手完成的:
template <typename T>
auto map(T fn)
{
return [=] (auto reduce_fn) {
return [=] (auto accum, auto input) {
return reduce_fn(accum, fn(input));
};
};
}
这段代码看起来简单多了。它再次包含一个内部 lambda 表达式,其签名与copy_and_advance
相同,因此可以替换它。该实现只是转发输入值,但是用fn
函数转换二进制函数调用的**右参数。
后来,当我们使用这些助手时,我们编写了以下表达式:
filter(even)(
map(twice)(
copy_and_advance
)
)
filter(even)
调用捕获even
谓词并给我们一个函数,该函数接受一个二进制函数以将其包装成另一个二进制函数,该函数进行额外的过滤*。map(twice)
函数与twice
变换函数相同,但是将二元函数copy_and_advance
包装成另一个二元函数,后者总是变换正确的参数。*
如果没有任何优化,我们将得到一个非常复杂的函数嵌套结构,它调用函数并且只做很少的工作。然而,对于编译器来说,优化所有代码是一项非常简单的任务。生成的二进制文件非常简单,就好像它是由transform_if
更直接的实现产生的一样。以这种方式,我们在绩效方面不支付任何费用。但是我们得到的是一个非常好的函数组合,因为我们能够将even
谓词和twice
转换函数结合在一起,就像它们是乐高积木一样简单。
Lambda 表达式结合参数包可用于复杂的任务。在本节中,我们将实现一个函数对象,该对象接受任意数量的输入参数,并使用本身生成该集合的笛卡尔乘积。
笛卡儿积是一种数学运算。记为A x B
,意为集合A
和集合B
的笛卡尔乘积。结果是另一个单套,包含套A
和B
的成对所有项目组合。操作的基本意思是,c 把 A 的每一项和 B 的每一项组合起来。下图说明了操作:
在上图中,如果A = (x, y, z)
、B = (1, 2, 3)
,那么笛卡尔积就是(x, 1)
、(x, 2)
、(x, 3)
、(y, 1)
、(y, 2)
等等。
如果我们确定A
和B
是同一个集合,比如说(1, 2)
,那么这个集合的笛卡尔积就是(1, 1)
、(1, 2)
、(2, 1)
和(2, 2)
。在某些情况下,这可能被宣布为冗余*,因为可能不需要项目与本身的组合(如在(1, 1)
中)或(1, 2)
和(2, 1)
的冗余组合。在这种情况下,可以用一个简单的规则过滤笛卡儿积。*
*在本节中,我们将实现没有任何循环的笛卡儿积,但是使用 lambda 表达式和参数包解包。
我们实现了一个函数对象,它接受一个函数、f
和一组参数。函数对象将创建参数集的笛卡儿积,过滤掉多余的部分,调用每个部分的f
函数:
- 我们只需要包括打印所需的 STL 标题:
#include <iostream>
- 然后,我们定义一个简单的助手函数,它打印一对值,我们开始实现
main
函数:
static void print(int x, int y)
{
std::cout << "(" << x << ", " << y << ")n";
}
int main()
{
- 最难的部分从现在开始。我们首先为下一步要实现的
cartesian
函数实现一个助手。这个函数接受一个参数f
,当我们以后使用它时,它将是print
函数。其他参数为x
和参数包rest
。这些包含我们想要笛卡儿积的实际项目。看f(x, rest)
的表达:对于x=1
和rest=2, 3, 4
,这将导致f(1, 2); f(1, 3); f(1, 4);
等调用。(x < rest)
测试用于消除生成的对中的冗余。我们将在后面更详细地讨论这一点:
constexpr auto call_cart (
[=](auto f, auto x, auto ...rest) constexpr {
(void)std::initializer_list<int>{
(((x < rest)
? (void)f(x, rest)
: (void)0)
,0)...
};
});
cartesian
函数是整个食谱中最复杂的一段代码。它接受参数包xs
并返回一个捕获它的函数对象。返回的函数对象接受一个函数对象,f
。 对于参数包xs=1, 2, 3
,内部 lambda 表达式将生成以下调用:call_cart(f, **1**, 1, 2, 3); call_cart(f, **2**, 1, 2, 3); call_cart(f, **3**, 1, 2, 3);
。从这个调用范围,我们可以生成我们需要的所有笛卡尔乘积对。 注意,我们使用...
符号对xs
参数包进行了两次的扩展,刚开始看起来很奇怪。第一次出现的...
将整个xs
参数包扩展为call_cart
调用。第二次出现导致多个call_cart
调用,第二个参数不同:
constexpr auto cartesian ([=](auto ...xs) constexpr {
return [=] (auto f) constexpr {
(void)std::initializer_list<int>{
((void)call_cart(f, xs, xs...), 0)...
};
};
});
- 现在,让我们生成数值集
1, 2, 3
的笛卡儿积并打印对。如果没有冗余对,这将产生数量对、(1, 2)
、(2, 3)
和(1, 3)
。如果我们忽略顺序,不希望一对中有相同的数字,就不可能有更多的组合。这意味着我们做不是想要(1, 1)
,考虑(1, 2)
和(2, 1)
同一个对。 首先,我们让cartesian
生成一个已经包含所有可能对的函数对象,并接受我们的打印函数。然后,我们用它来让所有这些对调用我们的print
函数。 我们声明print_cart
变量,constexpr
,这样就可以保证它持有的函数对象(以及它生成的所有对)是在编译时创建的:
constexpr auto print_cart (cartesian(1, 2, 3));
print_cart(print);
}
- 正如预期的那样,编译和运行会产生以下输出。通过移除
call_cart
函数中的(x < xs)
条件来处理代码,并看到我们得到了具有冗余对和相同数量对的完全笛卡尔乘积:
$ ./cartesian_product
(1, 2)
(1, 3)
(2, 3)
这是另一个看起来非常复杂的 lambda 表达式构造。但是一旦我们彻底理解了这一点,我们就不会很快被任何 lambda 表达式所迷惑!
那么,我们来详细了解一下。我们应该对需要发生的事情有一个清晰的认识:
这是三个步骤:
- 我们拿着我们的布景
1, 2, 3
并从中创作了三个新的布景。这些集合中的每一个的第一部分都是集合中连续的单个项目,第二部分是整个集合本身。 - 我们将第一个项目与集合中的每个项目相结合,从中获得尽可能多的对。
- 从这些结果对中,我们只选择那些不冗余的(例如
(1, 2)
和(2, 1)
冗余的)和不相同编号的(例如(1, 1)
)。
现在,回到实现:
constexpr auto cartesian ([=](auto ...xs) constexpr {
return [=](auto f) constexpr {
(void)std::initializer_list<int>{
((void)call_cart(f, xs, xs...), 0)...
};
};
});
内心的表达call_cart(xs, xs...)
,恰恰代表着(1, 2, 3)
分离成那些新的集合,比如1, [1, 2, 3]
。完整的表达式,((void)call_cart(f, xs, xs...), 0)...
和外面的另一个...
对集合的每个值都进行这种分离,所以我们也得到2, [1, 2, 3]
和3, [1, 2, 3]
。
第二步和第三步由call_cart
完成:
auto call_cart ([](auto f, auto x, auto ...rest) constexpr {
(void)std::initializer_list<int>{
(((x < rest)
? (void)f(x, rest)
: (void)0)
,0)...
};
});
参数x
始终包含从集合中选取的单个值,rest
再次包含整个集合。我们先忽略(x < rest)
的条件。在这里,表达式f(x, rest)
与...
参数包扩展一起生成函数调用f(1, 1)
、f(1, 2)
等,这导致对被打印。这是第二步。
步骤 3 通过仅过滤掉适用(x < rest)
的对来实现。
我们制作了所有的 lambda 表达式和保存它们的变量constexpr
。通过这样做,我们现在可以保证编译器将在编译时评估它们的代码,并编译一个已经包含所有数字对的二进制文件,而不是在运行时计算它们。请注意,只有当我们为 constexpr 函数提供的所有函数参数在编译时已经为所知时,才会出现*。*****