Skip to content

Latest commit

 

History

History
635 lines (453 loc) · 27.6 KB

File metadata and controls

635 lines (453 loc) · 27.6 KB

二、理解纯函数

纯函数是函数式编程的核心组件。它们是不可变的函数,这使得它们简单且可预测。用 C++ 编写纯函数很容易,但是有几件事你需要注意。由于 C++ 中的函数在默认情况下是可变的,我们需要学习告诉编译器如何防止突变的语法。我们还将探索如何将可变代码与不可变代码分开。

本章将涵盖以下主题:

  • 理解什么是纯函数
  • 用 C++ 编写纯函数和使用元组返回多个参数的函数
  • 确保 C++ 纯函数的不变性
  • 理解为什么输入/输出是可变的,需要从纯函数中分离出来

技术要求

您将需要一个支持 C++ 17 的 C++ 编译器。我用的是 GCC 7 . 3 . 0 版本。代码示例位于Chapter02文件夹中的 GitHub(https://GitHub . com/PacktPublishing/hand-On-Functional-Programming-with-Cpp)上,并有一个makefile文件,方便您使用。

什么是纯函数?

让我们花点时间想想一个简单的日常经历。当你打开电灯开关时,会发生两件事之一:

  • 如果灯亮着,它就会熄灭
  • 如果灯灭了,它就会亮

灯开关的行为是高度可预测的。它是如此的可预测,以至于当灯不亮的时候,你会立刻认为出了问题——也就是说,灯泡、保险丝或开关本身出了问题。

以下是一些你在打开或关闭开关时不会想到会发生的事情:

  • 你的冰箱不响了
  • 你邻居的灯不亮
  • 你浴室的水槽水开不了
  • 你的手机不会复位

当你打开电灯开关时,为什么会发生这些事情?那将会非常混乱;我们不想生活混乱,对吧?

然而,程序员经常在代码中遇到这种行为。调用函数通常会导致程序状态的改变;当这种情况发生时,我们说一个功能有副作用

函数编程试图通过扩展使用纯函数来减少由状态变化引起的混乱。纯函数是有两个约束的函数:

  • 对于相同的参数值,它们总是返回相同的输出值。
  • 它们没有副作用。

让我们探索一下如何编写电灯开关的代码。我们将假设灯泡是一个我们可以称之为的外部实体;就当是我们程序的输入/输出 ( I/O )的输出。结构化/面向对象程序员的自然代码如下所示:

void switchLight(LightBulb bulb){
    if(switchIsOn) bulb.turnOff();
    else bulb.turnOn();
}

这个函数发生了两件事。首先,它使用一个不属于参数列表的输入,即switchIsOn。其次,它直接对灯泡产生副作用。

那么,纯函数是什么样子的呢?首先,它的所有参数都是可见的:

void switchLight(boolean switchIsOn, LightBulb bulb){    if(switchIsOn) 
    bulb.turnOff();
    else bulb.turnOn();
}

第二,我们需要消除副作用。我们如何做到这一点?让我们将下一个状态的计算与打开或关闭灯泡的动作分开:

LightBulbSignal signalForBulb(boolean switchIsOn){
    if(switchIsOn) return LightBulbSignal.TurnOff;
    else return LightBulbSignal.TurnOn;
}
// use the output like this: sendSignalToLightBulb(signalForBulb(switchIsOn))

该函数现在是纯函数,我们将在后面更详细地讨论它;但是,现在让我们将其简化如下:

LightBulbSignal signalForBulb(boolean switchIsOn){
    return switchIsOn ? LightBulbSignal.TurnOff :    
    LightBulbSignal.TurnOn;
}
// use the output like this: sendSignalToLightBulb(signalForBulb(switchIsOn))

让我们把事情说得更清楚一些(我假设函数是类的一部分):

static LightBulbSignal signalForBulb(const boolean switchIsOn){
    return switchIsOn ? LightBulbSignal.TurnOff :  
    LightBulbSignal.TurnOn;
}
// use the output like this: sendSignalToLightBulb(signalForBulb(switchIsOn))

这个函数非常无趣:它非常容易预测,容易阅读,而且没有副作用。这听起来完全像一个精心设计的电灯开关。此外,这听起来就像我们在几十年内维护大量代码行时想要的那样。

我们现在明白了什么是纯函数,以及它为什么有用。我们还演示了一个将纯函数与副作用(通常是输入/输出)分开的例子。这是一个有趣的概念,但它能把我们带到哪里?我们真的能用这样简单的结构构建复杂的程序吗?我们将在接下来的章节中讨论如何构造纯函数。现在,让我们专注于理解如何用 C++ 编写纯函数。

C++ 中的纯函数

在前面的例子中,您已经看到了我们在 C++ 中需要用于纯函数的基本语法。你只需要记住以下四个想法:

  • 纯功能没有副作用;如果它们是一个类的一部分,它们可以是staticconst
  • 纯函数不会改变参数,所以每个参数都必须是constconst&const* const类型。
  • 纯函数总是返回值。从技术上讲,我们可以通过输出参数返回值,但通常只返回值更简单。这意味着纯函数通常没有 void 返回类型。
  • 以上几点都不能保证没有副作用或不变性,但它们让我们更加接近。例如,数据成员可以被标记为可变的,并且const方法可以改变它们。

在接下来的部分中,我们将探索如何将纯函数编写为自由函数和类方法。当我们浏览示例时,请记住我们现在正在探索语法,重点是如何使用编译器尽可能接近纯函数。

没有参数的纯函数

让我们从简单开始。我们可以使用没有参数的纯函数吗?当然可以。一个例子是当我们需要一个默认值。让我们考虑以下示例:

int zero(){return 0;}

这是一个独立的功能。让我们也理解如何在类中编写纯函数:

class Number{
    public:
        static int zero(){ return 0; }
}

现在,static告诉我们函数不改变任何非静态数据成员。但是,这并不妨碍代码更改static数据成员的值:

class Number{
    private:
        static int accessCount;
    public:
        static int zero(){++ accessCount; return 0;}
        static int getCount() { return accessCount; }
};
int Number::accessCount = 0;
int main(){
Number::zero();
cout << Number::getCount() << endl; // will print 1
}

幸运的是,我们将看到,我们可以通过放置良好的const关键词来解决大多数可变状态问题。以下情况也不例外:

static const int accessCount;

现在我们已经对如何编写没有参数的纯函数有了一些了解,是时候添加更多的参数了。

具有一个或多个参数的纯函数

让我们从一个带有一个参数的纯类方法开始,如下面的代码所示:

class Number{
    public:
        static int zero(){ return 0; }
        static int increment(const int value){ return value + 1; }
}

两个参数怎么样?当然,让我们考虑以下代码:

class Number{
    public:
        static int zero(){ return 0; }
        static int increment(const int value){ return value + 1; }
        static int add(const int first, const int second){ return first  
        + second; }
};

我们可以对引用类型执行同样的操作,如下所示:

class Number{
    public:
        static int zero(){ return 0; }
        static int increment(const int& value){ return value + 1; }
        static int add(const int& first, const int& second){ return 
        first + second; }
};

此外,我们可以对指针类型做同样的事情,尽管语法上有点复杂:

class Number{
    public:
        static int incrementValueFromPointer(const int* const value )   
        {return *value + 1;}
};

恭喜——你现在知道如何用 C++ 编写纯函数了!

嗯,算是吧;不幸的是,在 C++ 中实现不变性比我们目前看到的要复杂一点。我们需要更深入地看待各种情况。

纯函数和不变性

1995 年的电影《阿波罗 13 号》是我最喜欢的惊悚片之一。它涉及空间、真实故事和多个工程问题。在许多令人难忘的场景中,有一个特别的场景可以教会我们很多关于编程的知识。当宇航员团队正在准备一个复杂的程序时,由汤姆·汉克斯扮演的指挥官注意到,他的同事在一个命令开关上贴了一张贴纸,上面写着不要翻转这个。指挥官问他的同事为什么这么做,他的回答是类似于的意思:我头脑不清楚,我害怕我会翻转这个,把你送上太空。所以,我写这个提醒自己不要犯这个错误。

如果这项技术适用于宇航员,那么它也应该适用于程序员。幸运的是,当我们做错事时,编译器会告诉我们。然而,我们需要告诉编译器我们想要它检查什么。

毕竟,我们可以编写没有任何conststatic的纯函数。函数纯度不是语法问题,而是一个概念。有合适的贴纸可以防止我们犯错。然而,我们将看到编译器只能做到这一步。

让我们看一下实现前面讨论的增量函数的另一种方法:

class Number{
    public:
        int increment(int value){ return ++ value; }
};
int main(){
    Number number;
    int output = number.increment(Number::zero());
    cout << output << endl;
 }

这不是一个纯粹的功能。你能看出为什么吗?答案在下面一行:

 int increment(int value){ return ++ value; }

++ value不仅增加value,还改变输入参数。虽然在这种情况下这不是问题(value参数是通过值传递的,所以只有它的副本被修改),但这仍然是一个副作用。这表明用 C++ 或任何默认情况下不强制不变性的语言编写副作用是多么容易。幸运的是,编译器可以帮助我们,只要我们确切地告诉它我们想要什么。

回想一下之前的实现,如下所示:

 static int increment(const int value){ return value + 1; }

如果你试图在这个函数的主体中写入++ valuevalue++ ,编译器会立即告诉你你正在试图改变一个const输入参数。编译器真好,不是吗?

那么通过引用传递的参数呢?

不变性和引用传递

问题本可以更糟。想象以下功能:

 static int increment(int& value){ return ++ value; }

我们避免了传递值,这需要更多的内存。但是价值会怎么样呢?让我们看看下面的代码:

  int value = Number::zero(); //value is 0
      cout << Number::increment(value) << endl;
      cout << value << endl; // value is now 1

value参数从0开始,但是当我们调用函数时,它是递增的,所以现在它的value1。就像每次你开灯,冰箱门就会打开。幸运的是,如果我们只添加一个小的const关键词,我们将看到以下内容:

static int increment(const int& value) {return value + 1; }

然后,编译器再次很好地告诉我们,我们不能在它的主体中使用++ valuevalue++

这很酷,但是指针参数呢?

不变性和指针

当使用指针作为输入参数时,防止不必要的更改变得更加复杂。让我们看看当我们尝试调用这个函数时会发生什么:

  static int increment(int* pValue)

以下情况可能会改变:

  • pValue指向的值可能会改变。
  • 指针可以改变它的地址。

pValue所指的值可以在类似的条件下变化,正如我们之前发现的那样。例如,考虑以下代码:

 static int increment(int* pValue){ return ++*pValue; }

这将改变指向的值并返回它。为了使其不可能改变,我们需要使用一个位置合适的const关键词:

 static int increment(int* const pValue){ return *pValue + 1; }

指针地址的更改比您预期的要复杂。让我们看一个会以意想不到的方式表现的例子:

class Number {
    static int* increment(int* pValue){ return ++ pValue; }
}

int main(){
    int* pValue = new int(10);
    cout << "Address: " << pValue << endl;
    cout << "Increment pointer address:" <<   
    Number::incrementPointerAddressImpure(pValue) << endl;
    cout << "Address after increment: " << pValue << endl;
    delete pValue;
}

在我的笔记本电脑上运行此程序会得到以下结果:

Address: 0x55cd35098e80
Increment pointer address:0x55cd35098e80
Address after increment: 0x55cd35098e80
Increment pointer value:10

地址不会改变,即使我们使用++ pValue在函数中递增它。pValue++ 也是这样,但为什么会这样呢?

指针地址是一个值,它是通过值传递的,所以函数体中的任何变化都只适用于函数范围。要更改地址,您需要通过引用传递地址,如下所示:

 static int* increment(int*& pValue){ return ++ pValue; }

这告诉我们,幸运的是,编写改变指针地址的函数并不容易。我仍然觉得告诉编译器为我执行这条规则更安全:

 static int* increment(int* const& pValue){ return ++ pValue; }

当然,这并不妨碍您更改指向的值:

  static int* incrementPointerAddressAndValue(int* const& pValue){
      (*pValue)++ ;
      return pValue + 1;
  }

为了强制值和地址的不变性,您需要使用更多的const关键字,如以下代码所示:

  static const int* incrementPointerAddressAndValuePure(const int* 
      const& pValue){
          (*pValue)++ ;//Compilation error
          return pValue + 1;
  }

这涵盖了所有类型的类函数。然而,C++ 允许我们在类外编写函数。那么,static在这种情况下还管用吗?(剧透提醒:不完全如你所料)。

不变性和非类函数

到目前为止,所有的例子都假设函数是类的一部分。C++ 允许我们编写不属于任何类的函数。例如,我们可以编写以下代码:

int zero(){ return 0; }
int increment(int& value){ return ++ value; }
const int* incrementPointerAddressAndValuePure(const int* const& pValue){
    return pValue + 1;
}

你可能已经注意到我们不再使用static了。可以使用static,但需要注意的是,它与类中的函数有着完全不同的含义。static应用于独立功能意味着不能从不同的翻译单元使用;所以,如果你把函数写在一个 CPP 文件中,它将只在那个文件中可用,并且它将被链接器忽略。

我们已经讨论了所有类型的类和非类函数。但是有输出参数的函数呢?事实证明,他们需要一些工作。

不变性和输出参数

有时候,我们想要一个函数来改变我们传递的数据。标准模板库*(*)中有很多例子,最容易作为例子提供的是sort:

vector<int> values = {324, 454, 12, 45, 54564, 32};
     sort(values.begin(), values.end());

然而,这不符合纯函数的思想;sort的纯等价物如下:

vector<int> sortedValues = pureSort(values);

I can hear you thinking, but the STL implementation works in place for optimization reasons, so are pure functions less optimized? Well, as it turns out, pure functional programming languages, such as Haskell or Lisp, also optimize such operations; a pureSort implementation would just move the pointers around and only allocate more memory when one of the pointed values is changed. These are, however, two different contexts; C++ has to support multiple programming paradigms, while Haskell or Lisp optimize for immutability and functional style. We will discuss optimization further in Chapter 10, Performance Optimization. For now, let's examine how to make these types of functions pure.

我们已经发现了如何处理一个输出参数。但是如何才能写出有多个输出参数的纯函数呢?让我们考虑以下示例:

void incrementAll(int& first, int& second){
    ++ first;
    ++ second;
}

这个问题的一个简单解决方案是用vector<int>代替这两个参数。但是如果参数有不同的类型会发生什么呢?然后,我们可以使用一个结构。但如果这是我们唯一需要的时候呢?幸运的是,STL 为这个问题提供了一个解决方案,即通过元组:

const tuple<int, int> incrementAllPure(const int& first, const int&  
    second){
        return make_tuple(first + 1, second + 1);
 }
 int main(){
     auto results = incrementAllPure(1, 2);
     // Can also use a simplified version
     // auto [first, second] = incrementAllPure(1, 2);
     cout << "Incremented pure: " << get<0>(results) << endl;
     cout << "Incremented pure: " << get<1>(results) << endl;
 }

元组有许多优点,如下所示:

  • 它们可以与多个值一起使用。
  • 这些值可以有不同的数据类型。
  • 它们很容易构建——只需一次函数调用。
  • 它们不需要额外的数据类型。

根据我的经验,当您试图呈现一个具有多个纯输出参数的函数,或者一个返回值和一个输出参数的函数时,元组是一个很好的解决方案。然而,在我弄清楚如何设计它们之后,我经常尝试将它们重构为命名的结构或数据类。尽管如此,使用元组是一种非常有用的技术;节约使用。

到现在为止,我们已经使用了很多static功能。但是他们不是不好的做法吗?嗯,这取决于很多事情;接下来我们将更详细地讨论这一点。

静态函数不是不好的做法吗?

到目前为止,你可能在想纯函数是否好,因为它们与面向对象编程 ( OOP )或干净代码的规则相矛盾,也就是为了避免static。然而,直到现在,我们只编写了static函数。那么,它们是好是坏呢?

反对使用static函数有两种说法。

第一个反对static函数的理由是它们隐藏了全局状态。由于static函数只能访问static值,因此这些值成为全局状态。全局状态是不好的,因为很难理解是谁改变了它,当它的值出乎意料时也很难调试。

但是请记住纯函数的规则——对于相同的输入值,纯函数应该返回相同的输出值。因此,当且仅当函数不依赖于全局状态时,它才是纯函数。即使程序有状态,所有必要的值都会作为输入参数发送给纯函数。不幸的是,我们无法使用编译器轻松地实现这一点;程序员的惯例是避免使用任何类型的全局变量,而是将其转换为参数。

这种情况有一个优势,特别是在使用全局常量时。虽然常量是一种不可变的状态,但是考虑它们的演化也很重要。例如,考虑以下代码:

static const string CURRENCY="EUR";

在这里,你应该知道总有一天常数会变成变量,然后你必须改变一堆代码来实现新的需求。我的建议是,通常最好也传入常量。

反对static函数的第二个理由是它们不应该是类的一部分。我们将在后面的章节中更详细地讨论这个论点;可以说,目前,类应该将内聚函数分组,有时,纯函数应该整齐地放在一个类中。还有一种方法可以替代将内聚的纯函数分组到一个类中——只需使用一个名称空间。

幸运的是,我们不一定要在类中使用static函数。

静态函数的替代

我们在前一节中发现了如何使用static函数在Number类中编写纯函数:

class Number{
    public:
        static int zero(){ return 0; }
        static int increment(const int& value){ return value + 1; }
        static int add(const int& first, const int& second){ return  
        first + second; }
};

然而,还有另一种选择;C++ 允许我们避免static,但保持函数不变:

class Number{
    public:
        int zero() const{ return 0; }
        int increment(const int& value) const{ return value + 1; }
        int add(const int& first, const int& second) const{ return 
        first + second; }
};

每个函数签名后的const关键字只是告诉我们函数可以访问Number类的数据成员,但永远不能更改。

如果我们稍微修改一下这段代码,我们可以问一个关于类上下文中不变性的有趣问题。如果我们用一个值初始化这个数,并且总是加到初始值上,我们会得到下面的代码:

class Number{
    private:
        int initialValue;

    public:
        Number(int initialValue) : initialValue(initialValue){}
        int initial() const{ return initialValue; }
        int addToInitial(const int& first) const{ return first + 
        initialValue; }
};

int main(){
    Number number(10);
    cout << number.addToInitial(20) << endl;
}

这里有一个有趣的问题:addToInitial函数是纯函数吗?让我们按如下方式检查标准:

  • 有副作用吗?不,不是的。
  • 对于相同的输入值,它是否返回相同的输出值?这是一个棘手的问题,因为函数有一个隐藏的参数,即Number类或其初始值。然而,没有人能从Number班之外改变initialValue。换句话说,Number类是不可变的。因此,该函数将为相同的Number实例和相同的参数返回相同的输出值。
  • 它会改变参数值吗?嗯,它只接收一个参数,它不改变它。

结果是这个函数实际上是纯的。我们将在下一章中发现它也是部分应用的功能

我们之前提到,除了 I/O 之外,程序内部的一切都可以是纯的。那么,我们该如何处理执行 I/O 的代码呢?

纯函数和输入输出

看看下面,考虑一下这个函数是否是纯函数:

void printResults(){
    int* pValue = new int(10);
    cout << "Address: " << pValue << endl;
    cout << "Increment pointer address and value pure:" <<    
    incrementPointerAddressAndValuePure(pValue) << endl;
    cout << "Address after increment: " << pValue << endl;
    cout << "Value after increment: " << *pValue << endl;
    delete pValue;
}

好吧,让我们看看——它没有参数,所以没有值被改变。但是与我们前面的例子相比,有些东西是不存在的,也就是说,它不返回值。相反,它调用几个函数,其中至少有一个是纯函数。

那么,它有副作用吗?嗯,是的;几乎每一行代码都有一个:

cout << ....

这一行代码在控制台上写了一行字符串,这是副作用!cout基于可变状态,所以不是纯函数。此外,由于其外部依赖性,cout可能会失败,导致异常。

我们的程序需要输入输出,那么我们能做什么呢?嗯,这很简单——简单地将可变部分和不可变部分分开。把副作用和非副作用分开,尽量减少不纯的功能。

那么,我们如何在这里实现这一点呢?好吧,有一个纯函数在等着摆脱这个不纯的函数。关键是从问题出发;所以,我们把cout分开如下:

string formatResults(){
    stringstream output;
    int* pValue = new int(500);
    output << "Address: " << pValue << endl;
    output << "Increment pointer address and value pure:" << 
    incrementPointerAddressAndValuePure(pValue) << endl;
    output << "Address after increment: " << pValue << endl;
    output << "Value after increment: " << *pValue << endl;
    delete pValue;
    return output.str();
}

void printSomething(const string& text){
    cout << text;
}

printSomething(formatResults());

我们已经将cout产生的副作用转移到了另一个函数中,并使初始函数的意图更加清晰——它正在格式化某些东西,而不是打印。似乎我们干净利落地把纯函数和不纯函数分开了。

但我们有吗?让我们再次检查formatResults。它不像以前那样有副作用。我们正在使用stringstream,它可能不是纯的,并且正在分配内存,但是所有这些都是函数本地的。

Is memory allocation a side effect? Can a function that allocates memory be pure? After all, memory allocation may fail. However, it's virtually impossible to avoid some kind of memory allocation in functions. We will accept, therefore, that a pure function may fail if there's some kind of memory failure.

那么,它的产量呢?它会改变吗?嗯,它没有输入参数,但是它的输出可以根据new运算符分配的内存地址而变化。所以,它还不是一个纯粹的函数。我们如何使它纯净?这很简单,让我们输入一个参数pValue:

string formatResultsPure(const int* pValue){
    stringstream output;
    output << "Address: " << pValue << endl;
    output << "Increment pointer address and value pure:" << 
    incrementPointerAddressAndValuePure(pValue) << endl;
    output << "Address after increment: " << pValue << endl;
    output << "Value after increment: " << *pValue << endl;
    return output.str();
}

int main(){
    int* pValue = new int(500);
    printSomething(formatResultsPure(pValue));
    delete pValue;
}

在这里,我们将自己与副作用和易变状态隔离开来。代码不再依赖于输入/输出或new运算符。我们的功能是纯粹的,这带来了额外的好处——它只做一件事,更容易理解它做了什么,它是可预测的,我们可以非常容易地测试它。

至于我们有副作用的函数,请考虑下面的代码:

void printSomething(const string& text){
    cout << text;
}

我认为我们都可以同意,理解它的作用很容易,只要我们的其他功能都是纯粹的,我们就可以放心地忽略它。

总之,为了获得更可预测的代码,我们应该将纯函数和不纯函数分开,并尽可能将不纯函数推到系统的边界。可能有些情况下,这种改变是昂贵的,在你的代码中有不纯的函数是非常好的。只要确保你知道哪些是哪些。

摘要

在这一章中,我们探讨了如何用 C++ 编写纯函数。由于您需要记住一些技巧,这里列出了推荐的语法:

  • 按值传递的类函数:

    • static int increment(const int value)

    • int increment(const int value) const

  • 通过引用传递的类函数:

    • static int increment(const int& value)

    • int increment(const int&value) const

  • 按值传递指针的类函数:

    • static const int* increment(const int* const value)

    • const int* increment(const int* const value) const

  • 通过引用传递指针的类函数:

    • static const int* increment(const int* const& value)

    • const int* increment(const int* const& value) const

  • 传递值的独立函数:int increment(const int value)

  • 通过引用传递的独立函数:int increment(const int& value)

  • 按值传递指针的独立函数:const int* increment(const int* value)

  • 通过引用传递指针的独立函数:const int* increment(const int* const& value)

我们还发现,虽然编译器有助于减少副作用,但它并不总是告诉我们一个函数是否是纯函数。我们总是需要记住编写纯函数时要使用的标准,如下所示:

  • 对于相同的输入值,它总是返回相同的输出值。
  • 它没有副作用。
  • 它不改变输入参数的值。

最后,我们看到了如何将副作用(通常与输入/输出相关)与我们的纯功能分开。这非常简单,通常需要传入值和提取函数。

现在是向前迈进的时候了。当我们将功能视为设计中的一流公民时,我们可以在功能方面做得更多。要做到这一点,我们需要了解什么是 lambdas 以及它们是如何有用的。我们将在下一章这样做。

问题

  1. 什么是纯函数?
  2. 不变性和纯函数有什么关系?
  3. 如何告诉编译器防止对通过值传递的变量进行更改?
  4. 如何告诉编译器防止对通过引用传递的变量进行更改?
  5. 如何告诉编译器防止对通过引用传递的指针地址的更改?
  6. 如何告诉编译器防止指针指向的值发生变化?**