Skip to content

Latest commit

 

History

History
409 lines (298 loc) · 22.4 KB

File metadata and controls

409 lines (298 loc) · 22.4 KB

一、函数式编程导论

为什么函数式编程有用?在过去的十年里,函数式编程结构在所有主要的编程语言中都出现了。程序员已经享受到了它们的好处——简化的循环、更具表现力的代码和简单的并行化。但它还有更多——与时间脱钩,提供消除重复、可组合性和更简单设计的机会。函数式编程的更高采用率(包括金融领域对 Scala 的大规模采用)意味着一旦你了解并理解了它,你就有了更多的机会。虽然我们将在本书中深入探讨函数式编程,以帮助您学习,但请记住,函数式编程是另一个添加到工具箱中的工具,当问题和上下文合适时,您可以选择使用它。

本章将涵盖以下主题:

  • 函数式编程的介绍以及对您已经如何使用函数式构造的检查
  • 结构化循环与功能性循环
  • 不变
  • 面向对象编程 ( 面向对象程序设计)与功能设计
  • 可组合性和消除重复

技术要求

代码使用 g++ 7.3.0 和 c++ 17;为了您的方便,它包括一个makefile。你可以在Chapter01目录下的 GitHub 资源库(https://GitHub . com/PacktPublishing/动手-函数-用-Cpp 编程)中找到它。

函数式编程导论

我第一次接触函数式编程是在大学。我是一个对科幻、阅读和编程感兴趣的 20 岁极客;编程是我学术生活的亮点。与 C++、Java、MATLAB 和我们使用的其他一些编程语言相关的一切对我来说都很有趣。不幸的是,我不能对电气工程、电路或编译理论的学科说同样的话。我只想写代码!

基于我的兴趣,函数式编程对我来说应该是一门非常有趣的课程。我们的老师非常热情。我们必须写代码。但是出了点问题——我没有听懂老师告诉我们的话。为什么列表如此有趣?为什么语法如此落后,而且充满了括号?用 C++ 写同样的代码要简单得多,我为什么要用这些东西呢?我最终尝试将所有我知道的编程结构从 BASIC 和 C++ 翻译成 Lisp 和 OCaml。它完全错过了函数式编程的要点,但我通过了这门课程,多年后就忘记了。

我想你们中的许多人都可以理解这个故事,我对此有一个可能的原因。我现在相信我的老师,尽管非常热情,但使用了错误的方法。今天,我明白了函数式编程的核心有一定的优雅,因为它与数学的关系很强。但这种优雅需要一种我 20 岁时没有的洞察力,也就是说,这种感觉是我在多年的各种经历后幸运地建立起来的。现在对我来说很明显,学习函数式编程不应该与读者看到这种优雅的能力有关。

那么,我们可以用什么方法来代替呢?想想过去的我,也就是那个只想写代码的极客,只有一条路可走——看看代码中常见的问题,探索函数式编程如何减少或完全消除它们。另外,从头开始;您已经看到了函数式编程,已经使用了一些概念和构造,甚至可能发现它们非常有用。让我们来看看为什么。

函数式编程构造无处不在

大约在我完成大学函数式编程课程 10 年后,我和我的朋友费利克斯聊了聊。作为任何两个极客,我们很少见面,但多年来,我们一直在即时通讯上讨论各种无聊的话题,当然还有编程。

不知何故,函数式编程的话题出现了。费利克斯指出,我最喜欢和最喜欢的编程语言之一 LOGO,实际上是一种函数式编程语言。

LOGO is an educational programming language whose main characteristic is utilization of so-called turtle graphics.

回想起来很明显;下面是如何编写一个函数,在 KTurtle 版本的 LOGO 中绘制一个正方形:

learn square {
    repeat 4 {forward 50 turnright 90}
}

结果如下图所示:

你能看到我们是如何将两行代码传递给 repeat 函数的吗?那是函数式编程!函数式编程的一个基本原则是,代码只是另一种类型的数据,可以打包在一个函数中并传递给其他函数。我在 LOGO 中使用了这个构造数百次,但没有建立连接。

这种认识让我思考:是否有其他函数式编程结构是我在不知情的情况下使用的?事实证明,是的,有。事实上,作为一名 C++ 程序员,你很可能也使用过它们;让我们看几个例子:

int add(const int base, const int exponent){
   return pow(base, exponent);
}

这个函数是推荐的 C++ 代码的典型例子。我第一次从伯特兰·迈耶令人惊叹的书中了解到到处添加const的好处:*有效 c++**更有效 c++*有效 STL 。这种结构运行良好有多种原因。首先,它保护不应该改变的数据成员和参数。其次,它允许程序员通过消除可能的副作用来更容易地推理函数中发生了什么。第三,它允许编译器优化函数。

事实证明,这也是行动不变性的一个例子。正如我们将在以下章节中发现的,函数式编程将不变性置于程序的核心,将所有副作用移到程序的边缘。我们已经知道函数式编程的基本结构;说我们使用函数式编程只是意味着更广泛地使用它!

下面是 STL 的另一个例子:

std::vector aCollection{5, 4, 3, 2, 1};
sort (aCollection.begin(), aCollection.end());

STL 算法有很大的威力;这种力量来自多态性。我在比 OOP 更基本的意义上使用这个术语——它仅仅意味着集合包含什么并不重要,因为只要实现了比较,算法仍然可以正常工作。我不得不承认,当我第一次理解它时,我对这个聪明、有效的解决方案印象深刻。

sort函数有一个变体,即使没有实现比较,或者没有像我们希望的那样工作,也允许对元素进行排序;例如,当给我们一个Name结构时,如下所示:

using namespace std;

// Parts of code omitted for clarity
struct Name{
     string firstName;
     string lastName;
};

如果我们想按名字对vector<Name>容器进行排序,我们只需要一个compare函数:

bool compareByFirstName(const Name& first, const Name& second){
     return first.firstName < second.firstName;
}

另外,我们需要将其传递给sort函数,如下面的代码所示:

int main(){
    vector<Name> names = {Name("John", "Smith"), Name("Alex",
    "Bolboaca")};

    sort(names.begin(), names.end(), compareByFirstName);
}
// The names vector now contains "Alex Bolboaca", "John Smith"

这就构成了一种高阶函数。高级函数是使用其他函数作为参数的函数,以便允许更高级别的多态性。祝贺您—您刚刚使用了第二个函数式编程构造!

我甚至会说 STL 是函数式编程的一个很好的例子。一旦你对函数式编程结构有了更多的了解,你就会意识到它们在 STL 中无处不在。其中一些,如函数指针或函子,已经在 C++ 语言中存在了很长时间。事实上,STL 已经经受住了时间的考验,那么为什么不在我们的代码中也使用类似的范例呢?

除了 STL 中存在的函数循环,没有更好的例子来支持这个语句。

结构化循环与功能性循环

作为程序员,我们学习的第一件事就是如何编写循环,这并不奇怪。我在 C++ 中的第一个循环是打印从110的数字:

for(int i = 0; i< 10; ++ i){
    cout << i << endl;
}

作为一个好奇的程序员,我认为这个语法是理所当然的,研究了它的特性和复杂性,然后就使用了它。回顾过去,我意识到这个结构有一些不寻常的地方。一、为什么从0开始?我被告知这是一个惯例,由于历史原因。然后,for循环有三个语句——初始化、条件和增量。对于我们试图实现的目标来说,这听起来有点太复杂了。最后,结束条件迫使我犯了比我愿意承认的更多的错误。

此时,您将意识到 STL 允许您在遍历集合时使用迭代器:

for (list<int>::iterator it = aList.begin(); it != aList.end(); ++ it)
      cout << *it << endl;

这肯定比使用光标的for循环要好。它避免了一个接一个的错误,也没有0常规的恶作剧。然而,手术仍然有很多仪式。更糟糕的是,随着程序复杂性的增加,循环趋于增长。

有一种简单的方法可以显示这种症状。让我们回顾一下我使用循环解决的第一个问题。

让我们考虑一个整数向量,并计算它们的和;简单的实现如下:

int sumWithUsualLoop(const vector<int>& numbers){
    int sum = 0;
    for(auto iterator = numbers.begin(); iterator < numbers.end(); 
    ++ iterator){
        sum += *iterator;
    }
    return sum;
}

要是生产代码这么简单就好了!相反,当我们实现这段代码的时候,我们会得到一个新的需求。我们现在只需要对向量中的偶数求和。嗯,这很简单,对吧?让我们看看下面的代码:

int sumOfEvenNumbersWithUsualLoop(const vector<int>& numbers){
    int sum = 0;
    for(auto iterator = numbers.begin(); iterator<numbers.end(); 
    ++ iterator){
        int number = *iterator;
        if (number % 2 == 0) sum+= number;
    }
    return sum;
}

如果你认为这就是结局,那就不是。我们现在需要对同一个向量进行三次求和——一次是偶数,一次是奇数,一次是总数。现在让我们添加一些代码,如下所示:

struct Sums{
    Sums(): evenSum(0),  oddSum(0), total(0){}
    int evenSum;
    int oddSum;
    int total;
};

const Sums sums(const vector<int>& numbers){
    Sums theTotals;
    for(auto iterator = numbers.begin(); iterator<numbers.end(); 
    ++ iterator){
        int number = *iterator;
        if(number % 2 == 0) theTotals.evenSum += number;
        if(number %2 != 0) theTotals.oddSum += number;
        theTotals.total += number;
    }
    return theTotals;
}

我们最初相对简单的循环变得越来越复杂。当我第一次开始专业编程时,我们总是责怪用户和客户不能决定完美的特性,给我们最终的、冻结的需求。然而,这在现实中很少可能;我们的客户每天都从用户与我们编写的程序的互动中学习新的东西。这取决于我们如何清楚地解释这段代码,函数循环也是可能的。

多年后,我学会了 Groovy。作为一种基于 Java 虚拟机的编程语言,Groovy 专注于通过帮助程序员编写更少的代码和避免常见错误来简化他们的工作。下面是如何用 Groovy 编写前面的代码:

def isEven(value){return value %2 == 0}
def isOdd(value){return value %2 == 1}
def sums(numbers){
   return [
      evenSum: numbers.filter(isEven).sum(),
      oddSum: numbers.filter(isOdd).sum(),
      total: numbers.sum()
   ]
}

让我们比较一下这两者。没有循环。代码极其清晰。不可能一个接一个地出错。没有计数器,所以,所以,从 0开始就没有怪异。此外,它周围没有脚手架——我只写我想实现的,受过训练的读者很容易理解它。

虽然 C++ 版本更加冗长,但它允许我们实现相同的目标:

const Sums sumsWithFunctionalLoops(const vector<int>& numbers){
    Sums theTotals;
    vector<int> evenNumbers;
    copy_if(numbers.begin(), numbers.end(), 
    back_inserter(evenNumbers), isEven);
    theTotals.evenSum = accumulate(evenNumbers.begin(), 
    evenNumbers.end(), 0);

    vector<int> oddNumbers;
    copy_if(numbers.begin(), numbers.end(), back_inserter(oddNumbers), 
    isOdd);
    theTotals.oddSum= accumulate(oddNumbers.begin(), oddNumbers.end(), 
    0);

    theTotals.total = accumulate(numbers.begin(), numbers.end(), 0);

    return theTotals;
}

尽管如此,还是有很多仪式,以及太多的代码相似性。那么,让我们把它去掉,如下所示:

template<class UnaryPredicate>
const vector<int> filter(const vector<int>& input, UnaryPredicate filterFunction){
    vector<int> filtered;
    copy_if(input.begin(), input.end(), back_inserter(filtered), 
    filterFunction);
    return filtered;
}

const int sum(const vector<int>& input){
    return accumulate(input.begin(), input.end(), 0);
}

const Sums sumsWithFunctionalLoopsSimplified(const vector<int>& numbers){
    Sums theTotals(
        sum(filter(numbers, isEven)),
        sum(filter(numbers, isOdd)),
        sum(numbers)
    ); 
    return theTotals;
}

我们刚刚用一些更简单、更易读和可组合的函数替换了一个复杂的for循环。

那么,这个代码更好吗?嗯,这取决于你对更好的 T2 的定义。我喜欢根据优点和缺点来考虑任何实现。函数循环的优点是简单、易读、减少代码重复和可组合。有什么缺点吗?嗯,我们最初的for循环只需要一次通过向量,而我们当前的实现需要三次通过。对于非常大的集合,或者当响应时间和内存使用非常重要时,这可能是一个负担。这绝对值得讨论,我们将在第 10 章、*性能优化、*中更详细地探讨这一点,这一章只关注函数式编程的性能优化。现在,我建议你把重点放在理解函数式编程的新工具上。

为了做到这一点,我们需要重新审视不变性。

不变

我们已经明白,在 C++ 中,某种程度的不变性是首选的;常见的例子如下:

class ...{
    int add(const int& first, const int& second) const{
        return first + second;
    }
}

const关键字清楚地传达了代码的一些重要约束,例如:

  • 该函数在返回之前不会更改任何参数。
  • 该函数不会更改其所属类的任何数据成员。

现在让我们想象一下add的另一个版本,如下所示

int uglyAdd(int& first, int& second){
    first = first + second;
    aMember = 40;
    return first;
}

我称之为uglyAdd是有原因的——我在编程的时候不能容忍这样的代码!这个函数违反了最小惊喜原则,做了太多事情。阅读函数代码并不能揭示它的意图。想象一下调用者的惊讶,如果不小心,那么,仅仅通过调用一个add函数,两件事就发生了变化——一件是传递的参数,第二件是函数所在的类。

虽然这是一个极端的例子,但它有助于论证不变性。不可变函数很无聊;它们接收数据,不改变接收数据中的任何内容,不改变包含它们的类中的任何内容,并返回一个值。然而,当涉及到长时间维护代码时,无聊是好的。

不变性是函数编程中函数的核心属性。当然,你的程序中至少有一部分不能是不可变的——输入/输出 ( 输入/输出)。我们将接受输入/输出的本来面目,我们将专注于尽可能增加代码的不变性。

现在,你可能想知道你是否必须彻底重新思考你写程序的方式。你应该忘记所有关于 OOP 的知识吗?不完全是,让我们看看为什么。

面向对象与功能设计风格

我工作的一个重要部分是与程序员一起工作,帮助他们改进编写代码的方式。为此,我尽力为复杂的想法想出简单的解释。我对软件设计有一个这样的解释。对我来说,软件设计就是我们构建代码的方式,这样我们就可以为商业目的优化它。

我喜欢这个定义,因为它简单明了。但是有一件事困扰了我,在我开始尝试功能性结构之后;也就是说,函数式编程会产生如下代码:

const Sums sumsWithFunctionalLoopsSimplified(const vector<int>& numbers){
    Sums theTotals(
        sum(filter(numbers, isEven)),
        sum(filter(numbers, isOdd)),
        sum(numbers)
    );
    return theTotals;
 }

用面向对象的方式编写类似的代码很可能意味着创建类和使用继承。那么,哪种风格更好呢?另外,如果软件设计是关于代码结构的,那么这两种风格是否等价?

首先,我们来看看这两种设计风格真正促进了什么。什么是 OOP?多年来,我相信所有列出面向对象语言以下三个属性的书:

  • 包装
  • 遗产
  • 多态性

OOP 背后的思想家艾伦·凯并不真正同意这个列表。对他来说,OOP 是关于许多小对象之间的通信。作为一名生物学专业的学生,他看到了一个组织程序的机会,就像身体组织细胞一样,并允许物体像细胞一样进行交流。他更重视对象而不是类,以及通常列出的面向对象属性的通信。我最好把他的观点总结如下:系统中的动态关系比静态属性更重要。

这改变了很多面向对象的范例。那么,类应该与现实世界相匹配吗?不完全是。它们应该针对真实世界的表现进行优化。我们是否应该专注于拥有清晰的、经过深思熟虑的类层次结构?不,因为这些不如物体之间的交流重要。我们能想到的最小的物体是什么?要么是数据的组合,要么是函数。

最近在 Quora(https://www . Quora . com/not-摆脱邪恶状态-like-Haskells-approach-things-every-programming-they/answer/Alan-Kay-11上的一个回答中,Alan Kay 在回答一个关于函数式编程的问题时陈述了一个有趣的想法。函数式编程源于数学,源于为了实现人工智能而对现实世界进行建模的努力。这一努力解决了以下问题——亚历克斯在布加勒斯特亚历克斯在伦敦可能都是真的,但时间点不同。这个建模问题的解决方案是不变性;也就是说,时间成为函数的参数,或者数据结构中的数据成员。在任何程序中,我们都可以将数据变化建模为数据的有时间限制的版本。没有什么能阻止我们将数据建模为小对象,将变化建模为函数。此外,正如我们将在后面看到的,我们可以轻松地将函数转换为对象,反之亦然。

因此,总而言之,艾伦·凯所说的面向对象和函数式编程之间并没有真正的紧张关系。只要我们专注于增加代码的不变性,以及相互通信的小对象,我们就可以将它们结合在一起并互换使用。在接下来的章节中,我们将发现用函数替换类是多么容易,反之亦然。

但是使用 OOP 的方式有很多,与艾伦·凯的设想不同。我在我的客户那里看到了很多 C++ 代码,我已经看到了所有这些——大函数、庞大的类和深度继承层次结构。大多数时候,我被叫的原因是因为设计太难改变,也因为添加新功能会减慢速度。继承是一种非常强的关系,过度使用它会导致强耦合,从而导致难以更改的代码。长方法和长课程更难理解,也更难改变。当然,有些情况下继承和长类是有意义的,但是,一般来说,使用松散耦合的小对象可以实现可变性。

但是类是可以重用的,不是吗?我们能用函数做到吗?接下来我们来看看这个话题。

可组合性和消除重复

我们已经看到了一个存在大量重复的例子:

const Sums sumsWithFunctionalLoops(const vector<int>& numbers){
    Sums theTotals;
    vector<int> evenNumbers;
    copy_if(numbers.begin(), numbers.end(), back_inserter(evenNumbers), 
    isEven);
    theTotals.evenSum = accumulate(evenNumbers.begin(), 
    evenNumbers.end(), 0);

    vector<int> oddNumbers;
    copy_if(numbers.begin(), numbers.end(), back_inserter(oddNumbers), 
    isOdd);
    theTotals.oddSum= accumulate(oddNumbers.begin(), oddNumbers.end(), 
    0);

    theTotals.total = accumulate(numbers.begin(), numbers.end(), 0);

    return theTotals;
}

我们使用函数减少了它,如下面的代码所示:

template<class UnaryPredicate>
const vector<int> filter(const vector<int>& input, UnaryPredicate filterFunction){
    vector<int> filtered;
    copy_if(input.begin(), input.end(), back_inserter(filtered), 
    filterFunction);
    return filtered;
}

const int sum(const vector<int>& input){
    return accumulate(input.begin(), input.end(), 0);
}

const Sums sumsWithFunctionalLoopsSimplified(const vector<int>& numbers){
    Sums theTotals(
        sum(filter(numbers, isEven)),
        sum(filter(numbers, isOdd)),
        sum(numbers)
    );

    return theTotals;
}

看到函数是如何以各种方式组成的很有趣;我们有sum(filter())叫了两次,sum()叫了一次。而且,filter可以和多个谓语连用。另外,通过一些工作,我们可以使filtersum都具有多态功能:

template<class CollectionType, class UnaryPredicate>
const CollectionType filter(const CollectionType& input, UnaryPredicate filterFunction){
    CollectionType filtered;
    copy_if(input.begin(), input.end(), back_inserter(filtered), 
    filterFunction);
    return filtered;
}
template<typename T, template<class> class CollectionType>
const T sum(const CollectionType<T>& input, const T& init = 0){
    return accumulate(input.begin(), input.end(), init);
} 

现在很容易用除了vector<int>类型的参数来调用filtersum。实现并不完美,但它说明了我试图说明的一点,即小的、不可变的函数很容易变得多态和可组合。当我们可以将函数传递给其他函数时,这尤其有效。

摘要

我们已经讨论了很多有趣的话题!你刚刚意识到你知道函数式编程的基础。借助const关键字,可以用 C++ 编写不可变函数。您已经使用了 STL 中的高级函数。此外,你不必忘记关于 OOP 的任何事情,相反,只要从不同的角度来看待它。最后,我们发现了如何组合小的不可变函数来提供复杂的功能,以及它们如何在 C++ 模板的帮助下变得多态。

现在是时候深入了解函数式编程的构建模块,并学习如何在 C++ 中使用它们了。这包括纯函数、lambdas 以及具有函数组合、currying 或部分函数应用等功能的操作。

问题

  1. 什么是不可变函数?
  2. 如何编写不可变函数?
  3. 不可变函数如何支持代码简单性?
  4. 不可变函数如何支持简单设计?
  5. 什么是高级功能?
  6. 从 STL 可以举出什么高级函数的例子?
  7. 功能循环相对于结构化循环有什么优势?潜在的缺点是什么?
  8. 从艾伦·凯的角度来看,OOP 是什么?它与函数式编程有什么关系?