Skip to content

Latest commit

 

History

History
649 lines (459 loc) · 26.2 KB

File metadata and controls

649 lines (459 loc) · 26.2 KB

三、深入 lambdas

恭喜你!你刚刚掌握了纯函数的力量!现在是时候进入下一个层次了——类固醇的纯功能,或者传奇的 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 3

add是一个λ。如您所见,这是一个函数,它有两个参数并返回它们的和。由于 Groovy 有可选类型,所以我不必指定参数的类型。另外,我不需要使用return语句来返回总和;它将自动返回最后一条语句的值。在 C++ 中,我们不能跳过类型或return语句,这将在下一节中发现。

现在,让我们看看 lambda 的另一个属性,即从上下文中捕获值的能力:

def first = 5
def addToFirst = {second -> first + second}
addToFirst(10) // returns 5 + 10 = 15

在本例中,first不是函数的参数,而是上下文中定义的变量。λ捕捉变量的值,并在其体内使用。我们可以使用 lambdas 的这个属性来简化代码,或者逐渐重构为不变性。

我们将在以后的章节中探讨如何使用 lambdas 现在,让我们演示如何用 C++ 编写它们,如何确保它们是不可变的,以及如何从上下文中捕获值。

C++ 中的 Lambdas

我们探索了如何用 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 和纯函数有什么关系呢?

Lambdas 和纯函数

我们在第二章了解纯函数中了解到,纯函数有三个特点:

  • 对于相同的参数值,它们总是返回相同的值
  • 它们没有副作用
  • 它们不会改变参数的值

我们还发现,在编写纯函数时,需要注意不变性。这很容易,只要我们记住const关键词放在哪里。

那么,lambdas 如何处理不变性呢?我们必须做什么特别的事情还是他们只是工作?

Lambda 不变性和按值传递参数

让我们从一个非常简单的λ开始,如下所示:

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 不变性和通过引用参数传递

让我们探索一下当我们称之为 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,编译器将再次帮助我们获得错误信息。

嗯,这样更好;但是指针呢?

Lambda 不变性和指针参数

就像我们在第 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?

lambdas 和输入输出

还有什么比Hello, world程序更好的测试 lambdas 和 I/O 的方法:

auto hello = [](){cout << "Hello, world!" << endl;};

int main(){
    hello();
}

显然,lambdas 没有被保护起来,不能调用可变函数。这并不奇怪,因为我们对纯函数也学到了同样的东西。这意味着,与纯函数类似,程序员需要格外注意将 I/O(从根本上来说是可变的)与代码的其余部分(可以是不可变的)分开。

既然我们试图让编译器帮助我们实现不变性,我们能为捕获的值做到这一点吗?

Lambda 不变性和捕获值

我们发现 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 和类之间是什么关系呢?我们可以一起用吗?

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。接下来让我们探索这些想法。

作为数据成员的 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是一个有用的捷径。然而,我会在更持久的情况下避免它。最好直接捕获所需的变量,而不是整个指针。

作为静态变量的 Lambdas

我们也可以将λ定义为一个static变量。我们不能再捕获这些值,所以我们需要传入一个参数,但是我们仍然可以访问realimaginary私有数据成员:

    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

将静态函数转换为 lambda

有时候,我们需要把一个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 和类之间的交互时,我们有很多选择。它们会变得势不可挡,并且会使设计决策变得更加困难。

虽然知道这些选项很好,因为它们在经历困难的重构时会有所帮助,但我通过实践发现,在涉及 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。

问题

  1. 你能写的最简单的 lambda 是什么?
  2. 如何编写一个 lambda 来连接作为参数传递的两个字符串值?
  3. 如果其中一个值是被值捕获的变量,会发生什么?
  4. 如果其中一个值是被引用捕获的变量,会发生什么?
  5. 如果其中一个值是被值捕获的指针,会发生什么?
  6. 如果其中一个值是被引用捕获的指针,会发生什么?
  7. 如果使用默认捕获说明符按值捕获两个值,会发生什么?
  8. 如果使用默认捕获说明符通过引用捕获两个值,会发生什么?
  9. 在有两个字符串值作为数据成员的类中,如何将相同的 lambda 编写为数据成员?
  10. 怎么能把同一个 lambda 写成同一个类中的static变量?