恭喜你!你刚刚掌握了纯函数的力量!现在是时候进入下一个层次了——类固醇的纯功能,或者传奇的 lambdas。它们存在的时间比物体更长,它们周围有数学理论(如果你喜欢那种东西),它们非常强大,我们将在本章和下一章中发现。
本章将涵盖以下主题:
- 理解 lambdas 的概念和历史
- 如何用 C++ 写 lambdas
- 纯函数与 lambdas 相比如何
- 如何在类中使用 lambdas
您将需要一个支持 C++ 17 的 C++ 编译器。代码可以在Chapter03文件夹中的 GitHub 存储库中找到。提供了一个makefile文件,使您更容易编译和运行代码。
那一年是 1936 年。33 岁的数学家阿隆佐·邱奇发表了他对数学基础的研究。在这样做的时候,他创造了所谓的λ演算,这是最近创建的计算领域的模型。在艾伦·图灵的合作下,他将继续证明λ演算相当于图灵机。这一发现的相关性是编程的基础——它意味着我们可以通过使用 lambda 和利用 lambda 演算为现代计算机编写任何程序。这解释了为什么它被称为“T2λ”的原因——数学家们长期以来更喜欢每个符号都有一个希腊字母。但是到底是什么呢?
如果忽略所有的数学符号,λ只是一个纯函数,可以应用于变量或值。我们来看一个例子。我们将学习如何用 C++ 编写 lambdas,但是,目前,我将使用 Groovy 语法,因为这是我所知道的最简单的语法:
def add = {first, second -> first + second}
add(1,2) //returns 3add是一个λ。如您所见,这是一个函数,它有两个参数并返回它们的和。由于 Groovy 有可选类型,所以我不必指定参数的类型。另外,我不需要使用return语句来返回总和;它将自动返回最后一条语句的值。在 C++ 中,我们不能跳过类型或return语句,这将在下一节中发现。
现在,让我们看看 lambda 的另一个属性,即从上下文中捕获值的能力:
def first = 5
def addToFirst = {second -> first + second}
addToFirst(10) // returns 5 + 10 = 15在本例中,first不是函数的参数,而是上下文中定义的变量。λ捕捉变量的值,并在其体内使用。我们可以使用 lambdas 的这个属性来简化代码,或者逐渐重构为不变性。
我们将在以后的章节中探讨如何使用 lambdas 现在,让我们演示如何用 C++ 编写它们,如何确保它们是不可变的,以及如何从上下文中捕获值。
我们探索了如何用 Groovy 编写 lambdas。那么,我们可以在 C++ 中使用它们的力量吗?从 C++ 11 开始,引入了一种特定的语法。让我们看看我们的add lambda 在 C++ 中会是什么样子:
int main(){
auto add = [](int first, int second){ return first + second;};
cout << add(1,2) << endl; // writes 3
}让我们将语法解包如下:
- 我们的λ以
[]开头。这个块指定了我们从上下文中捕获的变量,稍后我们将看到如何使用它。因为我们什么都没捕捉到,所以街区是空的。 - 接下来,我们有了参数列表
(int first, int second),就像在任何其他 C++ 函数中一样。 - 最后,我们编写 lambda 的主体,使用一个返回语句:
{ return first + second; }。
语法比 Groovy 中的更有仪式感,但感觉像 C++,这是一件好事;一致性帮助我们记住事情。
或者,我们可以使用箭头语法,如以下代码所示:
auto add = [](int first, int second) -> int { return first +
second;};自从阿隆佐·邱奇在他的 lambda 演算中使用了这个符号,箭头语法就成了 lambda 的主要内容。除此之外,C++ 要求在 lambda 主体之前有返回类型规范,这可以在涉及类型转换的情况下提供清晰性。
由于其历史,箭头语法以这样或那样的方式存在于所有函数式编程语言中。它在 C++ 中很少有用;然而,如果你想习惯于函数式编程,了解这一点是很有用的。
现在是探索如何从上下文中获取变量的时候了。正如我们之前提到的,都在[]区块。
那么,如果我们想要捕捉变量呢?在 Groovy 中,我们只是使用了 lambda 范围内的变量。这在 C++ 中是行不通的,因为我们需要指定我们正在捕获哪些变量以及如何捕获它们。因此,如果我们只使用addλ中的first变量,我们将得到如下编译错误:
int main(){
int first = 5;
auto addToFirst = [](int second){ return first + second;};
// error: variable 'first' cannot be implicitly captured
cout << add(10) << endl;
}为了在 C++ 中捕获变量,我们需要在[]块内部使用一个捕获说明符。有多种方法可以做到这一点,这取决于你想要什么。最直观的方法是直接写下我们正在捕获的变量的名称。在我们的例子中,由于我们试图捕获第一个变量,我们只需要在 lambda 参数之前添加[first]:
int main(){
int first = 5;
auto addToFirst = [first](int second){ return first + second;};
cout << addToFirst(10) << endl; // writes 15
}正如我们将看到的,这意味着first变量被一个值捕获。由于 C++ 给了程序员很多控制权,我们期望它提供特定的语法来通过引用捕获变量。现在,让我们更详细地探讨捕获语法。
我们知道按值捕获变量的说明符只是写变量的名字,也就是[first]。这意味着变量被复制了,所以我们浪费了几个字节的内存。解决方案是通过引用捕获变量。捕获说明符的语法非常直观——我们可以只使用变量的名称作为[&first]引用:
int main(){
int first = 5;
auto addToFirstByReference = [&first](int second){ return first +
second;};
cout << addToFirstByReference(10) << endl; // writes 15
}我知道你在想什么:既然是通过引用传递的,lambda 现在能修改first变量的值吗?剧透警报——是的,可以。我们将在下一节重新讨论不变性、纯函数和 lambdas。目前,有更多的语法需要学习。例如,如果我们想从上下文中捕获多个变量,我们是否必须将它们都写在捕获说明符中?事实证明,有一些捷径可以帮助你避免这种情况。
那么,如果我们想要捕捉多个值呢?让我们探索一下,如果我们添加五个捕获的值,我们的 lambda 会是什么样子:
int second = 6;
int third = 7;
int fourth = 8;
int fifth = 9;
auto addTheFive = [&first, &second, &third, &fourth, &fifth]()
{return first + second + third + fourth + fifth;};
cout << addTheFive() << endl; // writes 35我们现在的语法有点多余,不是吗?我们可以使用默认的捕获说明符来代替。幸运的是,语言设计师也是这么想的;注意λ参数前的[&]语法:
auto addTheFiveWithDefaultReferenceCapture = [&](){return first + second + third + fourth + fifth;};
cout << addTheFiveWithDefaultReferenceCapture() << endl; // writes 35[&]语法告诉编译器通过引用从上下文中捕获所有指定的变量。这是默认的引用捕获说明符。
如果我们想复制它们的值,我们需要使用默认的按值捕获说明符,你必须记住它,因为这是唯一这样使用的地方。请注意λ参数前的[=]语法:
auto addTheFiveWithDefaultValueCapture = [=](){return first +
second + third + fourth + fifth;};
cout << addTheFiveWithDefaultValueCapture() << endl; // writes 35[=]语法告诉编译器,所有变量都将通过复制它们的值来捕获。至少,这是默认的。如果出于某种原因,您希望除first之外的所有变量都按值传递,那么您只需将默认值与变量说明符结合起来:
auto addTheFiveWithDefaultValueCaptureForAllButFirst = [=, &first](){return first + second + third + fourth + fifth;};
cout << addTheFiveWithDefaultValueCaptureForAllButFirst() << endl; // writes 35我们现在知道如何通过值和引用来捕获变量,以及如何使用默认说明符。这就给我们留下了一种重要的变量——指针。
指针只是简单的值。如果我们想通过值捕获指针变量,我们可以只写它的名称,如下面的代码所示:
int* pFirst = new int(5);
auto addToThePointerValue = [pFirst](int second){return *pFirst +
second;};
cout << addToThePointerValue(10) << endl; // writes 15
delete pFirst;如果我们想要通过引用捕获指针变量,捕获语法与捕获任何其他类型的变量相同:
auto addToThePointerValue = [&pFirst](int second){return *pFirst +
second;};默认说明符的工作原理与您预期的完全一样;也就是说,[=]通过值捕获指针变量:
auto addToThePointerValue = [=](int second){return *pFirst + second;};相比之下,[&]通过引用捕获指针变量,如下面的代码所示:
auto addToThePointerValue = [&](int second){return *pFirst +
second;};我们将探索通过引用捕获变量会对不变性产生什么影响。但是首先,由于有多种方法来获取 lambda 的变量,我们需要检查我们更喜欢哪一种,以及何时使用它们。
我们已经看到了一些捕获值的选项,如下所示:
- 命名变量以按值捕获它;例如
[aVariable] - 命名变量并在它前面加上引用说明符,以便通过引用捕获它;例如
[&aVariable] - 使用默认值说明符按值捕获所有使用的变量;语法是
[=] - 使用默认引用说明符通过引用捕获所有使用的变量;语法是
[&]
实际上,我发现使用默认值说明符是大多数情况下的最佳版本。这可能是受我喜欢不会改变捕获值的非常小的 lambdas 的影响。我相信简单很重要;当您有多个选项时,很容易使语法变得比必要的更复杂。仔细考虑每个上下文,使用最简单的语法;我的建议是从[=]开始,只有在需要的时候才能更改。
我们已经探索了如何用 C++ 编写 lambdas。我们没有提到的是它们是如何实现的。当前的标准将 lambdas 实现为在堆栈上创建的未知类型的 C++ 对象。像任何 C++ 对象一样,它后面有一个类,该类有一个构造函数、一个析构函数和作为数据成员存储的捕获变量。我们可以将一个λ传递给一个function<>对象,在这种情况下function<>对象将存储一个λ的副本。而且,小羊羔用懒人评价,不像function<>对象。
Lambdas 似乎是编写纯函数的更简单的方法;那么,lambdas 和纯函数有什么关系呢?
我们在第二章了解纯函数中了解到,纯函数有三个特点:
- 对于相同的参数值,它们总是返回相同的值
- 它们没有副作用
- 它们不会改变参数的值
我们还发现,在编写纯函数时,需要注意不变性。这很容易,只要我们记住const关键词放在哪里。
那么,lambdas 如何处理不变性呢?我们必须做什么特别的事情还是他们只是工作?
让我们从一个非常简单的λ开始,如下所示:
auto increment = [](int value) {
return ++ value;
};这里,我们通过值传递参数,所以我们不期望在调用 lambda:
int valueToIncrement = 41;
cout << increment(valueToIncrement) << endl;// prints 42
cout << valueToIncrement << endl;// prints 41由于我们复制了值,我们可能会使用一些额外的内存字节和一个额外的赋值。我们可以添加一个const关键词,让事情更清楚:
auto incrementImmutable = [](const int value) {
return value + 1;
};由于const说明符,如果 lambda 试图改变value,编译器会给出一个错误。
但是我们仍然在通过价值传递论点;通过参考怎么样?
让我们探索一下当我们称之为 lambda 时对输入参数的影响:
auto increment = [](int& value) {
return ++ value;
};事实证明,它与您的预期相对接近:
int valueToIncrement = 41;
cout << increment(valueToIncrement) << endl;// prints 42
cout << valueToIncrement << endl;// prints 42这里,lambda 改变了参数的值。这还不够好,所以让我们将其设为不可变,如以下代码所示:
auto incrementImmutable = [](const int& value){
return value + 1;
};如果 lambda 试图改变value,编译器将再次帮助我们获得错误信息。
嗯,这样更好;但是指针呢?
就像我们在第 2 章、理解纯函数中看到的一样,关于指针参数有两个问题,如下所示:
- lambda 能改变指针地址吗?
- λ能改变定点值吗?
同样,如果我们按值传入指针,地址没有变化:
auto incrementAddress = [](int* value) {
return ++ value;
};
int main(){
int* pValue = new int(41);
cout << "Address before:" << pValue << endl;
cout << "Address returned by increment address:" <<
incrementAddress(pValue) << endl;
cout << "Address after increment address:" << pValue << endl;
}
Output:
Address before:0x55835628ae70
Address returned by increment address:0x55835628ae74
Address after increment address:0x55835628ae70通过引用传递指针会改变这一点:
auto incrementAddressByReference = [](int*& value) {
return ++ value;
};
void printResultsForIncrementAddressByReference(){
int* pValue = new int(41);
int* initialPointer = pValue;
cout << "Address before:" << pValue << endl;
cout << "Address returned by increment address:" <<
incrementAddressByReference(pValue) << endl;
cout << "Address after increment address:" << pValue << endl;
delete initialPointer;
}
Output:
Address before:0x55d0930a2e70
Address returned by increment address:0x55d0930a2e74
Address after increment address:0x55d0930a2e74所以,我们再次需要用一个恰当的const关键词来保护我们自己不受这种变化的影响:
auto incrementAddressByReferenceImmutable = [](int* const& value) {
return value + 1;
};
Output:
Address before:0x557160931e80
Address returned by increment address:0x557160931e84
Address after increment address:0x557160931e80让我们也使该值不变。不出所料,我们需要另一个const关键词:
auto incrementPointedValueImmutable = [](const int* const& value) {
return *value + 1;
};虽然这是可行的,但我建议您支持一种更简单的传递[](const int& value)值的方法——也就是说,只需取消引用指针,并将一个实际值传递给 lambda,这将使参数语法更容易理解,并且更具可重用性。
所以,没有惊喜!我们可以使用与纯函数相同的语法来确保不变性。
但是 lambdas 可以调用可变函数吗,比如 I/O?
还有什么比Hello, world程序更好的测试 lambdas 和 I/O 的方法:
auto hello = [](){cout << "Hello, world!" << endl;};
int main(){
hello();
}显然,lambdas 没有被保护起来,不能调用可变函数。这并不奇怪,因为我们对纯函数也学到了同样的东西。这意味着,与纯函数类似,程序员需要格外注意将 I/O(从根本上来说是可变的)与代码的其余部分(可以是不可变的)分开。
既然我们试图让编译器帮助我们实现不变性,我们能为捕获的值做到这一点吗?
我们发现 lambdas 可以通过值和引用从上下文中捕获变量。那么,这是否意味着我们可以改变它们的价值?让我们来看看,如下所示:
int value = 1;
auto increment = [=](){return ++ value;};这段代码会立即给你一个编译错误——不能赋值给被复制捕获的变量。这是对按值传递参数的改进;也就是说,没有必要使用const关键字——它只是按预期工作。
那么,引用捕获的值呢?嗯,我们可以只使用默认的引用说明符[&],并在调用我们的increment lambda 之前和之后检查变量的值:
void captureByReference(){
int value = 1;
auto increment = [&](){return ++ value;};
cout << "Value before: " << value << endl;
cout << "Result of increment:" << increment() << endl;
cout << "Value after: " << value << endl;
}
Output:
Value before: 1
Result of increment:2
Value after: 2不出所料,value发生了变化。那么,我们如何防范这种突变呢?
不幸的是,没有简单的方法可以做到这一点。C++ 假设如果你通过引用获取变量,你想修改它们。虽然这是可能的,但它需要更多的语法糖。具体来说,我们需要将其强制转换为const类型,而不是变量:
#include <utility>
using namespace std;
...
int value = 1;
auto increment = [&immutableValue = as_const(value)](){return
immutableValue + 1;};
Output:
Value before: 1
Result of increment:2
Value after: 1如果可以选择,我更喜欢使用更简单的语法。因此,我宁愿使用按值捕获语法,除非我真的需要优化性能。
我们已经探索了如何在捕获值类型时使 lambdas 不可变。但是在捕获指针类型时,我们能保证不变性吗?
当我们使用指针时,事情变得有趣起来。如果我们按值捕获它们,我们就不能修改地址:
int* pValue = new int(1);
auto incrementAddress = [=](){return ++ pValue;}; // compilation
error但是,我们仍然可以修改指向的值,如下面的代码所示:
int* pValue = new int(1);
auto increment= [=](){return ++(*pValue);};
Output:
Value before: 1
Result of increment:2
Value after: 2约束不变性需要const int*类型的变量:
const int* pValue = new int(1);
auto increment= [=](){return ++(*pValue);}; // compilation error但是,有一个更简单的解决方案,那就是只捕获指针的值:
int* pValue = new int(1);
int value = *pValue;
auto increment = [=](){return ++ value;}; // compilation error通过引用捕获指针也允许您更改内存地址:
auto increment = [&](){return ++ pValue;};我们可以使用与之前相同的技巧来加强内存地址的恒定性质:
auto increment = [&pImmutable = as_const(pValue)](){return pImmutable
+ 1;};然而,这变得相当复杂。这样做的唯一原因如下:
- 我们希望避免最多复制 64 位
- 编译器没有为我们优化它
更简单的方法是坚持按值传递的值,也就是说,除非你想在你的 lambda 中做指针运算。
你现在知道 lambdas 如何以不变性工作了。但是,在我们的 C++ 代码中,我们习惯了类。那么,lambdas 和类之间是什么关系呢?我们可以一起用吗?
到目前为止,我们已经学会了如何用 C++ 编写 lambdas。所有的例子都使用类外的 lambda 表达式,或者作为变量,或者作为main()函数的一部分。然而,我们大多数的 C++ 代码都存在于类中。这就引出了一个问题——我们如何在课堂上使用 lambdas?
为了探究这个问题,我们需要一个简单类的例子。让我们使用一个表示基本虚数的类:
class ImaginaryNumber{
private:
int real;
int imaginary;
public:
ImaginaryNumber() : real(0), imaginary(0){};
ImaginaryNumber(int real, int imaginary) : real(real),
imaginary(imaginary){};
};我们想用我们新发现的 lambda 超能力写一个简单的toString函数,如下面的代码所示:
string toString(){
return to_string(real) + " + " + to_string(imaginary) + "i";
}那么,我们有什么选择呢?
嗯,lambdas 是简单的变量,所以它们可以是数据成员。或者,它们可以是static变量。也许我们甚至可以将类函数转换成 lambdas。接下来让我们探索这些想法。
让我们首先尝试将其写成成员变量,如下所示:
class ImaginaryNumber{
...
public:
auto toStringLambda = [](){
return to_string(real) + " + " + to_string(imaginary) +
"i";
};
...
}不幸的是,这会导致编译错误。如果我们想让 lambda 变量成为非静态数据成员,我们需要指定它的类型。为了做到这一点,让我们把λ包装成一个function类型,如下所示:
include <functional>
...
public:
function<string()> toStringLambda = [](){
return to_string(real) + " + " + to_string(imaginary) +
"i";
};函数类型有一个特殊的语法,允许我们定义 lambda 类型。function<string()>符号表示函数返回一个string值,并且不接收任何参数。
然而,这仍然不起作用。我们收到另一个错误,因为我们没有捕获我们正在使用的变量。我们可以使用到目前为止了解到的任何捕获。或者,我们可以改为捕捉this:
function<string()> toStringLambda = [this](){
return to_string(real) + " + " + to_string(imaginary) +
"i";
};因此,这就是我们如何编写一个 lambda 作为类的一部分,同时捕获类的数据成员。在重构现有代码时,捕捉this是一个有用的捷径。然而,我会在更持久的情况下避免它。最好直接捕获所需的变量,而不是整个指针。
我们也可以将λ定义为一个static变量。我们不能再捕获这些值,所以我们需要传入一个参数,但是我们仍然可以访问real和imaginary私有数据成员:
static function<string(const ImaginaryNumber&)>
toStringLambdaStatic;
...
// after class declaration ends
function<string(const ImaginaryNumber&)> ImaginaryNumber::toStringLambdaStatic = [](const ImaginaryNumber& number){
return to_string(number.real) + " + " + to_string(number.imaginary)
+ "i";
};
// Call it
cout << ImaginaryNumber::toStringLambdaStatic(Imaginary(1,1)) << endl;
// prints 1+1i有时候,我们需要把一个static函数转换成一个 lambda 变量。这在 C++ 中非常容易,如下面的代码所示:
static string toStringStatic(const ImaginaryNumber& number){
return to_string(number.real) + " + " + to_string(number.imaginary)
+ "i";
}
string toStringUsingLambda(){
auto toStringLambdaLocal = ImaginaryNumber::toStringStatic;
return toStringLambdaLocal(*this);
}我们可以简单地将一个函数从一个类赋给一个变量,正如您在前面的代码中看到的:
auto toStringLambdaLocal = ImaginaryNumber::toStringStatic;然后,我们可以像使用函数一样使用变量。正如我们将发现的,这是一个非常强大的概念,因为它允许我们编写函数,即使它们是在类中定义的。
当涉及到 lambdas 和类之间的交互时,我们有很多选择。它们会变得势不可挡,并且会使设计决策变得更加困难。
虽然知道这些选项很好,因为它们在经历困难的重构时会有所帮助,但我通过实践发现,在涉及 lambdas 时,最好遵循一个简单的原则;也就是说,选择减少 lambda 和代码其余部分之间耦合面积的选项。
例如,我们已经看到,我们可以将我们的 lambda 写成一个类中的static变量:
function<string(const ImaginaryNumber&)> ImaginaryNumber::toStringLambdaStatic = [](const ImaginaryNumber& number){
return to_string(number.real) + " + " + to_string(number.imaginary)
+ "i";
};该λ具有与ImaginaryNumber类一样大的耦合面积。但是,它只需要两个值:实部和虚部。我们可以很容易地将其重写为纯函数,如下所示:
auto toImaginaryString = [](auto real, auto imaginary){
return to_string(real) + " + " + to_string(imaginary) + "i";
};如果出于某种原因,您决定通过添加成员或方法、移除成员或方法、将其拆分为多个类或更改数据成员类型来更改虚数的表示,则不需要更改这个 lambda。当然,它需要两个参数而不是一个,但是参数类型不再重要,只要to_string对它们起作用。换句话说,这是一个多态函数,让您可以选择表示数据结构。
但是我们将在接下来的章节中讨论如何使用 lambdas 进行设计。
你刚刚获得了λ超能力!不仅可以用 C++ 编写简单的 lambdas,还知道以下内容:
- 如何从上下文中获取变量
- 如何通过引用或值来指定默认捕获类型
- 如何编写不可变的 lambdas,即使在捕获值时
- 如何在课堂上使用 lambdas
我们还谈到了低耦合的设计原则,以及 lambdas 如何对此有所帮助。我们将在接下来的章节中继续提到这个原则。
如果我告诉你兰姆达斯比我们目前看到的还要强大,你会相信我吗?嗯,我们会发现我们可以通过功能组合从简单到复杂的 lambdas。
- 你能写的最简单的 lambda 是什么?
- 如何编写一个 lambda 来连接作为参数传递的两个字符串值?
- 如果其中一个值是被值捕获的变量,会发生什么?
- 如果其中一个值是被引用捕获的变量,会发生什么?
- 如果其中一个值是被值捕获的指针,会发生什么?
- 如果其中一个值是被引用捕获的指针,会发生什么?
- 如果使用默认捕获说明符按值捕获两个值,会发生什么?
- 如果使用默认捕获说明符通过引用捕获两个值,会发生什么?
- 在有两个字符串值作为数据成员的类中,如何将相同的 lambda 编写为数据成员?
- 怎么能把同一个 lambda 写成同一个类中的
static变量?