Go-函数式编程-全-
Go 函数式编程(全)
原文:
zh.annas-archive.org/md5/a509a6c085ec16a5606577d6e161d01f译者:飞龙
前言
Go 是一种多范式编程语言。这意味着面向对象范式和函数式范式都是完全有效的解决问题的方法。在这本书中,我们将探讨函数式编程技术在 Go 中的应用。但本书不会仅仅关注函数式方面,而是会拥抱 Go 的本质——多范式。这意味着我们将突出函数式和面向对象解决问题的不同方式。
为了编写更易于测试、阅读和可靠的 Go 代码,我们将探讨以函数式编程为首要方法的途径,如函数作为一等公民、函数纯净性、柯里化等。我们将不仅探讨如何编写函数式代码,还将探讨 Go 的性能影响和局限性。
本书的目标是让读者习惯将函数式编程作为一种有效的范式,无论你是在开发一个全新的项目,还是在已经深入 OO 范式的项目中工作,都能通过它来提升你的代码质量。
对于不熟悉 Go 中新引入的泛型的读者,本书也提供了一个示例,展示了泛型成为标准库的一部分后,现在可以实现的可能性。最后,我们还将探讨可以用来为 Go 的泛型前和泛型后版本编写函数式代码的库。
这本书面向的对象
如果你是一位有 Java 或 C++等传统面向对象语言背景的 Go 工程师,希望扩展你对函数式编程的了解,这本书就是为你准备的。本书旨在教你如何将函数式编程的概念应用于现有的 Go 代码中,以及何时选择函数式方法。在每一步中,我们将突出函数式和面向对象方法之间的权衡,以了解它们是如何比较的。
本书涵盖的内容
在第一章《介绍函数式编程》中,我们将从宏观的角度了解函数式编程背后的是什么和为什么。首先,我们将简要回顾函数式编程方法的历史和当代状态。然后,我们将探讨函数式编程与传统面向对象编程的比较。
在第二章《将函数视为一等公民》中,我们将详细探讨为什么在将函数视为一等公民的语言中,函数如此强大。Go 语言天生就支持函数作为一等公民,这意味着我们能够获得这种功能。我们将看到这是如何使我们能够创建以函数为中心的结构,从而提高我们代码的可读性和可测试性。
在第三章,高阶函数中,我们将通过高阶函数探索函数组合的概念。这里引入了许多新的概念,例如闭包、部分应用和函数柯里化。我们将探讨一些实际示例和实际用例。
在第四章,使用纯函数编写可测试的代码中,我们将探讨一个语言和一个函数被认为是纯函数的含义。我们将探讨函数纯度与不纯度之间的权衡,并探讨纯函数如何帮助我们编写可测试的代码。
在第五章,不可变性中,我们讨论了不可变性的确切含义,以及 Go 语言如何帮助在结构级别上保持不可变性。为了理解这是如何工作的,我们将看看 Go 如何处理对象的指针和引用,性能影响是什么,以及如何在指针-引用权衡之间做出决定。我们还将深入研究垃圾收集、单元测试和纯函数式编程的影响。
在第六章,函数的三个常见类别中,我们将探讨一些利用到目前为止所涵盖的函数式编程概念的函数的实际实现。我们将构建过滤器函数、映射函数和归约器。
在第七章,递归中,我们将讨论递归。这是一个所有程序员迟早都会遇到的话题,因为它并不仅限于函数式范式。任何允许你表达函数调用的语言也允许你表达本质上递归的函数。但在函数式语言中,这些函数占据了中心舞台。我们将探讨 Go 语言中这一点的含义。
在第八章,使用流畅编程进行可读性函数组合中,我们将探讨在函数式编程中链式调用函数的不同方法。这里的最终目标是编写更易于阅读的代码,并减少视觉混乱。我们将探讨三种实现这一目标的方法。首先,我们将看看如何使用类型别名将方法附加到容器类型上,从而允许我们使用熟悉的点符号创建链式函数。接下来,我们将探讨传递风格编程,并考虑每种方法的权衡。
在第九章,功能设计模式中,我们将提升到更高的抽象层次。我们不会谈论单个函数和操作,而是会探讨设计模式。虽然我们不会详细解释每个设计模式,但我们会看看面向对象模式如何转化为函数式世界。
在第十章**,并发和函数式编程中,我们考虑了并发无处不在,无论是在现实世界还是在虚拟世界中。在这一章中,我们将首先探讨并发、并行和分布式计算。接下来,我们将关注 Go 中的并发机制如何帮助我们编写函数式代码。
在第十一章**,函数式编程库中,我们将探讨几个可以帮助我们在函数式范式下构建程序的库。我们将查看既有的非泛型库和泛型库。
为了充分利用这本书
在拿起这本书之前,读者应该熟悉 Go 和泛型。编程语言的基本概念(控制流、结构体和导入)、如何构建和运行应用程序以及如何从 GitHub 导入开源库也应该为读者所理解。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Go(泛型和泛型之前) | Windows、macOS 或 Linux |
安装 Go 1.18 或更高版本是本书大多数内容的先决条件。某些章节也将适用于 1.18 之前的 Go 版本,这将在每个章节中说明。大多数代码也将适用于 Go playgroundgo.dev/play/。
如果您正在使用本书的数字版,我们建议您自己输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将有助于避免与代码复制和粘贴相关的任何潜在错误。
一些章节将包含 Haskell 和 Java 的代码片段,以说明 Go 的(纯)函数式和面向对象对应物的示例。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Functional-Programming-in-Go。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/5tPDg。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在调用rollDice函数时,输出并不一致。如果它始终输出相同的数字,那将是一个非常糟糕的随机化函数。”
代码块设置如下:
func rollDice() int {
return rand.Intn(6)
}
任何命令行输入或输出都应如下所示:
go test -bench=.
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在这个主函数中,我们首先定义了一个在主函数结束前、函数退出前运行的延迟函数。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
customercare@packtpub.com并在您的消息主题中提及书名。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。
copyright@packt.com,并提供材料链接。
authors.packtpub.com。
分享您的想法
一旦您阅读了《Golang 中的函数式编程》,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781801811163
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱。
第一部分:函数式编程范式基础
在这部分,我们将探讨函数式编程范式包含的内容。我们将比较它与传统的面向对象方法,并学习每个范式编程语言之间的语言设计差异。我们还将讨论 Go 成为多范式语言的意义,并看看这对我们的用例有何好处。最后,我们将探讨函数式编程的一些关键思想,我们可以利用这些思想来编写更易读、可维护和可测试的代码。
本部分包含以下章节:
-
第一章,介绍函数式编程
-
第二章, 将函数视为一等公民
-
第三章**, 高阶函数
-
第四章**, 使用纯函数编写可测试的代码
-
第五章**, 不可变性
第一章:介绍函数式编程
在第一章中,我们将从宏观的角度了解函数式编程(FP)背后的是什么和为什么。在我们深入 FP 的细节之前,我们首先需要了解从应用这些技术到我们的代码中我们能获得什么好处。首先,我们将简要回顾 FP 的历史和当代状态。接下来,我们将探讨 FP 与传统面向对象编程(OOP)的比较。最后,我们还将讨论“Go 编程范式”。
本章我们将涵盖的主要内容如下:
-
什么是 FP?
-
FP 的简要历史
-
FP 的当前状态一览
-
传统面向对象和函数式方法的比较
-
关于 Go 编程范式及其如何融入 FP 的讨论
什么是函数式编程?
如你所猜,FP 是一种以函数为主角的编程范式。函数将是函数式程序员工具箱中的主食。我们的程序将由函数组成,以各种方式链接在一起以执行更复杂的任务。这些函数通常很小且模块化。
这与 OOP 形成对比,在 OOP 中,对象是主角。函数在 OOP 中也被使用,但它们的使用通常是为了改变对象的状态。它们通常与一个对象绑定在一起。这导致了熟悉的调用模式someObject.doSomething()。在这些语言中,函数被视为二等公民;它们被用来服务于对象的功能,而不是用于函数本身。
介绍一等函数
在 FP 中,函数被视为一等公民。这意味着它们被以类似于在传统面向对象语言中处理对象的方式对待。函数可以被绑定到变量名上,它们可以被传递给其他函数,甚至可以作为函数的返回值。本质上,函数被当作任何其他“类型”一样对待。这种类型与函数之间的等价性是 FP 力量的源泉。正如我们将在后面的章节中看到的,将函数视为一等公民为如何构建程序打开了一扇广泛的大门。
让我们看看将函数视为一等公民的一个例子。如果你觉得这里发生的事情还不完全清楚,不要担心;我们将在本书稍后的章节中对此进行详细介绍:
package main
import “fmt”
type predicate func(int) bool
func main() {
is := []int{1, 1, 2, 3, 5, 8, 13}
larger := filter(is, largerThan5)
fmt.Printf(“%v”, larger)
}
func filter(is []int, condition predicate) []int {
out := []int{}
for _, i := range is {
if condition(i) {
out = append(out, i)
}
}
return out
}
func largerThan5(i int) bool {
return i > 5
}
让我们稍微分析一下这里发生的事情。首先,我们使用“类型别名”来定义一个新的类型。这个新类型实际上是一个“函数”,而不是原始类型或结构体:
type predicate func(int) bool
这告诉我们,在我们代码库的任何地方,当我们找到predicate类型时,它期望看到一个接受int并返回bool的函数。在我们的filter函数中,我们使用这个来表示我们期望一个整数切片作为输入,以及一个匹配predicate类型的函数:
func filter(is []int, condition predicate) []int {…}
这些是函数在函数式语言和面向对象语言中处理方式不同的两个示例。首先,类型可以定义为函数,而不仅仅是类或原语。其次,我们可以将满足我们的类型签名的任何函数传递给filter函数。
在main函数中,我们展示了将isLargerThan5函数传递给filter函数的示例,类似于在面向对象语言中传递对象的方式:
larger := filter(is, largerThan5)
这是我们可以用 FP 做到的一小部分示例。这个基本思想,即把函数视为我们系统中的一种类型,可以像结构体一样使用,将引导我们在这本书中探索的强大技术。
什么是纯函数?
FP 通常被认为是一种纯粹学术范式,在工业界应用很少或没有。我认为这源于一种观念,即 FP 在某种程度上比 OOP 更复杂、不够成熟。虽然 FP 的根源在学术领域,但这些语言的核心概念可以应用于我们在工业界解决的许多问题。
通常,FP 被认为比传统的 OOP 更复杂。我认为这是一个误解。当人们提到 FP 时,他们真正想说的是纯 FP。一个纯函数程序是 FP 的一个子集,其中每个函数都必须是纯的——它不能改变系统的状态或产生任何副作用。因此,纯函数是完全可预测的。给定相同的输入集,它总是产生相同的输出集。我们的程序变得完全确定。
本书将专注于 FP,而不将其视为“纯”FP 的更严格子集。这并不是说纯度没有给我们带来价值。在纯函数语言中,函数是完全确定的,系统的状态在调用它们时不会改变。这使得代码更容易调试和理解,并提高了可测试性。第六章专门讨论函数纯度,因为它可以为我们的程序带来巨大的价值。然而,从我们的代码库中根除所有副作用通常弊大于利。本书的目标是帮助您以改进可读性的方式编写代码,因此我们经常需要在(纯)函数式风格和更宽容的 FP 风格之间做出权衡。
为了简要而抽象地展示函数纯度是什么,考虑以下示例。假设我们有一个Person类型的结构体,其中有一个Name字段。我们可以创建一个函数来更改人的姓名,例如changeName。有两种实现方式:
-
我们可以创建一个函数,它接受一个对象,将
name字段的内文更改为新名称,然后不返回任何内容。 -
我们可以创建一个函数,它接受一个对象,并返回一个应用了更改的新对象。原始对象不会被更改。
第一种方法并没有创建一个纯函数,因为它已经改变了我们系统的状态。如果我们想避免这种情况,我们可以创建一个changeName函数,该函数返回一个新的Person对象,该对象每个字段与原始Person对象具有相同的字段值,但在name字段中有一个新名字。这里的图更抽象地展示了这一点:

图 1.1:纯函数(顶部)与不纯函数(底部)的比较
在顶部的图中,我们有一个函数(用 Lambda 符号表示),它接受一个特定的对象A作为输入。它对这个对象执行一个操作,但不是改变这个对象,而是返回一个新的对象B,这个新对象应用了变换。底部的图显示了前面段落中解释的内容。该函数接受对象A,在对象值上“就地”进行更改,并返回空值。它只改变了系统的状态。
让我们看看这将在代码中是什么样子。我们首先定义我们的结构体,Person:
type Person struct {
Age int
Name string
}
要实现一个会修改Person对象并在Name字段中放置新值的函数,我们可以编写以下代码:
func changeName(p *Person, newName string) {
p.Name = newName
}
这与图的下部相当;传递给函数的Person对象被修改了。现在我们系统的状态与函数调用之前不同。现在所有引用该Person对象的地方都将看到新名字而不是旧名字。
如果我们用纯函数的方式来写这个,我们会得到以下结果:
func changeNamePure(p Person, newName string) Person {
return Person{
Age: p.Age,
Name: newName,
}
}
在这个第二个函数中,我们复制了原始Person对象(p)的Age值,并将newName值放置在Name字段中。这个结果作为新对象返回。
虽然从表面上看,前一种不纯的写法似乎更容易且更省力,但对于维护一个函数可以改变系统状态的系统来说,其影响是巨大的。在更大的应用程序中,保持对你系统状态的清晰理解将帮助你更容易地调试和复制错误。
这个例子考察了在不可变数据结构上下文中的纯函数。纯函数不会改变我们系统的状态,并且对于相同的输入总是返回相同的输出。
在这本书中,我们将关注函数式编程(FP)的精髓以及我们如何将技术应用于 Go 语言以创建更易读、可维护和可测试的代码。我们将探讨核心构建块,如高阶函数、函数柯里化、递归和声明式编程。如前所述,FP 不等同于“纯”FP,但我们将讨论纯度方面。
随意说出你想要的内容,而不是你想要的方式
函数式编程语言之间有一个共同点,那就是函数是声明性的,而不是命令性的。在函数式语言中,作为程序员,你可以说出你想要实现的目标,而不是实现它的方法。比较这两个 Go 代码片段。
这里第一个片段是一个有效的 Go 代码示例,其中结果是通过声明性获得的:
func DeclarativeFunction() int {
return IntRange(-10,10).
Abs().
Filter(func(i int64) bool {
return i % 2 == 0
}).
Sum()
// result = 60
}
注意,在这段代码中,我们说了以下内容:
-
给我们一个介于 -10 和 10 之间的整数范围
-
将这些数字转换为它们的绝对值
-
筛选出所有的偶数
-
给我们这些偶数的总和
我们没有说如何实现这些事情。在命令式风格中,代码看起来如下:
func iterativeFunction() int {
sum := 0
for i := -10; i <= 10; i++ {
absolute := int(math.Abs(float64(i)))
if absolute%2 == 0 {
sum += absolute
}
}
return sum
}
虽然,在这个例子中,对于有 Go 经验的人来说,这两个片段都很容易阅读,但我们可以想象,对于更大的例子,这种情况将不再成立。在命令式示例中,我们必须明确地说明计算机应该如何给我们一个结果。
函数式编程的简要历史
如果你看看过去十年的主流语言,你会注意到主流的编程范式是面向对象编程(OOP)。这可能会让你认为函数式编程是一种新兴的范式,与成熟的面向对象方法相比,它处于一个年轻的状态。然而,当我们看看函数式编程的历史,我们可以追溯到 1930 年代,这比我们谈论现代意义上的编程要早得多。
函数式编程的根源可以追溯到 1930 年代由 Alonzo Church 开发的 Lambda 演算。这是一个基于函数抽象和应用的正式系统,使用变量绑定。这个演算有两种变体;它可以是类型化的,也可以是无类型的。这与今天的编程语言直接平行,例如 Java 和 Go 是静态类型的,而 Python 是动态类型的。Lambda 演算在 1937 年被证明是图灵完备的——再次,这与今天所有主流编程语言都是图灵完备的相似。
Lambda 演算比现代编程早了几十年。要到达第一个可以被认为是函数式编程的代码,就像我们今天理解的编程一样,我们必须向前推进几十年。LISt Processor(LISP)最初在 1950 年代被创建,作为一种数学符号的实际应用。这受到了 1930 年代 Church 提出的 Lambda 演算的影响。
LISP 可以被认为是第一个达到一定知名度的函数式编程语言。它在人工智能研究领域尤其受欢迎,但经过几十年,它逐渐进入了工业领域。LISP 的衍生语言在很长时间内一直很受欢迎, notable achievements such as the Crash Bandicoot game, and Hacker News being written in derivatives of this language.
LISP 是在 20 世纪 50 年代末由约翰·麦卡锡开发的。为了定义 LISP 函数,他从丘奇开发的 Lambda 演算中汲取了灵感。通过引入递归,一个对于函数式语言工作方式的基本概念,它将 LISP 扩展到了数学系统之外。除了递归之外,LISP 还将函数视为一等公民,并通过包括垃圾回收和条件语句等特性推动了编程语言设计的创新。
在 20 世纪 60 年代初,肯尼思·E·伊夫森开发了A 编程语言(APL)。APL 再次是一种函数式语言,它最著名的可能是其符号的使用和简洁的代码。例如,以下是一个生成康威生命游戏的代码片段的图像:

图 1.2:APL 中的康威生命游戏
跳过十年,到了 1973 年,我们得到了一种名为元语言(ML)的语言。这种语言引入了多态的 Hindley-Milner 类型系统——也就是说,一个类型系统,其中类型是自动分配的,无需显式类型注解。此外,它还支持诸如函数柯里化等特性,我们将在本书后面的功能 Go 代码中应用这些特性。它还支持对函数参数进行模式匹配,正如我们可以在以下计算数字阶乘的函数片段中看到的那样:
fun fac 0 = 1
| fac n = n * fac (n – 1)
在这个例子中,模式匹配器将查看输入值是fac函数的什么,然后如果输入值是0,则继续第一行,在其他所有情况下则继续第二行。请注意,这也是一个表达得相当优美的递归函数。遗憾的是,本书将不会进一步探讨模式匹配,因为 Go 目前没有提供执行此操作的方法。我们将看到一种使用映射和高阶函数执行类似类型函数分派的方法。
1977 年,创建了一种名为 FP 的语言。约翰·巴克特开发这种语言是为了专门支持 FP 范式。虽然这种语言本身在学术界之外并没有得到太多关注,但它介绍该语言的论文(能否从冯·诺伊曼风格中解放编程?)确实重新激起了对 FP 的兴趣。
在 ML 和 FP 相同的十年中,还开发了一种名为Scheme的语言。这是 LISP 的第一个使用词法作用域和尾调用优化的方言。尾调用优化导致了递归算法的实际实现。虽然本书第七章([B18771_07.xhtml#_idTextAnchor113])将讨论尾调用优化的细节,但简要地说,它允许递归算法以有效的方式实现,并且不需要比传统循环更多的内存,从而消除了在深度递归过程中可能发生的“栈溢出异常”。
Scheme 是影响最大的 LISP 方言之一,至今仍有一定的人气。尽管它是在 1975 年创建的,但最新的标准是在 2013 年定义的(R7RS-Small)。Scheme 反过来又影响了其他 LISP 方言,其中最著名的是 Common Lisp。有趣的是,尽管起源于 FP,Common Lisp 引入了Common Lisp 对象系统(CLOS)。CLOS 促进了 LISP 中的面向对象编程。因此,我们可以将 LISP 视为一个真正的多范式语言,就像 Go 一样。
在我们转向当代函数式语言之前,最后要讨论的语言是 Miranda。Miranda 是一种懒加载的纯函数式语言。这里引入的关键概念是懒加载。当一个语言声称支持懒加载时,这意味着表达式只有在实际需要值时才会被解析。它可以用来实现无限数据结构。例如,你可以定义一个生成所有斐波那契数的函数,这是一个永不结束的序列——但是,而不是创建整个列表(这是不可能的),它只会生成与你要解决的问题相关的列表子集。例如,以下 Miranda 代码片段计算所有平方数:
squares = [ n*n | n <- [0..] ]
这样,我们就到达了下一个要简要讨论的语言,即 Haskell。
现代函数式编程
在简要回顾了函数式编程的历史之后,是时候深入现代函数式语言了。在严格的函数式编程语言中,Haskell 是今天流行的一种语言。当人们学习 FP 或接触到它时,通常是通过这种语言。Haskell 是一种静态类型函数式语言。它有诸如类型推断(类似于 ML)和懒加载(类似于 Miranda)等好处。
当人们想要了解更多关于纯 FP 的知识时,我的建议总是从 Haskell 开始。它有一个优秀的社区和丰富的资源,并教你所有关于 FP 领域的内容。
它可能是最受欢迎的纯函数式语言,但在 GitHub 上的活跃用户不到 1%(www.benfrederickson.com/ranking-programming-languages-by-github-users/)。有趣的是,如果我们看看 Go,它目前大约有大约 4%的活跃用户。对于一个大约十年前才出现的语言来说,这已经很不错了!
在.NET 的世界中,另一种相对流行的语言是 F#。虽然这并不是像 Haskell 那样的纯函数式语言,但它是一种以函数优先的语言。它更倾向于函数范式,但并不强制执行。与 Haskell 类似,它在 GitHub 上的活跃用户不到 1%。然而,C#似乎获得了 F#的所有流行特性,因此至少 F#为.NET 带来的函数概念将会受到欢迎。
那这意味着函数式编程在到来时就死了?嗯,并不完全是这样。你现在正在阅读的这本书是关于 Go 的,而 Go 并不是一种纯粹的函数式编程语言。我的看法是,函数式编程的概念通常是有用的,并且可以在面向对象的语言中创建更好的代码——而且我希望我不是唯一这样想的人。我们认为是面向对象的语言中的许多语言已经变得越来越函数式。
我们甚至可以在最流行的主流面向对象语言中看到这种转变的发生。Java 在每次迭代中都引入了函数式编程的概念,提供了诸如模式匹配、高阶函数和通过 Lambda 函数的声明式编程等功能。C# 在每次发布中都越来越像 F#(C# 的微软函数式编程对应物)。它们实现了模式匹配、不可变性、内置元组支持等更多功能。
这种转变正在发生,因为尽管纯粹的函数式编程语言可能并不总是适合工业界,但函数式编程语言的概念使我们能够更有信心地编写面向对象的代码。它们导致代码更容易测试、更容易阅读,并且更容易调试。
目前最受欢迎的编程语言是 JavaScript。虽然当谈到函数式编程时,这或许不会立刻出现在人们的脑海中,但它确实满足了我们对于函数式语言的一部分“要求”。它具有以下特点:
-
首等函数
-
匿名(Lambda)函数
-
闭包
当结合这些特性时,我们可以创建许多结构,使我们能够以函数式编程风格利用代码。
对于那些希望在浏览器中拥有纯粹函数式语言的人来说,有一些语言可以转换为 JavaScript,例如 Elm 和 PureScript。
现在让我们来看看这本书的明星,Go 语言,以及它是如何融入这幅图画的。
Go 编程范式
除非这是你第一次接触 Go 语言,否则你可能知道 Go 是一种静态类型编程语言。你也知道它有 struct,我们可以从这些 struct 中实例化对象。你很可能也知道 Go 语言可以选择性地将函数绑定到 struct 上,但这不是必需的。完全有可能编写一个不创建对象的整个 Go 程序,这在更严格的面向对象语言中很少允许。
事实上,Go 语言中最简单的 Hello World 程序并没有 struct 或对象的意识:
package main
import “fmt”
func main() {
fmt.Println(“Hello Reader!”)
}
如你所见,当我们开始学习 Go 语言时,许多人编写的入门级 Go 程序没有 struct 或对象的意识来执行有用的操作。Println 是在 fmt 包中定义的函数,但它并没有绑定到对象上。
对于像 Go 这样的语言,术语是多范式的。Go 语言并不强迫我们用面向对象范式或函数式范式来编写代码。我们,程序员,有完全的自由来按自己的意愿使用这种语言。这就是你现在正在阅读的这本书存在的原因。
Go 提供了几个特性,使我们能够相对轻松地编写功能性的 Go 代码:
-
函数作为一等公民
-
高阶函数
-
不可变性保证
-
泛型(虽然不是必需的,但可以使生活更轻松)
-
递归
这些内容将在本书的后续部分进行更详细的探讨。我还想指出一些 Go(截至 1.18 版本)缺乏的特性,这些特性将提高我们的生活质量:
-
尾调用优化
-
惰性求值
-
纯净性保证
这些并不是决定性的因素。本书的重点是利用 FP 在 Go 中编写更好的代码。即使我们没有纯静态类型系统可供工作,我们也可以利用我们所拥有的。
我绝不想将 FP 定位为编写 Go 代码的优越方式。我也不想将其定位为“正确”的范式选择。Go 是多范式的,就像程序员为任何问题选择正确的语言一样,我们也要为每个问题选择正确的范式。我们甚至可以选择在 90% 的时间里坚持函数式概念,最终得到的代码比完全坚持它时更干净。例如,编写纯函数式代码将防止使用任何副作用。然而,许多副作用确实有其作用。任何我们想要向用户展示输出或从用户那里获取输入的时候,我们实际上都在处理副作用。
为什么选择函数式编程?
所有这些还不足以告诉我们为什么我们要投入时间学习函数式编程。我们希望通过函数式编程获得的主要好处如下:
-
更易于阅读的代码
-
更容易理解和调试代码
-
更容易的测试
-
更少的错误
-
更容易的并发
这些可以通过一组相对较小的 FP 特性来实现。为了编写更易于阅读的代码,可以通过声明式编程来实现。声明式编程将向我们展示正在发生的事情,而不是如何发生。声明式代码通常比命令式代码更简洁。简洁性并不一定是代码可读性的好处(记得之前提到的 APL 例子吗?),但正确应用时,它可以成为好处。
FP 通过优先考虑纯净性而非杂质,使代码更容易理解、调试和测试。当每个函数总是产生一个确定的输出时,我们可以相信函数只做它所说的。当你遇到一个名为 square(n int) 的函数时,我们可以确信该函数所做的只是对输入进行平方。
此外,系统状态不会改变。如果我们正在处理结构和对象,这有助于我们保证对象持有的值不会被操作它的函数所改变。这减少了推理程序时的认知负担。
纯净、不可变的代码使代码更容易测试,以下是一些原因:
-
系统的状态不会影响我们的函数——因此我们在测试时不需要模拟状态。
-
对于给定的输入,一个给定的函数总是返回相同的输出。这意味着我们得到了可预测的、确定的函数。
我不会在这里提倡测试驱动开发或任何类似的东西,但我确实认为测试对于编写好代码至关重要。至少,可以避免在凌晨 3 点被叫醒,因为一个函数开始向用户抛出无法理解的错误代码。
与可测试的代码相结合,函数式编程帮助我们编写更少的错误。这可能很难量化,但这里的想法是,没有可变状态,并且代码中只有可预测的函数,我们将有更少的边缘情况需要考虑。如果状态对程序很重要,你必须知道,在每一个时间点,系统的状态可能是什么以及它如何影响你正在编写的函数。这会很快变得复杂。
最后,函数式编程将使编写并发代码变得更加容易。Go 以其内置的并发特性而闻名。并发性是 Go 从诞生之初就具备的特性,而不是像一些其他主流语言那样后来才添加的。因此,Go 拥有相当坚实的并发编程工具。
函数式编程帮助的方式在于函数是确定性和不可变的。因此,并发运行相同的函数永远不会影响另一个正在运行函数的结果。如果函数从不依赖于系统的状态,线程 A 不能使线程 B 的系统状态无效。
我想再次强调的一点,因为它很重要,就是我不会提倡在 Go 中坚持纯函数式编程。这样做可能会让你的生活,以及你同事的生活,比本应更艰难。选择适合工作的正确工具——有时那将是对象,有时那将是函数。
为什么不在 Go 中使用函数式编程?
为了提供一个全面的视角,了解函数式编程如何帮助我们,作为 Go 程序员,我们还应该考虑何时不使用函数式编程。我认为函数式编程是我的工具箱中的一个工具,当一个问题适合使用它时,我会乐意使用它——但同样重要的是,我们必须认识到何时这行不通。
关于函数式编程(FP)的一个担忧是性能——虽然关于这个话题有很多可以说的,正如我们将在后面的章节中看到的,性能担忧可能意味着我们会为了追求速度而放弃一些函数式概念,比如不可变性。这比一开始听起来要复杂得多,因为 Go 的指针并不保证比 Go 的按值传递函数更快。我们将在后面的章节中进一步探讨性能担忧。
不选择函数式编程的另一个原因是 Go 缺乏尾调用优化。理论上,你程序中写的每个循环都可以被递归调用所替代,但截至 Go 1.18,Go 没有必要的工具来有效地执行此操作,你可能会遇到栈溢出。我们将会看到有绕过这个问题的方法,但如果它开始显著牺牲性能或可读性,我的建议是直接写一个循环。这并不是说递归永远不会是正确的方法。如果你广泛地使用过树或图,你可能已经编写了一些递归算法,并发现它们工作得很好。
最后,如果你正在处理一个有许多其他贡献者的现有代码库,最好的做法是遵循代码库的风格。虽然函数式编程的一些概念可以相当容易地引入,但在一个不支持整个想法的团队中强制执行它们会更难。幸运的是,今天许多程序员都看到了函数式编程关键概念的益处。即使在 Java 或 C# 中,不可变代码的想法也被接受。同样,副作用也越来越被视为不受欢迎的。
让我们拥抱 Go 作为一种完全的多范式语言,并在合理的地方利用每个范式。
比较函数式编程(FP)和面向对象编程(OOP)
正如我们在前面的页面所看到的,函数式编程并不完全是新事物。实际上,它比面向对象范式早了几十年。虽然 Go 是多范式的,我们可以接受两种编程风格,但让我们快速看一下两者之间的具体比较。
| 函数式编程 | 面向对象编程 |
|---|---|
| 函数是基础 | 类和对象是基础 |
| 声明式代码 | 命令式代码 |
| 优先考虑不可变性 | 可变状态 |
| 可以强制纯净性 | 通常不关注纯净性 |
| 递归 | 循环 |
表 1.1:比较函数式编程(左)和面向对象编程(右)
这种比较有点肤浅。许多面向对象的语言也有递归的概念,但并不总是语言设计的核心。同样,面向对象的代码可以封装可变状态,并尽可能多地尝试实现不可变性。
在当今世界,即使是传统上被认为是面向对象的编程语言,如 Java,实际上也在变得越来越多范式。
作为旁注,这种比较可能会让人感觉只有三种可能的范式:函数式、面向对象或多范式。虽然这些确实是最常见的,但还有其他范式,如文献编程、逻辑编程和响应式编程。由于面向对象编程在这个领域是主要参与者,因此大多数读者都熟悉,这本书将重点关注比较。
摘要
正如我们在第一章中看到的,FP 范式并不是“新来的孩子”。它是一种起源于 20 世纪 30 年代 Alonzo Church 工作的范式。自 20 世纪 50 年代以来,它一直持续稳定地受到投资,各种语言推动了这一范式不断向前发展。
正如我们看到的,FP 和 OOP 在现代语言中越来越多地被结合在一起,Java 和 C#将函数式范式的思想整合到它们的面向对象范式中。本书的明星语言 Go 更进一步,是一种多范式语言。Go 赋予我们完全的自由,以最适合我们的领域编写代码。
从本章中需要记住的核心思想是,FP 范式将帮助我们编写更容易测试、阅读和维护的代码。它通过限制副作用、不改变我们系统的状态以及偏好小型可组合函数来减少认知负担。
最后,我们还需要记住,尽管在这本书中我们提倡 FP 范式,但 Go 语言是多范式的,我们必须为解决我们正在解决的问题选择正确的范式。
第二章:将函数视为一等公民
如我们在上一章中确立的,我们函数式程序的核心部分将是函数。在本章中,我们将详细探讨为什么在将函数视为一等公民的语言中函数如此强大。Go 默认将函数作为一等公民,这意味着我们默认获得这种功能。越来越多的语言正在选择这种方法。在本章中,我们将看到这将如何允许我们创建有趣的构造,这将提高我们代码的可读性和可测试性。
具体来说,我们将涵盖以下主题:
-
首类函数的优点
-
为函数定义类型
-
将函数当作对象使用
-
匿名函数与命名函数的比较
-
在数据类型或结构体中存储函数
-
使用所有前面的内容创建一个函数分发器
技术要求
本章的所有示例都可以在github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter2找到。对于这个示例,任何 Go 版本都可以使用
首类函数的优点
在我们讨论“一等函数”之前,让我们首先定义在编程语言设计中称任何事物为“一等”的含义。当我们谈论“一等公民”时,我们指的是一个实体(对象、原始数据类型或函数),对于该实体,所有常见的语言操作都是可用的。这些操作包括赋值、将其传递给函数、从函数返回或将其存储在另一种数据类型(如映射)中。
看这个列表,我们可以看到所有这些操作通常都适用于我们在语言中定义的结构体。对象和原始数据类型可以在函数之间传递。它们通常作为函数的结果返回,并且我们确实将它们分配给变量。当我们说函数是一等公民时,你可以简单地将其视为将函数当作对象来对待。它们的等价性将帮助我们创建本书中的所有未来构造。这将提高可测试性,例如,通过允许我们模拟结构体的函数,以及提高可读性,例如,通过移除单个函数分发器的庞大 switch 语句。
为函数定义类型
Go 是一种静态类型语言。尽管如此,我们不必为每个赋值指定类型——类型在底层已经存在。实际上,编译器会为我们处理这一点。当我们用 Go 处理函数时,它们也会隐式地被分配一个类型。虽然以编译器的方式为函数定义类型是一项困难的任务,但我们可以使用函数别名概念来为我们的代码库增加类型安全性。
在本书的其余部分处理函数时,我们经常会使用类型别名。这将帮助编译器提供更易读的错误信息,并且通常使我们的代码更易读。然而,类型别名不仅适用于函数的上下文。它是 Go 的一个很棒的功能,但并不常用。这也是你在其他主流语言中不太容易找到的功能。所以,让我们深入了解类型别名是什么。
实质上,类型别名正是它所说的那样;它为类型创建了一个别名。这类似于在 Unix 系统中为命令创建别名的方式。它帮助我们创建一个具有与原始类型相同属性的新类型。我们可能想要这样做的一个原因是为了可读性,正如我们在创建函数别名时将看到的。另一个原因是当我们编写代码时,更清晰地传达我们的意图。例如,我们可以使用我们的类型系统将CountryID和CityID定义为String的别名。尽管这两种类型在底层都是字符串,但在代码中它们不能互换使用。因此,它们向读者传达了实际期望的值。
原始类型别名
在面向对象的语言中,一个常见的模式是 OO 语言变成了Person结构体,而我们想要在这个结构体上设置一个电话号码:
type Person struct {
name string
phonenumber string
}
func (p *Person) setPhoneNumber(s string) {
p.phonenumber = s
}
在这个例子中,它受到了 Java 的很大影响,我们正在创建一个类似于“setter”的函数,该函数接受phonenumber作为字符串输入并相应地更新我们的对象。如果你使用的是提供函数类型提示的 IDE,它将告诉你setPhoneNumberfunction期望一个字符串,这意味着任何字符串都是有效的。现在,如果我们有一个类型别名,我们可以使这个提示更有用。
那么,让我们做一些修改,并使用类型别名phoneNumber:
type phoneNumber string
type Person struct {
name string
phonenumber phoneNumber
}
func (p *Person) setPhoneNumber(s phoneNumber) {
p.phonenumber = s
}
通过这个修改,我们的类型现在更清楚地传达了我们的意图,并且没有创建一个新结构体来模拟电话号码的开销。我们可以这样做,因为电话号码本质上可以看作是一个字符串。
使用这个,因为类型别名等同于底层类型,就像使用一个真正的字符串一样简单:
func main() {
p := Person{
name: "John",
phonenumber: "123",
}
fmt.Printf("%v\n", p)
}
好的,太棒了。所以,我们有一个名字,它只是一个字符串,还有一个phonenumber,它是一个phoneNumber类型,它等于一个字符串。那么,好处从哪里来呢?嗯,一部分是在传达意图中获得的。代码被比原作者更多的人阅读,所以我们的代码要尽可能清晰。另一部分是在错误信息中。使用类型别名,错误信息将明确告诉我们期望什么,而不仅仅是说期望一个字符串。让我们创建一个可以更新name和phonenumber的函数,并且首先使用string为两者:
func (p *Person) update(name, phonenumber string) {
p.name = name
p.phonenumber = phonenumber
}
当我们尝试编译我们的代码时会发生什么?嗯,我们将得到以下错误:
./prog.go:26:18: cannot use phonenumber (variable of type
string) as type phoneNumber in assignment
在这个简单的例子中,它并没有做什么。但随着你的代码库的扩展,这确保了所有开发者都在思考应该传递给函数的类型。这通过向函数传递无效数据来降低错误的风险。根据 IDE 的不同,还有一个额外的优点,即你的 IDE 也会显示签名。如果你有一个接受五种不同类型字符串的大函数,你的 IDE 可能只会显示函数期望输入(string, string, string, string, string),没有任何明确的参数传递顺序。如果每个字符串都是不同的类型,这可能会变成name, phonenumber, email, street, country。特别是在像 Go 这样的语言中,单字母变量名经常被使用,这可以带来可读性的好处。
为了让我们的代码工作,我们只需要对函数签名进行一个小改动:
func (p *Person) update(name string, phonenumber phoneNumber) {
p.name = name
p.phonenumber = phonenumber
}
这是一个简单的修复,仅仅是一个小的改动,但持续这样做可以让你的代码仅通过类型系统传达更多的意义。最终,类型的存在是为了向其他读者以及编译器传达意义。
让我们来看看类型别名的一个额外好处。让我们给我们的结构体添加一个带有其自己的类型别名的age字段:
type age uint
type Person struct {
name string
age age
phonenumber phoneNumber
}
在 Go 中,我们不能像对uint这样的原始类型那样,将函数附加到它们上。然而,当我们分配一个类型别名时,这种限制就消失了。因此,现在我们可以将函数附加到age类型上,这实际上就是将函数附加到uint上:
func (a age) valid() bool {
return a < 120
}
func isValidPerson(p Person) bool {
return p.age.valid() && p.name != ""
}
在前面的代码中,我们正在创建一个valid函数,它绑定到age类型。现在,在其他函数中,我们可以使用熟悉的点符号在类型上调用valid()函数。这个例子可能有点微不足道,但它是一些在原始类型上无法工作的事情。
如果我们尝试将一个函数附加到一个原始类型上,我们将无法编译我们的程序:
func (u uint) valid() bool {
return u < 120
}
这会抛出以下错误:
./prog.go:30:7: cannot define new methods on non-local type
uint
Go build failed.
仅此一项就让类型别名变得非常强大。这也意味着你现在可以扩展你代码库中不是由你创建的类型。你可能正在使用一个公开结构的第三方库,但你想要向它添加自己的功能。实现这一点的其中一种方法是通过创建类型别名并使用你自己的功能来扩展它。虽然深入这个例子超出了我们本章要探讨的范围,但可以简单地说,类型别名是一个强大的结构。
函数的类型别名
由于在 Go 中函数是一个一等公民,我们可以像处理任何其他数据类型一样处理它们。因此,就像我们可以为变量或结构体创建类型别名一样,我们也可以为函数创建类型别名。
我们为什么要这样做呢?我们代码的读者获得的主要好处将是它带来的清晰度和可读性。看看以下filter函数的代码:
func filter(is []int, predicate func(int) bool) []int {
out := []int{}
for _, i := range is {
if predicate(i) {
out = append(out, i)
}
}
return out
}
这个函数是使用函数作为一等公民的一个很好的例子。在这里,predicate函数是一个传递给filter函数的函数。它以我们通常传递对象的方式传递。
如果我们想要清理这个函数签名,我们可以引入一个类型别名并重写过滤器函数:
type predicate func(int) bool
func filter(is []int, p predicate) []int {
out := []int{}
for _, i := range is {
if p(i) {
out = append(out, i)
}
}
return out
}
在这里,你可以看到第二个参数现在接受predicate类型。编译器将把这个类型转换为func(int) bool,但我们可以只在代码库中写predicate。
引入类型别名的好处之一是,我们的错误消息变得更加易读。让我们想象一下,我们向filter传递了一个不遵循predicate类型声明的函数:
filter(ints, func(i int, s string) bool { return i > 2 })
没有类型别名,错误消息的读法如下:
./prog.go:9:15: cannot use func(i int, s string) bool {…}
(value of type func(i int, s string) bool) as type func(int)
bool in argument to filter
这是一个错误消息,虽然非常明确,但读起来相当冗长。有了类型别名,消息将告诉我们期望的是哪种类型的函数:
./prog.go:9:15: cannot use func(i int, s string) bool {…}
(value of type func(i int, s string) bool) as type predicate in
argument to filter
将函数作为对象使用
在前面的章节中,我们看到了如何创建类型别名来使我们的代码在处理函数时更加易读。在本节中,让我们简要地看看函数如何像对象一样使用。这就是一等的含义。
将函数传递给函数
我们可以将函数传递给函数,就像前面的过滤器函数那样:
type predicate func(int) bool
func largerThanTwo(i int) bool {
return i > 2
}
func filter(is []int, p predicate) []int {
out := []int{}
for _, i := range is {
if p(i) {
out = append(out, i)
}
}
return out
}
func main() {
ints := []int{1, 2, 3}
filter(ints, largerThanTwo)
}
在这个例子中,我们创建了一个largerThanTwo函数,它遵循predicate类型别名。请注意,我们不必在任何地方指定这个函数遵循我们的predicate类型;编译器将在编译时解决这个问题,就像它对常规变量所做的那样。接下来,我们创建了一个filter函数,它期望一个ints切片以及一个predicate函数。在我们的main函数中,我们创建了一个ints切片,并使用largerThanTwo函数作为第二个参数调用filter函数。
内联函数定义
我们不需要在包作用域中创建像largerThanTwo这样的函数。我们可以像创建内联结构体一样创建内联函数:
func main() {
// functions in variables
inlinePersonStruct := struct {
name string
}{
name: "John",
}
ints := []int{1, 2, 3}
inlineFunction := func(i int) bool { return i > 2 }
filter(ints, inlineFunction)
}
inlinePersonStruct在这个代码中作为内联函数与内联结构体定义比较的例子。实际上,由于这个结构体在main函数的其余部分没有使用,所以代码不会编译。
匿名函数
我们还可以在需要的地方动态创建函数。这些被称为匿名函数,因为它们没有分配给它们的名字。继续使用我们的filter函数,一个largerThanTwo谓词的匿名函数版本看起来是这样的:
func main() {
filter([]int{1, 2, 3}, func(i int) bool { return i > 2 })
}
在前面的例子中,我们既创建了一个整数切片,也创建了内联的谓词函数。它们都没有命名。切片不能在该main函数的任何其他地方引用,函数也是如此。虽然这类函数定义会使我们的代码更加冗长,并可能阻碍可读性,但我们将看到它们在第三章和第四章中的应用。
从函数中返回函数
任何编程语言的核心概念之一是从函数中返回一个值。由于函数被当作一个普通对象来处理,我们可以从一个函数中返回一个函数。
在前面的例子中,我们的largerThanTwo谓词函数始终检查一个整数是否大于两个。现在,让我们创建一个可以生成此类谓词函数的函数:
func createLargerThanPredicate(threshold int) predicate {
return func(i int) bool {
return i > threshold
}
}
在这个例子中,我们创建了一个createLargerThanPredicate函数,它返回一个predicate。记住,类型predicate只是一个类型别名,代表一个接受整数作为输入并返回 bool 作为输出的函数。接下来,我们在函数体中定义我们返回的函数。
我们返回的函数遵循predicate的类型签名,如果i大于threshold则返回 true。请注意,i函数并没有传递给createLargerThanPredicate函数本身。我们是在内联定义的。当我们调用createLargerThanPredicate函数时,我们得到的不是谓词函数的结果,而是一个遵循内部签名的新的函数:
func main() {
ints := []int{1, 2, 3}
largerThanTwo := createLargerThanPredicate(2)
filter(ints, largerThanTwo)
}
在这里,在main函数中,我们首先调用createLargerThanPredicate(2)函数。这返回一个新的func(i int) bool函数。这里的2指的是threshold参数,而不是i参数。
在下一行,我们再次可以使用新创建的largerThanTwo函数调用filter函数。
当我们深入研究更高级的主题,如传递风格编程和函数柯里化时,从函数中返回函数将是一个核心概念。目前,主要的收获是这允许我们即时创建可定制的函数。例如,我们可以创建一系列具有各自阈值的“大于”谓词:
func main() {
largerThanTwo := createLargerThanPredicate(2)
largerThanFive := createLargerThanPredicate(5)
largerThanHundred := createLargerThanPredicate(100)
}
注意,这个例子无法编译,因为我们没有在main块的其余部分使用这些函数。但这展示了我们如何基本上“生成”具有一个固定参数的函数。我们不必在函数块内创建这些函数,而是可以将它们移动到包特定的var块。
函数在 var 中
继续前面的例子,我们可以创建一系列可以在整个包中使用的函数:
var (
largerThanTwo = createLargerThanPredicate(2)
largerThanFive = createLargerThanPredicate(5)
largerThanHundred = createLargerThanPredicate(100)
)
这些“函数工厂”使我们能够在整个代码中创建一些自定义函数。这里需要注意的是,这将在var块内部工作,但如果我们将这些移动到const块,则无法编译:
const (
largerThanTwo = createLargerThanPredicate(2)
largerThanFive = createLargerThanPredicate(5)
largerThanHundred = createLargerThanPredicate(100)
)
这将生成以下错误:
./prog.go:8:23: createLargerThanPredicate(2) (value of type
predicate) is not constant
./prog.go:9:23: createLargerThanPredicate(5) (value of type
predicate) is not constant
./prog.go:10:23: createLargerThanHundred(100) (value of type
predicate) is not constant
我们的函数从包的角度来看不被认为是“常量”。
数据结构内部的函数
到目前为止,我们创建了一大堆函数,这些函数要么是在顶层 var 块中定义的,要么是在函数内联定义的。如果我们想在应用程序的运行时内存中某个地方存储我们的函数怎么办?
好吧,就像我们可以在运行时内存中存储原始类型和结构体一样,我们也可以在那里存储函数。
让我们从将我们的 largerThan 谓词存储在数组中开始。我们将谓词声明移回 var 块,并在 main 函数中传递给 filter 函数:
var (
largerThanTwo = createLargerThanPredicate(2)
largerThanFive = createLargerThanPredicate(5)
largerThanHundred = createLargerThanPredicate(100)
)
func main() {
ints := []int{1, 2, 3, 6, 101}
predicates := []predicate{largerThanTwo, largerThanFive,
largerThanHundred}
for _, predicate := range predicates {
fmt.Printf("%v\n", filter(ints, predicate))
}
}
在前面的例子中,我们创建了一个“谓词切片”。类型将是 []predicate,作为声明的一部分,我们还将我们之前创建的三个谓词推送到这个切片中。在这行代码之后,切片包含对三个函数的引用:largerThanTwo、largerThanFive 和 largerThanHundred。
一旦我们创建了切片,我们就可以像任何常规切片一样迭代它。当我们写 for _, predicate := range predicates 时,predicate 的值将依次取我们存储在切片中的每个函数的值。因此,当我们为每个后续迭代打印过滤函数的输出时,我们得到以下内容:
[3 6 101]
[6 101]
[101]
在第一次迭代中,predicate 指的是 largerThanTwofunction;在第二次迭代中,它变为 largerThanFive,最后变为 largerThanHundred。
同样,我们可以在映射中存储函数:
func main() {
ints := []int{1, 2, 3, 6, 101}
dispatcher := map[string]predicate{
"2": largerThanTwo,
"5": largerThanFive,
}
fmt.Printf("%v\n", filter(ints, dispatcher["2"]))
}
在这个例子中,我们创建了一个存储谓词并关联谓词函数与字符串作为键的映射。然后我们可以调用 filter 函数并要求映射返回与 "2" 键关联的函数。这返回以下内容:
[3 6 101]
这种模式非常强大,我们将在本章后面的 示例 1 中探讨。
在我们深入那个例子之前,让我们看看如何在结构体内部存储函数。
结构体内部的函数
到现在为止,我们可以在任何可以使用数据类型的地方使用函数,让我们看看这在结构体中是如何体现的。让我们创建一个名为 ConstraintChecker 的结构体,该结构体用于检查一个值是否介于两个值之间。
让我们从定义我们的结构体开始。ConstraintChecker 结构体有两个字段。每个字段都是类型为 predicate 的函数。第一个函数是 largerThan,第二个是 smallerThan。这些是输入数字应该位于其间的边界:
type ConstraintChecker struct {
largerThan predicate
smallerThan predicate
}
接下来,我们为这个结构体创建一个方法。check 方法接受一个整数输入,并将其分别传递给 largerThan 和 smallerThan 函数。由于这两个谓词函数都返回一个布尔值,我们只需检查输入在这两个函数中返回的值是否为真:
func (c ConstraintChecker) check(input int) bool {
return c.largerThan(input) && c.smallerThan(input)
}
现在我们已经创建了结构体和我们的方法,让我们看看我们如何使用这个结构体:
func main() {
checker := ConstraintChecker{
largerThan: createLargerThanPredicate(2),
smallerThan: func(i int) bool { return i < 10 },
}
fmt.Printf("%v\n", checker.check(5))
}
在我们的主函数中,我们首先实例化函数。请注意,我们可以通过提供现有函数(如我们为largerThan所做的那样)以及使用匿名函数(如smallerThan字段的情况)来创建ConstraintChecker结构体。
这展示了结构体可以存储函数,以及这些函数如何被当作结构体中的任何其他字段来对待。本质上,我们可以将绑定到结构体的每个函数视为结构体的一个字段函数。将函数作为字段传递与绑定相比有一些优势,我们将在本章的示例 2中更详细地探讨。
主要区别在于,绑定到函数的函数本质上是不变的——实现不会改变。而传递给字段的函数则完全灵活。实际的实现对我们这个结构体来说是未知的。我们将在示例 2中更详细地探讨这是如何允许我们为测试模拟函数的。
示例 1 – 映射调度器
这些一等函数类型使得“映射调度器模式”成为可能。这是一种模式,其中我们使用“键到函数”的映射。
创建一个简单的计算器
对于这个第一个例子,让我们构建一个真正简单的计算器。这只是为了演示基于特定输入值调度函数的想法。在这种情况下,我们将构建一个计算器,它接受两个整数作为输入,一个操作,并将这个操作的结果返回给用户。对于这个第一个例子,我们只支持加法、减法、乘法和除法操作。
首先,让我们定义支持的基本函数:
func add(a, b int) int {
return a + b
}
func sub(a, b int) int {
return a - b
}
func mult(a, b int) int {
return a + b
}
func div(a, b int) int {
if b == 0 {
panic("divide by zero")
}
return a / b
}
到目前为止,这些都是相当标准的东西。我们的计算器支持一些函数。在大多数情况下,结果会立即返回,但对于除法函数,我们会快速检查以确保我们不是除以零,否则会恐慌。在实际应用中,我们会尽量避免使用panic操作,但在这个例子中,它实际上没有任何影响。在这个例子中,没有用户因为恐慌而受到伤害!
接下来,让我们看看如何实现calculate函数,它接受两个数字和所需的操作。我们将首先不考虑函数作为一等公民,而是使用switch语句来决定调度哪个操作:
func calculate(a, b int, operation string) int {
switch operation {
case "+":
return add(a, b)
case "-":
return sub(a, b)
case "*":
return mult(a, b)
case "/":
return div(a, b)
default:
panic("operation not supported")
}
}
switch语句的每个分支都会对我们的数字执行所需的操作并返回结果。如果选项耗尽且没有匹配输入,我们会恐慌。每次我们向计算器添加一个新函数时,我们都需要通过另一个分支扩展这个函数。随着时间的推移,这可能不是最可读的选项。所以,让我们看看本章到目前为止学到的替代方案。
首先,让我们介绍这类函数的类型:
type calculateFunc func(int, int) int
接下来,让我们创建一个映射,我们可以将用户的字符串输入绑定到计算器函数:
var (
operations = map[string]calculateFunc{
"+": add,
"-": sub,
"*": mult,
"/": div,
}
)
这个映射被称为 operations。映射的键是用户将提供的输入,即我们在计算器中支持的运算。我们将每个输入绑定到特定的函数调用。
现在,如果我们想实现实际的 calculate 函数,我们只需在我们的映射中查找键并调用相应的函数。如果请求的操作与我们的映射中的键不匹配,我们会陷入恐慌。这与基于 switch 的方法的默认分支类似:
func calculateWithMap(a, b int, opString string) int {
if operation, ok := operations[opString]; ok {
return operation(a, b)
}
panic("operation not supported")
}
这样,我们可以用映射调度器替换 Switch 语句。同时记住,映射查找通常是常数时间完成的,所以这种函数调度器的实现相当高效。它确实需要我们使用更多内存来绑定键到函数,但这可以忽略不计。使用这种方法,添加新操作只需在我们的映射中添加一个新条目,而不是扩展 switch 语句。
使用匿名函数,我们还可以在行内定义调度函数。例如,这是我们将如何扩展映射以包含位移函数的方式:
var (
operations = map[string]calculateFunc{
"+": add,
"-": sub,
"*": mult,
"/": div,
"<<": func(a, b int) int { return a << b },
">>": func(a, b int) int { return a >> b },
}
)
以这种方式,我们可以为匿名函数创建一个映射调度器。但这可能会变得难以阅读,所以在应用时请使用最佳判断。
示例 2 – 测试时模拟函数
在以下示例中,我们将通过使用本章学到的内容来模拟函数。我们将构建和测试的应用程序是一个简单的待办事项应用程序。这个待办事项应用程序简单地允许用户向待办事项添加文本,或覆盖所有内容。
我们不会使用实际的数据库,所以我们将想象这个数据库存在,并使用文件系统和程序参数。我们的目标将是创建测试此应用程序,其中我们可以模拟数据库交互。为了实现这一点,我们将使用函数作为一等公民和类型别名以提高代码可读性。
完整的示例可以在 GitHub 上找到:github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter2/Examples/TestingExample
让我们从设置我们的主要结构体开始。我们需要两个结构体:Todo 和 Db。Todo 结构体表示待办事项,它将包含一段文本。该结构体还包含对 Db 结构体的引用:
type Todo struct {
Text string
Db *Db
}
func NewTodo() Todo {
return Todo{
Text: "",
Db: NewDB(),
}
}
在这个例子中,我们还创建了一个“构造函数”,以确保用户得到一个正确初始化的对象。
我们将为这个结构体添加两个绑定函数:Write 和 Append。Write 函数将覆盖 Text 字段的内容,而 Append 函数将内容添加到现有字段的现有内容中。让我们还假设对这些函数的调用只能由授权用户进行。因此,我们首先进行数据库调用,以确定用户是否有权执行此操作:
func (t *Todo) Write(s string){
if t.Db.IsAuthorized() {
t.Text = s
} else {
panic("user not authorized to write")
}
}
func (t *Todo) Append(s string) {
if t.Db.IsAuthorized() {
t.Text += s
} else {
panic("user not authorized to append")
}
}
在此基础上,让我们看看模拟数据库。因为我们希望在稍后编写的测试中能够模拟数据库的函数,我们将利用一等函数的概念。首先,我们将创建一个Db结构体。因为我们只是在假装连接到一个真实的数据库,所以我们不会麻烦地设置连接并让一个实际的数据库在某处运行:
type authorizationFunc func() bool
type Db struct {
AuthorizationFn authorizationFunc
}
这是Db的结构体定义。记住,函数可以作为结构体的字段存储。这正是这里发生的事情,我们的Db结构体包含一个名为AuthorizationFn的单个字段。这是一个指向类型authorizationFunc的函数的引用。记住,这只是一个类型别名。编译器实际上会期望一个具有func() bool签名的函数。因此,我们期望一个不接受任何输入参数并返回 bool 值的函数。
现在,让我们创建这样一个授权函数。由于这个示例是自包含的,我们不对使用实际数据库的开销感兴趣。对于这个例子,假设如果程序参数包含作为程序第一个参数的admin字符串,则用户被授权:
func argsAuthorization() bool {
user := os.Args[1]
// super secure authorization layer
// in a real application, this would be a database call
if user == "admin" {
return true
}
return false
}
注意这个函数与类型authorizationFunc的函数签名相匹配。因此,它可以存储在我们的Db结构体的authorizationFn字段中。接下来,让我们为我们的Db创建一个构造函数类型函数,这样我们就可以为用户提供一个正确初始化的结构体:
func NewDB() *Db {
return &Db{
AuthorizationFn: argsAuthorization,
}
}
注意我们是如何将argsAuthorization函数传递给AuthorizationFn字段的。因此,每次我们创建数据库时,我们都可以更改AuthorizationFn的实现以匹配我们的用例。我们将利用这一点来进行单元测试,但你也可以利用这一点来提供不同的授权实现,从而提高我们结构体的可重用性。
在这里引入一个方便的结构是创建一个绑定到Db对象的函数,该函数将调用内部授权函数:
func (d *Db) IsAuthorized() bool {
return d.AuthorizationFn()
}
这是一个简单的质量改进。这样,我们可以在IsAuthorized中添加代码,无论选择哪种实现方式,它都会运行。我们可以在那里添加日志进行调试、收集指标、处理潜在的异常等等。在我们的情况下,我们将保持它为一个简单的对AuthorizationFn的函数调用。
在此基础上,让我们现在考虑测试我们的代码。如果不模拟IsAuthorized函数,我们的测试将无法通过Write和Append测试,因为只有授权用户才能调用这些函数。我们的测试运行不应该依赖于“外部世界”来成功。单元测试应该在隔离状态下运行,而不关心真实的底层系统(在这种情况下,程序参数,但在实际场景中,实际的数据库)。
那么,我们如何解决这个问题呢?我们将通过创建一个包含我们自己的AuthorizationFn的Db结构体来模拟authorizationFn的实现:
func TestTodoWrite(t *testing.T) {
todo := pkg.Todo{
Db: &pkg.Db{
AuthorizationF: func() bool { return true },
},
}
todo.Write("hello")
if todo.Text != "hello" {
t.Errorf("Expected 'hello' but got %v\n", todo.Text)
}
todo.Append(" world")
if todo.Text != "hello world" {
t.Errorf("Expected 'hello world' but got %v\n",
todo.Text)
}
}
注意在这个测试的设置中,我们手动构建了一个Todo结构体,而不是调用构造函数类型的newTodo()函数。我们还在手动构建Db。这是为了避免在单元测试中运行默认实现。我们不是使用代码中找到的现有函数,而是提供了一个自定义的授权函数。我们的自定义函数简单地对每次调用IsAuthorized返回 true。这是我们测试用例中期望的行为,因为我们想测试Todo结构体的功能,而不是Db的功能。使用这种模式,我们可以模拟实现的核心部分。我们还得到了额外的好处,即我们的结构体本身变得更加灵活,因为实现现在可以在运行时进行替换。
摘要
在本章中,我们探讨了作为 Go 开发者,一等函数是什么以及它们为我们打开了哪些类型的用例。我们探讨了函数与对象之间的等价性,例如它们如何被实例化、作为参数传递、存储在其他数据结构中,以及从其他函数返回。
我们还学习了如何使用类型别名来创建更易读的代码,并提供更清晰的错误消息。我们看到了这些如何应用于函数以及结构体和原始数据类型的常规数据类型。
在示例中,我们看到了如何创建一个可读的函数分发器,以及如何利用一等函数创建函数的模拟。在下一章中,我们将使用本章学到的知识来构建高阶函数。
第三章:高阶函数
在本章中,我们将通过高阶函数来探讨函数组合的概念。这里我们将介绍多种新的概念,例如闭包、偏应用和函数柯里化。我们将查看一些实际例子和这些概念的实际应用场景。
首先,我们将从抽象的角度介绍函数组合的核心概念,然后我们将结合实际例子来应用这些概念。在这里我们所学到的所有内容都高度依赖于在第二章中介绍的概念,在那里我们学习了如何将函数视为一等公民。
在本章中,我们将涵盖以下内容:
-
高阶函数简介
-
闭包和变量作用域
-
偏应用
-
函数柯里化,或者如何将 n 元函数简化为一元函数
-
示例:
技术要求
本章的所有示例都可以在github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter3找到。对于这个例子,任何版本的 Go 语言都可以使用。
高阶函数简介
从本质上讲,高阶函数是指那些要么接受函数作为输入,要么返回函数作为输出的函数。回想一下上一章,这两者都是通过支持函数作为“一等公民”而成为可能的。尽管将它们称为“高阶函数”可能并不常见,但许多编程语言都默认支持这些函数。例如,在 Java 和 Python 中,map、filter和reduce函数都是高阶函数的例子。
让我们在 Go 语言中创建一个简单的例子。我们将有一个返回hello,的函数A,以及一个接受A作为输入参数的函数B。这是一个高阶函数,因为A函数被用作B函数的输入:
func A() string {
return "hello"
}
func B(a A) string {
return A() + " world"
}
在这里需要指出的是,我们并不是简单地将A函数的结果传递给B函数——我们实际上是在执行B函数的过程中运行A函数。到目前为止,我所展示的内容与我们之前在第二章中看到的内容并没有本质的不同。实际上,一等函数通常是通过高阶函数的实现来展示的。
当它们变得有趣时,是你开始使用它们进行偏应用计算,或者使用它们来构建函数柯里化的时候,但在我们深入这些内容之前,让我们先看看闭包的概念。
闭包和变量作用域
闭包与特定编程语言中变量作用域的工作方式密切相关。为了完全理解它们是如何工作的以及它们如何变得有用,我们首先将快速回顾一下 Go 中变量作用域是如何工作的。接下来,我们将提醒自己匿名函数是如何工作的以及它们是什么。最后,我们将探讨在这个上下文中闭包是什么。这将为我们理解稍后章节中提到的部分应用和函数柯里化打下基础。
Go 中的变量作用域
Go 中的变量作用域是通过所谓的 词法作用域 来实现的。这意味着变量在其创建的上下文中被识别并可用。在 Go 中,“块”用于界定代码中的位置。例如,看以下代码:
package main
import "fmt"
// location 1
func main() {
// location 2
b := true
if b {
// location 3
fmt.Println(b)
}
}
这段代码中有三个作用域位置:
-
第一个位置,
location 1,是包作用域。我们的主函数位于这个作用域级别。 -
下一个位置是在我们的
main函数内部。这就是我们定义b布尔变量的地方。 -
第三个位置是在
if语句内部。在 Go 以及许多其他语言中,块是由花括号定义的。
注意
通常情况下,在“更高位置”定义的变量在较低位置是可用的,但在较低位置定义的变量在周围较高位置是不可用的。在前面的例子中,我们的代码按预期工作,因为 b 可以从 location 3 内部访问,尽管它是在 location 2 中定义的。
到目前为止,对于经验丰富的 Go 程序员来说,这一切应该都表现得相当符合预期。让我们看看一些其他关于范围示例。在继续阅读之前,试着找出代码的输出:
范围示例 1:
func main() {
{
b := true
}
if b {
fmt.Println("b is true")
}
}
这里的输出会是什么?正确答案是… 编译错误。在这个例子中,我们定义 b 的作用域与 if 块的作用域不同。因此,在这个作用域级别我们没有访问 b 的权限。
现在,想想这里的输出会是什么:
范围示例 2:
func main() {
s := "hello"
if true {
s := "world"
fmt.Println(s)
}
fmt.Println(s)
}
正确答案是 world hello。这可能会让人有些惊讶。你知道在 Go 中你不能在给定的作用域中重新声明一个变量,但在这个例子中,if 语句内部的范围与我们的 main 函数的范围不同。因此,在 if 函数内部声明新的 s 变量是有效的。请注意,当我们使用在 if 语句外部声明的 s 变量时,它保持未变。这可能会有些令人惊讶的行为。让我们在跳到第三个例子之前稍微修改一下我们的代码。
让我们尝试猜测以下示例的输出可能是什么:
范围示例 3:
func main() {
s := "hello"
if true {
s = "world"
fmt.Println(s)
}
fmt.Println(s)
}
为了指出这个片段中的差异,我们将 if 语句中的第一行从以下内容更改:
S := world
现在,情况如下:
S = world
这个看似微小的差异产生了以下输出:world world。要理解这一点,请记住,当我们使用:=语法时,我们是在声明一个新的变量。当我们只写=时,我们是在重新声明一个现有的变量。在这个例子中,我们只是在更新s变量的内容。
现在,让我们对这个例子做一个最后的修改:
作用域示例 4:
func main() {
s := "hello"
s := "world"
fmt.Println(s)
}
如你所猜到的,这段代码无法编译。虽然 Go 允许我们声明具有相同名称的变量,但它只允许我们在它们不在同一块作用域内时这样做。这里的一个显著例外是当函数返回多个值时。例如,在下面的代码片段中,我们可以将错误值重新声明为func1和func2的返回值:
func main() {
str1, err := func1()
if err != nil {
panic(err)
}
str2, err := func2()
if err != nil {
panic(err)
}
fmt.Printf("%v %v\n", str1, str2)
}
func func1() (string, error) {
return "", errors.New("error 1")
}
func func2() (string, error) {
return "", errors.New("error 2")
}
在前面的代码片段中,尽管我们使用了:=语法,但err值仍然被重新声明。这在 Go 语言中很常见,因为错误值会从每个函数向上冒泡,最终到达处理多个错误的事件处理方法。
记住作用域的工作方式和花括号在界定块中的作用,以及记住引入新变量与简单重新声明现有变量之间的区别非常重要。这样,我们就有了足够的背景知识,可以深入探讨在函数内部使用函数时的变量作用域。
在函数中捕获变量上下文(闭包)
在上一章中,我们看到了每次遇到花括号时,都会引入一个新的变量作用域。这发生在我们声明一个函数、分支到if语句、引入for循环,或者在函数的任何地方放置花括号时,就像我们的第一个作用域示例一样。我们还看到在第二章中,我们可以在函数内部创建函数——正如你可能猜到的,这又创建了一个新的作用域。
在本章的剩余部分,我们将频繁使用匿名函数。请记住,匿名函数本质上是一个没有与标识符关联的函数声明。这是我们使用的通用模板:
// location 1
func outerFunction() func() {
// location 2
fmt.Println("outer function")
return func() {
// location 3
fmt.Println("inner function")
}
}
在这个例子中,我还标记了三个变量作用域的位置。正如你所看到的,位置 3,它是匿名函数的一部分,其作用域比位置 2低。这是闭包能够工作的一个关键原因。定义一个新的函数并不会自动创建一个顶级作用域。当我们在一个函数内部定义另一个函数时,这个新函数的作用域比其引入的位置低。
此外,请注意outerFunction是一个高阶函数。尽管我们没有将函数作为输入,但我们返回一个函数作为输出。这是高阶函数的有效特征。
现在,让我们具体说明我们所说的闭包是什么。闭包是指任何使用在外部函数中引入的变量来执行其工作的内部函数。让我们通过一个例子来使这个概念更加具体。
在这个例子中,我们将创建一个函数,该函数创建一个问候函数。我们的外部函数将是确定要显示的问候信息的函数。内部函数将要求输入一个名字,并返回与名字结合的问候语:
func main() {
greetingFunc := createGreeting()
response := greetingFunc("Ana")
fmt.Println(response)
}
func createGreeting() func(string) string {
s := "Hello "
return func(name string) string {
return s + name
}
}
在前面的例子中,我们使用了闭包。匿名内部函数引用外部变量s来创建问候。这段代码的输出是Hello Ana。这里重要的是,尽管s变量在createGreeting函数结束时已经超出作用域,但变量内容实际上被捕获在内部函数中。因此,在我们main函数中调用greetingFunc之后,捕获被固定为Hello。在内部函数中捕获变量就是我们所说的闭包的含义。
我们可以通过将问候字符串作为createGreeting函数的输入参数来使这个函数更加灵活,从而得到以下结果:
func createGreeting(greeting string) func(string) string {..}
这个小小的改变带我们进入了下一个主题的开始:偏应用。
偏应用
现在我们已经理解了闭包,我们可以开始考虑偏应用。名称“偏应用”非常明确地告诉我们发生了什么——它是一个部分应用的函数。这也许仍然有点晦涩。一个部分应用的函数正在接受一个接受N个参数的函数,并“固定”这些参数的一个子集。通过固定参数的一个子集,它们就变得固定不变,而其他输入参数仍然保持灵活。
这最好用一个例子来说明。让我们扩展我们在本章前一部分中构建的createGreeting函数:
func createGreeting(greeting string) func(string) string {
return func(name string) string {
return greeting + name
}
}
我们在这里所做的改变是将问候语作为输入传递给createGreeting函数。每次我们调用createGreeting时,实际上我们都在创建一个新的函数,该函数期望name作为输入,但greeting字符串是固定的。现在让我们创建几个这样的函数并使用它们来打印输出:
func main() {
firstGreeting := createGreeting("Well, hello there ")
secondGreeting := createGreeting("Hola ")
fmt.Println(firstGreeting("Remi"))
fmt.Println(firstGreeting("Sean"))
fmt.Println(secondGreeting("Ana"))
}
运行此函数的输出如下:
Well, hello there Remi
Well, hello there Sean
Hola Ana
在这个例子中,我们将firstGreeting函数的第一个参数固定为Well, hello there,,而对于secondGreeting函数,我们将值固定为Hola。这是偏应用——当我们创建用于问候用户的函数时,这个函数的部分已经被应用。在这种情况下,greeting变量被固定,但你也可以固定函数参数的任何子集——这不仅仅局限于一个变量。
示例:DogSpawner
在这个例子中,我们将把到目前为止所学的一切结合起来。对于这个例子,我们将创建 DogSpawner。你可以想象这可以用于创建游戏或需要维护狗信息的其他应用程序的上下文中。就像我们之前的例子一样,我们将将其简化到最基本的部分,并且不会制作一个真正的游戏。然而,在这个例子中,我们将利用之前章节中学到的知识,并用干净的函数式代码将它们全部结合起来。
从一个高层次的角度来看,我们的应用程序应该支持多种品种的狗。品种应该是易于扩展的。我们还希望记录狗的性别并给狗起一个名字。在我们的例子中,假设你想要生成许多狗,那么就会有大量的类型和性别的重复。我们将利用部分应用来防止这些函数调用的重复性,并提高代码的可读性。
首先,我们将定义这个程序需要的类型。记得从第一章中,我们可以使用 type 系统来提供有关代码中发生情况的更多信息:
type (
Name string
Breed int
Gender int
NameToDogFunc func(Name) Dog
)
注意,我们可以使用一个 type 块,类似于我们如何使用 var 或 const 块。这可以防止我们不得不重复 type Name string 结构。在这个 type 块中,我们简单地将 Name 选择为 string 对象,Breed 和 Gender 选择为 int 对象,而 NameToDogFunc 是一个接受给定的 Name 并返回一个给定的 Dog 结果的函数。我们选择 int 对象作为 Breed 和 Gender 的原因是我们将使用 Go 的 Enum 定义等效物来构建它们。我们将继续填充这些枚举值:
// define possible breeds
const (
Bulldog Breed = iota
Havanese
Cavalier
Poodle
)
// define possible genders
const (
Male Gender = iota
Female
)
如前例所示,默认的 iota 关键字与我们所定义的类型无缝配合。再次证明,我们的类型别名编译成底层类型,在这种情况下,是 int 类型,iota 就定义在这个类型上。你可以在本例中将两个 const 块合并为一个块,但在处理枚举时,每个块只服务于单一目的时代码更易于阅读。
在这些常量和类型就绪后,我们可以创建一个结构体来表示我们的 Dog:
type Dog struct {
Name Name
Breed Breed
Gender Gender
}
在这个结构体中有点重复,因为我们的变量名与类型相同。对于这个例子,我们可以保持它轻量级,不需要向我们的 Dog 添加更多信息。有了这个,我们就有了开始实现部分应用函数所需的一切,但在我们到达那里之前,让我们看看在没有部分应用函数的情况下如何创建 Dog 结构体:
func createDogsWithoutPartialApplication() {
bucky := Dog{
Name: "Bucky",
Breed: Havanese,
Gender: Male,
}
rocky := Dog{
Name: "Rocky",
Breed: Havanese,
Gender: Male,
}
tipsy := Dog{
Name: "Tipsy",
Breed: Poodle,
Gender: Female,
}
}
在前面的例子中,我们创建了三只狗。前两只都是雄性巴厘犬,因此我们不得不在那里重复品种和性别信息。这两只狗之间唯一独特的东西就是名字。现在,让我们创建一个函数,允许我们创建具有各种性别和品种组合的DogSpawner:
func DogSpawner(breed Breed, gender Gender) NameToDogFunc {
return func(n Name) Dog {
return Dog {
Breed: breed,
Gender: gender,
Name: n,
}
}
}
前面的DogSpawner函数是一个接受品种和性别作为输入的函数。它返回一个新的函数NameToDogFunc,该函数接受名字作为输入并返回一个新的Dog结构体。因此,DogSpawner函数允许我们创建新的函数,其中狗的品种和性别已经部分应用,但名字仍然需要作为输入。
使用DogSpawner函数,我们可以创建两个新的函数,maleHavaneseSpawner和femalePoodleSpawner。这些函数将允许我们通过只提供狗的名字来创建雄性巴厘犬和雌性贵宾犬。让我们继续在包作用域的var块中创建两个新的函数:
var (
maleHavaneseSpawner = DogSpawner(Havanese, Male)
femalePoodleSpawner = DogSpawner(Poodle, Female)
)
在这个定义之后,maleHavaneseSpawner和femalePoodleSpawner函数可以在该包的任何地方使用。你也可以将它们公开为任何使用该包的人都可以访问的公共函数。让我们在我们的main函数中演示这些函数如何被使用:
func main() {
bucky := maleHavaneseSpawner("bucky")
rocky := maleHavaneseSpawner("rocky")
tipsy := femalePoodleSpawner("tipsy")
fmt.Printf("%v\n", bucky)
fmt.Printf("%v\n", rocky)
fmt.Printf("%v\n", tipsy)
}
在这个main函数中,我们可以看到如何利用部分应用函数。我们本可以创建一个创建狗的函数,例如newDog(n Name, b Breed, g Gender) Dog{},但这仍然会导致在创建我们的狗时有很多重复,如下所示:
func main() {
createDog("bucky", Havanese, Male)
createDog("rocky", Havanese, Male)
createDog("tipsy", Poodle, Female)
createDog("keeno", Cavalier, Male)
}
尽管只有三个参数时仍然相当易读,但更多的参数将显著降低可读性。我们将在讨论了函数柯里化之后,在本章的最后示例中展示这一点。
函数柯里化,或如何将多元函数简化为一元函数
函数柯里化常被误认为是部分应用。正如你将看到的,函数柯里化和部分应用是相关但不同的概念。当我们谈论函数柯里化时,我们是在谈论将接受单个参数的函数转换为一个序列的函数,其中每个函数恰好接受一个参数。在伪代码中,我们所做的是将如下函数转换为一个由三个函数组成的序列:
func F(a,b,c): int {}
第一个函数(Fa)接受a参数作为输入并返回一个新的函数(Fb)作为输出。(Fb)接受b作为输入并返回一个(Fc)函数。(Fc)是最终函数,它接受c作为输入并返回一个int对象作为输出:
func Fa(a): Fb(b)
func Fb(b): Fc(c)
func Fc(c): int
这是通过再次利用第一公民和高阶函数的概念来实现的。我们将能够通过从函数中返回一个函数来实现这种转换。我们将从这一核心特性中获得更可组合的函数。就我们的目的而言,你可以将这视为单参数的部分应用。
这里要注意的一点是,在其他编程语言如 Haskell 中,函数柯里化比在我们的 Go 示例中扮演着更重要的角色。Haskell(以 Haskell Curry 的名字命名),将每个函数转换为一个柯里化函数。编译器会处理这件事,所以作为用户你通常不会意识到这一点。Go 编译器不做这样的事情,但我们仍然可以手动以这种方式创建函数。在我们深入更大的端到端示例之前,让我们快速看看如何将之前的伪代码转换为功能性的 Go 代码。
没有柯里化,我们的函数将看起来像这样:
func threeSum(a, b, c int) int {
return a + b + c
}
现在,有了柯里化,相同的例子将转化为这样:
func threeSumCurried(a int) func(int) func(int) int {
return func(b int) func(int) int {
return func(c int) int {
return a + b + c
}
}
}
当在main函数中调用它们时,这些返回相同的结果。注意main函数中两次调用之间的语法差异:
func main() {
fmt.Println(threeSum(10, 20, 30))
fmt.Println(threeSumCurried(10)(20)(30))
}
不言而喻,这个函数的柯里化版本比非柯里化版本更难阅读和理解。这回到了我在第一章提到的事情——你应该在合理的地方利用函数式概念。对于这个简单的例子,这样做并不合理,但它确实展示了我们试图做到的事情。函数柯里化的真正力量只有在我们也决定将其与部分应用结合以创建灵活函数时才会派上用场。为了展示这是如何工作的,让我们深入一个例子。
示例:柯里化函数
在这个例子中,我们将扩展我们构建的DogSpawner示例的功能,以演示部分应用。如果我们查看该应用程序的main函数中的主要DogSpawner代码,我们可以看出我们几乎使用了一个一元函数:
func DogSpawner(breed Breed, gender Gender) NameToDogFunc {
// implementation
}
这让我们接近了,但还不够。为了成为一个正确的柯里化函数,DogSpawner只能接受一个参数。本质上,我们将创建一个由三个函数组成的序列,这些函数依次接受参数来创建Dog,DogSpawner(Breed)(Gender)(Name)。如果我们用 Go 实现这个函数,我们得到以下代码:
func DogSpawnerCurry(breed Breed) func(Gender) NameToDogFunc {
return func(gender Gender) NameToDogFunc {
return func(name Name) Dog {
return Dog{
Breed: breed,
Gender: gender,
Name: name,
}
}
}
}
读取这个的方式是DogSpawnerCurry是一个接受breed作为输入的函数。它返回一个接受gender作为输入的函数,而这个函数反过来又返回一个接受name作为输入的函数,该函数返回Dog。这有点复杂,但你会习惯的。这也是类型别名派上用场的地方。如果没有类型别名,这将更加冗长,这会阻碍阅读并使编写时更容易出错:
func DogSpawnerCurry(breed Breed) func(Gender) func(Name) Dog {
return func(gender Gender) func(Name) Dog{
return func(name Name) Dog {
return Dog{
Breed: breed,
Gender: gender,
Name: name,
}
}
}
}
现在我们已经涵盖了本章的三个主要主题,让我们看看一些进一步的例子来展示这些技术。
示例:服务器构造函数
在这个第一个例子中,我们将利用到目前为止所学到的知识来创建数据类型的灵活构造函数。我们还将看到如何创建具有我们选择的默认值的构造函数。
在我们的设置中,Server 结构体是一个简单的结构体,具有固定数量的最大连接数、传输类型和名称。我们不会构建实际的 Web 服务器,而是仅用少量开销来展示这些概念。我们在这个示例中想要关注的是核心思想,您可以在任何合适的地方应用这些思想。我们的服务器只有三个可配置的参数,但您可以想象,当有更多参数需要配置时,这种好处更为明显。
和往常一样,我们将从定义应用程序的自定义类型开始。为了保持轻量级,我定义了两个——TransportType,它是一个用作枚举的 int 类型,以及 func(options) options 的类型别名。让我们也为 TransportType 设置一些值:
type (
ServerOptions func(options) options
TransportType int
)
const (
UDP TransportType = iota
TCP
)
现在我们有了这个,让我们将结构体放到位——我们将用作 Server 和 options 的两个结构体:
type Server struct {
options
}
type options struct {
MaxConnection int
TransportType TransportType
Name string
}
在此示例中,我们未为嵌入的字段声明新名称就嵌入了 options。在 Go 中,这可以通过简单地写出您想要嵌入的结构体的类型来实现。这样做时,Server 结构体会包含 options 结构体具有的所有字段。这是在 Go 中建模对象组合的一种方式。
这可能看起来有点奇特,需要进一步调查。在更典型的设置中,您可能会在 Server 结构体中包含我们放在 options 结构体内部的变量。使用 options 结构体并将其嵌入 Server 的主要原因是为了将其用作我们希望用户提供的服务器配置。我们不希望用户提供不包含在此结构体中的数据,例如 isAlive 标志。这清楚地分离了关注点,并允许我们在其之上构建更高阶的函数和部分应用层。
下一步是为我们创建一种通过多次函数调用配置 options 结构体的方式。对于 options 结构体内部的每个变量,我们创建一个高阶函数。这些函数接收要配置的参数,并返回一个新的函数,ServerOptions:
func MaxConnection(n int) ServerOptions {
return func(o options) options {
o.MaxConnection = n
return o
}
}
func ServerName(n string) ServerOptions {
return func(o options) options {
o.Name = n
return o
}
}
func Transport(t TransportType) ServerOptions {
return func(o options) options {
o.TransportType = t
return o
}
}
如您在前三个函数(MaxConnection、ServerName 和 TransportType)中看到的,我们正在使用闭包来构建这个配置。每个函数接收一个 options 类型的结构体,更改相应的变量,并返回应用更改后的相同 options 结构体。请注意,这些函数只更改它们对应的变量,结构体中的其他所有内容都保持不变。
现在我们有了这个,我们就准备好了一切,可以开始构建我们的服务器。对于我们的构造器,我们将编写一个函数,它接受一个可变参数列表ServerOptions作为输入。请记住,这些输入实际上是其他函数。我们的构造器是一个高阶函数,它接受函数作为输入,并返回服务器作为输出。因此,当我们遍历我们的ServerOptions时,我们得到一系列可以调用的函数。我们将创建一个默认的options结构体,并将其传递给这些函数:
func NewServer(os ...ServerOptions) Server {
opts := options{}
for _, option := range os {
opts = option(opts)
}
return Server{
options: opts,
isAlive: true,
}
}
在这里的代码中,您可以看到我们的Server是如何基于options结构体最终构建的。我们还设置了isAlive标志为true,因为这不是用户可以输入的内容。
太好了,我们已经准备好了一切,可以开始创建服务器了——那么我们该如何着手呢?嗯,我们的构造器与您可能见过的其他构造器略有不同。我们不是以原语或结构体等变量作为输入,而是将函数作为输入传递。让我们在main函数中演示如何调用这个构造器:
func main() {
server := NewServer(MaxConnection(10), ServerName("MyFirstServer"))
fmt.Printf("%+v\n", server)
}
如您所知,我们在构造器内部调用了MaxConnection(10)函数。这个函数的输出不仅仅是结构体;输出是function(options) options。当运行这段代码时,我们得到以下输出:
{options:{MaxConnection:10 TransportType:0 Name:MyFirstServer}
isAlive:true}
太好了——现在,我们有一个相当灵活的构造器。如果您注意输出,我们会得到TransportType: 0作为输出,即使我们没有在我们的options结构体中配置这个。这是因为 Go 为其原始类型使用了一个合理的默认零值。我们当前构造器设置允许我们做的事情之一是,通过仅对我们的代码进行少量修改,就可以创建我们自行设置的默认值。让我们更新NewServer函数,使用TCP(TransportType: 1)作为默认值:
func NewServer(os ...ServerOptions) Server {
opts := options{
TransportType: TCP,
}
for _, option := range os {
opts = option(opts)
}
return Server{
options: opts,
isAlive: true,
}
}
在这个例子中,我们唯一做的改变是在options的初始化中添加了TransportType: TCP。现在,如果我们再次运行相同的main代码,我们得到以下输出:
{options:{MaxConnection:10 TransportType:1 Name:MyFirstServer}
isAlive:true}
这就是当用户没有提供任何内容时创建我们自己的默认值有多简单。正如这个例子所示,我们可以轻松地使用函数式编程的概念来构建灵活的函数,如构造器,并实现 Go 中本不存在的功能。在一些语言中,例如 Python,当用户没有提供时,您可以为一个函数设置默认值。现在,我们可以使用我们的服务器options结构体来做同样的事情。
摘要
在本章中,我们涵盖了三个主题:闭包、偏应用和柯里化。通过使用闭包,我们学习了如何在内外函数之间共享变量上下文。这使得我们能够构建灵活的应用程序,例如最后的“构造函数”示例。接下来,我们学习了如何使用偏应用函数将某些参数固定到多元函数中。这展示了我们如何为函数创建默认配置,例如在我们示例中创建的HavaneseSpawner选项。最后,我们了解了函数柯里化及其与偏应用的关系。我们展示了如何通过将每个函数转换为单参数函数调用来扩展我们的偏应用示例。这三种技术使我们能够创建更多可组合和可重用的函数。
到目前为止,我们并没有关注函数的纯度,对系统的状态处理得有些草率。在下一章中,我们将讨论函数纯度的含义,如何封装副作用,以及这对编写可测试代码带来的好处。
第四章:使用纯函数编写可测试的代码
当你阅读有关函数式编程的内容时,相当常见的是指的是“纯”函数式编程。正如我们在第一章中提到的,这并不是函数式编程或函数式语言的严格要求。如果你决定学习函数式编程语言,你很可能选择像 Haskell 或 Elm 这样的语言。如果是这样,你就选择了两种纯函数式语言,并且可能会将你对纯函数式的理解与函数式相结合。另一方面,如果你选择了像 Lisp、Clojure 或 Erlang 这样的语言,你就选择了一种不纯但仍然是函数式的语言。
在本章中,我们将讨论以下主题:
-
纯度究竟是什么?
-
为什么纯度很重要?
-
我们如何创建纯函数?
-
学习如何通过编写纯函数来影响单元测试
技术要求
对于本章,可以使用 Go 1.12 之后的任何版本。你可以在github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter4找到完整的示例。
什么是纯度?
当谈论纯函数式编程语言时,我们指的是一种每个函数都遵循以下特性的语言:
-
不产生任何副作用
-
提供相同的输入时返回相同的输出(幂等性)
这意味着我们的函数是完全确定性的。
最好的前进方式可能是通过展示一些例子来演示我们正在讨论的内容。因此,在本节中,我们将查看两个函数,一个是纯函数,另一个是不纯函数。然后,我们将更多地讨论这类函数的特性及其对我们所编写的程序的重要性。
展示纯函数与不纯函数调用的区别
这的一个简单例子就是一个加法函数。这是一个接收两个整数作为输入并返回和作为输出的函数:
func add(a, b int) int {
return a + b
}
当我们用相同的输入调用这个函数时,我们将得到一致的结果。因此,无论我调用add(10,5)函数多少次,代码总是会返回相同的输出:15。在创建纯函数时,这一点非常简单。我们没有在函数外部使用任何状态来确定答案,也没有在函数外部更新任何内容。
接下来,让我们看看一个不纯函数的例子,其输出总是随机的:
func rollDice() int {
return rand.Intn(6)
}
当调用rollDice函数时,输出并不一致。如果它总是输出相同的数字,那将是一个非常糟糕的随机化函数。如果我们调用rollDice函数五次,我们会得到五个不同的输出:
func main() {
for i := 0; i < 5; i++ {
fmt.Printf("dice roll: %v\n", rollDice())
}
}
这将产生以下输出:
dice roll: 5
dice roll: 3
dice roll: 5
dice roll: 5
dice roll: 1
指称透明性
有一个特性帮助我们思考纯函数,那就是引用透明性的特性。在数学和计算机科学中,如果一个函数可以被其输出所替换,而不改变程序的结果,那么这个函数就被认为是引用透明的。在数学中,这一点很容易理解。如果我们解出任何公式,我们实际上可以用其结果替换方程的一部分,而不会改变结果。例如,考虑以下方程:
X = 1 + (2 * 2)
结果是 5。如果我们用其结果替换乘法,我们同样可以得到相同的结果,如下所示:
X = (1 + 4)
这个特性就是我们所说的引用透明性。所有数学运算都具有这个特性,我们中的许多人都在代数、微积分或其他数学课程中利用了这个特性来解决问题。
让我们回到软件工程的领域,进一步探讨这个问题。在编程语言中,引用透明性意味着函数调用可以被其结果所替换。如果我们将这个相同的测试应用到我们之前编写的add函数上,我们可以看到这是如何成立的。让我们用一小段代码来演示这一点:
func main() {
fmt.Printf("%v\n", add(10, add(10, 5)))
fmt.Printf("%v\n", add(10, 15))
}
func add(a, b int) int {
return a + b
}
在这个例子中,我们用其结果替换了其中一个add函数。果然,我们程序的输出保持一致,并且功能正确。你可能认为这是显而易见的,但有许多我们依赖的函数并不具备这个特性。让我们引入另一个打破这个特性的函数。我们将保持简单,创建一个告诉我们当前时间的程序:
func main() {
fmt.Printf("%v\n", time.Now())
}
在这个片段中,我们使用了time.Now函数。没有哪个值可以替换这个函数调用,同时保证你的程序在功能上等效且正确。如果我们硬编码当前时间,那么在程序编译和运行时,这个时间就会是错误的。
为了进一步说明这一点,让我们看一个比time.Now函数更大的例子。在下面的代码片段中,让我们假设我们正在编写一个函数来选择游戏的起始玩家。我们将使用从Player到string的简单类型别名,而不是创建一个完整的结构体。由于这是一个游戏,我们希望我们的起始玩家在每次程序运行时都是随机选择的:
type Player string
const (
PlayerOne Player = "Remi"
PlayerTwo Player = "Yvonne"
)
func selectStartingPlayer() Player {
randomized := rand.Intn(2)
switch randomized {
case 0:
return PlayerOne
case 1:
return PlayerTwo
}
panic("No further player available")
}
在前面的代码中,我们违反了代码的引用透明性要求,因为没有办法用一个单一值替换这个函数调用,同时保持程序等价的结果。前面的代码也不可测试。思考一下——你将如何为这个函数编写单元测试?这将证明在代码当前状态下是无法做到的,并且需要一些重构。我们将在本章后面展示如何重构此代码并使其可测试,你可以在 GitHub 上找到它:github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter4/TestableCode。
幂等性
纯函数的另一个特性是它们是幂等的。这意味着无论函数执行多少次,只要输入参数保持不变,它总是会返回相同的输出。在前面的例子中,add函数总是返回相同的两个数的和,前提是输入相同。另一方面,time.Now函数不是(这也不是预期的行为)。
你可能对幂等性很熟悉,因为它在实现REST服务或处理 HTTP 调用时也会出现。当正确实现时,GET, HEAD, PUT, 和 DELETE方法应该是幂等的。一个值得注意的例外是POST方法。
无状态
纯函数不应依赖于系统的任何状态。这意味着输入和输出都不应改变状态。通常说 Web 请求是无状态的;每个请求可以独立于其他请求运行,并且仍然产生相同的结果。在 Go 的术语中,这也意味着我们的函数不应依赖于诸如全局变量、文件系统上的文件或一般的 I/O 操作等事物。
副作用
之前提到的属性结合在一起,创建出没有副作用的功能。副作用是指你的函数所做的任何改变系统状态的操作。在下一章中,我们将更深入地探讨在struct级别上状态不可变的意义。在这一章中,我们将考虑状态为程序运行的系统。
为什么纯度能提高我们的代码?
到目前为止,我们已经探讨了纯函数代码的一些特性。我们也看到了纯函数和不纯函数的例子。现在,让我们看看编写纯函数代码可以期待哪些好处。
提高了我们代码的可测试性
当编写纯函数时,你的函数将更容易测试。这是它们既是幂等的又是无状态的后果:
-
幂等性:运行函数任意次数都会得到相同的结果
-
无状态:每个函数将独立于系统的状态运行
对于幂等性,很容易看出这一点是正确的。在我们的测试套件中,如果函数对于相同的输入返回不同的输出,那么为该函数编写测试将会很困难。毕竟,如果你无法预测某个函数的输出,你只能猜测应该测试的值。它无状态的好处可能并不立即明显。这归结于我们的测试套件无法在我们的生产系统环境中运行。因此,如果我们以某种方式依赖于系统状态,我们必须保证我们的测试状态在函数被调用的那一刻复制了生产状态。让我们用一个例子来演示这一点。
回想一下本章前面提到的,当我们创建一个函数来为游戏选择一个随机玩家时?让我们将这段代码重构为更易于测试的形式。我们需要做两个改动——首先,我们需要使函数具有确定性。这听起来好像打破了随机性,确实如此,但我们将很快展示如何解决这个问题。我们将做的第二个改动是移除任何副作用。在我们的第一个例子中,如果随机化函数返回大于 1 的整数,我们有一个panic函数。我们将用从我们的函数返回一个包含(Player, error)元组的做法来替换这个panic,这遵循了 Go 语言中常见的错误处理惯例。有了这些改动,我们新的函数看起来是这样的:
func PlayerSelectPure(i int) (Player, error) {
switch i {
case 0:
return PlayerOne, nil
case 1:
return PlayerTwo, nil
}
return Player(""), fmt.Errorf("no player matching input: %v", i)
}
在这些改动到位后,我们的函数现在是确定的。对于每个输入,我们总是生成相同的输出,如下所示:
PlayerSelectPure(0) = PlayerOne, nil
PlayerSelectPure(1) = PlayerTwo, nil
PlayerSelectPure(n > 1) = Player{}, error
注意在最后一种情况下,当n大于一时,我们并没有简单地返回nil和错误。这需要一些解释。其核心思想是我们将尽可能避免在我们的代码中使用指针。在 Go 语言中,如果你不使用指针,你就无法表示nil。我们为什么要避免使用它以及它的含义将在下一章中详细解释,第五章。
现在我们已经看到了每种情况下的预期输出,并且我们同意这个函数是纯的,我们可以编写一个测试用例来确认输出是否符合我们的预期:
func TestPlayerSelectionPure(t *testing.T) {
selectPlayerOne, err := PlayerSelectPure(0)
if selectPlayerOne != PlayerOne || err != nil {
t.Errorf("expected %v but got %v\n", PlayerOne, selectPlayerOne)
}
selectPlayerTwo, err := PlayerSelectPure(1)
if selectPlayerTwo != PlayerTwo || err != nil {
t.Errorf("expected %v but got %v\n", PlayerOne, selectPlayerTwo)
}
_, err = PlayerSelectPure(2)
if err == nil {
t.Error("Expected error but received nil")
}
}
上述代码中发生的一切都很简单明了。对于每个有效输入(0 和 1),我们确认分别返回第一个或第二个玩家。对于大于 1 的输入,我们确认抛出了一个错误。技术上,你可以扩展这个单元测试,以穷举测试所有可能的整数输入,并确认每个输入都会抛出错误。不过,对于这个简单的函数来说,这可能有点过于详尽。
在这种情况下,唯一需要解决的事情就是:我们的代码不再选择一个随机玩家,而是期望一个整数输入并返回一个确定性的值。你可能注意到,我们只是将问题转移了,因为随机选择函数仍然需要存在于某个地方。这是正确的。如果我们看看在实际游戏中如何使用这段代码,我们可能会发现这样的代码:
func main() {
random := rand.Intn(2)
player.PlayerSelectPure(random)
// start the game
}
在这里,我们可以看到一种重复出现的模式,因为我们旨在提高代码的纯净度。策略将是限制副作用和非确定性可能发生的地方。当你改变思考代码结构的方式,更倾向于函数纯净并隔离破坏它的位置时,你可能会得到 90%的纯代码和 10%的不纯代码。当然,你并不是 100%的纯函数式,但我们在 Go 中编程,我们可以原谅自己 10%的不纯代码。正如我们详细探讨的那样,纯函数式编程是函数式编程的一个子集。此外,没有纯函数式编程警察会因为你写了一个不纯的函数而追捕你。
这是否意味着完全纯净是不可能的?好吧,并不完全是这样。毕竟,有一些纯函数式编程语言,如 Haskell,可以在现实世界的生产环境中使用。它们处理这些不纯函数的方式是通过一种称为单子的封装形式。虽然可以在 Go 中创建单子,但这可能会引起不必要的摩擦,这就是我为什么主张拥抱函数式而非纯函数式代码的理念。为了娱乐和扩展我们对纯函数式代码的探索,我们将在下一章中探讨单子。
提高我们代码的信心
虽然这与提高可测试性密切相关,但你对代码的信心提升远不止于此。当处理不纯函数和状态时,你的程序更难以理解。如果你在一个足够复杂的系统中工作,该系统包含不纯函数和状态突变,如通过全局变量,推理起来会更困难。想象一下,你在一个这样的复杂系统中工作,一个用户报告了一个错误。如果系统是可变的,你需要完全理解错误出现时整个系统的样子,才能开始调试。这可能导致许多痛苦且浪费时间的调试。有一个流行的概念,称为海森堡虫,这是其后果之一。在这种情况下,如果导致错误的函数依赖于系统的状态,你可能需要重复用户所做的确切步骤才能重现错误。
另一个好处是,我们的代码更容易调试。在调试程序时,任何足够先进的调试器也会显示调试期间你的系统状态。它会告诉你程序各个部分在内存中持有的值。这是一个伟大的工具,可以帮助你找到错误并消除它们。但如果你程序根本不依赖于这种状态呢?这将消除对像高级调试器这样的关键的需求。
你可以查看一个单独的函数,并对其所做的事情进行推理,而无需同时记住执行时刻整个系统的样子。人类在保持“工作记忆”方面很糟糕;我们可以在任何给定时刻处理大约 7 ± 2 件事情。如果我们优化并尝试让我们的程序对大多数人类来说是可理解的,我们就必须将状态变量限制为只有 5 个。这是忽略我们函数可能有一些变量的事实。因此,我们很快就会超过人类记忆的上限。
提高对函数名称和签名的信心
提高你代码的可读性和可理解性的另一个巨大好处是,你突然会对你的函数有额外的信心。想象一下,你正在阅读一个代码库,你遇到了以下这段代码:
func main() {
i := 1
for i < 10 {
i = add1(i)
fmt.Printf("%v,", i)
}
}
输出会是什么样子?你可能自然会假设add1是一个纯函数,输出如下:
1,2,3,4,5,6,7,8,9,10,
但是,你会错的。实际输出如下:
1, 2, 3, 4, 5, 6, panic: can not increment any more
goroutine 1 [running]:
main.add1(...)
/tmp/sandbox1318301126/prog.go:17
main.main()
/tmp/sandbox1318301126/prog.go:10 +0xa5
Program exited.
要理解为什么,让我们看看add1函数:
func add1(input int) int {
if input != 0 && input > rand.Intn(input) {
panic("can not increment any more")
}
return input + 1
}
在前面的函数中,我们可以看到add1函数是不纯的。它不是确定的,因为每次运行的输出取决于生成的随机数。此外,它还产生了副作用。每次一个函数中有一个panic语句时,该语句会产生一个超出你函数正常输出的副作用。这是一个有点人为的例子,但它表明,当在一个函数可以包含副作用且不是幂等的环境中工作时,你对函数签名本身的信任度会降低。
更安全的并发
Go 的一个卖点,以及它区别于许多主流语言的特点之一,是它处理并发的容易程度。使用 Go,启动多个线程并使它们并行工作非常简单。这是通过通道和goroutines概念实现的。关于 Go 中并发的工作方式有很多可以说的,足以写一本书。在这里,我们将简要关注并发的正确性方面。在 Go 中启动 goroutines 和并行处理是否比 Java 等语言更容易?不正确的是,编写正确的并发代码更容易。
让我们看看一些并发代码。在这个例子中,我们将创建一个整数切片,并在addToSlice函数中向其中追加。在我们的main函数中,我们将一个整数推送到切片中:
var (
integers = []int{}
)
func addToSlice(i int, wg *sync.WaitGroup) {
integers = append(integers, i)
wg.Done()
}
func main() {
wg := sync.WaitGroup{}
numbersToAdd := 10
wg.Add(numbersToAdd)
for i := 0; i < numbersToAdd; i++ {
go addToSlice(i, &wg)
}
wg.Wait()
fmt.Println(integers)
}
想想这个程序,并尝试猜测输出结果会是什么。正确答案是这个程序的输出是非确定性的。我们在多个线程中运行,向我们的切片中追加内容,最后调用wg.Done()。当与这些等待组一起工作时,我们传递几个线程去等待。这是在wg.Add(numbersToAdd)中完成的。每次调用wg.Done()时,等待的线程数减一。在这个例子中,我们正在处理一个共享的整数切片,因此无法准确预测在最后一个线程执行add操作时切片看起来是什么样子。这意味着我们的输出可能是所有数字随机排序的,例如[9 0 1 2 3 4 5 6 7 8],但同样可能的结果是[4 9 0 1 2]。在并发函数中有可变的数据源是灾难性的,会导致一些难以追踪的 bug。
所以,正如你可以从这个小片段中看到的,启动多个线程非常简单,但避免代码中的 bug 并不那么简单。纯函数可以帮助解决这个问题。记住,当一个函数是纯的,相同的输入总是生成相同的输出,而不会引起任何副作用。
在这个例子中,我们的副作用是修改了切片,这在 Go 中不是线程安全的。程序不会崩溃,但结果将是随机的。如果我们将纯函数编程推向极端,我们将消除所有这样的不纯函数,并且在这个过程中,我们可以无限并行地运行所有函数而不会引起任何麻烦。
注意
在实践中,有使用互斥锁来避免这种情况发生的方法。一些库会处理并行性,从而抽象掉一些复杂性。
不应该编写纯函数的情况
到目前为止,我们已经看到了纯函数是什么以及纯函数可以提供什么样的优势。但我们应该至少花一点时间思考一下我们可能想要牺牲函数纯度的场合。现在,如果你向“纯粹主义者”提出这个问题,这个问题的答案可能大致是这样的:“永远不,决不,绝不。”这是可以的,有些语言使得编写不牺牲函数纯度的函数代码变得相当简单。但是,让我们看看一些牺牲一些函数纯度是有意义的例子。现在,在我们深入这些例子之前,让我首先承认,所有这些所谓的都是可以规避的。是的,像 Haskell 这样的语言处理这些问题大多数情况下都很优雅。
但我们不是在用 Haskell 编程;我们是在用 Go 编程。虽然 Go 允许我们编写纯函数代码,如果我们愿意的话,但有些事情通过暂时原谅自己编写不纯代码的“罪行”更容易实现。
输入/输出操作
考虑从代码中完全消除副作用的影响。如果我们说我们在编写纯函数代码并消除了所有副作用,那么我们也消除了为我们的用户提供价值的一部分。每次我们从用户那里获取输入或以某种方式向用户显示输入时,在技术上都是副作用。每次我们在本地存储中存储数据或上传到服务器时,我们都在产生副作用。许多应用程序会接收某种类型的输入,许多也会生成某种类型的输出。
非确定性可能是期望的
我们可能不希望创建纯函数的另一个原因是,非确定性特性适合于我们构建的领域。如果我们正在构建《大富翁》游戏,那么期望rollDice函数返回一个非确定性结果是合乎愿望的。《大富翁》的例子并非偶然。随机性是我们周围许多游戏固有的特性,因此,纯确定性不是每个函数期望的结果。
当我们真的不得不panic时!
当你的程序处于无法正常继续运行的状态时,处理这种情况的典型方式是使用panic。虽然panic应该谨慎使用,但它们是你产生副作用的一个实例。在本章的早期,我们看到了一个函数在执行过程中可能会不可预测地引发panic的例子。那个例子是人为的,对于panic函数来说是一个相当糟糕的使用案例。但这并不意味着永远没有使用panic的有效理由。例如,如果你试图预留超出系统可用内存的内存,那可能就是引发panic的原因。一般来说,panic应该用来表示正常操作无法继续进行,且没有优雅地继续运行应用程序的方法。
有两点值得指出。第一点是使用panic关键字应该是例外而不是常规操作。第二点是 Go 语言中有一个常见的错误处理模式,即返回一个包含潜在错误值的元组。从函数中返回错误与使用panic是不同的操作,它们服务于不同的用例。
我们如何创建纯函数?
到目前为止,在本章中,我们查看了一些纯函数的性质。我们还简要提到了通过将所有函数编写为纯函数所能获得的一些优势。现在,让我们看看我们可以做些什么来使编写纯函数更容易。
避免全局状态
我们可以促进编写纯净函数式代码的一种方法是在我们的程序中避免全局状态。在 Go 语言中,这相当于尽可能避免在包级别使用const和var块。当你看到这些块时,有很大可能性是某些函数依赖于程序状态,从而产生副作用或具有非确定性的程序执行。虽然我们不可能完全避免这样的状态变量,但我们应尽可能限制它们的使用。防止函数依赖这种状态的方法是通过正常函数参数将状态传递给函数。这相当直接。以下是一个小例子,一次使用var块中的状态,一次不使用:
var (
name = "Remi"
)
func sayHello() string {
return fmt.Sprintf("hello %s", name)
}
func main() {
sayHello()
}
我们可以通过将name参数作为输入传递给我们的函数,而不使用var块来获得与前面块相同的功能:
func sayHello(name string) string {
return fmt.Sprintf("hello %s", name)
}
func main() {
sayHello("Remi")
}
这就是重点。接下来,让我们看看处理包含杂质代码的一般方法。
区分纯净和杂质功能
如前所述,要完全纯净是很难的。我们不应试图消除 I/O 操作、API 调用等,因为消除这些操作,我们可能会丢弃使我们的程序有价值的大部分内容。主要的练习将是尝试创建尽可能多的小型、纯净函数,并将这些组合成更大的程序。仍然会有副作用,但我们将限制它们的发生。
错误冒泡
一种相对常见的副作用是由错误产生的。我们的程序最终处于无法优雅继续的状态,而且没有真正的方法可以绕过这个问题。在这里隔离纯净和杂质方面的一种方法是通过使用 Go 的错误处理习惯用法,并将错误“冒泡”到可以处理的公共层。我们在选择随机玩家的例子中已经看到了这一点。自 Go 1.13 以来,还有额外的内置工具可用于错误冒泡。
每个函数只做一件事
这是一般性的好建议。一般来说,一个函数应该只做一件事,这显著降低了我们的函数产生副作用的可能性。你同样可以在传统的面向对象语言中找到这个原则。业界或多或少都同意这是正确的方法,但打破这个良好意图却出奇地容易。看看以下简单加法函数的代码:
func add(a, b int) int {
sum := a + b
fmt.Println(sum)
return sum
}
这不是一个纯净函数。这个片段的副作用是我们将sum值打印到标准输出。确实,这并不太有害,但如果我们的用户依赖于这个功能,我们如何确保这个函数正常工作?换句话说,你将如何测试这个函数是否将正确的输出打印到屏幕上?
这种变体可能是将写入文件系统或数据库调用作为函数的一部分,而这种情况本不应该发生。让我们看看一个用于注册新用户到服务的函数。我们期望输入是一个用户名和一个密码,User结构体上定义了一些逻辑来确保密码符合密码规则:
func createUser(username, password string) {
u := User{username, password}
if u.validPassword() {
userDb.save(u)
} else {
panic("invalid password")
}
}
这个函数的问题在于它试图做两件事。首先,它创建一个新的用户结构体并确认密码是否符合要求。接下来,它将User结构体存储在数据库中,假设密码有效;否则,它将引发恐慌。我们可以将这个操作拆分成多个函数,一个用于验证密码,一个用于存储用户,第三个函数用于协调这些操作:
func signup(username, password string) {
user, err := createUser(username, password)
if err != nil {
saveUser(user)
} else {
Panic("Could not create account")
}
}
func createUser(username, password string) (User, error) {
u := User{username, password}
if u.validPassword() {
return u, nil
}
return User{}, Errors.new("invalid password")
}
func saveUser(u User) {
userDb.save(u)
}
在前面的例子中,我们已经分离了关注点,但我们仍然留下了两个不纯的函数。然而,问题现在被更严格地限制在单个函数内部。这段代码还不够完美,还有改进的空间,我们将在下一章中看到。不过,在去那里之前,让我们看看一个更广泛的例子。
示例 1 – 热狗店
在我们的第一个例子中,我们将查看一些以不纯方式编写的代码,这些代码几乎违反了编写纯函数的所有良好原则。我们将随着代码的进行进行重构,以创建更可测试的代码,同时提高代码的可读性和可理解性。
不好的热狗店
首先,让我们看看如何不创建这个热狗店系统。我们将从一个常量开始定义,这是一个全局变量,它决定了我们热狗的价格:
const (
HOTDOG_PRICE = 4
)
接下来,我们将创建一些结构体。我们需要一个结构体来表示热狗,还需要一个结构体来存储我们的信用卡信息。为了简化,目前热狗不包含任何状态变量,而信用卡只存储卡上可用的信用额度。在这个例子中,信用额度是一个整数值。它并不准确地代表现实生活中的货币价值,但对于这个例子来说已经足够了:
type CreditCard struct {
credit int
}
type Hotdog struct{}
定义好这些之后,我们可以着手实现我们关心的第一个功能。我们需要一种方式来为我们的信用卡充值一定金额:
func (c *CreditCard) charge(amount int) {
if amount <= c.credit {
c.credit -= amount
} else {
panic("no more credit")
}
}
在之前的charge方法中,我们通过减少卡上的可用信用额度来为信用卡收取一定金额。如果没有足够的信用额度来执行扣款,我们使用panic来停止程序。目前,这个函数的主要问题是使用了副作用。有两个副作用。首先,如果遇到某个分支,我们会使用panic。下一个副作用是我们改变了CreditCard的状态。结构体的不可变性是我们将在下一章详细讨论的主题,所以现在让我们忽略这个问题,继续编写我们的热狗店代码。对于用户来说最重要的功能是订购热狗。所以,让我们看看如何实现这个功能的实现:
func orderHotdog(c *CreditCard) Hotdog {
c.charge(HOTDOG_PRICE)
return Hotdog{}
}
之前的代码,再次,是不纯的代码。用户的信用卡正被一个在函数外部定义的价格所扣除,使用了全局状态。这个函数做了不止一件事——它既为用户创建了一个要返回的热狗,同时也扣除了他们的信用卡。
想想你是如何测试这个功能的。测试这个功能是可能的——但并不方便。你需要测试或模拟信用卡,以确保这个函数确实返回了一个热狗。此外,你还得捕捉一个潜在的恐慌,这并不是在orderHotdog函数中发生的,而是在更深层次的调用中。另外,因为charge也是不纯的,orderHotdog的读者如果没有查看那个特定的函数,就没有意识到charge可能会引发恐慌。正如我们之前学到的,纯函数式代码在阅读代码时给我们带来了更多的信心。我们相信一个函数会做它所说的——不多,也不少。带着这个想法,让我们看看我们如何可以重构这段代码。
更好的热狗店
在这个热狗店的版本中,我们将尝试解决我们在上一个例子中发现的一些问题。完整的代码可以在github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter4/Examples/HotdogShop/PureHotdogShop找到。
让我们从定义我们的类型开始:
type CreditCard struct {
credit int
}
type Hotdog struct {
price int
}
type CreditError error
type PaymentFunc func(CreditCard, int) (CreditCard,
CreditError)
在这里,我们定义了所有我们需要表示这个小型应用程序数据的类型。我们的CreditCard结构体包含一个整数金额的信用额度,而热狗的价格也是一个整数。我们定义了一个名为CreditError的错误,以及一个支付函数的类型别名。让我们也为CreditCard和Hotdog设置一些类似构造函数的函数:
func NewCreditCard(initialCredit int) CreditCard {
return CreditCard{credit: initialCredit}
}
func NewHotdog() Hotdog {
return Hotdog{price: 4}
}
这些相当直接。我们将添加一个全局变量来表示一个错误,即用户没有足够的信用额度来对信用卡执行操作:
var (
NOT_ENOUGH_CREDIT CreditError = CreditError(errors.
New("not enough credit"))
)
如您可能记得的,之前我曾反对使用这类包级别的声明。这一点依然成立,我建议尽可能避免使用它们。然而,对于错误声明来说,这几乎是编写 Go 代码的公认、惯用的方式。
我们可以在这里避免它,并在适用的地方直接实例化错误,但这会稍微损害我们稍后要编写的测试代码。一般来说,请记住,我提倡在 Go 中使用函数式编程,而不是纯函数式编程。
无论哪种方式,让我们编写我们的第一个非平凡函数。我们将以纯净的方式重写最初的charge函数。这里的目的是通过返回一个包含潜在错误的元组来消除我们之前没有通过panic而是通过返回副作用的方式。
func Charge(c CreditCard, amount int) (CreditCard, CreditError) {
if amount <= c.credit {
c.credit -= amount
return c, nil
}
return c, NOT_ENOUGH_CREDIT
}
如您在前面的代码片段中可以看到,我们不仅返回了一个错误值,还返回了一个CreditCard类型的值。这并不是由调用者传递给函数的同一个CreditCard。因为我们没有使用CreditCard的指针,当函数调用Charge时,CreditCard将在Charge函数内部使用。由于我们正在操作一个副本,c.credit -= amount这一语句只会影响副本,而不会影响原始的CreditCard。这是新晋 Go 程序员常见的陷阱。在下一章中,我们将更深入地探讨不可变性,并讨论这种方法和基于指针的函数调用之间的权衡。但可以肯定的是,当前这个函数是足够纯净的。
这个Charge函数也易于测试。让我们编写一个单元测试来确保行为符合我们的预期。首先,我们将定义我们的测试用例。以下结构是表格驱动测试的设置:
var (
testChargeStruct = []struct {
inputCard CreditCard
amount int
outputCard CreditCard
err CreditError
}{
{
CreditCard{1000},
500,
CreditCard{500},
nil,
},
{
CreditCard{20},
20,
CreditCard{0},
nil,
},
{
CreditCard{150},
1000,
CreditCard{150}, // no money is withdrawn
NOT_ENOUGH_CREDIT,
// payment fails with this error
},
}
)
在前面的代码片段中,我们正在测试我们的代码可以采取的几个路径。我们可以尝试在信用额度超过成本时扣款,当有恰好足够的金额时扣款,或者当信用额度不足时扣款。有了这种表格结构,添加更多测试用例变得非常简单。现在,让我们编写实际的单元测试,它将为之前定义的每个测试用例运行测试:
func TestCharge(t *testing.T) {
for _, test := range testChargeStruct {
t.Run("", func(t *testing.T) {
output, err := Charge(test.inputCard, test. amount)
if output != test.outputCard || !errors. Is(err, test.err) {
t.Errorf("expected %v but got %v\n, error expected %v but got %v",
test.outputCard, output, test.err, err)
}
})
}
}
哇!这是对charge函数的完整单元测试。这在非纯净示例中几乎是不可能的。现在,让我们也重构一下我们之前提到的OrderHotdog函数。就像任何事物一样,解决这个问题有多个方法。我们在这里实施的方法是使用高阶函数将计算延迟到更晚的阶段。这将把实际扣款的副作用向上移动到调用链中:
func OrderHotdog(c CreditCard, pay PaymentFunc) (Hotdog, func() (CreditCard, error)) {
hotdog := NewHotdog()
chargeFunc := func() (CreditCard, error) {
return pay(c, hotdog.price)
}
return hotdog, chargeFunc
}
让我们分析一下这里发生的事情。首先,是我们的函数签名。OrderHotdog函数仍然接受CreditCard作为输入,但还接受一个PaymentFunc。回想一下,我们定义PaymentFunc为一个接受CreditCard和一个int作为参数,并返回CreditCard和CreditError的函数。OrderHotdog函数返回热狗本身,以及一个将返回CreditCard和error的函数。这可能会一开始有些令人困惑,但在函数体中会变得清晰。
第一步是创建一个新的热狗。之后,我们必须在行内创建一个新的函数。回想一下,这是可能的,因为 Go 支持将函数作为一等公民。在这个函数内部,我们使用提供的信用卡,为热狗的价格调用pay。这是一个OrderHotdog函数,它返回热狗和刚刚创建的函数。需要注意的是,当调用OrderHotdog函数时,chargeFunc并不会被执行。这个函数中没有发生副作用;副作用被延迟到了后续的阶段。再次强调,我们将尽可能地将副作用隔离。在调用链的更高层次进行副作用处理是一个更好的选择,因为我们的代码通常是从抽象层次较高的地方开始阅读的。这可以避免在实现细节中隐藏的意外。
通过这种方式,我们已经重新创建了原始热狗店的功能。在我们查看测试OrderHotdog之前,我们将首先看看如何使用这个函数的例子。在下面的main函数中,我们将订购一个热狗,随后调用pay函数来对信用卡进行扣费:
func main() {
myCard := NewCreditCard(1000)
hotdog, creditFunc := OrderHotdog(myCard, Charge)
fmt.Printf("%+v\n", hotdog)
newCard, err := creditFunc()
if err != nil {
panic("User has no credit")
}
myCard = newCard
fmt.Printf("%+v\n", myCard)
}
好了,这是一个可用的订购热狗的例子。让我们看看我们是如何调用OrderHotdog的。我们传递了信用卡以及我们之前编写的Charge函数。你可以在 GitHub 的示例仓库中运行这个例子,并对其进行实验。让我们也通过编写单元测试函数来确认这段代码是可测试的。
对于这个例子,我们不需要一个表格驱动的测试。OrderHotdog函数需要被测试以确保它执行以下操作:
-
创建一个新的热狗
-
创建一个调用支付函数的函数
-
返回热狗和函数
我们的测试函数将确认是否创建了一个新的热狗,并且支付函数被调用。由于这是一个单元测试,我们并不关心支付函数本身。我们将模拟一个支付函数以确保它被从返回的函数中调用。实际的charge函数如之前所见,将被单独测试:
func TestOrderHotdog(t *testing.T) {
testCC := CreditCard{1000}
calledInnerFunction := false
mockPayment := func(c CreditCard, input int) (CreditCard,
CreditError) {
calledInnerFunction = true
testCC.credit -= input
return testCC, nil
}
hotdog, resultF := OrderHotdog(testCC, mockPayment)
if hotdog != NewHotdog() {
t.Errorf("expected %v but got %v\n", NewHotdog(),
hotdog)
}
_, err := resultF()
if err != nil {
t.Errorf("encountered %v but expected no error\n",
err)
}
if calledInnerFunction == false {
t.Errorf("Inner function did not get called\n")
}
}
在前面的代码中,我们严格测试我们的函数是否为热狗和闭包创建了正确的值。在这种情况下,正确的闭包函数意味着返回的函数调用了传递给它的支付函数。注意我们如何可以模拟原始行为并创建一个bool来确保函数被调用。再次证明,这是在 Go 中拥有一等函数的力量。
摘要
在本章中,我们探讨了纯函数式编程。首先,我们探讨了编程语言为何被称为纯函数式,而不是不纯函数式,以及函数式编程的含义。接下来,我们更详细地探讨了纯代码如何通过消除副作用来提高可测试性。我们还了解到,纯代码让读者对所阅读的代码更有信心,因为函数更可预测,不会改变系统的状态。我们还讨论了何时不应使用纯函数,例如处理应生成随机行为的游戏函数或处理 I/O 的函数。
虽然我们只是简要地提到了它,但我们已经看到了通过不改变结构体的值,不可变性如何在编写纯函数中扮演核心角色。在下一章中,我们将深入探讨不可变性,它如何(或不如何)影响性能,以及我们如何利用它与纯函数结合来编写更易于维护的代码。
第五章:不可变性
在本章中,我们将探讨不可变性。我们将讨论什么是不可变性,以及 Go 语言如何帮助在结构体级别上保持不可变性。为了理解这是如何工作的,我们将查看 Go 如何处理对象的指针和引用,性能影响是什么,以及如何在指针-引用权衡之间做出决定。我们还将深入研究垃圾回收、单元测试和纯函数式编程的影响。
这些是我们将在本章中涵盖的主要主题:
-
什么是不可变性?
-
如何编写不可变代码
-
Go 中的指针和引用是如何工作的?
-
分析可变和不可变代码的性能
-
使用不可变代码进行并发和测试的示例
技术要求
对于本章,你可以使用 Go 1.18 或更高版本的任何版本,因为我们将使用泛型来编写一些后续示例。你可以在 GitHub 上找到所有代码:github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter5。
什么是不可变性?
当我们在这章中谈论不可变性时,我们是在谈论那些状态随时间不变的结构体。换句话说,当结构体被创建时,这就是它在整个生命周期中将如何表示的。我们仍然可以创建新的结构体和删除旧的结构体。因此,系统级别的状态将通过创建新的结构体和删除旧的结构体而有效地改变。这有几个优点:
-
首先,因为我们的结构体不会改变,我们可以安全地将数据传递给一个函数,并且知道无论发生什么,传递给函数的副本都将保持完整。
-
其次,不可变结构体使得编写正确、并发的代码更加容易。由于结构体的状态不能被任何调用它的函数改变,我们可以安全地进行并行执行,并使用相同的结构体作为输入数据调用多个函数。
-
第三,这使得我们的代码更容易推理。在每一步中,我们的结构体的状态都更加可预测。
不可变性不仅仅是我们在编写函数式代码时追求的东西。在许多面向对象的编程语言中,编写不可变代码是首选。这本书中提到它的原因是因为它与纯函数紧密相关,我们在上一章中看到了纯函数。如果你想编写真正的纯函数式代码,你需要不可变结构体。如果你在函数中更改结构体,这将被视为副作用。回想一下上一章,我们将尽可能消除副作用。话虽如此,本章中的几乎所有内容都可以应用到传统的面向对象语言中。
数据层上的不可变性
不可变性是一个强大的概念,我们可以将其应用于我们编写的程序。但它也表现为我们存储的数据的概念。如果我们正在编写处理极其敏感数据的软件,例如电子健康记录(EHR),我们可能希望数据是不可变的。也就是说,每当我们的 EHR 中的某些信息发生变化时,我们希望这种变化是完全可追踪的。这样,您的 EHR 的整个历史在任何时候都是可见的。
通过使医疗数据不可变,您可以始终查看文件过去的样子。例如,您可以查看患者所做的任何血液检查或之前记录的任何笔记。这也有助于作为可审计的日志——记录的每次更改都是可追踪的。想象一下,如果一位医生不小心删除了血液检查的结果。如果您的数据存储是不可变的,血液检查将不会在数据层被删除(而是被标记为“已删除”,以便应用层可以选择不将其显示给用户)。它还可以防止恶意行为——如果恶意行为者获得了对应用程序的访问权限并决定开始更改医生的笔记文本,这将显示为新笔记。原始笔记仍然存在,至少在数据层中。
想象一下,如果我们没有不可变性,每次新数据可用时实际信息都会更新会发生什么。这将远非理想。想象一下,每次血液检查都会覆盖过去的结果——这将模糊您医疗历史中的任何趋势,删除对医疗从业者有价值的信息。或者更糟,一旦删除了医疗图像,它就会一直被删除,患者将不得不进行相同的测试系列。这不仅对患者的体验不利,而且在一些国家,这还可能很昂贵。
这种在数据层实现的可追溯性和不可变性的想法,在某种程度上,最终导致了现在被称为区块链的东西。虽然我不知道有任何主流的基于区块链数据库的电子健康记录(EHR)系统,但至少全球有公司正在努力使这一现实成为可能。这样做是有意义的。
区块链数据库默认是不可变的。除了适合之前提到的电子健康记录(EHR)示例外,它目前还被用于货币交易。在区块链数据库中,整个区块的历史都是可见的。当对区块进行更新时,会向链中添加一个新的区块,其中包含更新的信息,而不是覆盖现有的区块。这就是加密货币可以模拟金融交易的方式。这里所解释的只是冰山一角,因为我省略了区块链如何保证不可变性和提供篡改机制的具体解释。
在数据层对不可变性的深入研究超出了本书的范围,但希望这个简短的概述为您进一步探索这些想法提供了一个良好的起点。
如何在 Go 中编写不可变代码
当我们谈论 Go 中的不可变性时,我们特别关注如何在代码中拥有不可变的结构体。在这个核心,我们必须看看 Go 如何使用指针以及按值传递和按引用传递之间的区别。这是让新 Go 程序员感到困惑的事情,而且有足够多的边缘情况,即使是经验丰富的 Go 程序员偶尔也会自己绊倒。
从本质上讲,这取决于我们在传递结构体到函数时是否使用指针。如果我们的代码完全不含指针,那么我们也会编写不可变代码。
为了演示这一点,让我们看一下以下代码片段。我们有一个结构体来定义一个人,以及一个用来更改这个人名字的函数:
type Person struct {
name string
age int
}
func main() {
p := Person{
name: "Benny",
age: 55,
}
setName(p, "Bjorn")
fmt.Println(p.name)
}
func setName(p Person, name string) {
p.name = name
}
这个函数的结果,可能出乎意料,是Benny。setName函数并没有改变Person对象的名字。最终,我们都习惯了这样一个想法:要在函数中更新结构体,我们需要使用指针:
func main() {
p := Person{
name: "Benny",
age: 55,
}
setName(&p, "Bjorn")
fmt.Println(p.name)
}
func setName(p *Person, name string) {
p.name = name
}
现在,当我们运行这段代码时,输出是Bjorn,正如我们所预期的。这两个例子之间的区别在于,在第一个例子中,我们使用的是按值传递,而在第二个例子中,我们使用的是按引用传递。
如果我们看看第一个函数中发生的事情,我们会看到我们的Person对象正在被复制,而这个副本随后被传递给setName函数。因此,我们对这个结构体进行的每一个操作都是在副本本身上进行的,而不是在真正的对象上。然而,在第二个例子中,通过使用指针,我们能够访问实际的Person对象,而不仅仅是副本。在底层,第二个例子传递了结构体的地址(指针)。Go 的语法为我们模糊了一些指针引用和取消引用的操作,这使得它看起来像是一个相当小的变化。
通常,我们希望保持代码的不可变性。因此,我们希望避免在代码中使用指针。那么,我们如何更新我们的结构体呢?setName函数为我们提供了有用的功能。回想一下,尽管我们不能改变我们使用的对象的状态,但我们仍然可以自由地创建和销毁它们。解决方案是创建一个新的对象,它具有我们原始对象的所有属性,并应用了一些更改。为了继续我们的上一个例子,让我们重构setName函数以实现所需的功能:
func main() {
p := Person{
name: "Benny",
age: 55,
}
p = setName(p, "Bjorn")
fmt.Println(p.name)
}
func setName(p Person, name string) Person {
p.name = name
return p
}
在前面的例子中,你可以看到我们需要在不破坏不可变性的前提下更新结构体的核心变化。我们通过让函数接受副本(按值传递)作为输入并返回一个应用了更改的新结构体来实现这一点。在我们的调用函数中,我们现在可以选择是否保留两个对象或者丢弃原始对象,只保留新返回的对象。
这种语法对 Go 程序员来说应该相当熟悉,因为这与我们处理切片时的做法类似。例如,如果我们想向切片中添加一个值,我们会编写如下代码:
func main() {
names := []string{"Miranda", "Paula"}
names = append(names, "Yvonne")
fmt.Printf("%v\n", names)
}
这段代码会返回[Miranda Paula Yvonne]。当与不可变结构体一起工作时,我们的语法将看起来类似于这样。
为集合数据类型编写不可变代码
之前,我们看到了如何轻松地将函数从不可变转换为可变。我们只需用一个接受值并返回新值的函数替换掉接受指针的函数。当与集合Map数据类型一起工作时,情况会有所不同,以下示例中可以看出:
func main() {
m := map[string]int{}
addValue(m, "red", 10)
fmt.Printf("%v\n", m)
}
func addValue(m map[string]int, colour string, value int) {
m[colour] = value
}
这段代码的输出是[red 10]。尽管我们在addValue函数中没有使用指针,但该函数并不是在映射的副本上操作,而是在映射本身上操作。在 Go 中,映射总是以引用传递的方式操作。
如果我们尝试使用类似设置与切片(另一种集合数据类型)一起使用,它将按预期工作:
func main() {
names := []string{"Miranda"}
addValue(names, "Yvonne")
fmt.Printf("%v\n", names)
}
func addValue(s []string, name string) {
s = append(s, name)
}
这里的输出是Miranda。使用指针,我们再次可以使函数可变:
func main() {
names := []string{"Miranda"}
addValue(&names, "Yvonne")
fmt.Printf("%v\n", names)
}
func addValue(s *[]string, name string) {
*s = append(*s, name)
}
如果我们运行前面的代码,输出将是[Miranda Yvonne]。这在 Go 中很常见,经验丰富的程序员已经习惯了这一点,但这也可能让初学者感到困惑。
可变和不可变代码的性能测量
关于不可变代码的常见抱怨是,它的性能不如其可变版本。即使没有深入研究 Go 运行时的性能特征,这似乎是一个合理的说法。毕竟,在不可变版本中,每次函数调用都会生成一个对象的新副本。然而,在实践中,这些性能差异通常是可以忽略不计的。
尽管如此,即使会有显著的性能影响,你也需要质疑这些性能牺牲在你的环境中是否合理。作为交换,你得到了线程安全、易于维护、理解和测试的代码。作为工程师,我们常常非常渴望寻求最优化解决方案,尽可能少地使用内存和 CPU 时间。然而,对于许多实际应用来说,性能影响足够小,以至于这并不是用户会注意到的。而且对于维护你代码的其他工程师来说,他们通常更希望得到的是更易于理解的东西,而不是更快的东西。
与其他语言不同,Go 由于垃圾回收而可能会遭受一定的性能损失。如果你想从系统中榨取每一分性能,也许 Go 也不是完成这项工作的正确工具。现在我们已经排除了这一点,我们应该看看实际的基准测试,并深入探讨不可变代码的性能影响。
基准测试函数
虽然我们可以从抽象的角度推理函数的性能,例如空间-时间复杂度,但要真正了解性能,我们应该进行性能测试。毕竟,可变和不可变函数的运行时复杂度可以近似相同。关心指针的实现过于底层,不值得考虑。因此,我们将设置一个测试来确定哪种性能更差。作为提醒,这里的假设是,使用指针的可变代码将比我们的不可变版本更快。这种假设的潜在原因是,复制结构体比将指针传递给函数更耗费资源。
让我们设置两个类似于构造函数的函数,一个用于不可变版本,另一个用于可变版本。第一个函数创建一个 Person 对象,然后将该函数传递给一个设置人名的函数,然后传递给另一个设置人年龄的函数:
func immutableCreatePerson() Person {
p := Person{}
p = immutableSetName(p, "Sean")
p = immutableSetAge(p, 29)
return p
}
func immutableSetName(p Person, name string) Person {
p.name = name
return p
}
func immutableSetAge(p Person, age int) Person {
p.age = age
return p
}
在这里,我们可以看到 Person 对象首先被复制到 immutableSetName,然后又被复制到 immutableSetAge。最后,我们将这个 Person 返回给调用函数。
现在,让我们也设置一个可变版本的代码。在可变版本中,我们创建一个 Person 对象。但是,当将其传递给设置姓名和年龄的可变函数时,我们将传递一个指向我们对象的指针:
func mutableCreatePerson() *Person {
p := &Person{}
mutableSetName(p, "Tom")
mutableSetAge(p, 31)
return p
}
func mutableSetName(p *Person, name string) {
p.name = name
}
func mutableSetAge(p *Person, age int) {
p.age = age
}
在这里,我们可以看到指针被用来在函数之间避免复制 Person 对象。在这些示例中,有一点需要指出的是,这两个函数在 Go 中是相同的:
func mutableSetName(p *Person, name string)
而当函数绑定到一个对象上时:
func (p *Person) mutableSetName(name string)
在我们调用这些函数的方式以及函数名冲突的后果方面,有一些实际的区别。尽管如此,对于可变和不可变示例,它们的性能特征是相同的。
在处理完这些之后,让我们编写我们的基准测试。Go 有内置的基准测试支持,就像它有内置的测试支持一样。这使得编写基准测试变得相当容易,因为要基准测试的整个代码可以放在一页上:
package pkg
import "testing"
func BenchmarkImmutablePerson(b *testing.B) {
for n := 0; n < b.N; n++ {
immutableCreatePerson()
}
}
func BenchmarkMutablePerson(b *testing.B) {
for n := 0; n < b.N; n++ {
mutableCreatePerson()
}
}
使用这个内置的基准测试支持,我们可以使用以下命令运行我们的基准测试:
go test -bench=.
在我的 Amazon Web Service (AWS) EC2 实例上,经过几次运行的平均结果如下:
BenchmarkImmutablePerson 0.3758 ns/op
BenchmarkMutablePerson 0.3775 ns/op
这些 ns/op 属性的具体值在您的机器上可能会有所不同,所以不要过于关注具体值。这里应该令人惊讶的是,我们的不可变代码比我们的可变代码表现更好。
要理解发生了什么,我们需要看看垃圾回收以及栈与堆分配。
理解栈、堆和垃圾回收
垃圾回收是一个足够复杂的话题,可能值得整章讨论。在这里,我们将采取一些捷径,并深入了解这一过程,但会简化一些步骤。Go 本身是开源的,并且有良好的文档。
通过垃圾回收回收内存
Go 是一种垃圾回收语言,这意味着内存管理由 Go 运行时负责。这减少了程序员方面的努力,因为它消除了手动管理内存的需要。这可以消除或减少代码中某些类型错误的可能性,例如内存泄漏。
通过自动垃圾回收,我们,程序员,不需要考虑管理我们应用程序的内存。内存会为我们保留,并在之后无我们的干预下归还给系统。为了使这一过程工作,Go 运行时需要在幕后做一些工作。本质上,运行时会触发一个“垃圾回收”过程来释放内存。它通过暂时冻结我们的应用程序,检查哪些对象不再需要,并将它们从我们应用程序的工作内存中移除来实现。有不同方法来确定哪些对象不再需要,以及在我们程序的生命周期中删除它们的机制。通常,垃圾回收器会尝试确定是否还有对数据的引用。如果有对数据的引用,它仍然可以通过你的程序访问,因此不应该被删除。
要了解这个过程如何影响性能,将垃圾回收视为一个“停止世界”的过程是有帮助的。这意味着它完全停止所有执行,识别垃圾,并将其移除以释放内存。在实践中,Go 使用多个线程来识别垃圾对象。这种方法被称为“并发标记-清除垃圾回收器”。尽管这是并发的,但仍然存在性能开销。当人们在决定为他们的应用程序使用哪种语言时,垃圾回收的开销在谈话中经常意外地出现。这在与 Go、C/C++ 或 Rust 之间的选择中尤为明显。
虽然在最新的 Go 版本中垃圾回收的性能影响已经减少,但影响不能完全消除。有方法可以调整 Go 中垃圾回收器的行为,但通常这不是一个推荐的方法。通常,算法的低效实现会超过垃圾回收带来的负面影响。
栈和堆
我们接下来要讨论的主题是栈和堆。在运行时,有两种类型的内存可用,即栈和堆。栈是一种后进先出(LIFO)的数据结构。这意味着当数据从栈中移除时,最后插入的项目将被删除。Go 使用栈来存储函数调用链中的数据,这包括局部变量、函数的输入参数等等。
当一个函数被调用时,该函数的数据将被推送到栈顶。当函数执行完毕后,这些数据将从栈中移除。因此,当你的应用程序中调用函数时,栈会持续地增长和缩小。栈可用的空间是有限的;超出这个限制会导致一个众所周知的问题,即栈溢出。栈上的元素可以被认为是具有有限生命周期的,因为它们在函数结束时迅速从内存中移除。
另一方面,堆是应用程序生命周期内的共享内存。存储在这里的数据不仅限于函数的生命周期。这意味着这些数据可以从应用程序的多个地方被引用(指向)。为了避免堆不断扩展,堆内存由垃圾回收器管理。垃圾回收器会扫描堆中的内存,以确定是否还需要这些数据。如果数据不再需要,它将被删除。
在栈和堆的实现中,从堆中回收内存比从栈中回收内存更便宜。栈不需要垃圾回收器“停止世界”来扫描要删除的对象。因此,如果我们尽可能在栈上而不是在堆上分配内存,我们的程序将运行得更快。但这并不总是可能的,因为有些数据我们希望在单个函数之外的环境中保持活跃。此外,堆分配通常比栈分配慢,因为堆分配所需的内存需要从内存池中回收——这是 Go 从操作系统申请的一组内存。这是一个可能很慢的操作,因为程序需要等待内存变得可用。
要了解这如何影响之前我们查看的不变和可变示例的性能,我们需要了解 Go 如何选择存储变量的位置。在理论上,这听起来很简单——如果数据只需要在单个函数中使用,它就是一个栈变量;否则,我们必须将其存储在堆上。然而,在实践中,还有更多的事情需要考虑。
首先,编译器将尝试证明一个变量仅属于单个函数。编译器通过一个称为 逃逸分析 的过程来完成这项工作,在这个过程中,它会寻找那些逃离单个函数上下文的变量。如果一个变量不属于单个函数,它就会将其存储在堆上。Go 运行时还会查看数据的大小。将大型数据存储在堆上而不是栈上更有意义,因为栈的空间通常更有限。栈空间是一个真实的问题,我们将在下一章讨论递归时对其进行更深入的探讨。
这如何与我们的指针可变性的讨论联系起来?在示例代码中,我们用它来基准测试两个函数,不可变代码可以在栈上分配所有内存。可变示例不太幸运,因为它使用了指针,这会导致数据分配在堆上,这是逃离单个函数的上下文。因此,我们在性能上看到的影响是由垃圾收集器回收内存所造成的。
重要的是要注意,垃圾收集器的具体实现,甚至逃逸分析的算法,随着时间的推移可能会改变。要了解 Go 最新版本中的垃圾收集器是如何工作的,最好阅读该版本的文档。
观察逃逸分析的实际操作
让我们通过探索 Go 中的逃逸分析行为来展示我们的推理是有道理的。首先,我们将稍微修改我们的代码,通过添加一个 pragma 来防止编译器内联我们的函数。在 Go 中,pragma 是一种特殊的注释,它向编译器提供一些指令。我们将为每个函数添加这个 pragma,这样它们都会包含这个注释,如下所示:
//go:noinline
func immutableCreatePerson() Person {
p := Person{}
p = immutableSetName(p, "Sean")
p = immutableSetAge(p, 29)
return p
}
这意味着函数不会被编译器删除。函数内联是编译器的一种优化过程,它在幕后发生,以加快我们程序的执行速度。再次强调,这值得单独一章来讨论,但超出了本书的范围。
一旦我们为每个函数添加了 pragma,我们就可以使用以下命令构建我们的应用程序:
go build -gcflags '-m -l'
这告诉 Go 编译器向我们解释逃逸分析决策在哪里被做出,以及这些决策的结果是什么。当我们查看输出时,我们得到以下内容:
# github.com/PacktPublishing/Chapter5/Benchmark/pkg
./person.go:17:23: leaking param: p to result ~r0 level=0
./person.go:17:33: leaking param: name to result ~r0
level=0
./person.go:23:22: leaking param: p to result ~r0 level=0
./person.go:37:21: p does not escape
./person.go:37:32: leaking param: name
./person.go:42:20: p does not escape
./person.go:30:7: &Person{} escapes to heap
这表明,在第 30 行,我们的 Person 正逃逸到堆上。当一个对象逃逸到堆上时,最终必须由垃圾收集器来处理,以便我们的内存空间可以被回收。
在幕后发生了很多事情,我们简化了 Go 中垃圾收集工作的一些方式。但总体来说,这应该作为一个例子,说明指针和可变代码比没有指针的不可变代码更快这一假设是不成立的。
何时编写可变函数
到目前为止,本章主要讨论了为什么我们更喜欢编写不可变函数。但有些情况下,无论是编写可变函数都有意义。唯一真正的理由是性能。正如我们之前看到的,性能影响通常可以忽略,但并非总是如此。如果你使用包含大量数据的结构体,将它们复制到每个函数中可能会对性能产生负面影响,足以削弱你的应用程序。唯一真正知道这是否是这种情况的方法是向你的应用程序添加性能指标。即便如此,也必须在更高效的代码和更易于维护的代码之间做出权衡。通常,试图从你的应用程序中挤出更多性能会阻碍长期的可维护性。
使用指针编写可变代码的另一个可能原因是需要在你应用程序中具有唯一性的资源。如果你在代码中实现传统的面向对象模式,你可能已经实现了单例模式。如果你想有一个真正的单例,你应该使用指针而不是复制单例。否则,你将在不同的函数中有多个单例副本,每个可能都有不同的状态。在你的代码中是否使用单例是一个不同书籍中的讨论话题。
函子和单子是什么?
在上一章中,我们讨论了函数纯度的概念。一个函数不应该产生任何副作用,并且应该是幂等的。在本章中,我们看到了如何使结构体不可变,以及这与函数纯度的联系。正如之前提到的,即使在纯函数式语言中,尽可能消除副作用,你仍然有期望的副作用行为。例如,从用户那里获取输入,或将数据写入数据库,都是增加程序价值的副作用。
在本节中,我们将尝试理解纯函数式语言如何实现这一点。我们还将查看 Go 语言中的实现,以实现相同的结果,基于我们对不可变结构和纯函数的知识。
在介绍本节之前,通常说已经有太多的单子解释,而且它们都是错误的或者在某些方面有所欠缺。关于函数式编程有很多书籍,或者博客文章和视频,试图提供好的解释。新解释频繁提出的事实应该让你对这一主题的复杂性有所了解。我没有提供“最终需要的单子解释”的宏伟目标。相反,我将尝试将其简化为核心思想,并尽可能接近实际应用。因此,我们将避免深入到范畴论的理论层面。以下是一个希望是“足够好”的解释,而不是一个完美整体性的解释。
什么是函子?
在我们演示什么是单子之前,我们需要了解什么是函子。简单来说,函子是一个可以对数据结构中包含的每个元素应用操作的函数。在 Haskell 中,这个函数的实现称为 fmap。在 Go 中,这个函数可能看起来像这样:
func fmapA, B any B, sliceA []A) []B
在前面的类型签名中,我们使用了切片。切片是一种包含其他数据元素的数据类型。fmap 实现不必在切片上操作——任何包含数据元素的数据结构都可以,例如指针(它们可以可选地包含一个数据元素)、函数本身、树,或者正如我们将在下一页看到的那样,一个单子。
如果我们要在 Go 中编写一个操作切片的 fmap 实现如之前所示的功能签名,我们只需为 sliceA 中的每个元素调用提供的 mapFunc。这个结果将存储在新的切片 sliceB 中:
func fmapA, B any B, sliceA []A) []B {
sliceB := make([]B, len(sliceA))
for i, a := range sliceA {
sliceB[i] = mapFunc(a)
}
return sliceB
}
注意前面示例中泛型的使用,我们可以用它来在两个 any 类型之间进行映射。但输入是 A,输出是 B。因此,映射函数改变了我们数据的数据类型。
让我们看看如何使用这个函数。想象一下,我们有一个整数切片,我们想将其转换成字符串切片。我们可以使用我们的 fmap 函数来完成这个任务。我们只需要向 fmap 提供一个函数,该函数接受一个整数并返回一个字符串:
import (
"fmt"
"strconv"
)
func main() {
integers := []int{1, 2, 3}
strings := fmap(strconv.Itoa, integers)
fmt.Printf("%T transformed to %T - %v\n", integers,
strings, strings)
}
当我们运行前面的函数时,我们得到以下输出(回想一下,%T 打印变量的类型):
[]int transformed to []string - [1 2 3]
这告诉我们,我们的 int、slice 被转换成了字符串切片,并且不出所料,包含的值是 [1, 2, 3]。
这基本上就是一个函子的定义。它是一个将给定数据结构中的所有数据转换为不同类型数据的函数。fmap 实现是一个纯的、高阶函数。
从函子到单子
下一步是从函子到单子的转换。那么,单子究竟是什么呢?当我们试图对单子进行某种理论描述时,我们可能会得到以下内容。
单子是一种软件设计模式。它是一种可以将类似类型的函数组合起来,并将非单子类型的结果包装成一个新的单子类型,提供额外函数的数据类型。为了使一个类型成为单子,它需要定义两个函数:
-
一个将 T 类型的值包装到 Monad[T] 中的函数
-
一个组合 Monad[T] 类型函数的函数
我们将通过一个实际示例来演示单子。一个流行的单子是 Maybe 单子,在一些编程语言中也称为 Optional。Maybe 单子是一种可能包含具体值的类型,但也可能为空。
要在 Go 中模拟 Maybe 单子,我们将使用一个定义我们结构体操作的接口。接下来,我们还将创建两个实现,一个用于值存在的情况,另一个用于值不存在的情况:
type Maybe[A any] interface {
Get() (A)
GetOrElse(def A) A
}
在前面的接口实现中,我们定义了两个函数:Get和GetOrElse。可以定义更多;具体的函数并不那么重要。重要的是,我们有一种方式来模拟可能存在或可能不存在的值。
注意,我们在这里没有使用指针,我们只使用了具体类型。Maybe单子通常被引入以避免使用指针。通过避免使用指针,我们可以消除在运行时调用空指针时发生的一类错误。在 Go 中,null或nil从类型分类的角度来看也没有真正的意义。nil指针属于每个类型,这意味着其中没有真正有用的信息,我们希望我们的类型系统尽可能声明式。 (Go 确实有“类型 nil”,函数可以在其上安全地调用。然而,在使用时仍需谨慎。这并不是编程语言的常见行为,甚至可能会让经验丰富的 Go 程序员感到困惑。)
乔治·霍尔,他首次引入了空指针概念,称这是他的“十亿美元的错误”。
我们将用于模拟值的存在和不存在的情况的两个实现分别是Just和Nothing。这些名称是从 Haskell 借用的;你会在不同的编程语言中找到这些值的不同的名称。Just表示存在一个具体值,而Nothing表示不存在。我们将首先实现值存在的情况,使用JustMaybe类型:
type JustMaybe[A any] struct {
value A
}
func (j JustMaybe[A]) Get() (A) {
return j.value
}
func (j JustMaybe[A]) GetOrElse(def A) A {
return j.value
}
上述代码遵循Maybe接口。因此,我们可以将JustMaybe用作Maybe的一个实例。为了实现值的缺失,我们将实现类似的NothingMaybe:
type NothingMaybe[A any] struct{}
func Nothing[A any]() Maybe[A] {
return NothingMaybe[A]{}
}
func (n NothingMaybe[A]) Get() (A) {
return *new(A)
}
func (n NothingMaybe[A]) GetOrElse(def A) A {
return def
}
每个函数的实现相当直接。也许最令人惊讶的是Get函数中对于NothingMonad的return语句,我们写的是:
return *new(A)
这个语句返回A的新实例,但A在编译时是一个未知值。通过使用new,我们可以实例化它,但它将返回一个指针值,我们将解引用它以返回一个具体值。
接下来,让我们也创建这两个实现的构造函数,这些是可以将给定类型的值包装到单子表示中的函数。回想一下,这是我们单子模式的要求:
func JustA any JustMaybe[A] {
return JustMaybe[A]{value: a}
}
func Nothing[A any]() Maybe[A] {
return NothingMaybe[A]{}
}
这两种实现将使我们能够实现给定值的“存在”和“不存在”。例如,我们现在可以在一个函数中使用这些实现:
func getFromMap(m map[string]int, key string) Maybe[int] {
if value, ok := m[key]; ok {
return Justint
} else {
return Nothing[int]()
}
}
在前面的函数中,我们通过查找给定的键从映射中获取一个值。如果存在值,我们返回我们的单子JustMaybe实现;否则,我们返回NothingMaybe实现。
可以编写一些便利函数,例如fromNullable(*value),它将根据传递给函数的值是否存在返回JustMaybe或NothingMaybe。
记住,我们的单子类型是一个包含底层元素的数据结构。因此,我们也可以在这个类型上实现fmap函数。在这个实现中,我们将一个类型为A的Maybe转换为类型为B的Maybe。我们需要提供一个函数来从底层类型A映射到底层类型B以完成此操作:
func fmapA, B any B) Maybe[B]
{
switch m.(type) {
case JustMaybe[A]:
j := m.(JustMaybe[A])
return JustMaybe[B]{
value: mapFunc(j.value),
}
case NothingMaybe[A]:
return NothingMaybe[B]{}
default:
panic("unknown type")
}
}
在前面的代码中,我们使用类型切换来确定我们的Maybe单子的类型,以确定它是否代表JustMaybe或NothingMaybe实现。如果类型匹配JustMaybe,我们将从类型A映射到类型B的底层值,并返回这个新封装的单子。
这是一个单子的不完整定义,但是一个实际实现的实例。这个概念可以进一步扩展,但 Go 没有提供方便的方式来进一步探索,所以它不太可能在现实世界中经常使用。
摘要
在本章中,我们简要回顾了 Go 中的不可变性。我们通过值传递或引用传递来回顾了 Go 中不可变性的工作方式。我们了解到指针并不能保证你的代码比避免它们时更高效。我们还讨论了不可变代码的一些好处,例如提高代码库的可读性和可理解性。我们还简要介绍了这使得并发更容易正确实现,因为状态在函数之间不会被修改。
最后,我们通过查看单子以及使用Maybe单子的实际实现来结束了对上一章开始讨论的纯函数的讨论。
在下一章中,我们将探讨编写函数式代码时必须拥有的函数。
第二部分:使用函数式编程技术
在我们建立了函数式编程的基本思想并看到它们如何与面向对象范式相关联之后,我们将继续这部分内容。在这里,我们将探讨如何在类级别上利用函数式编程来组合更大的程序。我们将学习如何迭代地解决问题与递归地解决问题,函数类型的三种重要类别,以及如何将函数链接起来以编写更易读的代码。
这部分包含以下章节:
-
第六章**,函数的三个常见类别
-
第七章**,递归
-
第八章**,使用流畅编程进行可读的函数组合
第六章:函数的三个常见类别
在前面的章节中,我们已经探讨了函数式编程的一些核心组件。我们讨论了如何编写既符合函数式编程又符合纯函数式编程的函数。
在本章中,我们将探讨一些利用这些概念的实际函数实现。我们将涵盖以下类别和主题:
-
我们将要探讨的第一个类别是基于谓词的函数
-
然后,我们将查看数据转换函数,这些函数保持我们数据结构(更多内容将在后面介绍)
-
最后,我们将探讨函数,这些函数将数据转换并减少信息到一个单一值
这并不是一个详尽的列表,但有了这三个类别,我们可以构建我们日常应用的大部分内容。
技术要求
对于本章,你可以使用任何 Go 1.18 或更高版本的 Go,因为我们将在一些后续示例中使用泛型。你可以在 GitHub 上找到所有代码,链接为 github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter6。
基于谓词的函数
我们将要探索的第一种函数类型是基于谓词的函数。函数体内的 if 语句。一个常见的用例是将一组数据过滤成符合特定条件的子集 - 例如,给定一个人员列表,返回所有年龄大于 18 岁的人。
首先,我们可以引入一个函数类型别名,它定义了谓词的类型签名:
type Predicate[A any] func(A) bool
这个类型别名告诉我们,该函数接受一个类型为 A 的输入,它可以代表程序中的 any 类型,但需要返回一个 bool 值。这个类型使用了泛型,它是在 Go 1.18 中引入的。我们现在可以在任何期望谓词的地方使用这个类型。第一个使用谓词工作的函数是简单的 Filter 函数。
实现一个 Filter 函数
Filter 函数是函数式程序员工具箱中的基本工具。让我们假设我们没有可用的高阶函数,并且我们想要编写一个类似 Filter 的函数。为此,让我们假设我们有一组数字,并且我们想要过滤出所有大于 10 的数字。我们可以编写如下内容:
func Filter(numbers []int) []int {
out := []int{}
for _, num := range numbers {
if num > 10 {
out = append(out, num)
}
}
return out
}
这已经足够好了,但它不够灵活。在这种情况下,这个函数将始终只过滤出大于 10 的数字。我们可以通过调整函数的输入参数中的阈值值来使其更加灵活。通过微小的改动,我们得到以下函数:
func Filter(numbers []int, threshold int) []int {
out := []int{}
for _, num := range numbers {
if num > threshold {
out = append(out, num)
}
}
return out
}
这使我们有了更灵活的Filter函数。然而,正如我们所知,需求经常变化,用户几乎无限期地需要现有系统的新功能。我们函数的下一个需求是可选地过滤出“大于”或在某些情况下“小于”。思考一段时间后,你可能会意识到这可以作为一个函数实现(函数体在代码片段中被省略,因为它是一个微不足道的更改):
func FilterLargerThan(numbers []int, threshold int) []int {
..
}
func FilterSmallerThan(numbers []int, threshold int) []int {
..
}
当然,这会起作用——但工作永远不会停止。接下来,你必须实现一个可以过滤出大于给定值但小于另一个值的数字的函数。然后,我们的用户对奇数特别感兴趣,因此需要有一个可以找到所有奇数的过滤器。后来,用户要求你计算某个值出现的确切次数,因此你还需要一个过滤器来确保你的数字列表中恰好有一个特定的值。你明白我的意思了;我们可以创建一系列适合所有这些用例的函数,但这种方法听起来并不是最佳选择。
拥有一个支持高阶函数的语言的好处之一是我们可以减少重复的实现并抽象我们的算法。所有上述用例都适合在函数式编程语言中经常被称为Filter的函数。Filter函数的实现相当直接。它支持的基本操作是遍历一个容器,如切片,并对容器中包含的每个数据元素应用谓词函数。如果谓词函数返回true,我们将此数据元素追加到我们的输出中。如果不匹配,我们简单地丢弃不匹配的元素。
由于我们希望遵循实现这些函数的最佳实践,因此这些函数将是纯函数且不可变的。在我们的过滤器函数内部,原始切片永远不会被修改,其中的元素也不会被修改:
func FilterA any []A {
output := []A{}
for _, element := range input {
if pred(element) {
output = append(output, element)
}
}
return output
}
这个Filter实现是一个相当典型的实现,你会在许多函数式(和多范式)编程语言中找到。通过这种方式使用高阶函数,我们实际上可以使算法的一部分可配置。换句话说,我们抽象了我们的算法。使用Filter函数,if语句的实际谓词部分是可定制的。
注意,我们使用的是泛型。Filter不关心它正在处理的数据类型。任何可以存储在切片中的东西都可以传递给Filter函数。让我们通过创建我们之前讨论的一些函数来查看我们如何在实践中使用它。我们将从实现LargerThan和SmallerThan过滤器开始:
func main() {
input := []int{1, 1, 3, 5, 8, 13, 21, 34, 55}
larger20 :=
Filter(input, func(i int) bool { return i > 20 })
smaller20 :=
Filter(input, func(i int) bool { return i < 20 })
fmt.Printf("%v\n%v\n", larger20, smaller20)
}
我们传递给Filter作为输入的函数有点冗长,因为在编写本文时,Go 还没有创建匿名函数的语法糖。注意,我们不需要为这个实现重复Filter函数的主体。
实现其他过滤器,如大于 X 但小于 Y或过滤偶数,同样容易实现。记住,我们每次只需要传递if语句的逻辑,列表的迭代由Filter函数本身处理:
func main() {
input := []int{1, 1, 3, 5, 8, 13, 21, 34, 55}
larger10smaller20 := Filter(input, func(i int) bool {
return i > 10 && i < 20
})
evenNumbers := Filter(input, func(i int) bool {
return i%2 == 0
})
fmt.Printf("%v\n%v\n", larger10smaller20, evenNumbers)
}
通过使用泛型实现,我们的Filter函数可以与任何数据类型一起工作。让我们看看这个函数如何与我们在前面章节中使用过的Dog结构体一起工作。
记住,我们的Dog结构体有三个字段:Name、Breed和Gender:
type Dog struct {
Name Name
Breed Breed
Gender Gender
}
此代码片段省略了Breed和Gender的const声明以及类型别名。这些与第三章中的相同,完整的实现可以在 GitHub 上找到:github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter3。
由于我们在Filter函数的实现中使用了泛型,这将适用于任何数据类型,包括自定义结构体。因此,我们可以直接使用该函数,无需任何修改。让我们实现一个对所有哈瓦那犬品种的狗进行过滤的过滤器:
func main() {
dogs := []Dog{
Dog{"Bucky", Havanese, Male},
Dog{"Tipsy", Poodle, Female},
}
result := Filter(dogs, func(d Dog) bool {
return d.Breed == Havanese
})
fmt.Printf("%v\n", result)
}
这就是全部内容。接下来,让我们看看一些使用谓词的其他函数。
任何或所有
确保某些元素或所有元素符合特定条件是很常见的。将这种需求抽象成高阶函数的使用场景与Filter函数相同。如果我们不进行抽象,就必须为每个使用场景实现单独的All和Any函数。虽然这些在多范式语言或面向对象语言中并不常见,但在纯函数式语言中仍然存在,并且非常有用。
寻找匹配项
首先要查看的函数是Any函数。有时,你可能想知道某个值是否存在于列表中,而不关心它出现的次数或实际上使用这些值。如果是这种情况,Any函数正是你所需要的。
没有使用Any函数,同样的结果可以通过Filter函数以某种临时方式实现。你可能会写出如下内容:
func main() {
input := []int{1, 1, 3, 5, 8, 13, 21, 34, 55}
filtered := Filter(input, func(i int) bool { return i ==
55 })
contains55 := len(filtered) > 0
fmt.Printf("%v\n", contains55)
}
请注意,我将其分成多行是为了清晰起见,但在像 Python 和 Haskell 这样的非冗长语言中,这种过滤器仍然是一个很好的单行代码。在 Go 中,如果你决定这样做,我会对行长度稍微谨慎一些。
这种实现有一个主要缺陷。如果你有一个包含 1000 万个元素的非常大的列表怎么办?Filter函数将遍历列表中的每个元素。它始终以线性时间,O(n)运行。我们的Any函数可以做得更好,尽管我们仍然会以O(n) – 最坏情况时间运行。然而,在实践中,它可能更高效。
注意
如果我们知道我们只需要查找整数,那么比我们的 Any 实现更好的算法。然而,我们希望为任何类型的数据编写它,所以那些其他算法对于字符串或自定义结构体等数据类型将失败。
尽管理论上的最坏情况复杂度为线性时间,但获取一些性能的最简单方法是通过遍历切片直到第一个元素匹配我们的搜索。如果找到匹配项,我们返回 true。否则,我们在函数结束时返回 false:
func AnyA any bool {
for _, element := range input {
if pred(element) {
return true
}
}
return false
}
寻找所有匹配项
All 匹配的实现与 Any 匹配类似,具有相同的抽象 if 语句实现的优点。All 的实现具有与 Any 实现类似的实际优点。一旦一个元素返回 false。否则,我们在函数结束时返回 true:
func AllA any bool {
for _, element := range input {
if !pred(element) {
return false
}
}
return true
}
实现 DropWhile 和 TakeWhile
下面的两个实现仍然是基于谓词的,但它们不是返回单个 true 或 false 作为输出,而是用来操作切片。从这个意义上说,它们更接近原始的 Filter 实现,但不同之处在于它们截断列表的开始或尾部。
TakeWhile 实现
TakeWhile 是一个函数,只要满足条件,就会从输入切片中取元素。一旦条件失败,就会返回包含列表开始直到失败谓词的结果:
func TakeWhileA any []A {
out := []A{}
for _, element := range input {
if pred(element) {
out = append(out, element)
} else {
return out
}
}
return out
}
在这个函数中,这正是所发生的事情。只要我们的谓词对每个后续元素都成立,这个元素就会被存储在我们的输出值中。一旦谓词失败一次,输出就会被返回。让我们用一个简单的包含连续数字的切片来演示这一点。我们的谓词将寻找奇数。因此,只要数字是奇数,它们就会被追加到输出切片中,但一旦我们遇到偶数,我们迄今为止收集到的内容就会被返回:
func main() {
ints := []int{1, 1, 2, 3, 5, 8, 13}
result := TakeWhile(ints, func(i int) bool {
return i%2 != 0
})
fmt.Printf("%v\n", result)
}
在这个例子中,输出结果是 [1 1]。注意这与普通的 Filter 函数不同——如果将这个相同的谓词给 Filter 函数,我们的输出将是 [1 1 3 5 13]。
实现 DropWhile
实现 DropWhile 是 TakeWhile 的对应函数。这个函数会在满足条件的情况下丢弃元素。因此,从第一个失败的谓词测试开始直到列表的末尾返回元素:
func DropWhileA any []A {
out := []A{}
drop := true
for _, element := range input {
if !pred(element) {
drop = false
}
if !drop {
out = append(out, element)
}
}
return out
}
让我们用与我们的 TakeWhile 函数相同的输入数据来测试这个实现:
func main() {
ints := []int{1, 1, 2, 3, 5, 8, 13}
result := DropWhile(ints, func(i int) bool {
return i%2 != 0
})
fmt.Printf("%v\n", result)
}
这个函数的输出结果是 [2 3 5 8 13]。因此,被丢弃的唯一元素是 [1 1]。如果你将 TakeWhile 和 DropWhile 的输出结合起来,给定相同的谓词,你将重新创建输入切片。
Map/转换函数
我们将要探讨的下一类函数是Map函数。这些函数将转换函数应用于容器中的每个元素,改变元素甚至可能改变数据类型。这是函数式程序员工具箱中最强大的函数之一,因为它允许你根据给定的规则转换你的数据。
我们将探讨两种主要的实现。第一种实现是简单的Map函数,其中对每个元素执行操作,但转换前后数据类型保持不变——例如,乘以切片中的每个元素。这将改变值的内 容,但不会改变值的类型。Map的另一种实现是数据类型也可以改变。这将被实现为FMap,这是我们上一章在探讨 Monads 时引入的。
保持数据类型不变的转换
我们将要探讨的第一个转换函数是数据类型保持不变的那种。每当程序员遇到这个函数时,他们可以确信函数调用后的数据类型与传递给函数的数据类型相同。换句话说,如果函数被用于一个包含Dog类型元素的列表,那么这个函数的输出仍然是一个包含Dog元素的列表。不过,这些结构体(structs)字段的实际内容可能会有所不同(例如,名称属性可能会被更新)。
就像Filter实现一样,这些将纯函数式地实现。调用Map函数永远不应该对我们提供给函数作为输入的对象进行就地更改。
总体来说,实现Map函数很简单。我们将遍历我们的值切片并对每个值调用转换函数。本质上,我们使用Map函数所做的就是抽象实际的转换逻辑。核心算法是我们对切片的迭代,而不是具体的转换。这意味着我们再次构建了一个高阶函数:
type MapFunc[A any] func(A) A
func MapA any []A {
output := make([]A, len(input))
for i, element := range input {
output[i] = m(element)
}
return output
}
在这个例子中,我们的泛型类型签名告诉我们,在调用MapFunc时数据类型是保留的:
type MapFunc[A any] func(A) A
给定A,我们将得到A。请注意,类型可以是任何类型,根据泛型合约。我们的Map实现不需要类型约束。让我们看看将切片中的每个元素乘以2的示例:
func main() {
ints := []int{1, 1, 2, 3, 5, 8, 13}
result := Map(ints, func(i int) int {
return i * 2
})
fmt.Printf("%v\n", result)
}
这个函数也可以与任何数据类型一起工作。让我们看看一个示例,我们将对列表中每只狗的名称进行转换。如果狗的性别是男性,我们将名称前缀设置为Mr.;如果性别是女性,我们将前缀设置为Mrs.:
func dogMapDemo() {
dogs := []Dog{
Dog{"Bucky", Havanese, Male},
Dog{"Tipsy", Poodle, Female},
}
result := Map(dogs, func(d Dog) Dog {
if d.Gender == Male {
d.Name = "Mr. " + d.Name
} else {
d.Name = "Mrs. " + d.Name
}
return d
})
fmt.Printf("%v\n", result)
}
运行此代码将产生以下输出:
[{Mr. Bucky 1 0} {Mrs. Tipsy 3 1}]
重要的是要强调,这些更改是对数据副本进行的,而不是对原始Dog对象进行的。
从一到多的转换
Map 函数的一个变体是 Flatmap 函数。这个函数将映射一个 Flatmap。
我们将要使用的函数实现并不那么高效,但对于大多数目的来说足够好了。对于切片中的每个元素,我们将调用转换函数,该函数将我们的单个元素转换为一个元素切片。我们不会将这个中间结果作为切片的切片存储,而是立即将每个切片折叠并连续存储在内存中的单个元素:
func FlatMapA any []A) []A {
output := []A{}
for _, element := range input {
newElements := m(element)
output = append(output, newElements…)
}
return output
}
让我们通过实现一个示例来演示这一点。对于切片中的每个整数 N,我们将将其转换为从 0 到 N 的所有整数的切片。最后,我们将这个结果作为连续切片返回:
func main() {
ints := []int{1, 2, 3}
result := FlatMap(ints, func(n int) []int {
out := []int{}
for i := 0; i < n; i++ {
out = append(out, i)
}
return out
})
fmt.Printf("%v\n", result)
}
运行此代码的输出如下:
[0 0 1 0 1 2]
这就是我们展示在图像中的内容。每个单独的元素都被转换为一个切片,然后这些切片被组合。对于输入切片中的每个元素,中间输出将如下所示:
0: [0]
1: [0 1]
2: [0 1 2]
这个中间输出随后被组合成一个单一的切片。接下来,让我们看看在函数式编程语言中起着关键作用的函数的最后一类。
数据减少函数
我们将要查看的最后一组函数是 reducer 函数。这些函数将操作应用于元素容器,并从中导出一个单一值。结合本章前面看到的函数,我们可以组合我们的大多数应用程序。至少,就数据操作而言。在函数式编程中,这类函数有几个不同的名称。在 Haskell 中,你会找到名为 Fold 或 Fold 加后缀的函数,例如 Foldr,而在某些语言中它们被称为 Reduce。本书余下部分我们将使用 Reduce 术语。
我们将要查看的第一个函数就是简单的 Reduce。这个高阶函数将操作抽象为列表中的两个数据元素。然后它重复这个操作,累加结果,直到得到一个单一答案。就像 Filter 和 Map 函数一样,这些函数是纯函数,所以实际输入数据永远不会改变。
这个算法中的抽象函数是一个接受相同数据类型两个值的函数,并返回该数据类型的一个单一值。结果是通过对它们执行一些操作来实现的,这些操作是由函数的调用者提供的:
type (
reduceFunc[A any] func(a1, a2 A) A
)
这个函数最终将迭代地调用切片中的每个元素,存储中间结果,并将这些结果反馈回函数:
注意
这听起来像是递归,但在这个章节的实现中它不是递归的。我们将在下一章查看递归方法。
func ReduceA any A {
if len(input) == 0 {
// return default zero
return *new(A)
}
result := input[0]
for _, element := range input[1:] {
result = reducer(result, element)
}
return result
}
在这个例子中,我们也在处理我们的边缘情况。如果我们得到一个空切片,我们返回传递给我们的函数的类型的default-nil值。如果切片中只有一个项目,则无法执行任何操作,我们只需返回该值(通过不执行循环,因此基于input[0]立即返回结果)。
这些高阶函数抽象是如何将两个元素组合成一个答案的。一个可能的归约器是sum reducer,它将两个数字相加并返回结果。以下匿名函数是这个函数的一个示例:
func(a1, a2 A) A { return a1 + a2 }
这是一个匿名函数,我们将将其传递给Reduce以执行所有元素的求和——但就目前的写法而言,这种方法有一个问题。Reduce函数是泛型的,可以接受+运算符,但并非为每个数据类型定义。为了解决这个问题,我们可以创建一个Sum函数,它内部调用归约器,但将类型签名收紧,只允许提供数字作为输入。
记住,由于 Go 中有多种数字数据类型,我们希望能够为所有这些使用Sum函数。这可以通过为我们的泛型函数创建一个自定义类型约束来实现。我们还将考虑将Number的类型别名视为有效——这可以通过在每个类型前添加~前缀来实现:
type Number interface {
~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uint |
~int8 | ~int16 | ~int32 | ~int64 | ~int |
~float32 | ~float64
}
接下来,我们可以将此类型用作泛型函数(如Sum函数)的类型约束:
func SumA Number A {
return Reduce(input, func(a1, a2 A) A { return a1 + a2 })
}
就这样——现在,我们可以使用这个函数来返回数字切片的求和,其中数字是 Go 中我们定义在约束中的任何当前支持的类似数字的数据类型:
func main{
ints := []int{1, 2, 3, 4}
result := Sum(ints)
fmt.Printf("%v\n", result)
}
这个函数的输出是10。实际上,我们的归约器执行了1 + 2 + 3 + 4的求和。有了归约器,我们因此可以将这些操作抽象为列表。添加一个执行每个元素乘法的类似函数与编写求和函数一样容易:
func ProductA Number A {
return Reduce(input, func(a1, a2 A) A { return a1 * a2 })
}
这个实现的工作方式与Sum函数相同。
在 Haskell 和其他函数式语言中,提供了一些不同的归约器实现,每个实现都略微改变了核心算法。你将找到以下内容:
-
从列表的开始到结束迭代的归约器
-
从列表的末尾到开始迭代的归约器
-
从列表的第一个元素而不是默认值开始的归约器
-
从具有默认值开始并从列表的末尾到开始迭代的归约器
反向归约器(从列表的末尾迭代到开始)留作读者独立探索的练习,但它们的完整代码可以在 GitHub 上找到:github.com/PacktPublishing/Functional-Programming-in-Go./blob/main/Chapter6/pkg/reducers.go。然而,我们将探讨具有起始值的归约器。
提供不同的起始值将允许我们编写一个函数,例如“将所有数字相乘,然后最终乘以二”。我们可以通过修改我们的Reducer函数来实现这一点:
func ReduceWithStartA any A {
if len(input) == 0 {
return startValue
}
if len(input) == 1 {
return reducer(startValue, input[0])
}
result := reducer(startValue, input[0])
for _, element := range input[1:] {
result = reducer(result, element)
}
return result
}
我们正在处理与原始Reduce函数类似的边缘情况,但一个关键的区别是我们始终有一个默认值返回。我们可以在切片为空时返回它,或者在切片恰好包含一个元素时返回起始值与切片中第一个元素的组合。
在下一个示例代码中,我们将使用逗号将字符串连接起来,但为了展示我们新的ReduceWithStart函数,我们将提供一个起始值first:
func main() {
words := []string{"hello", "world", "universe"}
result := ReduceWithStart(words, "first", func(s1, s2
string) string {
return s1 + ", " + s2
})
fmt.Printf("%v\n", result)
}
如果我们运行此代码,我们将得到以下输出:
first, hello, world, universe
在这些函数到位后,让我们看看一个示例,其中我们将结合使用所有三类函数。
示例 – 处理机场数据
在这个例子中,我们将结合本章中的函数来分析机场数据。在我们能够使用我们创建的函数之前,我们需要做一些工作。在 GitHub 上,你可以在github.com/PacktPublishing/Functional-Programming-in-Go./blob/main/Chapter6/resources/airlines.json找到.json提取文件。
以下片段是数据集的模板:
{
"Airport": {
"Code": string,
"Name": string
},
"Statistics": {
"Flights": {
"Cancelled": number,
"Delayed": number,
"On Time": number,
"Total": number
},
"Minutes Delayed": {
"Carrier": number,
"Late Aircraft": number,
"Security": number,
"Total": number,
"Weather": number
}
}
}
要处理这些数据,我们将重新创建.json结构作为 Go 中的结构体。我们可以使用内置的.json标签和反序列化器来在内存中读取这些数据。我们用于处理这些数据的 Go 结构体如下所示:
type Entry struct {
Airport struct {
Code string `json:"Code"`
Name string `json:"Name"`
} `json:"Airport"`
Statistics struct {
Flights struct {
Cancelled int `json:"Cancelled"`
Delayed int `json:"Delayed"`
OnTime int `json:"On Time"`
Total int `json:"Total"`
} `json:"Flights"`
MinutesDelayed struct {
Carrier int `json:"Carrier"`
LateAircraft int `json:"Late
Aircraft"`
Security int `json:"Security"`
Weather int `json:"Weather"`
} `json:"Minutes Delayed"`
} `json:"Statistics"`
}
这段文字稍微有些冗长,但它只是文件第一项内容的复制。在此之后,我们需要编写一些代码来将文件内容作为条目读入内存:
func getEntries() []Entry {
bytes, err := ioutil.ReadFile("./resources/airlines.
json")
if err != nil {
panic(err)
}
var entries []Entry
err = json.Unmarshal(bytes, &entries)
if err != nil {
panic(err)
}
return entries
}
与前几章一样,我们在代码中使用panic。这是不被鼓励的,但出于演示目的,这是可以的。此代码将读取我们的资源文件,根据我们创建的结构体将其解析为json,并作为切片返回。
现在,为了演示我们创建的函数,这是我们的问题陈述:编写一个返回西雅图机场(机场 代码:SEA)总延误小时的函数。
根据这个问题陈述,我们可以看到有三个动作需要执行:
-
通过机场代码 SEA 过滤数据。
-
将
MinutesDelayed字段转换为小时。 -
汇总所有小时数。
步骤 2 和步骤 3 的顺序可以颠倒,但这样,它遵循我们在本章中介绍这些函数的结构:
func main() {
entries := getEntries()
SEA := Filter(entries, func(e Entry) bool {
return e.Airport.Code == "SEA"
})
WeatherDelayHours := FMap(SEA, func(e Entry) int {
return e.Statistics.MinutesDelayed.Weather / 60
})
totalWeatherDelay := Sum(WeatherDelayHours)
fmt.Printf("%v\n", totalWeatherDelay)
}
我们就这样开始了。我们使用本章中看到的三种函数实现了我们的用例。正如你所见,每次我们调用一个函数时,我们都会将结果存储在一个新的切片中。因此,原始数据永远不会丢失,如果我们选择这样做,我们仍然可以使用它来处理函数的其他部分。
摘要
在本章中,我们看到了三种类型的函数,这些函数将帮助我们以函数式的方式构建程序。首先,我们看到了基于谓词的函数,这些函数可以将我们的数据过滤成满足特定要求的子集,或者告诉我们数据集是否完全或部分符合某个条件。接下来,我们看到了数据如何以函数式的方式改变,以及数据类型保证保持不变的数据转换方式,以及那些也在改变类型本身的函数。
最后,我们研究了 reducer 函数,这些函数将一系列元素减少到一个单一值。我们在机场数据示例中展示了这三种类型函数的组合方式。
在下一章中,我们将深入探讨递归,看看它在函数式编程中的作用,以及编写递归函数在 Go 中的性能影响。
第七章:递归
在本章中,我们将讨论递归。这是一个所有程序员迟早都会遇到的话题,因为它并不局限于函数式范式。任何允许你表达函数调用的语言都允许你表达本质上递归的函数。对于许多人来说,这并不是一个一开始就难以理解的话题。在 Haskell 等函数式编程语言中,递归占据着中心舞台。
因此,本章致力于理解递归的确切工作方式,包括这样做所带来的性能影响,以及 Go 中递归的限制。我们还将探讨一些使用函数作为一等公民处理递归的实用结构。
在本章中,我们将涵盖以下主要内容:
-
递归的含义
-
为什么使用递归函数?
-
何时以及如何使用递归函数
-
利用函数作为一等公民来编写递归函数
-
理解 Go 中递归函数的限制
-
理解尾递归和编译器优化
本章我们将学习的内容将为我们在后续章节中讨论 Continuation-Passing style 和流畅编程打下成功的基础。
技术要求
对于本章,您应使用 Go 1.18 或更高版本的任何版本。所有代码都可以在 GitHub 上找到,链接为 github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter7。
什么是递归?
简而言之,递归函数是一个调用自身的函数。在实践中,这意味着以下函数是一个递归函数的例子:
func recursive() {
recursive()
}
在这个例子中,如果用户调用函数“recursive”,它只会无限期地调用自身。实际上,这是一个无限循环,并不是最有用的函数。为了使递归函数有用,我们可以通过设置两条规则来进一步扩展递归函数的定义:
-
函数必须有一个条件,以便它可以调用自身(递归)
-
函数必须有一个条件,它在不调用自身的情况下返回
第一个条件只是说明,在函数体中的某个点上,函数 X 将再次被调用。第二个条件是存在一种情况,函数 X 在不调用自身的情况下从函数中返回。这个第二个条件通常被称为递归函数的基准情况。
为了理解这看起来是什么样子,让我们实现一个经典的数学运算,这个运算非常适合递归,即阶乘函数。阶乘函数定义为给定一个输入 N,将 N 下的所有数字乘到 1;例如:
Fact(5) = 5 * 4 * 3 * 2 * 1
为了理解为什么这是一个递归函数,我们可以展示调用 Fact(5) 的结果实际上是调用 5 乘以调用 Fact(4) 的结果。因此,如果我们这样写出来,我们会得到以下结果:
Fact(5) = 5 * Fact(4) = 5 * 24 = 120
Fact(4) = 4 * Fact(3) = 4 * 6 = 24
Fact(3) = 3 * Fact(2) = 3 * 2 = 6
Fact(2) = 2 * Fact(1) = 2 * 1 = 2
Fact(1) = 1 * Fact(0) = 1 * 1
Fact(0) = 1
注意,在这个例子中,0 的阶乘简单地等于 1。这被定义为我们的基本情况;当将 0 的值传递给我们的函数时,我们简单地返回整数值 1。然而,在所有其他输入情况下,我们正在将输入数字与调用阶乘函数 input-1 的输出相乘。
如果我们将这段代码转换为 Go 代码,我们会得到以下结果:
func main() {
fmt.Println(Fact(5))
}
func Fact(input int) int {
if input == 0 {
return 1
}
return input * Fact(input-1)
}
如果你有一段时间没有看到递归了,可能需要几分钟的时间来理解这里发生的事情。一种思考方式是,每次对 Fact 的函数调用都会将一个函数推入我们的栈中。当所有函数都推入栈中时,它们从上到下被评估,栈的每一层都可以使用来自上一层的结果:

图 7.1:递归函数调用和栈分配
以这种方式思考基于栈的递归将帮助我们理解本章后面递归的例子和陷阱。但在我们到达那里之前,让我们看看你为什么可能想要选择编写递归函数而不是迭代函数,以及为什么函数式语言通常更喜欢递归。
为什么函数式语言更喜欢递归?
在我们讨论在 Go 中何时使用递归函数之前,让我们回答一下为什么函数式语言似乎更喜欢递归而不是 for 循环。最好的答案是递归本质上比迭代解决方案更纯净。尽管每个可以用递归表示的程序也可以用迭代表示,但迭代解决方案需要维护比递归解决方案更多的状态。
我们简单的阶乘示例在编写迭代实现时突出了这一点:
func factorial(n int) int {
result := 1
for i := 1; i <= n; i++ {
result = result * i
}
return result
}
在这个阶乘实现中,我们在 for 循环的每次迭代中修改“结果”。这是一个封闭的修改,因为它没有逃离函数本身,但无论如何它是一个修改状态的操作。与此同时,我们的纯递归示例从不修改状态。它不是修改状态,而是通过将输入参数与函数调用的输出组合来返回一个新的值:
return input * Fact(input-1)
作为一条一般规则,递归允许我们创建具有复制状态的新函数,修改这些复制,并返回结果,所有这些都不需要在递归调用本身中修改任何值。这意味着程序状态的变化被包含在每个栈帧中。
Go 中的递归状态变化
在 Go 和其他非纯语言中,在递归函数调用中修改状态是可能的。在这些语言中,递归并不保证状态的不可变性,但它确实使得编写不可变实现变得更加容易。
何时使用递归函数
要了解何时使用递归函数,我们必须讨论迭代函数和递归函数之间的主要权衡。但在我们到达那里之前,让我们首先说,任何可以用迭代实现的东西也可以用递归实现。因此,任何在 Go 中有for语句的函数都可以替换为使用递归函数调用的等效函数来代替for循环。
然而,我们并不总是希望这样做。递归函数的两个主要缺点是它们通常需要更多的时间和空间。多次调用函数会创建多个栈帧。这些栈帧消耗了我们程序部分的工作内存。通常,每个栈帧都会包含从其下方栈帧(在递归函数中)复制的数据,这意味着在先前的阶乘示例中,每个函数调用使用的内存量与之前的函数相似。然而,所有这些栈帧在某个时刻都是活跃的。递归调用栈不会在最后的递归调用完成之前弹出栈。因此,在图 7.1中,我们可以看到所有栈帧叠加在一起,然后从上到下进行评估(后进先出,或LIFO)。如果我们以迭代的方式编写相同的函数,我们只会有一个函数在调用栈上。
递归函数的第二个限制是它们通常比它们的迭代版本慢。这主要是因为从编程语言特性的角度来看,函数调用是昂贵的操作。鉴于我们刚刚学到的关于调用栈的知识,这很有道理。每个函数调用都必须将内存复制到新位置,执行核心算法,然后再次复制以供下一次递归调用使用。
那么,我们为什么还想继续使用递归函数呢?好吧,尽管这些限制很重要,但我们的主要目标是实现代码的可读性和可维护性。一旦掌握递归,可以使程序不仅更容易编写,也更容易理解。涉及遍历图或树的问题很容易适合递归函数(因为这些数据结构本身就是递归数据结构)。本书的一个主要主题是我们将为了你,程序员,以及代码的后续读者方便而权衡性能。
作为旁注,在 Haskell 等语言中,编写递归函数比在 Go 中涉及更少的语法开销——特别是当与称为模式匹配的概念结合使用时。在不偏离本章核心内容太多的情况下,让我们快速看一下 Haskell 中的阶乘实现:
factorial :: Integral -> Integral
factorial 0 = 1
factorial n = n * factorial (n-1)
上述代码片段是阶乘函数的完整实现。注意,它几乎就像是对问题的更数学化的描述。这使得编写递归解决方案更具吸引力。此外,Haskell 还会对递归函数进行编译器级别的优化。我们将在本章后面简要介绍一种这样的优化,即尾调用优化。
遍历树
为了演示前面的假设,即某些代码以递归方式编写比以函数方式编写更容易,让我们看看遍历树的示例。树是递归数据结构,因此应该适合这种实现。为了简单起见,让我们假设我们有一个存储整数的树;实际值并不那么重要。我们将构建一个看起来像这样的树:

图 7.2:二叉树示例
每个节点的实际值并不重要,但让我们假设我们想要找到所有节点的和。用简单的话说,我们必须获取每个节点的值。然后,对于每个节点,我们需要确定它是否有子节点。如果有,我们将子节点的值添加到我们的运行和中。接下来,对于所有这些子节点,我们需要确定它们是否有子节点,如果有,也将它们的值添加到我们的运行和中。我们这样做,直到我们看到了所有节点。
为了演示这一点,让我们创建一个表示我们的树的数据结构。类型声明本身很简单:我们有一个包含值的节点,每个节点都有一个指向左子节点和右子节点的指针。这些子节点是可选的:
type node struct {
value int
left *node
right *node
}
在设置好这个结构之后,让我们也介绍一个实际的树,我们可以在本章后面演示我们的示例函数。我们可以在 var 块中创建这个作为包级别的对象。我们将模拟 图 7.2 中显示的树:
var (
ExampleTree = &node{
value: 1,
left: &node{
value: 2,
left: &node{
value: 3,
},
right: &node{
value: 4,
},
},
right: &node{
value: 5,
},
}
)
在我们将它写成递归解决方案之前,让我们先使用普通的 for 循环将其写成迭代解决方案。
使用 for 循环迭代解决树问题
在我们能够使这成为可能之前,我们需要引入一些额外的数据结构。我们将使用的数据结构是一个 Queue。对于每个我们访问的节点,我们将节点的值添加到我们的和中。对于节点的每个子节点,我们将子节点添加到我们的 Queue 中。我们将继续这样做,直到我们的 Queue 为空。作为一个起始值,我们将树的根添加到我们的 Queue 中,以启动整个过程。
一个重要的免责声明是,在撰写本文时,Go 并没有提供易于使用的、开箱即用的队列实现。然而,Go 确实包含开箱即用的缓冲通道。我们可以使用缓冲通道来获得类似队列的行为,这正是我们将要演示的。要获得类似队列的行为,以下是一些主要属性:
-
能够将一个元素推送到队列中
-
能够以 LIFO(后进先出)风格从队列中弹出(移除)一个元素
你可以使用切片来获取这种行为,但这甚至需要一些管理切片的开销,并且这不是最高效的实现。一个真正的队列将提供常数时间的添加和删除。关于这一点,也许缓冲通道在底层以优化的方式执行此操作,但这超出了本书的范围。然而,我们必须做出的一个必要假设是,我们事先知道队列的大小。
在现实世界的场景中,情况往往并非如此。你可以将队列大小的最佳努力估计传递给缓冲通道,但这似乎容易出错。为了教学目的,并且不分散对算法本质的注意力,我们暂时接受这些假设。在此声明之后,让我们学习如何迭代地实现一个获取树中所有节点之和的函数:
func sumIterative(root *node) int {
queue := make(chan *node, 10)
queue <- root
var sum int
for {
select {
case node := <-queue:
sum += node.value
if node.left != nil {
queue <- node.left
}
if node.right != nil {
queue <- node.right
}
default:
return sum
}
}
}
在这个例子中,我们增加了一些额外的开销,因为我们正在使用缓冲通道管理我们的队列行为。然而,核心算法是相同的。你可以想象,在使用真正的队列实现时,如果没有select块,可以节省一些代码行。
接下来,让我们看看我们如何递归地解决这个问题。
递归解决树问题
当递归地思考这个问题时,它变得更加清晰和易于实现。
记住,从我们的阶乘示例中,我们是在遇到一个基本案例时向我们的栈帧添加调用,对于这个基本案例,我们可以返回一个值而不调用函数本身。这个实现的基本案例是一个缺失的节点(nil 指针)。这样的节点将返回 0,因为没有要做的和。对于其他每个节点,我们返回其值的和,以及所有子节点值的和。将此可视化为一个栈,我们是从底部到顶部向栈中添加帧,但是从顶部到底部进行评估,随着我们的进行,汇总总和:
func sumRecursive(node *node) int {
if node == nil {
return 0
}
return node.value + sumRecursive(node.left) +
sumRecursive(node.right)
}
这段递归代码是解决此问题的一种方法,开销不大。它是迭代解决方案的更易读版本,我们的代码更接近我们的意图。递归解决方案如何与我们迄今为止学到的函数式编程相关?
在函数式编程语言中,你想要告诉计算机“解决什么”问题,而不是“如何”解决问题。当你手动编写循环时,你坚定地处于给定问题的“如何”领域,而不是“解决什么”领域。此外,我们的递归解决方案在任何地方都没有修改状态,这使我们更接近函数式编程世界中的理想函数。
函数式语言与循环
虽然在函数式语言中更倾向于使用递归,但许多语言也提供了创建手动循环的结构。话虽如此,它们通常为递归函数提供编译器优化,这使得它们成为解决问题的更有吸引力的选择。
递归与函数作为一等公民
本章到目前为止所看到的内容可以应用于任何具有函数调用的语言,即使在更严格遵循面向对象领域的语言中也是如此。在本节中,我们将学习如何利用一些使递归编写和管理更简单的函数式和多范式语言的概念。
我发现最有用的功能之一是将递归与闭包结合。为了举例说明何时这很有用,想象一下在递归处理数据结构时需要跟踪一些状态。与其在包级别跟踪状态,或者使递归函数复杂化以在递归函数中跟踪状态,我们可以创建一个非递归的外部函数,然后使用递归的内层函数。让我们用一个例子来演示这一点,以消除一些潜在的混淆。
使用与上一个例子相同的树,让我们编写一个函数来找到树中节点的最大值。为了实现这一点,我们需要一种跟踪最大值的方法,我们之前已经看到了。实现这一点的选项之一是在递归函数外部跟踪状态。这很混乱但会起作用。例如,以下代码遍历树并使用全局变量跟踪遇到的最大值如下:
var maximum = 0
func MaxGlobalVariable(node *node) {
if node == nil {
return
}
if node.value > maximum {
maximum = node.value
}
MaxGlobalVariable(node.left)
MaxGlobalVariable(node.right)
}
func main() {
maximum = int(math.MinInt)
MaxGlobalVariable(ExampleTree)
fmt.Println(maximum)
}
上述代码不是理想的解决方案。首先,使用全局变量跟踪任何状态应该受到谴责。在编写多线程代码时,这会导致巨大的麻烦,如果你在递归函数运行之前忘记重置全局变量,结果将不可靠,即使是单线程运行。
另一种更好的方法是跟踪每次递归调用中的当前最大值。这是通过扩展函数签名来实现的,使其包括我们正在跟踪的整数值,如下面的代码所示:
func.maxInline(node *node,
maxValue int) int {
if node == nil {
return maxValue
}
if node.value > maxValue {
maxValue = node.value
}
maxLeft := maxInline(node.left, maxValue)
maxRight := maxInline(node.right, maxValue)
if maxLeft > maxRight {
return maxLeft
}
return maxRight
}
在这里,我们在maxValue变量中跟踪最大值,该变量在每次递归调用中传递。然后,在每次调用中,我们使用node.value和maxValue之间的最大值继续递归调用向下。我们通过比较树的左右两侧并返回两侧的最大值来结束调用。
这可能是忽略调用者代码外观的情况下编写递归函数本身最干净的方式。如果我们想调用maxInline函数,我们的调用函数将看起来像这样:
func main() {
fmt.Println(maxInline(ExampleTree, 0))
}
在 maxInline 函数调用中,我们实际上向调用者泄露了实现细节。调用者必须将初始起始值传递给我们的递归函数。这相当混乱,对于更复杂的函数,我们不一定期望调用者知道适当的值。理想情况下,我们不希望向调用者泄露这样的状态细节。传统的面向对象语言通过暴露一个公开的非递归函数来解决此问题,该函数调用一个带有状态附加的私有递归函数。在 Go 中建模,我们得到以下代码:
func main() {
fmt.Println(MaxInline(ExampleTree))
}
func MaxInline(root *node) int {
return maxInline(root, 0)
}
func maxInline(node *node, maxValue int) int {
if node == nil {
return maxValue
}
if node.value > maxValue {
maxValue = node.value
}
maxLeft := maxInline(node.left, maxValue)
maxRight := maxInline(node.right, maxValue)
if maxLeft > maxRight {
return maxLeft
}
return maxRight
}
在这里,我们创建了一个公开的 MaxInline 函数,它不暴露 maxInline 的内部机制。调用者只需要将根节点提供给公开函数。然后,这个函数将使用适当的起始状态调用私有的 maxInline 函数。这种模式在面向对象的语言中非常常见,如果这些语言不支持一等函数,这是正确的做法。
然而,在 Go 语言中,我们可以做得更好。前面方法的主要问题是,你仍然在包私有空间中添加了一个任何在包中工作的人都可以使用的函数。这可能是期望的行为,但并不总是如此。一种解决方法是将递归函数封装在非递归函数中。这样,我们可以在非递归函数内部跟踪状态,这个状态对递归内部函数是可访问的。
以下实现正是如此:
func Max(root *node) int {
currentMax := math.MinInt
var inner func(node *node)
inner = func(node *node) {
if node == nil {
return
}
if node.value > currentMax {
currentMax = node.value
}
inner(node.left)
inner(node.right)
}
inner(root)
return currentMax
}
让我们看看这里发生了什么。首先,请注意,我们的 Max 函数本身不是递归的。这允许我们执行一些我们知道只会发生在 Max 调用一次的操作。例如,这是一个记录活动、添加性能指标或添加一些状态的绝佳位置,就像我们在这里所做的那样。在我们的例子中,我们创建了一个名为 currentMax 的变量。这个变量将跟踪我们遇到的最大值。
接下来,我们创建了一个名为 inner 的变量,其类型为 func(node *node)。这是一个重要的步骤。我们不是立即内联创建函数;首先,我们需要设置这个变量而不附加实现。我们这样做的原因是,我们可以在匿名函数内部引用 inner 变量。
下一步是实例化这个 inner 函数。如果我们把这个块连接起来,我们得到这个:
var inner func(node *node)
inner = func(node *node) {
if node == nil {
return
}
if node.value > currentMax {
currentMax = node.value
}
inner(node.left)
inner(node.right)
}
这显示了我们在 inner 函数内部如何调用 inner(node.left) 和 inner(node.right)。如果我们没有先定义函数而不实例化,这将不会工作。换句话说,以下代码将不会工作:
inner := func(node *node) {
if node == nil {
return
}
if node.value > currentMax {
currentMax = node.value
}
inner(node.left)
inner(node.right)
}
这看似是一个小的改动,但它会破坏我们的函数。毕竟,如果没有编译器编译你试图创建的函数,我们怎么能引用 inner 呢?
我们代码的最后一个步骤是调用内部的递归函数本身:
inner(root)
在这个例子中,我们看到了如何使用函数作为一等公民来帮助我们编写递归代码。但是,这样做也有性能影响。我们将在下一节中探讨这一点。
递归函数的限制
递归函数有性能惩罚。在创建递归函数调用时,我们正在从一个函数栈复制状态到下一个函数栈。这涉及到将大量数据复制到我们的工作内存中,但还需要额外的计算开销来使函数调用本身发生。至少在 Go 中,解决递归问题的主要限制是我们最终会耗尽空间来使递归调用发生。另一个限制是递归解决方案通常比迭代解决方案慢。
测量递归与迭代解决方案的性能
在我们查看递归函数调用期间程序使用的空间影响之前,让我们比较适合我们工作内存的递归和迭代解决方案的性能。为了演示这一点,我们将使用本章开头看到的相同的迭代和递归解决方案来解决阶乘问题:
package pkg
func IterativeFact(n int) int {
result := 1
for i := 2; i <= n; i++ {
result *= i
}
return result
}
func RecursiveFact(n int) int {
if n == 0 {
return 1
}
return n * RecursiveFact(n-1)
}
为了测试这两个函数,我们可以使用 Go 的基准测试功能,这在之前的章节中已经探讨过。迭代和递归方法的基准测试设置都很简单:
package pkg
import "testing"
func BenchmarkIterative100(b *testing.B) {
for n := 0; n < b.N; n++ {
IterativeFact(10)
}
}
func BenchmarkRecursive100(b *testing.B) {
for n := 0; n < b.N; n++ {
RecursiveFact(10)
}
}
为了基准测试这些函数,我们将生成Factorial(10)的结果。这是一个相当小的数字,因为它只需要 10 步就能得出答案。然而,性能影响是明显的。多次运行的平均值如下:
| 函数 | ns/op |
|---|---|
| 迭代 | 8.2 |
| 递归 | 24.8 |
表 7.1:迭代函数与递归函数在 ns/op 中的性能
如我们所见,每个迭代函数完成所需的时间大约是递归函数的四分之一。以下图表显示了不同输入到阶乘函数的每个函数的运行时间(ns/op):

图 7.3:迭代(底部)与递归(顶部)在 ns/op 中的运行时间
前面的图表显示,递归函数通常比它们的迭代对应物慢,而且它们比迭代解决方案慢得更加明显。在决定编写递归函数时,请记住这些性能考虑因素。
基准测试注意事项
这些结果是在使用运行 Amazon Linux 的 Amazon Web Services EC2 实例(t2.micro)的情况下获得的。这些结果的实际值是机器相关的。在不同的机器上运行这些基准测试不一定会得到不同的结果,但总体趋势应该保持不变。在相同的t2.micro实例上运行基准测试仍然可能导致结果的变化。
递归函数的空间限制
除了在典型场景中速度较慢之外,递归函数还遭受另一个缺点:每个被递归函数调用的函数都会给我们的栈添加另一个帧。所有当前迭代的当前数据都会被复制并传递给新函数。回想一下 图 7**.1 中,这些栈以后进先出的方式叠加。一旦我们的栈无法再增长,程序将停止。好消息是,在 Go 中,这个限制相对较大,可能不会立即引起实际问题的出现。在现代 64 位机器上,这个栈可以容纳高达 1 GB 的数据,而在 32 位机器上,限制是 250 MB。
在实践中,限制最终会被触及。让我们看看以下例子:
func main() {
infiniteCount(0)
}
func infiniteCount(i int) {
if i%1000 == 0 {
fmt.Println(i)
}
infiniteCount(i + 1)
}
如果我们在 32 位机器上运行这个函数,输出的尾部将看起来像这样:
1861000
1862000
1863000
1864000
runtime: goroutine stack exceeds 262144000-byte limit
runtime: sp=0xc008080380 stack=[0xc008080000, 0xc010080000]
fatal error: stack overflow
runtime stack:
runtime.throw({0x496535?, 0x50e900?})
/usr/lib/golang/src/runtime/panic.go:992 +0x71
runtime.newstack()
/usr/lib/golang/src/runtime/stack.go:1101 +0x5cc
runtime.morestack()
/usr/lib/golang/src/runtime/asm_amd64.s:547 +0x8b
因此,在大约 180 万次迭代后,我们的程序将崩溃。实际的限制取决于每个栈帧的大小。对于更复杂且管理更多内部状态的递归函数,这个限制会低一些。但我们可以做些什么来避免触及这个限制呢?在 Go 中,处理递归函数时,没有完全避免这个限制的方法。然而,我们可以调整限制(尽管在 64 位机器上的 1 GB 限制应该足够了)。
要更改限制,我们可以使用 debug.SetMaxStack(bytes) 函数。为了演示这一点,让我们将 32 位机器的限制更改为默认大小的两倍:
func main() {
debug.SetMaxStack(262144000 * 2)
infiniteCount(0)
}
func infiniteCount(i int) {
if i%1000 == 0 {
fmt.Println(i)
}
infiniteCount(i + 1)
}
现在,函数可以在耗尽栈空间之前运行更长的时间:
3724000
3725000
3726000
3727000
3728000
runtime: goroutine stack exceeds 524288000-byte limit
runtime: sp=0xc010080388 stack=[0xc010080000, 0xc020080000]
fatal error: stack overflow
runtime stack:
runtime.throw({0x496535?, 0x50e900?})
/usr/lib/golang/src/runtime/panic.go:992 +0x71
runtime.newstack()
/usr/lib/golang/src/runtime/stack.go:1101 +0x5cc
runtime.morestack()
/usr/lib/golang/src/runtime/asm_amd64.s:547 +0x8b
如我们所见,我们现在可以在遇到 500 MB 栈的限制之前完成大约 370 万次迭代。虽然 32 位机器上的 250 MB 限制并不大,但对于大多数实际应用来说,64 位机器上的 1-GB 限制应该是足够的。
尾递归作为解决栈限制的方案
考虑到递归函数的这些限制,功能语言更倾向于递归而不是迭代,这似乎有些奇怪。通常,这些语言,如 Haskell,只有递归可用,并且它们嘲笑迭代函数。在本节中,我们将简要探讨 Haskell 等语言是如何使递归工作的。
提示
这里需要注意的重要一点是,在编写 Go 语言时,这是不可能实现的。
一些功能语言使用的技巧被称为尾调用优化。即使是非功能语言也可能提供这种优化——JavaScript 是一个显著的例子。这是一种编译器(或解释器)优化,其中递归函数调用不会分配新的栈帧。回想一下,递归函数的主要缺点是它们可能会耗尽栈空间——因此,如果我们解决了这个问题,我们就可以实现无限递归。
编译器确实需要程序员的帮助才能实现这一点。我们将使用 Go 语言来演示这些示例,但请注意,到目前为止,在 Go 语言中,编译器不执行任何优化,因此我们最终仍然会溢出栈。
将递归函数重写为尾调用递归函数
尾调用递归函数和普通递归函数之间的关键区别在于,在尾调用变体中,每个栈帧都是相互独立的。为了展示这一点,让我们再次检查阶乘函数:
func Fact(input int) int {
if input == 0 {
return 1
}
return input * Fact(input-1)
}
在这个函数的最后一行,我们返回input * Fact(input – 1)。这实际上将每个调用的结果与后续调用的结果绑定在一起。为了评估乘法,我们首先必须运行Fact函数一个层级更深。我们可以重写这个函数来避免这种情况,并使每个栈帧独立于下一个。
要做到这一点,让我们再次利用我们的函数作为一等公民。我们将创建一个名为tailCallFactorial的外部函数,它不是递归的,它反过来调用一个名为factorial的内部函数,该函数是递归的。
要递归地编写这个函数并解耦每个栈帧,我们将进行两个更改。首先,我们将使用一个计数器,从input递减到 0。这相当于for i := n; i > 0; i— for循环。接下来,我们还将继续累加每次乘法的结果。我们将通过在下一个帧的输入参数上执行乘法并传递乘积值来完成这项工作:
func tailCallFactorial(n int) int {
var factorial func(counter, result int) int
factorial = func(counter, result int) int {
if counter == 0 {
return result
}
return factorial(counter-1, result*counter)
}
return factorial(n, 1)
}
使这个函数成为尾递归的关键代码行如下:
return factorial(counter-1, result*counter)
通过这个简单的更改,每个栈帧都可以单独评估。并且一些编译器检测到当前栈帧可以在下一个帧被调用时立即释放。这是对尾调用优化的高级概述,但请注意,在编写本文时,Go 并不执行此类编译器优化。
摘要
在本章中,我们探讨了递归为什么是函数式编程语言的一个关键部分。我们研究了递归函数如何使强制函数纯净性和不可变性变得更容易。接下来,我们看到了函数作为一等公民如何使管理我们的递归函数调用状态变得更容易。我们通过创建外部非递归函数来实现这一点,这些函数利用内部递归函数来执行计算。
之后,我们探讨了递归和迭代解决方案的性能问题。在这里,我们注意到递归解决方案通常比它们的迭代版本要慢,并且最终,递归函数会耗尽内存来执行操作,导致我们的程序停止(即使这在 64 位机器上可能需要非常长的时间)。
最后,我们探讨了尾调用优化和尾调用递归函数。尾调用优化是一种实用的编译器优化,许多语言,如 Haskell 和 JavaScript,都支持这种优化以克服递归函数的限制。关键的是,我们了解到 Go 语言不支持尾调用优化,即使我们编写了尾调用递归函数。
在下一章中,我们将探讨声明式和流畅式编程。我们将利用递归以传递继续风格编写程序。
第八章:可读的函数组合与流畅编程
在本章中,我们将探讨在函数式编程中链式调用函数的不同方法。我们的最终目标是编写易于阅读且占用较少视觉空间的代码。我们将探讨三种实现这一目标的方法:
-
首先,我们将探讨如何使用类型别名将方法附加到容器类型上,从而允许我们使用熟悉的点符号创建链式函数。
-
我们接下来将讨论懒加载与急加载代码评估。
-
接下来,我们将探讨延续传递风格(CPS)编程。在 CPS 中,我们将使用高阶函数来创建控制流,而无需我们的函数。
-
我们还将讨论每种方法的权衡。
技术要求
对于本章,最低要求是 Go 1.18,因为我们将会使用泛型编写代码。所有代码都可以在 GitHub 上找到:github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter8。
本章中的一些代码将建立在第五章和第六章中创建的函数之上。第五章 和 第六章。在必要时,我已经将那些章节中的相关函数和类型复制到了Chapter8子文件夹中。例如,Chapter8/LazyEvaluation/pkg是Chapter5/Monads/pkg和Chapter6/pkg的副本。这样,Chapter8中的示例始终可以在不要求其他章节的情况下运行。
通过点符号链式调用函数
通过点符号链式调用函数并不是函数式编程独有的概念。实际上,许多面向对象的设计模式,如建造者模式,也明确地这样做。在我们深入探讨如何利用 Go 的类型别名来实现这一点之前,让我们先看看一个更面向对象风格的编程示例,然后再深入链式调用函数。
对象创建的链式方法(建造者模式)
我们将创建一个包私有person对象,并添加一些公共函数来改变人的状态,尽管记住在 Go 中,这不是实例化新对象的最佳方式。然而,这是许多传统面向对象语言选择的方法:
type person struct {
firstName string
lastName string
age int
}
func newPerson() *person {
return &person{}
}
func (p *person) SetFirstName(firstName string) {
p.firstName = firstName
}
func (p *person) SetLastName(lastName string) {
p.lastName = lastName
}
func (p *person) SetAge(age int) {
p.age = age
}
在此示例中,我们有一个person结构体和三个设置器 - SetFirstName、SetLastName和SetAge。所有这三个都用于修改我们的对象状态。如果我们想创建一个新的对象,我们可以使用以下函数调用:
func main() {
alice := newPerson()
alice.SetFirstName("alice")
alice.SetLastName("elvi")
alice.SetAge(30)
fmt.Println(alice)
}
或者,可以创建一个构造函数:
func constructor(firstName, lastName string, age int)
person {
return person{firstName, lastName, age}
}
只要我们的对象包含的字段不多,这种方法就可以很好地工作。如果一个对象包含很多字段,构造函数和设置器方法就会变得容易出错,而且坦白说,编写和维护起来都很繁琐。当一些字段需要默认值时,在许多传统语言中建模就变得更加困难(尽管一些语言,如 Python 和 TypeScript,可以优雅地处理这种场景)。解决这个特定问题的方法是构建器模式,它允许你链式调用函数,以获得更易读的对象创建体验。它还提供了额外的优势,例如能够定义默认值,但为了本章的目的,我们只关注链式调用方法。
为了实现这一点,我们将创建一个新的类型,personBuilder,它为每个我们想要设置的域都有一个函数。然而,我们不会简单地修改person对象,而是将应用了更改的personBuilder返回。回想一下前面的章节,这是一种确保我们的函数是纯函数的方法。这也允许我们创建这些函数,而无需使用指针,因为我们的状态将是不可变的。我们还需要一个额外的函数build(),它将返回一个完全实例化的对象:
type personBuilder struct {
person
}
func (pb personBuilder) FirstName(firstName string)
personBuilder {
pb.person.firstName = firstName
return pb
}
func (pb personBuilder) LastName(lastName string)
personBuilder {
pb.person.lastName = lastName
return pb
}
func (pb personBuilder) Age(age int) personBuilder {
pb.person.age = age
return pb
}
func (pb personBuilder) Build() person {
return pb.person
}
当我们想要使用personBuilder创建一个人时,我们可以使用熟悉的点表示法链式调用函数:
func main() {
bob := personBuilder{}.FirstName("bob").
LastName("Vande").
Age(88).
Build()
fmt.Println(bob)
}
点表示法用于在切片上链式调用函数
在简要回顾了点表示法的工作原理以及它在面向对象语言中的应用之后,让我们深入探讨如何利用这个概念来处理在函数式编程语言中遇到的函数。回想一下前面的章节,我们创建了诸如filter、map和sum(作为reduce的抽象)之类的函数。当我们想要按顺序运行多个函数时,我们必须在单独的语句中这样做,并在其中跟踪值。例如,假设我们有一个数字切片。我们想要将每个数字翻倍,然后只保留大于 10 的数字,最后返回它们的总和。使用第六章中的函数,我们可以这样写:
func main() {
ints := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
doubled := Map(ints, func(i int) int { return i * 2 })
larger10 := Filter(doubled, func(i int) bool {
return i >= 10 })
sum := Sum(larger10)
fmt.Println(sum)
}
从技术上讲,我们不需要中间步骤。我们可以将其写为一行,但这很快就会变得难以理解:
func oneliner() {
ints := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
sum := Sum(Filter(Map(ints, func(i int) int {
return i * 2 }), func(i int) bool {
return i >= 10 }))
fmt.Println(sum)
}
经过一些微小的格式调整,它变得稍微容易阅读一些,但仍然不是很好,尽管它带有一点 Lisp 风格的气息:
func oneliner() {
ints := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
sum := .Sum(
.Filter(
.Map(ints,
func(i int) int {
return i * 2 }),
func(i int) bool { return i >= 10 }))
fmt.Println(sum)
}
如果你花些时间阅读像前面例子那样的函数,你会逐渐习惯它。Lisp 是一个很好的例子;括号使得它一开始难以阅读,但随着时间的推移,它变得像第二本能一样自然。然而,我敢打赌,大多数你的同事并不是熟练的 Lisp 程序员,并且可能不想花时间学习如何阅读这样的代码。由于面向对象的点表示法是方法链的最常见方式,我们应该选择一个更接近大多数人习惯的解决方案。我们可以在 Go 中使用类型别名来实现这一点。记得从第二章中,类型别名允许我们将函数附加到自定义类型,并且我们可以创建一个自定义类型来表示一个切片。
第一步,然后,是为我们的容器类型创建一个类型别名。这对所有类型都适用,但我们将用整数来演示它:
type ints []int
接下来,我们将为此类型别名附加自定义方法。在我们的例子中,我们将使用 Map、Filter 和 Sum,就像前面的例子一样,但这适用于任何函数。对于每个函数,它们将调用我们现有的(通用的)Map、Filter 和 Sum 方法。然而,值得注意的是,这些函数现在被附加到一个具体类型上。这在某种程度上类似于创建一个 外观 模式来进行函数分发:
func (i ints) Map(f func(i int) int) ints {
return .Map(i, f)
}
func (i ints) Filter(f func(i int) bool) ints {
return Filter(i, f)
}
func (i ints) Sum() int {
return .Sum(i)
}
如你所见,前面的片段中并没有真正发生什么魔法,但这个小小的变化将允许我们以熟悉的点表示法将我们的函数链接在一起。例如,以下方法与前面的非链式示例相同:
func chaining() int {
input := ints([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
return input.Map(func(i int) int { return i * 2 }).
Filter(func(i int) bool { return i >= 10 }).
Sum()
}
我敢打赌,对于许多人来说,这是更易读的版本,尤其是与更 Lisp 风格的例子相比。然而,在某种程度上,这仅仅是个人偏好和习惯。话虽如此,在 Go 程序员群体中,点表示法函数链是更常见的方法。这种方法的主要缺点是,需要创建新的函数来简单地允许点表示法链。好消息是,有可用的解决方案,但它们会使你的项目设置变得更加复杂。我们可以使用 Go 编译器预处理器系统自动为我们生成这些函数。在第十一章中,我们将看到一些可以做到这一点的库的例子。
函数调用的惰性评估
每当我们选择 Go 中的前面点表示法声明性编程风格时,都会发生权衡。为了理解为什么在 Go 中链式调用函数时可能会有潜在的性能影响,而在像 Haskell 这样的语言中则没有,我们需要理解函数评估的概念,尤其是惰性评估。当一个编程语言声称支持函数调用的 惰性评估 时,这意味着函数只有在需要结果的时候才会执行,而不是提前执行。
我们可以将这与其他的贪婪求值(也称为严格求值)进行对比,在函数调用时,每个函数的整个结果都会被计算。贪婪求值是大多数编程语言采用的执行策略,因此你很可能最熟悉它。Go 语言没有选择惰性求值,但我们仍然可以模拟它。 要了解编程语言惰性求值的意义,我们首先来谈谈贪婪求值及其相关的思维模型。考虑以下代码片段的执行流程:
func main() {
x := 3
y := 4
z := x + y
fmt.Println(z)
}
当阅读这段代码时,执行流程基本上遵循我们阅读的方式。首先评估最上面的一行,然后是下面的一行,一直到最后一行代码。

图 8.1:从上到下的执行流程
这是一种自然的方式来阅读代码并跟踪正在发生的事情。让我们通过一些函数调用来扩展这个例子。

图 8.2:带有函数调用的执行流程
在图 8.2中,我们可以看到当存在函数调用时,执行流程是如何建模的。首先,是main函数,它最终会打印出我们创建在第六章中的Filter方法中存储的结果:
func main() {
input := []int{1, 2, 3, 4, 5, 6}
isEven := func(i int) bool {
return i%2 == 0
}
numberPrinter(pkg.Filter(input, isEven))
}
func numberPrinter(input []int) {
for _, in := range input {
fmt.Println(in)
}
}
在贪婪求值中,前面的代码片段中发生的情况是,在将整个结果传递给numberPrinter之前,会先解析对Filter的调用。本质上,最深层嵌套的函数会首先被评估,最外层的函数最后被评估(并使用内部评估的结果)。再次强调,这是我们大多数人合理化代码的方式。然而,惰性求值只想在结果变得需要时才执行计算。 在前面的例子中,“偶数过滤器”变得相关的那一刻,是我们开始在numberPrinter中迭代结果的时候。因此,执行流程看起来就像图 8.3所示的那样。

图 8.3:惰性求值执行流程
在图 8**.3中,我们聚焦于一旦到达numberPrinter(pkg.Filter(input, isEven))行时发生的情况。在惰性求值期间发生的事情是,我们跳入numberPrinter函数。因为过滤后的数字列表尚未与该函数相关,所以对pkg.Filter的调用尚未发生。然而,我们的运行时记录下这个函数最终需要被调用。接下来,我们到达numberPrinter的第一行,它遍历我们的输入。在这个时候,Filter函数的结果变得相关。因此,我们需要通过调用pkg.Filter来确定哪些数字是奇数。一旦计算出了结果,执行将继续在[..] range input [..]行。因此,执行实际上被延迟到需要的时候。这就是惰性求值的要点——直到我们知道它绝对必要之前,不会消耗任何工作(即不会消耗处理能力)。
建立在这一点上的语言强烈需要函数的纯净性,因为如果系统状态发生变化与这种惰性求值执行模式相结合,那将是一场灾难,也将是函数程序员头疼的主要原因。Go 不会自动将我们的代码翻译成使用惰性求值的函数,但我们可以通过利用高阶函数来强制它这样做。在讨论急切求值与惰性求值如何影响我们正在编写的声明式代码之前,让我们构建一个简单的程序,在前面场景中强制惰性求值。再一次,我们将创建一个数字列表,过滤出仅保留偶数的数字,然后将它们传递给numberPrinter:
func main() {
input := []int{1, 2, 3, 4, 5, 6}
isEven := func(i int) bool {
return i%2 == 0
}
numberPrinter(func() []int {
return Filter(input, isEven)
})
}
func numberPrinter(lazyGet func() []int) {
fmt.Println("At this line, we don't yet know what our
input values will be")
for _, in := range lazyGet() {
fmt.Println(in)
}
}
在前面的修改示例中,我们的numberPrinter函数不再接受一个整数切片作为输入。相反,它接受一个返回整数切片的函数作为输入。 这是一个关键的区别,因为它现在允许我们在不知道要打印的数字的情况下调用numberPrinter函数。一旦numberPrinter认为有必要知道这些数字,它就可以调用lazyGet函数,该函数将生成每个数字。当我们想要使用numberPrinter时,我们必须提供一种方法让函数能够获取真实输入。我们通过一个匿名函数做到了这一点,简单地将对Filter的调用封装在一个新函数中,该函数将输出传递下去:
numberPrinter(func() []int {
return pkg.Filter(input, isEven)
})
这样,我们就可以在 Go 中模拟惰性求值。我认为这种方法与通常被认为是“惰性求值语言”的方法的主要区别在于,在“惰性语言”中,这种惰性是由编程语言本身处理的。在 Go 中,虽然我们可以对函数的每个中间结果进行惰性求值,但这样做会需要大量的开销。
延迟和避免执行
正确理解惰性评估的方式并非仅仅是延迟执行,而是延迟和避免执行。当与列表一起工作时,这转化为仅生成解决问题所需的列表子集。如果我们不想编写声明式代码并手动编写循环,那么在 Go 中模拟这种行为很容易,但如果我们要编写声明式代码,那就困难得多。正如本书前面提到的,我们的目标是使代码尽可能声明式,因为这会增加可读性。下一个示例将突出延迟和 避免执行的含义。
假设我们想要找到第一个大于一千万的阶乘结果,并且我们想要以声明式的方式编写这个程序。为了演示这一点,我们还将重用前面章节中学到的知识。我们将使用在第五章中引入的Maybe类型,创建一个新的函数(head),将这个函数附加到切片类型(ints)上,创建一个生成预填充整数切片的函数(IntRange),最后,将这些结合起来形成一个单一解决方案。
完整示例可以在 GitHub 上找到:github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter8/LazyEvaluation。让我们首先设置head函数:
func HeadA any Maybe[A] {
if len(input) == 0 {
return Nothing[A]()
}
return Just(input[0])
}
此函数返回Maybe,它要么包含列表的底层头元素,要么返回Nothing。为了将其附加到类型以用于我们的点符号链,我们需要提供一个包装函数:
func (i ints) Head() Maybe[int] {
return Head(i)
}
接下来,我们需要生成一个数字切片。IntRange函数将生成介于下限和上限之间的数字范围。记住,在编写声明式代码时,我们想要关注的是什么,而不是如何。由于 Go 没有提供这种功能,我们将一次性编写生成器函数(如何),然后只在以后重用生成器(什么):
func IntRange(start, end int) []int {
out := []int{}
for i := start; i <= end; i++ {
out = append(out, i)
}
return out
}
如果我们编写足够多的这种类型的生成器,我们理想情况下将永远不需要再手动编写循环。现在我们已经编写了这些函数,结合第六章中的Filter和第七章中的Factorial,我们可以将这些结合起来形成一个声明式解决方案。在我们的main函数中,我们首先创建一个内部函数,该函数检查一个数字是否大于一千万。然后,我们将通过以下方式声明性地链接步骤,以找到第一个大于一千万的阶乘:
-
从 0 生成到 100 的范围。
-
将范围内的每个数字映射到其阶乘结果。
-
筛选出大于一千万的结果。
-
返回此列表的第一个元素:
func main() {
largerThan10Mil := func(i int) bool {
return i > 10_000_000
}
res := ints(IntRange(0, 100)).
Map(Factorial).
Filter(largerThan10Mil).
Head()
fmt.Printf("%v\n", res)
}
如果我们运行此代码,我们得到以下结果 - {``39916800}。
虽然这很容易阅读和理解,但实现中隐藏着一个相当大的缺点,这是由于 Go 缺乏惰性评估。我们在前两个步骤中所做的是如下:
-
生成从 0 到 100 的所有数字。
-
获取它们的阶乘结果。
然而,第一个超过 10_000_000 的阶乘实际上发生在 n=11 的值上。这意味着从 12 到 100 的每个后续数字都被生成并添加到切片中,然后没有理由地计算了它的阶乘。在像 Haskell 这样的惰性评估语言中,列表只会生成找到结果所需的价值,然后短路执行。
谓词中的短路
大多数主流编程语言,包括 Go,都存在一种短路和惰性评估的形式,这是对谓词的短路。在 if 条件中,如果有多个条件,例如 if A() && B(),如果 A 已经返回 false,则不会执行 B 函数。同样,对于 if A() || B() 语句,如果 A 已经返回 true,则不会执行 B 函数。这样可以节省不必要的计算。(如果你正在编写依赖运行两个谓词结果的副作用代码,这可能会很难调试。又是避免副作用的一个原因。)
无限数据结构和惰性评估
惰性评估的另一个优点是可以对无限数据结构进行建模,例如包含从 0 到无穷大所有数字的列表。我们之所以能够在惰性评估语言中处理无限结构,是因为你只计算整个操作链所需的数据。Go 不支持惰性评估,因此在这个关于无限数据结构世界的简短过渡中,示例将使用 Haskell 和一个假想的 Go 实现。
在 Haskell 中,定义一个无限列表是一个简单的操作:
InfiniteInts :: [Int]
InfiniteInts = [1..]
那么,我们如何处理它们呢?嗯,我们需要一个终止函数。为了使惰性评估与无限列表一起工作,我们需要有一个清晰的结束状态,使得列表操作完成。例如,让我们创建一个无限列表,检查每个数字是否是素数,一旦我们生成了 100 万个素数就停止。
首先,让我们创建 naturals 函数,它生成从 2 到无穷大的所有数字。这样做的原因是我们不知道确切在哪里停止。让我们还定义一下欧几里得筛子看起来是什么样子:
naturals :: [Int]
naturals = [2..]
sieve :: [Int] -> [Int]
sieve (p:xs) = p : sieve [x | x <- xs, x `mod` p /= 0]
筛子将移除(筛选出)给定起始值列表中的所有非素数。接下来,让我们将这些两个步骤组合成一个函数,通过将无限数字列表输入到筛子中,并指定我们想要生成的数量(n)来生成素数:
primes :: Int -> [Int]
primes n = take n (sieve naturals)
在这里,我们有我们的终止函数。take n告诉我们,从无限的数据列表中,我们只想生成达到n所需的数量。让我们在main函数中调用它来生成前一百方个:
main :: IO ()
main = do
let millionPrimes = primes 1000000
putStrLn $ "Generated " ++ show (length millionPrimes)
++ " prime numbers"
前面的代码全部是用 Haskell 编写的,但现在让我们回到这本书的主角 Go 的领域。如果我们考虑如何在 Go 中实现类似的功能,最简单的方法是使用for { }循环。具体来说,我的意思是循环的while行为。我们循环直到满足条件,没有后置条件来增加值。忽略素数检查,我们可能会编写类似以下的内容:
func main() {
primes := []int{}
for len(primes) != 1_000_000 {
// sieve or other algorithm to get prime
}
}
前面的实现方法是可行的,假设我们填充了for循环的主体(这实际上是一个事实上的无限生成器;如果我们从未达到一百万的计数,它将永远循环。在实践中,这意味着你的算法是错误的)。然而,在编写这段代码时,我们已经放弃了声明式编程风格。我们回到了详细说明“如何”达到结果而不是关注“结果应该是什么”的领域。在一个假想的 Go 实现中,我们想要编写的代码如下:
func main() {
millionPrimes :=
IntRange(2
Filter(func(i int) bool {
return isPrime(i)
}).
Take(1_000_000)
}
前面的代码将是等效的(函数式)实现,尽管我们为了简单起见,是在过滤而不是使用筛子。这结束了我们对惰性评估及其益处的过渡。现在让我们转向另一种将函数链接在一起的风格。
延续传递风格编程
我们将要探讨的下一个编程风格是延续传递风格(CPS)。与熟悉的方法链点号表示法不同,CPS 只能在支持函数作为一等公民的语言中实现。核心思想是延续——换句话说,执行的下一步——是另一个作为参数传递给原始函数的函数。这允许我们通过函数传递来控制程序的流程,而不是通过分支和显式函数调用。主要好处是这将帮助我们阅读和理解复杂的函数链,并且我们可以以最小的努力来更改它们。在我们深入 Go 的 CPS 编程实现之前,让我们简要地偏离一下,并解释延续的概念。
延续是什么?
在编程语言的领域中,延续(continuation)是一个相对抽象的概念。它是一个表示程序下一次计算的函数。它本质上捕捉了程序在执行时刻的状态(更具体地说,是栈),并提供了一个可以调用的执行下一步的函数。
延续被用来在编程语言中实现控制流程。它们可以被看作是一种数据结构,它代表了我们的当前执行状态以及我们将要过渡到的下一个执行状态。这个抽象概念是编程语言如何实现我们日常编程中更熟悉的控制流程结构,例如异常处理、for 循环和 goroutines 的方式。
在某些语言中,例如 Scheme,延续(continuations)被暴露给程序员,并且可以用来在更高层次上抽象地控制程序执行的流程。这相当于编程自己的控制结构,但额外的优势在于延续可以在原地修改以采取不同的行为。在 Go 语言中,这并不容易实现。在 Go 中实现这一点的挑战之一是它是一种静态类型语言,这使得定义延续作为数据结构变得更加困难。
接近 Go 中延续的例子可能是 panic 和 recover 模式。想象以下函数:
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r)
}
}()
fmt.Println("Normal execution happening")
panic("Execution flow is broken")
fmt.Println("This line will not be executed")
}
在这个 main 函数中,我们首先定义了一个 main 函数,就在函数退出之前。defer 指定了一个函数的延续,在 Go 中是一个特殊情况,因为它在函数退出之前执行,无论我们如何退出函数。在延迟函数内部,如果父函数的执行过程中遇到了 panic,我们将从中恢复。
除了延迟函数(deferred function)之外,我们在第一个 print 语句之后明确地调用了 panic。这同样是一个延续的例子。这里发生了一些可能不是立即明显的事情。首先,对 panic 的调用不是一个简单的函数调用。panic 用于表示我们的执行栈最终处于损坏状态,正常的执行流程不再可能。它捕获了 panic 被调用时的整个栈状态,并停止了我们的函数。然而,因为我们已经将 defer 函数作为延续添加到 main 中,defer 能够从 panic 中访问这个保存的栈。然后,它可以在 panic 被调用时的那一刻显示栈的内容,帮助我们恢复到无效状态,并继续程序执行而不会停止。换句话说,我们的 panic 延续正在捕获有关程序状态的宝贵信息,并在执行过程中稍后将其暴露给另一个函数。然而,请记住,在 Go 中不建议使用 panic。
深入探讨延续超出了本书的范围,但这个小引言应该足以说明,尽管在 Go 中没有明确地称为延续,但延续在语言本身中仍然很普遍。尽管如此,我们可以利用一种显式的延续形式,这就是我们最终进入 CPS(Continuation-Passing Style)编程领域的原因。
在 Go 中实现 CPS 代码
为了理解 CPS(Continuation Passing Style,延续传递风格),让我们先来看一个简单的例子。回想一下上一章,我们创建了几种计算数字阶乘的方法。在这个例子中,让我们重写递归版本以遵循 CPS 模式。为了启用 CPS,我们需要将延续作为参数传递给我们的递归函数。计算阶乘的逻辑的其他部分保持不变:
func factorial(n int, f func(int)) {
if n == 1 {
f(1) // base-case
} else {
factorial(n-1, func(y int) {
f(n * y)
})
}
}
在这个阶乘示例中,我们使用一个高阶函数 f 来表示我们递归函数调用的延续。而在递归函数调用中,我们只是简单地将函数的当前输入乘以函数调用的结果,这里我们使用闭包在更深的一层进行乘法操作。我们说的是,为了继续计算阶乘,我们需要将 n 乘以 y。然而,y 在这个栈帧中尚未定义;它只会在下一个函数调用中定义。
要运行这个函数,让我们创建一个主函数来打印乘法的结果:
func main() {
factorial(5, func(i int) {
fmt.Printf("result: %v", i)
})
}
注意在这个函数中,我们在闭包内部调用 fmt.Printf。这意味着打印语句将沿着我们的函数调用链传递下去,最终将由我们的阶乘函数进行评估。这是 CPS 的一个优点——它使递归的较低步骤发生的事情变得明确,而不是让程序员看不到。最顶层的栈帧被推入一个函数,即 print 函数,而每个后续的调用栈都会推入一个函数,即“将我们的输入与下一个调用的输入相乘”的函数。

图 8.4:带有可变函数的调用栈
如果我们查看 图 8**.4,我们可以看到不同的函数被推送到我们的调用栈的不同层级。最底层的调用栈有一个 Println 函数调用,而上面的那些有一个乘法闭包的函数调用。最后的栈帧简单地有一个常数 n * 1)。通过引入 CPS,我们有效地抽象了递归函数调用,并为我们程序通过调用栈的流程增加了额外的灵活性。而在正常的递归函数中,每一层后续层主要是前一层的一个副本(除了基本情况),而使用 CPS,我们可以在每个帧中引入不同的行为,具体取决于我们想要做什么。
当我们使用调用栈的这种心理模型时,有一点需要注意,它们永远不会被返回。在 CPS 程序中,我们不使用return语句;相反,我们通过传递一个高阶函数作为后续步骤(下一个动作)。这就是为什么我们的print语句会一直传递到最终迭代。为了明确这一点,每个调用栈帧都会被添加到栈中,但评估是从底部向上滚动的——也就是说,我们的print语句被推送到它上面的栈帧,然后推送到更高的栈帧,以此类推,直到顶部。
函数的结果在执行过程中被逐步展开,这与常规递归不同,在常规递归中,我们的栈帧是以相同的方式添加的,但评估是从顶部到底部的。理解这种评估流程的倒置可能需要一点时间。背后的原因是,在每一个栈帧中,我们都是将闭包作为输入传递给下一个函数。然而,请记住,将一个函数作为输入传递给另一个函数并不会立即评估该函数。 因此,我们在到达最终栈帧之前延迟了每个函数的执行(就像惰性求值一样)。在这种情况下,f(1)是最终的栈帧。一旦我们到达这个栈帧,所有的闭包函数都会被有效地评估。(从最后一个闭包到第一个闭包,它们在最终栈帧中被评估。因此,底部的print语句打印出最终评估的结果。)
现在我们已经看到了这个递归示例的工作原理,让我们看看一个稍微复杂一点的例子,在这个例子中,我们实际上并没有使用递归。这是为了表明任何类型的控制流都可以用 CPS 来建模。
使用 CPS 进行简单的数学运算
在前面的例子中,我们看到了使用 CPS 进行的递归阶乘计算。这可能会让我们认为 CPS 只是编写递归函数的另一种方式。虽然使用 CPS 编写递归函数确实有一些优势,但函数不一定是递归的。以下是一个例子。假设我们从一个整数切片开始。我们首先想要过滤掉输入中的偶数。如果一个数字是偶数,我们想要将其翻倍。最后,我们想要打印出结果整数。如果我们想用 CPS 来编写这个程序,我们需要将每个后续动作(后续步骤)视为一个要传递给原始函数的函数。不深入细节的话,这会产生以下后续动作流:
Input []int -> isEven(int) -> double(int) -> print(int)
这表明我们需要三个后续函数,以及一个第四个函数,在这个函数中我们将创建输入的切片并开始操作链。用 Go 语言编写,这将产生以下结果:
func main() {
is := []int{1, 2, 3, 4, 5, 6}
isEven(is, func(i int) {
double(i, print)
})
}
func isEven(input []int, cont func(int)) {
for _, i := range input {
if i%2 == 0 {
cont(i)
}
}
}
func double(input int, cont func(int)) {
cont(input * 2)
}
func print(i int) {
fmt.Println(i)
}
我们的所有函数,除了print,都会对我们的输入执行操作并调用一个延续函数。延续函数将提供我们算法的下一步。在isEven函数中,只有当数字匹配i%2==0条件时,才会调用延续,从而确保延续只发生在偶数上。现在,当我们阅读我们的main函数时,整个操作链都被明确地列出来了:
func main() {
is := []int{1, 2, 3, 4, 5, 6}
isEven(is, func(i int) {
double(i, print)
})
}
首先,我们创建一个整数切片。接下来,我们调用isEven;然后,我们进行加倍,最后打印。注意,这里奇怪的是,我们实际上正在创建一个匿名函数作为isEven延续的输入。在 Go 中,我们不能简单地这样写函数:
func main() {
is := []int{1, 2, 3, 4, 5, 6}
isEven(is, double(i, print))
}
好吧,如果我们改变isEven的签名,使其接受以下输入参数:
func isEven(input []int, cont func(int, func(int))) {
然而,现在我们的isEven函数绑定到一个显式接受一个延续作为请求的函数。如果我们只想打印偶数而不对它们执行任何其他操作会怎样呢?
这触及了为什么 CPS 在 Go 中难以正确实现的核心原因。类型系统过于严格,难以轻松管理 CPS 编程风格的函数。我们将更详细地讨论 CPS 的缺点,但首先,让我们看看 CPS 实际上可以带来真正优势的场景。
CPS 和 goroutines
CPS 确实可以帮助的一个领域是管理并发代码。每当你在像 JavaScript 这样的语言中听到关于回调的时候,你实际上是在使用一个延续并将其传递给一个异步函数。一旦异步部分完成,延续(回调)会自动调用,并带有异步部分的结果。通常,这是通过 Web 请求完成的,其中启动一个 Web 请求,当请求完成时调用回调,回调的状态被填充为请求的结果。这个结果通常是一个状态码(例如,200)和一个有效负载(在GET请求的情况下)。这种模式现在如此常见,以至于我们忽略了其背后的概念,而且我们实际上也不需要真正理解它们就能使用回调。然而,让我们用回调和异步 Go 代码来模拟我们的如果偶数则加倍函数,以提供一个明确的例子:
func main() {
callback := func(input int, b bool) {
if b {
fmt.Printf("the number %v is
even\n", input)
} else {
fmt.Printf("the number %v is
odd\n", input)
}
}
for i := 0; i < 10; i++ {
go isEven(i, callback)
}
_ := <-make(chan int)
}
func isEven(i int, callback func(int, bool)) {
if i%2 == 0 {
callback(i, true)
} else {
callback(i, false)
}
}
之前的代码是一个略微修改的版本。我们将异步验证一个数字是否为偶数,如果是,我们将打印the number x is even;如果不是,我们将打印the number x is odd。CPS 旨在简化的关键部分是异步调用的流程控制部分。我们将使用go关键字启动一个调用,因为延续被编码为我们调用的函数的一部分,所以我们不需要担心异步等待函数调用的结果然后启动下一个函数。在不支持高阶函数的语言中,启动调用,等待结果,继续计算的模式通常被建模为 async/await 操作。由于 Go 是一种多范式语言,我们可以利用高阶函数和 CPS。这使我们能够专注于异步部分,而不必担心等待和继续部分。话虽如此,Go 实际上有一个基于 goroutines 和 channels 的坚实的并发范式,所以这种 CPS 风格的编程需求大部分得到了缓解。
何时使用 CPS?
对于大多数用例来说,CPS 可能会使你的程序比它应有的复杂。这不是阅读递归函数最容易的方式,即使你习惯了它,它也可能让你出错。然而,它在某些领域,如编译器/解释器设计中被使用。通常,如果你想要模拟复杂的控制流,CPS 可以使这种控制更加明确,从而更容易理解和阅读。
除了这个之外,另一个用例是在异步编程中使用回调。虽然我们并不经常调用那些 CPS(Continuation-Passing Style,即延续传递风格),甚至延续,但它们确实是一种 CPS 的形式。由于使用了 goroutines 和 channels,我们在像 JavaScript 这样的语言中找到的更熟悉的回调风格稍微少一些,但无论如何,这是一个我们可以使用它们的有用领域。
摘要
在本章中,我们探讨了两种不同的方式来组合我们的函数式代码。第一种方式是通过熟悉的点符号链式连接方法。这是一种连接各种函数的输入和输出的方式,而不需要在它们之间进行中间变量赋值。虽然大多数程序员熟悉这种编程风格,但在 Go 中使用泛型编写(纯)函数式代码时,需要一些开销。
我们在这里讨论的另一个权衡是函数评估的急切模式与懒模式。虽然在 Go 中可以模拟懒评估,但编译器和语言并没有为我们做任何繁重的处理。这意味着如果我们从像 Haskell 这样的函数式语言移植代码,性能特征将会有显著的不同。
最后,我们还探讨了延续和 CPS 编程。延续是对算法中任何“下一步”的抽象表示,无论是函数调用、循环还是“goto”语句。CPS 编程使递归操作的本质变得明确,并允许我们抽象地处理函数链的生成。虽然 CPS 是一种强大的技术,但在日常生活中的应用场景相对有限,尽管我们在底层大量使用了 CPS,例如在建模回调函数时。
在下一章中,我们将提升一个抽象层次,通过功能设计模式来观察程序组合。
第三部分:设计模式和函数式编程库
在这部分,我们将通过使用函数式编程技术来观察软件架构,从而提升到一个更高的抽象层次。我们再次比较面向对象方法与更函数式方法的差异。我们将看到 Go 的并发范式如何在函数式环境中得到利用。最后,我们将了解有助于我们构建函数式应用的库。
本部分包含以下章节:
-
第九章**,函数式设计模式
-
第十章**,并发与函数式编程
-
第十一章**,函数式编程库
第九章:函数式设计模式
在本章中,我们将提升到一个更高的抽象层次。而不是讨论单个函数和操作,让我们来看看一些设计模式。虽然我们不会详细解释每个设计模式,但我们会看看面向对象模式如何转化为函数式世界。
在本章中,我们将涵盖以下主要内容:
-
函数范式中的经典设计模式:
-
策略模式
-
装饰者模式
-
好莱坞原则
-
-
函数式设计模式
技术要求
在本章中,任何 1.18 版或更高版本的 Go 都将适用于所有 Go 相关代码。一些代码片段是用 Java 编写的;这些代码片段将适用于 1.5 版以上的任何 Java 版本。
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter9。
函数范式中的经典设计模式
任何在面向对象语言中编程的人都会在某个时刻遇到设计模式。设计模式是一种针对常见工程问题的通用解决方案。一个关键点是,他们提供的解决方案应被视为一个起点,一种解决问题的方法,这种方法已被证明是有用的。通常,解决方案不能直接使用,需要根据你的具体环境和情况进行调整。一个给定的设计模式可能为问题提供 90%的解决方案,其余的 10%则需要用自定义的非模式代码来填补。
本章的目标不是全面覆盖设计模式。实际上,关于设计模式已经写出了整本书,例如著名的《设计模式:可复用面向对象软件元素》(Gang of Four)一书。本章的目标是展示某些面向对象设计模式如何转化为函数范式,以及它们在这个范式下通常如何更简单地进行表达。对于每个设计模式,我们将查看面向对象实现、模式的一般问题和好处,以及最终,函数实现的样子。我们将从策略模式开始,然后继续装饰模式,最后是控制反转(IoC)原则。
这三种模式在面向对象代码中很常见。策略模式是一种在运行时改变程序行为并解耦具有具体实现类的方法。装饰者模式允许我们动态地扩展函数而不破坏开闭原则,而 IoC 原则是许多面向对象框架的基石,其中控制顺序被委派给调用树的最高层。
策略模式
我们将要查看的第一个模式是策略模式。策略模式是一种设计模式,它允许我们在运行时动态地更改方法或函数的算法。通过这样做,我们可以修改程序在整个运行期间的行为。在我们将要解决的例子中,我们将有一个 EncryptionService,它支持各种密码。
我们将保持简单,并使用改变输出中字母的替换密码。我们将实现三种不同的密码机制:
-
凯撒密码
-
Atbash 密码
-
自定义密码
每个密码都需要支持给定字符串的加密和解密,如下所示:
Input = decipher(cipher(Input))
换句话说,我们应该能够从加密输出中重建输入。对于我们的实现,我们还将限制自己只改变字母表中的字母 a-z,并忽略大小写。
密码和安全
值得注意的是,这些密码绝对不应该用于实际的加密。它们非常脆弱,在当今这个时代无法提供真正的保护来抵御恶意行为者。它们对于研究历史背景很有趣,实现起来既有趣又容易理解。
面向对象策略模式
首先,我们将以面向对象的方式解决这个问题。记住,Go 是一种多范式语言,因此我们可以在 Go 中轻松应用面向对象的设计模式。*图 9**.1 显示了这个解决方案的架构:

图 9.1:密码实现的策略模式
在面向对象的实现中,我们从 CipherService 开始。这是任何想要使用密码的类。而不是有一个具体的实现,CipherService 通过对象组合包含一个 CipherStrategy。这个 CipherStrategy 是一个接口,它指定了 Cipher 和 Decipher 方法。这两个方法都接受一个字符串作为输入,并返回加密或解密后的字符串。在 *图 9**.1 中,我们有三种具体的密码实现:
-
凯撒
-
Atbash
-
自定义密码
这些都是实现所需方法(Cipher 和 Decipher)的类(结构体)。我们还可以在这些类中包含一个有用的状态,正如我们将在接下来的代码示例中所看到的,其中我们将 Rotation 变量作为凯撒密码的一部分来维护。凯撒密码和 Atbash 密码都是所谓的替换密码。它们用一个字母替换另一个字母。在凯撒密码的情况下,替换字母位于字母表中一定数量的位置之后。对于 Atbash 密码,这是将每个字母简单地替换为反向字母表中相同位置的字母(z-a)。
凯撒
让我们在 Go 中开始实现这个。首先,我们将设置CipherService,以及一个包含我们将支持的所有字母表的切片。我们还需要确定给定 rune 在这个字母表切片中的索引,我们将通过实现一个indexOf函数来完成:
var (
alphabet [26]rune = [26]rune{'a', 'b', 'c', 'd', 'e',
'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p',
'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'}
)
func indexOf(r rune, rs [26]rune) (int, bool) {
for i := 0; i < len(rs); i++ {
if r == rs[i] {
return i, true
}
}
return -1, false
}
type CipherService struct {
Strategy CipherStrategy
}
为了遵循更传统的面向对象语言模式,我们还可以将Cipher和Decipher方法附加到CipherService上。这只会将调用委托给选择的实现(Strategy):
func (c CipherService) Cipher(input string) string {
return c.Strategy.Cipher(input)
}
func (c CipherService) Decipher(input string) string {
return c.Strategy.Decipher(input)
}
在设置好之后,我们还将定义一个接口,CipherStrategy,它将强制任何实现都必须有Cipher和Decipher方法:
type CipherStrategy interface {
Cipher(string) string
Decipher(string) string
}
在此基础上,我们可以开始实现我们将支持的战略。为了简洁,我们只会实现凯撒和 Atbash 密码。实现自定义密码,如图 9**.1所示,将是这个的简单扩展。为了实现凯撒密码,我们首先定义一个结构体来表示这个策略:
type CaesarCipher struct {
Rotation int
}
凯撒密码是一种密码,其中输入中的字母被替换为字母表中一定位置的字母。我们使用的位置数定义为密码的旋转。例如,如果我们有abc输入和一个旋转1,每个字母都被替换为字母表中位置更进一步的字母,所以输出将是bcd。
类似地,如果旋转是2,输出将是cde,依此类推。以下是在 Go 中实现凯撒Cipher和Decipher方法的实现。理解实现并不那么重要;重要的是要注意我们如何选择CipherService使用的实现,甚至在程序执行期间更改它:
func (c CaesarCipher) Cipher(input string) string {
output := ""
for _, r := range input {
if idx, ok := indexOf(r, alphabet); ok {
idx += c.Rotation
idx = idx % 26
output += string(alphabet[idx])
} else {
output += string(r)
}
}
return output
}
func (c CaesarCipher) Decipher(input string) string {
output := ""
for _, r := range input {
if idx, ok := indexOf(r, alphabet); ok {
idx += (26 - c.Rotation)
idx = idx % 26
output += string(alphabet[idx])
} else {
output += string(r)
}
}
return output
}
现在我们已经实现了凯撒密码,让我们也实现 Atbash 密码。
Atbash
Atbash 密码是直接将每个字母替换为字母表中相同索引位置的字母,但字母表是反向的。所以,a变成z,b变成y,以此类推,直到z变成a。因此,解密可以通过再次调用密码来实现,因为我们实际上是在镜像字母表(镜像两次会返回原始结果)。
与CaesarCipher不同,我们不需要任何真实的状态来管理AtbashCipher结构体,因为CaesarCipher中我们维护旋转作为一个类变量。然而,我们仍然需要创建这个结构体,以便我们的策略模式实现能够正确工作。它将只是一个空的结构体,带有附加到它的函数:
type AtbashCipher struct {}
func (a AtbashCipher) Cipher(input string) string {
output := ""
for _, r := range input {
if idx, ok := indexOf(r, alphabet); ok {
idx = 25 - idx
output += string(alphabet[idx])
} else {
output += string(r)
}
}
return output
}
func (a AtbashCipher) Decipher(input string) string {
return a.Cipher(input)
}
再次,这里的代码实现并不那么重要。很整洁的是,我们可以通过再次调用Cipher来解密它,这在函数式示例中会变得更加有趣。无论如何,让我们看看我们如何在执行期间更改实现,并在CaesarCipher和AtbashCipher之间切换:
func main() {
svc := CipherService{}
svc.Strategy = CaesarCipher{Rotation: 10}
fmt.Println(svc.Cipher("helloworld"))
svc.Strategy = AtbashCipher{}
fmt.Println(svc.Cipher("helloworld"))
}
这是策略模式的面向对象实现。我们创建了三个类(CipherService、CaesarCipher和AtbashCipher),一个接口(CipherStrategy),以及每个 struct 两个函数(用于加密和解密)。现在,让我们看看函数式实现。
策略模式的函数式实现
我们已经在之前的章节中看到,我们可以通过利用函数是一等公民的事实,像在传统的面向对象语言中一样传递它们,动态地改变算法的实现细节。如果我们重构了我们的CipherService,我们只需要知道这个服务需要一个函数,该函数接受一个字符串并返回一个字符串两次(一次用于加密,一次用于解密)。
首先,让我们定义这个新服务的结构,以及两个类型来定义Cipher和Decipher函数:
type (
CipherFunc func(string) string
DecipherFunc func(string) string
)
type CipherService struct {
CipherFn CipherFunc
DecipherFn DecipherFunc
}
func (c CipherService) Cipher(input string) string {
return c.CipherFn(input)
}
func (c CipherService) Decipher(input string) string {
return c.DecipherFn(input)
}
现在我们已经有了CipherService,我们需要定义我们的 Caesar 和 Atbash 加密相关的函数。与面向对象的例子不同,我们不需要定义一个新的 struct 来这样做。我们可以在与我们的CipherService相同的包中定义我们的函数,但我们不必这样做。事实上,任何正确类型的函数都可以用作Cipher或Decipher函数。
让我们先实现CaesarCipher。我们必须注意的一件事是我们不再有一个可以保存状态的 struct。在我们的例子中,CaesarCipher struct 将Rotation存储为一个类变量。在函数式方法中,旋转需要成为CaesarCipher函数本身的一部分。这是一个微小但重要的变化。除了这个变化之外,实现保持不变:
func CaesarCipher(input string, rotation int) string {
output := ""
for _, r := range input {
idx := indexOf(r, alphabet)
idx += rotation
idx = idx % 26
output += string(alphabet[idx])
}
return output
}
func CaesarDecipher(input string, rotation int) string {
output := ""
for _, r := range input {
idx := indexOf(r, alphabet)
idx += (26 - rotation)
idx = idx % 26
output += string(alphabet[idx])
}
return output
}
同样,我们可以将AtbashCipher实现为一个函数。这里的一个优点是由于 Atbash 加密和解密之间的关系,我们实际上不需要为Decipher函数编写任何实现。相反,我们可以直接将Decipher函数等同于Cipher函数:
func AtbashCipher(input string) string {
output := ""
for _, r := range input {
if idx, ok := indexOf(r, alphabet); ok {
idx = 25 - idx
output += string(alphabet[idx])
} else {
output += string(r)
}
}
return output
}
var AtbashDecipher = AtbashCipher
最后一行实际上定义了一个新的函数,AtbashDecipher,其实现与AtbashCipher相同,再次利用了这样一个事实,即我们的函数仅仅是数据,可以在 Go 中以变量的形式存储。
当在 Go 中使用这种函数式实现时,我们必须为我们的服务的Cipher和Decipher实现提供func(string) string类型的函数。由于CaesarCipher需要一个额外的变量来确定旋转,我们确实需要为我们的CipherService创建一个闭包。在我们的main方法中,我们可以动态地更新我们想要使用的加密方式为AtbashCipher,而不需要闭包,因为 Atbash 加密是一种简单的加密方式,遵循func(string) string:
func main() {
fpSvc := {
CipherFn: func(input string) string {
return (input, 10)
},
DecipherFn: func(input string) string {
Return fp.CaesarDecipher(input, 10)
},
}
fmt.Println(fpSvc.Cipher("helloworld"))
fpSvc.CipherFn = AtbashCipher
fpSvc.DecipherFn = AtbashDeciphe
fmt.Println(fpSvc.Cipher("helloworld"))
fmt.Println(fpSvc.Decipher(fpSvc.Cipher("hello")))
}
这个例子展示了如何使用我们的函数式实现来打印一些加密和解密的内容。使用这个函数式实现,我们可以轻松地实现特定的加密算法,而不必将它们定义为独立的函数。Cipher和Decipher的实现都接受匿名函数来指定实现细节。这就是我们通过将凯撒密码包裹在匿名函数中来使其工作的方式。
装饰器模式
让我们修改我们的代码,使其也遵循装饰器模式。装饰器模式是一种在不修改它们的情况下向我们的方法和类添加功能的方式。这意味着 SOLID 中的开闭部分得到了尊重。在面向对象编程中,这是通过函数组合(以及在支持这种功能的语言中通常与继承一起)来实现的。在 Go 语言中,组合是构建结构体的首选方式,因此装饰器模式对于函数式和面向对象风格的实现来说都很自然。
面向对象设计的 SOLID 原则
SOLID 是一组设计健壮面向对象系统的原则。它代表单一职责、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。无论你使用哪种范式,这些原则都是值得遵循的,但它们的实现方式不同。例如,函数应该只有一个职责,对修改封闭但对扩展开放,并且函数应该依赖于抽象(高阶)函数而不是具体实现。
面向对象的装饰器模式
首先,让我们从以面向对象的方式实现装饰器模式开始。我们将扩展我们的策略模式示例,包括各种加密算法。为了保持简单,让我们假设我们只想为每个Cipher和Decipher函数记录输入。为了使我们的程序更易于组合,我们不希望通过修改现有的CaesarCipher和AtbashCipher结构体来添加log函数。如果我们这样做,我们还需要更新每个结构体的log功能,以防日志要求发生变化。相反,我们将实现一个LogCipherDecorator结构体。这个结构体通过实现Cipher和Decipher的函数来遵循CipherStrategy接口。这些函数将首先写入日志,然后将每个调用委托给底层的Cipher或Decipher实现。"图*9**.2"显示了该模式的类图。

图 9.2:装饰器模式的类图
现在,我们可以将其转换为代码;让我们首先看看结构定义。我们有一个新的LogCipherDecorator结构体,它通过组合使用CipherStrategy:
type CipherLogDecorator struct {
CipherI CipherStrategy
}
现在,我们将实现必要的函数,使这个新的结构本身遵守CipherStrategy。在每个函数中,首先,我们将记录输入,然后再将调用分派到底层的CipherStrategy:
func (c CipherLogDecorator) Cipher(input string) string {
log.Printf("ciphering: %s\n", input)
return c.CipherI.Cipher(input)
}
func (c CipherLogDecorator) Decipher(input string) string {
log.Printf("deciphering: %s\n", input)
return c.CipherI.Decipher(input)
}
这基本上就是实现装饰器模式所需的所有内容。它在各种场景中都很有用,但在与用户界面(UI)代码(例如 Swing 这样的 Java UI 库)一起工作时,尤其经常遇到。
在main函数中,我们现在可以在期望CipherStrategy的任何地方使用CipherLogDecorator。我们将必须实例化装饰器并使用底层类来获取额外的功能:
func main() {
cld := {
CipherI: oop.CaesarCipher{Rotation: 10},
}
svc := oop.CipherService{Strategy: cld}
ciphered := svc.Cipher("helloworld")
fmt.Println(ciphered)
}
在这个片段中,我们可以看到CipherService如何接受CipherLogDecorator,就像接受任何其他CipherService一样。当我们运行这个main函数时,日志语句出现在每个打印语句之前。运行该函数,我们得到以下结果:
[ec2-user@ip-172-31-29-49 Chapter9]$ go run main.go
2023/01/14 15:50:05 ciphering: helloworld
rovvygybvn
接下来,让我们以函数式的方式实现这一点,并比较两种方法。
函数式装饰器模式实现
将装饰器模式应用于函数式编程并不需要我们在本书中之前没有见过的任何东西。我们已经学习了函数组合,并在前面的章节中使用了它。面向对象的代码中的装饰器模式实际上不过是函数式编程范式中的函数组合。
因此,创建一个在每次cipher或decipher调用之前添加日志语句的函数,就是一个创建高阶函数的问题,该函数接受一个Cipher或Decipher函数作为输入,并返回一个新的函数,该函数首先调用log,然后委托剩余的功能到底层函数。让我们通过查看加密和解密时的装饰函数LogCipher和LogDecipher来具体说明:
func LogCipher(cipher CipherFunc) CipherFunc {
return func(input string) string {
log.Printf("ciphering: %s\n", input)
return cipher(input)
}
}
func LogDecipher(decipher DecipherFunc) DecipherFunc {
return func(input string) string {
log.Printf("deciphering: %s\n", input)
return decipher(input)
}
}
实质上,这就是装饰函数以添加新功能所需发生的所有事情。LogCipher接受任何遵守CipherFunc类型定义的函数,并返回一个新的函数,该函数也遵守那个类型定义。然后,作为从LogCipher返回的匿名函数创建的新函数,调用log并随后调用最初传递的CipherFunc。
与面向对象和函数式范式相比,实现策略中的主要区别只是我们如何定义对预期功能的遵守。在面向对象的方法中,我们使用接口来定义遵守,而在函数式方法中,我们使用类型系统来定义遵守。
在我们的main函数中,我们可以使用装饰函数而不是底层加密来创建CipherService:
func main() {
caesarCipher := func(input string) string {
return CaesarCipher(input, 10)
}
caesarDecipher := func(input string) string {
return CaesarDecipher(input, 10)
}
fpSvc := {
CipherFn: LogCipher(caesarCipher),
DecipherFn: LogDecipher(caesarDecipher),
}
fmt.Println(fpSvc.Cipher("hello"))
}
注意,在这个例子中,为了可读性,装饰器函数与CipherService的创建是分开的,但这也可能在一行内完成,就像早期策略模式实现中那样。如果我们用AtbashCipher来创建CipherService,这将是一个更易读的例子:
func main() {
fpSvc := fp.CipherService{
CipherFn: fp.LogCipher(caesarCipher),
DecipherFn: fp.LogDecipher(caesarDecipher),
}
fmt.Println(fpSvc.Cipher("hello"))
}
从这些例子中我们可以看到,函数组合是装饰函数以添加额外功能的关键,这些功能随后可以在实现之间共享。我们迄今为止所做事情的另一个优点可以描述为“好莱坞原则”,也称为“IoC”原则。
好莱坞原则
好莱坞原则“别叫我们,我们会叫你”也被称为 IoC 原则。IoC 是众所周知的依赖注入模式的抽象。依赖注入是编写面向对象应用程序的一个重要方面,并且对函数式范式也很有用。
不深入面向对象的实现,关键要点是对象应该将它们依赖的具体实现推迟到对象/调用层次结构中的最高级别。我们在之前的例子中通过利用接口而不是具体实现来隐式地做到了这一点。注意,面向对象的CipherService没有指定它将使用哪种加密,而是通过请求CipherStrategy接口的实现来将这个选择推迟给了CipherService的创建者:
type CipherStrategy interface {
Cipher(string) string
Decipher(string) string
}
type CipherService struct {
Strategy CipherStrategy
}
Go 通过没有为 struct 提供显式构造函数而非常自然地适合这种编程方式。在像 Java 这样的语言中,对象可以通过对象组合使用默认的类级对象进行实例化,因此更容易忽略针对抽象实现的编程。例如,以下 Java 代码片段将展示一个不遵循 IoC 但使用特定类型的加密(在这种情况下是凯撒加密)的CipherService实现:
class CaesarCipher {
int rotation;
CaesarCipher(int rotation) {
this.rotation = rotation;
}
}
class CipherService {
CaesarCipher cipher = new CaesarCipher();
CipherService() {}
public String cipher(String input) {
String result = "";
// implement cipher
return result;
}
}
为什么在这里突出显示这段 Java 代码?首先,为了展示 Go 的 struct 范式通过无构造函数的 struct 实例化自然地适合 IoC。这意味着 struct 没有固有的类状态。
这使我们转向服务的函数式实现。在 Go 中,我们有两种方式来实现 IoC:
-
第一种方式是通过使用接口,正如我们在面向对象示例中所做的那样
-
第二种方式是使用类型定义和函数作为一等公民来抽象 struct 的行为
为了说明差异,以下是我们使用的CipherService的两个定义,并且两者都按照它们的范式应用 IoC。
首先,让我们展示面向对象的方式:
type CipherStrategy interface {
Cipher(string) string
Decipher(string) string
}
type CipherService struct {
Strategy CipherStrategy
}
现在让我们看看函数式的方式:
type (
CipherFunc func(string) string
DecipherFunc func(string) string
)
type CipherService struct {
CipherFn CipherFunc
DecipherFn DecipherFunc
}
这只是一个简短的过渡,目的是指出在这两种情况下都在发生什么。让我们继续讨论设计模式。
函数式设计模式
在本章前面的部分,我们比较了函数式和面向对象的设计模式(策略、装饰者和依赖注入/IoC)。如果我们看看函数式和面向对象模式之间的主要区别,那么我们的模式是通过不同的函数组合来实现的。我们要么使用函数作为一等公民,将它们存储在结构体中的变量中,要么使用函数组合、高阶函数、匿名函数和闭包来实现传统上通过接口和类的继承所实现的功能。
这实际上应该是编写函数式代码时的主要收获。一切都是函数。设计模式变成了函数组合的模式。因此,对于面向对象世界的传统设计模式,没有真正的对应物。那么,函数范式在设计模式方面提供了什么?好吧,如果我们回到设计模式的定义,我们可以看到,模式是针对常见问题的可重用解决方案。这是一种可能解决 85%问题的模板方法,而剩余的 15%则需要超出模式本身来解决。函数式编程确实提供了这些解决方案,我们在这本书的前面讨论了许多。
当你想到函数柯里化,将不同的函数组合在一起,并将每个函数简化为 1 元函数,然后组合成任何 n 元函数时,这些步骤可以被视为一种函数式设计模式。同样,通过 CPS 使用闭包、单子以及回调都可以被视为解决常见问题的模式。在函数式编程中,我们没有对象分类法的开销,这正是面向对象代码中的设计模式所反映的。你可以争论,在传统的面向对象语言中,设计模式的需求更多的是对编程语言自身局限性的解决方案,而不是对程序员真正的益处。
避免传统设计模式的一种方式是通过函数组合的使用,但一个同样关键的部分是利用类型系统——一个可以将具体类型分配给具有指定签名的函数的类型系统。观察面向对象的设计模式,无论是装饰者模式、工厂模式还是访问者模式,它们都广泛利用接口来抽象实现细节。在 Go 语言中,我们可以使用类型系统来抽象实现,就像我们在前面的例子中所做的那样。
如果我们总结一下如何在函数范式下解决特定设计问题,那将会相当无聊,因为问题要么不存在,要么通过函数来解决。我们的解决方案看起来就像表 9.1:
| 设计模式 | 解决方案 |
|---|---|
| 策略模式 | 函数(高阶函数 + 函数类型) |
| 装饰器模式 | 函数组合(闭包) |
| 工厂模式 | 实际上不需要,因为我们不需要对象,但我们可以创建具有一组默认值的函数——因此,这将属于函数柯里化 |
| 访问者模式 | 函数 |
| 单例模式 | 不需要,因为我们避免使用对象和可变状态 |
| 适配器 | 可以看作是函数映射 |
| 门面模式 | 再次提及函数 |
表 9.1:设计模式及其函数式解决方案
然而,在 Go 语言中,我们使用的是一种多范式语言,因此我们可以兼得两者之长。当我们与结构体一起工作时,我们可以利用一些设计模式,但它们的实现通过使用函数式编程原则而不是面向对象原则而大大简化。尽管我们创建了一个接口来抽象结构体功能的实现,但我们仍然可以使用一个符合给定类型的函数,就像我们使用CipherService时所做的那样。
摘要
在本章中,我们探讨了在面向对象代码中常见的几种设计模式,即策略模式、装饰器模式和好莱坞原则(IoC)。我们了解到,这些模式可以在 Go 语言中通过利用函数作为一等公民来实现,而无需构建广泛的对象分类。我们还讨论了在函数式范式中对设计模式的需求,并得出结论:要么模式不是必需的,要么可以使用函数来解决。在解决常见问题的可重用实际函数式代码方面,我们指出了函数柯里化和函数组合等概念。在下一章中,我们将探讨如何利用函数式编程来实现并发代码。
第十章:并发与函数式编程
并发无处不在,无论是在现实世界还是在虚拟世界中。人类可以轻松地多任务处理(尽管我们可能两个任务都做不好)。在你阅读本章的同时喝一杯咖啡或者边听播客边跑步是完全可能的。对于机器来说,并发是一个复杂的任务,尽管我们可以通过选择编程语言来隐藏很多这种复杂性。
Go 语言被设计成一种包含现代软件工程师所需所有工具的语言。鉴于我们现在生活在一个 CPU 能力在大多数情况下都很充足的世界,当开发这门语言时,并发自然成为了一个主要关注点,而不是后来不得不添加上去。在本章中,我们将探讨函数式编程如何帮助处理并发,以及反过来,并发如何帮助函数式编程。
在本章中,我们将涵盖以下主题:
-
为什么函数式编程有助于我们编写并发代码
-
如何创建并发函数(过滤、映射等)
-
如何使用管道模式并发地链接函数
技术要求
对于本章,你可以使用 Go 语言 1.18 或更高版本。本章的所有代码都可以在 GitHub 上找到,链接为github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter10。
函数式编程与并发
我们已经在整本书中暗示了这一点,但函数式编程背后的思想可以帮助我们编写并发代码。通常,即使有 goroutines 和 channels 等现代工具支持,思考并发也会让人头疼。在我们深入探讨这个材料之前,让我们先稍微偏离一下,作为一个复习,来明确当我们谈论并发代码时我们到底指的是什么,以及它与并行和分布式计算有何不同。
并发、并行和分布式计算
有时,“并发”、“并行”和“分布式计算”这些术语会被互换使用。虽然它们是相关的,但它们并不完全相同。让我们首先指出我们所说的“并发”是什么意思。并发是指我们的程序可以同时执行多个任务的情况。例如,当我们玩游戏时,通常有一个线程播放音频,另一个处理玩家的输入,还有一个处理游戏内部逻辑,更新游戏状态并执行主游戏循环。
电子游戏已经存在很长时间了,像 DOOM 这样的游戏就是以这种方式运行的。也可以说,在 1995 年,人们并没有在拥有多个核心的电脑上玩这样的游戏。换句话说,单个核心可以管理这些不同任务的执行,并给人一种同时执行这些任务的外观。确切地说,这是如何做到的,超出了本书的范围,但作为总结,只需记住,我们将主要关注的并发性,是之前定义的并发性——不是代码的同时执行,而是代码的并发执行。不过,有一点需要注意,并发可以发生在多个核心或流水线上。然而,为了简化问题,我们可以想象使用单个核心的并发。
这引出了第二个术语,并行性。当我们谈论一个程序并行执行时,这意味着多个核心正在同时执行一个任务。没有物理手段同时运行两个任务,就无法实现并行性。Go 的本地机制,如通道和 goroutines,专注于并发而不是并行。这是两者之间的重要区别。然而,Go 仍然适合构建并行算法。
要了解这看起来是什么样子,有几个 Go 包提供了并行解决方案,例如 ExaScience 的 Pargo 包:github.com/ExaScience/pargo。在撰写本文时,这个包是以预泛型的方式编写的,所以在查看代码时请记住这一点。在 图 10.1 中,通过任务执行方式突出了并发和并行之间的区别。值得注意的是,在并发模型中,两个任务被分割成多个部分,并且每个部分交替分配 CPU 时间。

图 10.1:并发(上方)与并行(下方)执行对比
最后,我们有 分布式计算。虽然并发是分布式计算的一部分,但这并不是唯一的要求。分布式计算确实意味着将计算任务分散到多台机器上,从这个意义上讲,它是并发的,但与通常的并发或并行应用程序相比,它有更多的开销。
在分布式系统中,你需要有容错机制(如果网络中的一个节点变得不可用怎么办?)以及处理网络的机制(不可靠或不安全的网络)。因此,尽管人们可能会将分布式计算作为并发的例子来讨论,但并发只提供了所需的最基本功能。物理基础设施以及使分布式系统正常工作的无数困难超出了本书的范围。有一点需要记住的是,Go 是一种可以用来编写分布式系统的语言。事实上,goroutines 和 channels 的使用可能有助于构建分布式系统所需的基础设施,但你将需要比语言的基本功能更多。如果你想学习更多关于使用 Go 进行分布式计算的知识,那么《使用 Go 进行分布式计算》这本书是一个很好的起点:www.packtpub.com/product/distributed-computing-with-go/9781787125384?_ga=2.217817046.1391922680.1675144438-1944326834.1674539572。
在本章中,我们将只关注并发,而不会深入探讨并行化或分布式计算。然而,为什么我们想让我们的代码并发执行呢?这可以带来一些明显的优势:
-
更高的响应性:一个程序不需要等待单个长时间运行的任务完成后再开始另一个任务
-
更高的性能:如果我们能够将繁重的工作量分成几块,并在多个线程上执行(Go 可能会将这些线程调度到多个核心以获得并行性),这将减少完成操作所需的时间
函数式编程与并发
我在这本书中之前已经提出过,函数式编程使得编写并发代码更容易,但这个说法需要进一步细化。当我们谈论函数式编程如何使并发更容易时,我们是在谈论函数式编程的更严格子集,即“纯”函数式编程。纯函数式编程为我们提供了一些关键特性,使得推理并发执行更容易,并且我们的代码更不容易出错。这些是负责这一点的最主要特性:
-
不可变变量和状态
-
纯函数(无副作用)
-
引用透明性
-
惰性求值
-
可组合性
在本章的剩余部分,当我们谈论函数式编程时,可以假设我们严格指的是纯函数式编程。让我们分别关注这些特性,并解释为什么它们使得编写安全的并发代码更容易,或者至少使得我们的代码更容易推理。结果是,当我们的代码更容易理解时,它应该有助于我们减少其中的错误数量。
不可变变量和状态
当在面向对象模型中工作时,对象通常持有内部状态。如果允许这种状态发生改变,那么两个线程正在处理的状态可能会发生分歧。通过不允许状态改变,即使操作相同的数据源(或者更确切地说,相同数据的副本),我们的函数可以独立执行,而不会干扰共享内存。
在 Go 语言中,如果我们想使用结构体,有一些陷阱,我们已经在前面的章节中讨论过了。通过避免使用指针,我们可以避免结构体中突变的主要原因。在编写纯函数式代码时,我们代码的每个单独组件都需要是不可变的。当每个组件都是不可变的时候,我们可以更安全地并发执行函数。
通过使用不可变变量和状态,我们还可以避免资源竞争的问题。如果我们有一个真正的单一资源(面向对象模型中的单例),那么这个资源可能会被线程 A 锁定,导致线程 B 在资源被释放之前必须等待才能使用。通常,这是通过资源锁定机制实现的(线程 A 锁定资源 X,在其它线程等待资源 X 的同时执行操作,然后完成操作后最终移除锁)。在纯函数式世界中,我们不需要这样的单例操作,部分原因是我们的不可变状态,部分原因是其他好处,比如纯函数。
纯函数
正如我们在第四章中看到的,一个函数被认为是没有产生任何副作用并且不与外界交互时是纯函数。在这本书中,我们实现了许多函数,这些函数是函数式编程中常见的。所有这些都是在纯函数式风格下编写的(尽管请记住,纯函数式是函数式编程的一个子集,并不是严格必需的)。这里的优势与不可变状态相关,但也超出了它。如果我们的函数不依赖于程序状态,那么任何修改我们程序状态的操作都不能干扰我们的函数。
此外,它还消除了另一类问题。如果我们的函数被允许改变状态或系统,操作顺序就会变得重要。例如,想象一下,如果我们编写一个并发函数来向文件追加内容。向文件写入是一个明显的副作用案例,但在并发应用程序中,我们的文件内容现在将取决于我们的线程执行的顺序。这破坏了应用程序的确定性,并且还可能导致一个与我们期望不完全一致的文件。在面向对象模型中,这同样通过锁定来解决。在纯函数式语言中,“不纯”的函数将由 monads 处理。Go 语言不是纯函数式语言,但在这章的后面,我们将通过管道模式来查看如何建模数据流和控制副作用。
引用透明性
引用透明性意味着我们可以用函数的结果替换函数调用,而不会改变我们计算的结果。我们在第二章中更详细地介绍了这一点,但对于并发,重要的方面是如果所有我们的调用都是引用透明的,那么调用解决的确切时间(提前或即时)并不重要。这意味着当我们把代码分成并发函数时,在并发方式中提前解决某些函数调用是安全的。
惰性评估
对 URL 的GET请求。我们将使用两个回调,这些回调将被惰性评估。第一个回调只有在GET请求成功完成时才会解决,而第二个回调将在GET请求失败时解决。请注意,这里我们指的是GET请求本身确实工作,但我们收到了不在200范围内的响应代码:
import (
"fmt"
"io/ioutil"
"net/http"
)
type ResponseFunc func(*http.Response)
func getURL(url string, onSuccess, onFailure ResponseFunc)
{
resp, err := http.Get(url)
if err != nil {
panic(err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
onSuccess(resp)
} else {
onFailure(resp)
}
}
在前面的代码中,我们可以看到getURL需要一个表示要解析的 URL 的字符串,以及两个函数。这两个函数都有相同的ResponseFunc类型,它是一个具有func(*http.Response)签名的函数。
接下来,我们可以编写一个main函数,在其中调用getURL并提供两个回调:
-
第一次回调,
onSuccess,将在我们的GET请求返回200范围内的状态码时执行;这个函数将简单地打印出响应体的内容。 -
第二次回调,
onFailure,将简单地打印出错误消息以及我们收到的相应状态码。我们将调用getURL两次,一次是有效的 URL,一次是无效的 URL。然而,我们不会同步运行此代码,而是通过在每个调用前加上go前缀,在单独的 goroutines 上调用getURL。这意味着我们不知道哪个调用会先完成,但因为我们使用的是惰性函数(一种传递继续风格的编程),所以我们不需要编排我们程序的流程控制。正确回调将在其时间到来时执行。不必要的回调将永远不会被评估,因此我们避免了在不必要的情况下进行可能昂贵的计算:func main() { success := func(response *http.Response) { fmt.Println("success") content, err := ioutil.ReadAll (response.Body) if err != nil { panic(err) } fmt.Printf("%v\n", string(content)) } failure := func(response *http.Response) { fmt.Printf("something went wrong, received: %d\n", response .StatusCode) } go getURL("https://news.ycombinator.com", success, failure) go getURL("https://news.ycombinator.com/ ThisPageDoesNotExist", success, failure) done := make(chan bool) <-done // keep main alive }
在前面的示例中,我们的GET请求异步完成,然后调用在getURL函数中定义的相应回调。在主代码片段的末尾有一个有趣的代码片段。我们创建了一个bool通道,然后我们从未向其中写入过。这实际上使我们的应用程序保持活跃。如果我们没有这两条语句,我们的main函数可能会退出,从而在 goroutines 完成计算之前终止我们的程序。在实际应用中,您也可以使用waitgroup等待线程解决。如果您在终端运行此代码后卡住了,请按Ctrl + C来终止进程。
惰性求值将在本章后面当我们查看实现函数管道时再次出现。然而,我们将更多地从并发应用程序的直接视角来探讨它,而不是我们在这里看到的回调机制。
线程与 goroutine
虽然线程和goroutine这两个术语经常被互换使用,但它们是不同的事物。Goroutines 是 Go 语言中的一个结构,旨在利用并发执行任务。它们由 Go 运行时管理,轻量级且启动和执行速度快,并且内置了通信介质(通道)。另一方面,线程在硬件级别实现,由操作系统管理。它们启动较慢,没有内置的通信介质,并且依赖于硬件。
可组合性
函数可以通过无数种方式组合。这使我们能够定义应用程序的构建块,然后将它们链接起来以解决具体问题。由于每个块都是相互独立的,我们可以在它们之间构建并发层。这将是本章最后部分的重点,届时我们将创建可以并发运行的函数管道。然而,在我们到达那里之前,让我们先看看如何使我们的函数内部并发。
创建并发函数
从广义上讲,在本章中我们将探讨两种类型的并发。我们可以称它们为内部并发和外部并发:
-
内部并发是关于创建在函数内部并发实现的函数。例如,在第六章中,我们看到了各种函数,如
Filter、Map和FMap,它们适合并发实现。这将是本节的重点。值得注意的是,它们可以相互结合使用,这样我们就可以在算法的多个步骤中实现并发,甚至可以单独决定每个步骤所需的并发级别。 -
外部并发是关于使用 Go 内置的并发特性(通道和 goroutine)链接函数。这将在本章后面进行探讨。
为什么许多函数式编程的基本构建块是并发实现的良好候选者?首先,这是因为纯函数式实现本身非常适合并发实现,而不会带来太多麻烦。正如我们在上一章中看到的,不可变状态和副作用消除的概念使得我们可以轻松地将函数并发重写。不应该有其他函数的干扰,没有外部状态需要处理,也没有 I/O 需要竞争。然而,仅仅因为我们“可以”并不意味着我们“应该”。在本章中,我将假设并发实现将是解决我们问题的正确选择。在现实世界中,并发并不是零成本实现。编写并发应用程序确实会有一些实际的开销,因为线程执行需要由我们的系统(或者,在 Go 的情况下,是我们的运行时)来管理。
虽然在 Go 语言中我们不需要自己管理 goroutines,但在 Go 运行时的底层,上下文切换并不是零成本实现。这意味着仅仅添加并发调用并不能保证性能提升,实际上可能会损害性能。最终,就像任何为了性能而做的事情一样,理解可以获得的收益的关键在于通过分析你的应用程序来获得。分析本身超出了本节的范围;对此唯一要说的就是,Go 语言内置了基准测试工具,我们已经在前面的章节中看到了这些工具。这些工具也可以用来确定并发函数与顺序函数的成本效益。
并发过滤器实现
由于我们在前面的章节中从顺序过滤器实现开始,并在整本书中对其越来越熟悉,让我们从这个函数开始,将其转换为并发实现。请注意,我们的初始函数是一个纯函数,因此将其重构为并发函数不会引起太多麻烦。使这个函数并发化有几个步骤:
-
将输入数据分割成批次。
-
启动一个进程来过滤每个批次。
-
聚合每个批次的处理结果。
-
返回聚合后的输出。
为了实现这一点,我们确实需要重构初始的Filter实现。我们将利用 Go 的一些内置并发特性来实现这一点,我们首先想要利用的是通道和 goroutines。在我们的初始Filter函数中,我们遍历每个元素,如果它匹配谓词,则将其追加到输出切片中,最后返回输出切片。在这个版本中,我们不会返回输出切片,而是将结果写入通道:
type Predicate[A any] func(A) bool
func FilterA any
{
output := []A{}
for _, element := range input {
if p(element) {
output = append(output, element)
}
}
out <- output
}
将数据写入通道允许我们在 Go 中以传统的并发方式调用这个函数。然而,在我们到达那里之前,我们将在Filter周围建立一个包装函数,我们将称之为ConcurrentFilter。这个函数做了一些事情,包括允许我们配置批次大小。玩弄批次大小可以帮助我们调整性能,使其达到我们想要的状态(如果批次太少,并发运行的好处很小;太多,管理 goroutines 带来的开销同样会减少我们的好处)。除了批处理我们的输入之外,我们还需要调用前面带有go关键字的Filter函数,以便启动一个新的 goroutine。最后,这个函数将读取我们启动的每个 goroutine 的结果,并将这些结果汇总到一个单一的输出切片中:
func ConcurrentFilterA any []A {
output := []A{}
out := make(chan []A)
threadCount := int(math.Ceil(float64(len(input)) /
float64(batchSize)))
fmt.Printf("goroutines: %d\n", threadCount)
for i := 0; i < threadCount; i++ {
fmt.Println("spun up thread")
if ((i + 1) * batchSize) < len(input) {
go Filter(input[i*batchSize:(i+1)*batchSize], p, out)
} else {
go Filter(input[i*batchSize:], p, out)
}
}
for i := 0; i < threadCount; i++ {
filtered := <-out
fmt.Printf("got data: %v\n", filtered)
output = append(output, filtered...)
}
close(out)
return output
}
在前面的代码片段中,我们保留了打印语句,这样我们就可以看到运行这个函数时的执行情况。让我们创建一个简单的main函数,它将以这种方式过滤整数切片,并查看相应的输出:
func main() {
ints := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
output := ConcurrentFilter(ints, func(i int) bool {
return i%2 == 0 }, 3)
fmt.Printf("%v\n", output)
}
运行这个函数会给我们以下输出:
goroutines: 4
spun up thread
spun up thread
spun up thread
spun up thread
got data: [10]
got data: [2]
got data: [4 6]
got data: [8]
[10 2 4 6 8]
在这个输出中,我们可以看到需要启动4个 goroutine 来处理我们的输入,批次大小为3。这已经将我们的输入数据分片成以下段:
[]int{1,2,3}
[]int{4,5,6}
[]int{7,8,9}
[]int{10}
接下来,我们可以看到线程完成并返回输出的顺序。正如你可以从输出中看到的,我们以随机顺序得到输出。这在got data输出以及最终的汇总结果中都是可见的。
提示
在这里的一个重要提示是,通过分片我们的数据和并发运行我们的函数,我们不再在输出列表中有一个可预测的顺序。如果我们想恢复我们数据的顺序,我们应该在并发调用我们的函数之后实现一个Sort函数。
当我们想要使我们的函数并发运行时,这个Filter实现是一个很好的起点模板。让我们看看Map和FMap函数的并发实现。
并发 Map 和 FMap 实现
实现并发Map和FMap函数需要与并发Filter实现相同的步骤,如下所示:
-
将输入分割成批次。
-
启动一个进程来过滤每个批次。
-
汇总每个批次的结果。
-
返回汇总的输出。
因此,我们不会详细说明这些实现的每个步骤。每个步骤背后的解释以及我们如何实现它基本上与Filter实现相同。我们在这里展示这些是为了完整性,并展示将这些函数重构为并发操作的一般模式。
并发 Map
要并发实现我们的 Map 函数,我们首先重构了在 第六章 中创建的 Map 函数。在这里,我们再次移除了显式的返回,并将使用通道来传递映射每个元素的输出:
type MapFunc[A any] func(A) A
func MapA any {
output := make([]A, len(input))
for i, element := range input {
output[i] = m(element)
}
out <- output
}
接下来,我们将实现 ConcurrentMap 函数,像我们在 ConcurrentFilter 实现中做的那样,批量处理输出:
func ConcurrentMapA any []A {
output := make([]A, 0, len(input))
out := make(chan []A)
threadCount := int(math.Ceil(float64(len(input)) /
float64(batchSize)))
fmt.Printf("goroutines: %d\n", threadCount)
for i := 0; i < threadCount; i++ {
fmt.Println("spun up thread")
if ((i + 1) * batchSize) < len(input) {
go Map(input[i*batchSize:(i+1)
*batchSize], mapFn, out)
} else {
go Map(input[i*batchSize:],
mapFn, out)
}
}
for i := 0; i < threadCount; i++ {
mapped := <-out
fmt.Printf("got data: %v\n", mapped)
output = append(output, mapped...)
}
close(output)
return output
}
注意,ConcurrentFilter 和 ConcurrentMap 的实现都需要将 batchSize 作为输入传递给函数。这意味着我们可以用不同数量的 goroutines 处理每个步骤,并单独调整每个函数:
func main() {
ints := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
output := ConcurrentFilter(ints, func(i int) bool {
return i%2 == 0 }, 3)
fmt.Printf("%v\n", output)
output = ConcurrentMap(output, func(i int) int {
return i * 2 }, 2)
fmt.Printf("%v\n", output)
}
在这个例子中,我们使用 3 作为过滤的批量大小,但只使用 2 作为映射的批量大小。这个 main 函数的输出如下所示:
goroutines: 4
spun up thread
spun up thread
spun up thread
spun up thread
got data: [10]
got data: [2]
got data: [4 6]
got data: [8]
[10 2 4 6 8]
{next statements are the output for the map function}
goroutines: 3
spun up thread
spun up thread
spun up thread
got data: [16]
got data: [20 4]
got data: [8 12]
[16 20 4 8 12]
并发 FMap 实现
这个实现与 Map 实现非常相似。主要区别在于我们的通道类型已经改变。而不是让整个函数签名都操作相同的 A 类型,我们现在将有一个 A 和 B 的混合。这是一个小的变化,不会影响实现细节,除了需要为通道创建正确的类型:
func FMapA, B any B, out chan []B) {
output := make([]B, len(input))
for i, element := range input {
output[i] = m(element)
}
out <- output
}
func ConcurrentFMapA, B any []B {
output := make([]B, 0, len(input)
out := make(chan []B)
threadCount := int(math.Ceil(float64(len(input)) /
float64(batchSize)))
fmt.Printf("goroutines: %d\n", threadCount)
for i := 0; i < threadCount; i++ {
fmt.Println("spun up thread")
if ((i + 1) * batchSize) < len(input) {
go FMap(input[i*batchSize:
(i+1)*batchSize], fMapFn, out)
} else {
go FMap(input[i*batchSize:],
fMapFn, out)
}
}
for i := 0; i < threadCount; i++ {
mapped := <-out
fmt.Printf("got data: %v\n", mapped)
output = append(output, mapped...)
}
return output
}
我希望这能说明,对于以纯函数风格编写的函数,创建并发实现是多么容易。Go 语言的一个限制使得这比其他语言要冗长一些。由于 Go 是一种严格类型化的语言(这在一般情况下是好事),当使用高阶函数时,我们的函数签名需要完全匹配。否则,我们可以模板化函数的递归部分,并在每个节点上调用高阶函数进行实际实现。在伪代码中,我们可能会得到以下内容:
func ConcurrentRunner(input []Input, fn func(), batchSize
int) []Output {
// set up channels and batch logic
for batch in batches {
go Run(fn(batch))
}
// collect output and return
}
无论哪种方式,我们都看到,在我们的函数中利用并发相对无头痛,只需稍作重构即可实现。让我们继续本章的最后一个主题,即使用并发机制将函数链接在一起。
管道模式
在前面的章节中,我们关注的是在函数内部组织并发。然而,我们基本上是按照常规顺序在主函数中调用它们来将它们链接在一起。在本节中,我们将探讨管道模式,这将允许我们利用 goroutines 和通道将函数调用链接在一起。首先,让我们讨论一下管道究竟是什么。在 1964 年,道格·麦克伊罗伊写了以下内容:
我们应该有一些方法来耦合程序,就像花园的水管一样——当需要以另一种方式处理数据时,可以拧上另一个段。
这句话巧妙地表达了 Unix 编程哲学中的程序组合思想。我们很多人对 Unix 管道的概念都很熟悉,管道用|符号表示。通过使用管道,我们可以将 Unix 程序连接起来。一个程序输出成为下一个程序的输入。例如,我们可以使用cat来读取文件,使用wc来获取该文件的单词数。要将它们组合在一起,我们会写cat file.txt | wc。在 Unix 的模块化程序方法中,其理念是每个程序只服务于单一目的,但可以组合起来创建复杂的程序。这种哲学可以移植到函数式编程范式。我们希望将简单的函数连接起来,每个函数只具有单一目的,以创建复杂的程序。以下是一个例子;每个函数只服务于单一目的,我们通过管道(|)字符将它们连接起来:
cat main.go | grep "func" | wc -l | awk '{print "lines: "
$1}'
在这个例子中,我们首先使用cat读取main.go文件。我们将该文件的内容发送到grep,在内容中搜索func关键字。然后,我们将匹配此搜索的每一行发送到wc程序,并计算输出中的行数(-l标志计算换行符)。最后,我们将这些发送到awk并打印结果。以下是以类似方式链式调用 Go 函数的方法,而不是 Unix 命令。
使用通道链式调用函数
Go 语言自带了创建此类构建程序的必要工具,即通道。通道是向另一个函数发送消息(数据)的方式;因此,我们可以将通道作为 Unix 管道的替代品。
创建我们的管道的第一步是改变我们的函数获取输入和输出的方式。在本章的剩余部分,我们将主要关注两个函数,Filter和Map,但这可以扩展到任何其他函数。核心思想是使用通道进行输入和输出数据通信。首先,让我们看看Filter函数以及如何将其修改为遵循我们的通道输入/输出方法。我们将我们的新函数命名为FilterNode。我们将在稍后回到这个命名约定,但每个函数都可以被视为我们函数链中的一个节点。我们不再接受切片作为输入,而将有一个通道作为输入,我们可以从中读取传入的数据。我们仍然会有predicate,最后,我们将返回一个通道而不是数据切片:
func FilterNodeA any
<-chan A {
out := make(chan A)
go func() {
for n := range in {
if predicate(n) {
. out <- n
}
}
close(out)
}()
return out
}
在前面的函数中,过滤元素的算法保持不变。我们将测试每个值是否满足谓词;如果谓词返回true,我们将保留该值(通过将其发送到输出通道);否则,我们将丢弃它。请注意这里go关键字的使用。这个函数虽然立即执行,但它是在自己的 goroutine 上启动的。函数立即返回out通道,尽管 goroutine 上的评估不一定已经完成计算。
我们将要重构的下一个函数是Map函数。它与Filter函数类似。我们将使用一个通道接收函数的输入,一个通道返回输出,并在返回通道之前在 goroutine 中运行实际的映射逻辑:
func MapNodeA any <-chan A
{
out := make(chan A)
go func() {
for n := range in {
out <- mapf(n)
}
close(out)
}()
return out
}
到目前为止,一切顺利——我们已经重构了两个函数以适应这种新的设计。接下来,让我们解决接收这些函数输入的问题。从函数签名中,我们可以看出我们需要在类型为A的通道上接收数据。因此,任何可以提供这种数据的函数都可以用作我们函数的输入。我们将这些类型的函数称为生成器。我们将创建的第一个生成器接受类型为A的可变数量输入,并将每个这些值推送到通道中:
func GeneratorA any <-chan A {
out := make(chan A)
go func() {
for _, element := range input {
out <- element
}
close(out)
}()
return out
}
如你所见,主要逻辑仍然类似于之前的Filter和Map实现。主要区别在于我们不再通过通道接收值,而是通过其他输入数据结构(在这种情况下,可变数量输入参数)接收值。这也可能是一个读取文件并将每一行放置在通道上的函数。这与我们早期 Unix 示例中的cat操作类似:
func Cat(filepath string) <-chan string {
out := make(chan string)
f, err := ioutil.ReadFile(filepath)
if err != nil {
panic(err)
}
go func() {
lines := strings.Split(string(f), "\n")
for _, line := range lines {
out <- line
}
close(out)
}()
return out
}
关键点在于我们的函数将值放置在通道上并返回这个通道。如何获取这些值对于我们构建流水线来说并不那么重要。在我们能够从头到尾测试这个实现之前,我们仍然有一个障碍需要克服。在这个设置中,每个节点都将数据写入一个通道,但为了在最后收集输出,我们希望将其存储在一个更常见的数据结构中。切片是我们例子中的完美结构。我们可以将这种最后类型的函数称为收集器。收集器接收一个通道作为输入,并返回一个元素切片作为输出。本质上,它是在执行生成器的相反操作:
func CollectorA any []A {
output := []A{}
for n := range in {
output = append(output, n)
}
return output
}
在此基础上,我们可以将它们全部整合成一个单一的流水线。为了演示这一点,在我们的main函数中,我们将使用Generator将一些数字推送到一个通道中。然后,我们使用FilterNode对这些数字进行过滤,只保留偶数。这些数字随后通过MapNode进行平方,最后,我们使用Collector函数在一个切片中收集输出:
func main(){
generated := Generator(1, 2, 3, 4)
filtered := FilterNode(generated, func(i int) bool
{ return i%2 == 0 })
mapped := MapNode(filtered, func(i int) int {
return i * 2 })
collected := Collector(mapped)
fmt.Printf("%v\n", collected)
}
运行此代码的输出如下:
[4 8]
上述步骤是串联我们的函数的一个很好的第一步。然而,我们可以让它更简洁。我们可以构建一个ChainPipes函数,它将连接各种函数并为我们管理通道。
改进的函数链式调用
将函数链式连接起来的初始方法是一个可行的解决方案,但它需要一些开销,因为我们必须管理传递正确的通道给每个后续函数。我们想要实现的是,使用我们设置的工程师只需要关注要调用的函数以及它们的调用顺序。我们不希望他们关心通道在底层是如何操作的;我们可以将其视为实现细节。在本节中,我们将努力实现以下内容:
out := pkg.ChainPipes(generated,
pkg.CurriedFilterNode(func(i int) bool { return i%2 == 0 }),
pkg.CurriedMapNode(func(i int) int { return
i * i }))
这个代码片段给我们一些关于接下来要发生什么的暗示。为了像这样链式连接函数,我们需要利用函数柯里化。让我们一步一步地实现它。我们想要实现的是通过将函数传递给ChainPipes来执行函数组合,就像我们在前面的代码片段中看到的那样。Go 有一个严格的类型系统,因此为了使这个函数正常工作,我们想要为这样的函数定义一个自定义类型,这将允许我们在函数签名中使用它,并让编译器为我们进行类型检查。
我们首先要做的是定义代表我们对数据进行操作的主要函数的自定义类型。我们将称之为Nodes。根据之前的讨论,我们可以定义三种不同的节点类型——生成通道的节点、接收通道并返回新通道的节点,以及最终接收通道并返回具体数据结构(如切片)的节点:
type (
Node[A any] func(<-chan A) <-chan A
GeneratorNode[A any] func() <-chan A
CollectorNode[A any] func(<-chan A) []A
)
这些类型定义构成了我们可以用来链式连接应用程序的功能类型的精华。有了这个,我们可以定义ChainPipes函数如下:
func ChainPipesA any []A {
for _, node := range nodes {
in = node(in)
}
return Collector(in)
}
前面的代码片段创建了一个ChainPipes函数,它接受一个通道作为输入和一系列节点。最后,它将调用默认的收集器,并以类型[]A的切片返回数据。请注意,一个限制是我们假设在链中的每个节点都具有兼容的类型(A)。
为了使类型系统正常工作,每个节点都需要有相同的函数签名。在我们的初始设置中,这很困难,因为我们已经有了两个不同的函数签名用于Filter和Map:
func FilterNodeA any
<-chan A
func MapNodeA any <-chan A
更多的函数意味着更多的不同函数签名。因此,我们需要重构这些函数,使它们遵循相同的类型签名。我们已经通过函数柯里化学习了如何做到这一点。我们需要为每个Node创建两个新函数。每个函数都将包含Filter和Map的原始功能,但返回一个接受通道作为输入的新函数(因此函数是部分应用的):
func CurriedFilterNodeA any Node[A] {
return func(in <-chan A) <-chan A {
out := make(chan A)
go func() {
for n := range in {
if p(n) {
out <- n
}
}
close(out)
}()
return out
}
}
func CurriedMapNodeA any Node[A] {
return func(in <-chan A) <-chan A {
out := make(chan A)
go func() {
for n := range in {
out <- mapFn(n)
}
close(out)
}()
return out
}
}
我们可以从前面的例子中看出,每个函数的核心逻辑保持不变。然而,而不是在函数被调用时立即应用,返回一个新的函数,该函数期望接收一个通道作为输入,并返回一个通道作为输出。在这个匿名函数内部,我们分别编写了Filter和Map逻辑。
由于返回类型是Node,这意味着当我们调用CurriedFilterNode函数时,我们并没有收到一个结果,而是收到另一个需要在稍后阶段调用的函数,以实际计算过滤后的值列表:
pkg.CurriedFilterNode(func(i int) bool { return i%2 == 0 }}
这是使我们的管道构建器正常工作的关键部分。如果我们再次查看ChainPipes,主循环是调用提供给它的节点(函数),以通道作为输入,并将输出重新分配到用作输入的相同通道:
for _, node := range nodes {
in = node(in)
}
我们可以更进一步,将生成器从ChainPipes函数中抽象出来:
func ChainPipesA any []A {
in := gn()
for _, node := range nodes {
in = node(in)
}
return Collector(in)
}
实施这一变化意味着在调用函数时,我们需要另一个柯里化函数来提供生成器。这可以在行内完成,但为了清晰起见,以下示例是一个存在于包级别的独立函数。在这种情况下,我们将使用我们之前引入的Cat函数,并返回其柯里化版本:
func CurriedCat(filepath string) func() <-chan string {
return func() <-chan string {
out := make(chan string)
f, err := ioutil.ReadFile(filepath)
if err != nil {
panic(err)
}
go func() {
lines := strings.Split(string(f),
"\n")
for _, line := range lines {
out <- line
}
close(out)
}()
return out
}
}
再次强调,这个柯里化版本的函数与未柯里化的版本以相同的方式运行。然而,通过柯里化,我们可以使其符合ChainPipes函数指示的类型签名。现在我们可以将生成器和节点都传递给这个函数:
func main() {
out := ChainPipesstring,
CurriedFilterNode(func(s string) bool {
return strings.Contains(s, "func") }),
CurriedMapNode(func(i string) string {
return "line contains func: " + i }))
fmt.Printf("%v\n", out2)
}
注意,在前面的例子中,我们确实需要给ChainPipes提供一个类型提示,以指示CurriedCat函数的结果类型。在前一节中我们看到,通过使用通道、Go 类型系统、高阶函数,特别是函数柯里化,我们可以通过正确链接函数来构建程序。使用这种函数组合方法,重构我们的应用程序也更容易。如果我们想在过滤之前应用映射,我们只需更改传递给ChainPipes的节点的顺序即可。
摘要
在本章中,我们探讨了在函数式编程范式编写代码时如何使用 Go 的并发模型。我们以对并发、并行和分布式计算之间差异的简要讨论开始本章,以明确并发是什么。
一旦我们确立了并发是同时执行多个任务的能力(尽管不一定必须是同时进行),我们就研究了如何将第六章中的函数重构为并发实现,利用通道和 goroutines。我们通过查看管道来结束这一章,管道是一种通过组合函数来创建程序并使用通道来协调数据流的方法。我们还探讨了如何创建一个高阶函数来组合函数(ChainPipes),并观察到通过使用函数柯里化,我们可以创建符合我们的类型系统而不牺牲类型安全的函数。
在下一章和最后一章中,我们将探讨一些编程库,这些库可以帮助我们创建 Go 程序,同时遵循我们在本书中探讨的一些函数式编程原则。
第十一章:函数式编程库
在本书的前几章中,我们探讨了如何在 Go 中使用函数式编程技术。在这个过程中,我们查看了几种函数的创建方式,例如 Filter、Map、Reduce 等。我们还探讨了数据结构,如单子及其与 Maybe 数据类型的结合应用,它可以表示一个存在或不存在但不依赖于 nil 的值。
如前所述,这些是函数式程序员工具箱中的常用工具。因此,有一些开源库内置了这些功能。由于泛型是 Go 中最近添加的特性(在撰写本文时大约 1 年前),并非所有库都利用泛型来实现这些概念。因此,本章将涵盖适用于所有 Go 版本的库,以及仅适用于支持泛型的版本的库。
在本章中,我们将涵盖以下主题:
-
用于创建常见 FP 函数的预泛型库
-
用于创建常见 FP 函数的后泛型库
技术要求
对于本章,任何版本的 Go 都足以实现预泛型库代码。一旦我们转向后泛型库,就需要 1.18 或更高版本来支持代码。所有代码都可以在 GitHub 上找到,网址为github.com/PacktPublishing/Functional-Programming-in-Go./tree/main/Chapter11。
在我们深入探讨这个主题之前,有一些与技术要求相关的事项需要指出。
这个库是否仍然活跃——示例是否仍然与之匹配?
当撰写一本关于特定编程语言的书籍时,很难以永恒的方式撰写它。但编程库可能比其他任何内容都更难保持永恒。这里有两大原因,这是需要承认的:
-
实现可能会更改,并且版本控制并不总是得到尊重。
-
该库未来可能会失去支持。
第一个问题,更改实现,应该会通过本章只探讨流行库这一事实得到一定程度的缓解,其中流行度是通过 GitHub 上的参与度和 GitHub 上的星标来评判的。这是一个不完美的衡量标准,但总比没有任何依据要好。
我希望这些库尊重版本控制,并且尽可能地限制破坏性更改。尽管如此,我无法保证这些库不会更改,也无法保证你在阅读这一章时函数将按原样工作。在代码示例中,我将突出显示正在展示的库版本,以便至少可以通过获取库的正确版本来重现结果,即使这不是最新版本。这引出了第二个,相关问题。
该库可能会变得不再受支持。如果您正在使用库的较旧版本来重现本章中的示例,因为最新版本引入了一些破坏性更改,那么您会遇到一些已知问题的风险,并且您可能不会得到支持,因为您正在使用较旧版本。但是,即使这里显示的示例与库的最新版本正确工作,该库仍然可能过时。如果一切按预期工作,并且库被认为是功能完整的,这并不立即是一个红旗。
然而,这也意味着找到这些库可能会有困难。确定这一点最好的方法是通过查看 GitHub(或 GitLab)页面上的任何活动。例如,最近的提交是在几天或几周前,还是几年前?贡献者是否积极回应问题,或者他们是否都未得到回复?他们是否在 Discord 或 IRC 上与社区互动?这些都是可以暗示库维护得如何的例子。
法律要求
我会简要介绍这部分内容,因为我不是律师。但是,任何处理开源代码的人都应该意识到,并非所有开源代码都是许可的。
注意
在与库合作之前,尤其是在商业环境中,务必审查软件许可,并确认您的用例在法律上是允许的,以及哪些条件下允许。(例如,某些许可允许带有归属的使用代码。其他许可可能仅允许非商业用途,等等。)
用于创建常见函数的前泛型库
无论是否有泛型,在任何编程语言中操作集合式数据结构是很常见的。存储一系列值,无论是代表测试分数的数字列表,还是医院中所有员工的集合结构,都是如此常见,以至于您迟早会遇到这些数据结构。对这些数据结构执行的操作也可以归入几个类别,尤其是当我们将它们抽象为高阶函数时。您可能必须以某种方式修改数据元素(例如,将所有值乘以二)或以某种方式修改容器(例如,删除所有奇数)。正如我们所见,我们不想实现一个像removeOdds或multiplyNumbers这样的函数,我们想写的是一个可以根据谓词过滤任何元素或根据转换修改元素的函数(这些分别是过滤和映射函数)。
在泛型引入之前,没有明确且最佳的方式来处理这个问题。当时不抽象这些用例的理由是,针对你的数据结构编写特定函数会在性能方面提供最佳结果。所以,你会放弃一些开发者舒适度,但会得到一个性能更好的应用程序。事后看来,集合上的许多操作具有相同的实现,这意味着实际上没有真正的性能差异。人们想出构建重复实现抽象的方法,这只是一个自然的结果。
广义而言,在泛型引入之前,有两条途径可以解决这个问题——要么通过针对空接口(interface{})编程,这是一个任何数据类型在 Go 中隐式遵守的接口,要么通过代码生成。前者,针对interface{}的编程,在类型安全和运行时安全方面有太多的缺点,因此不太可能强烈推荐。但后者,代码生成,仍然值得一看,仅仅是因为代码生成在泛型世界之后仍然可能有用,尽管适用于不同的用例。
库与自定义实现
在这本书中,我们看到了创建遵循函数式编程范式的自定义函数集的方法。库可能提供更高效的实现,并可以防止你重新发明轮子。然而,如果你想保持你的依赖图轻量级,现在 Go 有了泛型,自己提供一些实现会更容易。在泛型引入之前的 Go 版本中,这样做要困难得多,我更倾向于基于库的方法。无论是基于空接口的方法还是代码生成的方法,在没有错误和头痛的情况下实现都不是很容易。
泛型之前的 Go 代码生成库
如同其名,代码生成是一种生成 Go 代码的技术,然后我们可以像使用常规 Go 代码一样在我们的应用程序中使用它。Go 工具链提供了所有必要的工具来实现这一点。在 Go 中,你可以给你的代码添加注释,编译器会将其解释为命令。这些注释使得在程序编译时触发特殊操作成为可能。这些注释被称为指令。例如,你可以在一个函数上添加注释,告诉编译器避免内联这个函数(编译器可以忽略它,所以这更像是一个建议而不是命令):
//go:noinline
func someFunc() {}
代码生成库背后的想法,我们将在稍后探讨,是使用这些特殊的注释可以触发为特定类型生成函数,这些函数实现了常见的函数式编程操作,如过滤、映射、归约等。我们将要探讨的第一个库,Pie,正是以这种方式工作的。
Pie 的切片
我们将要探索的库是由 Elliot Chance 编写的 Pie,可在 GitHub 上找到:github.com/elliotchance/pie/tree/master/v1。此库有两个版本:
-
版本 1 专注于 Go 1.17 或更低版本
-
版本 2 是用于处理泛型的较新版本,需要 Go 1.18 或更高版本才能运行
在版本 1 中,有两种使用此库的方法。您可以直接使用函数来操作常见数据类型([]string、[]float64 或 []int),或者您可以使用此库为您自己的数据类型生成函数。首先,我们将探索内置结构,然后转向为自定义类型生成函数。
使用 Pie 的内置函数
Pie 支持三种数据类型的内置函数:
-
[]string -
[]float64 -
[]int
这些相当常见,因此默认支持这些功能是有意义的。在本书的各个示例中,我们已经展示了如何过滤整数切片以保留仅偶数。然后,我们使用 Map 函数对它们进行平方。在 Pie 中这样做很容易,并且遵循与我们之前在 第六章 和之后实现的代码相同的思路。由于我们是通过使用库来完成这项工作的,让我们首先查看 go.mod 文件的内容,以突出显示我们正在使用 Pie 的哪个版本:
go 1.17
require github.com/elliotchance/pie v1.39.0
注意
这显示的是 go 1.17,因为我们明确地正在查看在泛型引入之前可以使用的库。
现在我们已经导入了库(在运行 go get 之后),我们可以在我们的应用程序中使用它。让我们构建前面解释过的 Filter 和 Map 示例:
package main
import (
"fmt"
"github.com/elliotchance/pie/pie"
)
func main() {
out := pie.Ints{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}.
Filter(func(i int) bool {
return i%2 == 0
}).
Map(func(i int) int { return i * i })
fmt.Printf("result: %v\n", out)
}
运行此代码将输出 result: [4 16 36 64 100],正如预期的那样。Pie 允许我们构建和链式调用函数,类似于我们在本书中迄今为止所看到的。开箱即用,这仅适用于字符串切片、整数和 float64。每种类型都需要在库中进行自定义实现。通过将函数附加到具体类型,它可以支持为不同数据类型定义的多个 Filter 和 Map 函数。这也是我们自己考虑过要做的事情,正如所指出的,这是一项耗时且重复的工作。
Pie 通过使用代码生成来生成每种数据类型的实现,从而减少了这部分重复工作。在这个库中代码生成是如何工作的细节超出了本书的范围,但我鼓励您在 GitHub 上查看这个库本身,并深入研究代码,以更好地理解它是如何构建的,因为这确实非常有趣。
Pie 随带了许多函数。要获取每个函数的最新列表及其描述,请查看 github.com/elliotchance/pie/tree/master/v1 上的 wiki。
Pie 用于自定义数据类型
如果我们想为我们的自定义数据类型使用 Pie,我们需要生成执行此操作的代码:
-
首先,让我们设置一个结构体,我们可以在所有以下示例中使用它。我们将创建一个表示狗的结构体,并为
[]Dog类型创建一个类型别名://go:generate pie Dogs.* type Dogs []Dog type Dog struct { Name string Age int } -
设置好之后,我们可以运行
go generate命令,为我们的自定义数据类型生成 Pie 的所有函数。这在我们类型定义所在的目录中创建了一个新的文件dogs_pie.go。通过查看生成的文件,我们可以看到哪些函数被生成。例如,Reverse函数是专门为Dog数据类型生成的。这里逐字复制如下:// Reverse returns a new copy of the slice with the elements ordered in reverse. // This is useful when combined with Sort to get a descending sort order: // // ss.Sort().Reverse() // func (ss Dogs) Reverse() Dogs { // Avoid the allocation. If there is one element or less it is already // reversed. if len(ss) < 2 { return ss } sorted := make([]Dog, len(ss)) for i := 0; i < len(ss); i++ { sorted[i] = ss[len(ss)-i-1] } return sorted } -
我们还可以找到为
Dog数据类型定义的 Filter 和 Map 函数。同样,这些函数是逐字复制的,但注释已被省略:func (ss Dogs) Filter(condition func(Dog) bool) (ss2 Dogs) { for _, s := range ss { if condition(s) { ss2 = append(ss2, s) } } return } func (ss Dogs) Map(fn func(Dog) Dog) (ss2 Dogs) { if ss == nil { return nil } ss2 = make([]Dog, len(ss)) for i, s := range ss { ss2[i] = fn(s) } return }
这种方法应该强调的是,如果您为许多不同的类型生成这些函数,您将在代码库中添加大量相似但不完全相同的代码。由于这个原因,您构建的可执行文件将更大,尽管这不再是您经常需要考虑的事情,但如果您针对的是内存可用性有限的平台,这可能会成为障碍。
话虽如此,让我们看看如何在main函数中的另一个示例中利用生成的函数。首先,我们将创建一些狗,每只狗都有一个名字和年龄。然后,我们将过滤出年龄大于 10 岁的狗。这些结果将根据年龄排序,并将作为结果打印出来:
func main() {
MyDogs := []pkg.Dog{
pkg.Dog{
"Bucky",
1,
},
pkg.Dog{
"Keeno",
15,
},
pkg.Dog{
"Tala",
16,
},
pkg.Dog{
"Amigo",
7,
},
}
results := pkg.Dogs(MyDogs).
Filter(func(d pkg.Dog) bool {
return d.Age > 10
}).SortUsing(func(a, b pkg.Dog) bool {
return a.Age < b.Age
})
fmt.Printf("results: %v\n", results)
}
给定这个输入,我们得到以下输出:
results: [{Keeno 15} {Tala 16}]
在 Pie 中,对于 Go 的预泛型版本,还有更多功能可以探索。但现在让我们将焦点转移到当代 Go 代码上,看看自 Go 1.18 以来我们可以利用的库。
go generate 和 go 环境
要使用 Pie 或通过go get下载的任何其他可执行文件运行go generate,您需要确保您的环境设置已正确配置,以便发现此类可执行文件。在基于*nix 的系统上,这意味着需要将go/bin添加到$PATH变量中。在 Windows 上,您需要将go/bin添加到环境变量中。在最坏的情况下,您可以下载 GitHub 源代码或查找下载 go 依赖项的目录,并通过go install自行构建它们,然后将可执行文件移动到已为您系统注册的环境位置。
饼图和 Hasgo
为了保持透明度,还有一个库遵循与 Pie 类似的方法,但将函数定制为类似 Haskell 的实现。这个库叫做 Hasgo (github.com/DylanMeeus/hasgo),我是它的作者。虽然这两个库的工作方式相似,但 Pie 提供了更多的内置函数,并且完全支持 Go 1.18。但是,如果你之前编写过 Haskell,Hasgo 在函数命名和文档方面可能感觉更熟悉。
泛型之后的函数式编程库
自从 Go 中引入泛型以来,函数式编程库的受欢迎程度有所上升。不再需要与空接口纠缠,也不必依赖代码生成来构建构成函数式编程语言的基础。在本节中,我们将探索几个库,并比较它们的实现方式。在这样做的时候,我们将坚持使用大致相同但可能展示一些与本书中迄今为止所见不同的函数的示例。
带有泛型的 Pie
我们将要查看的第一个库是 Pie。在上一节中,我们指出目前有两个版本的 Pie 可用:v1,它针对泛型引入之前的 Go 进行了定制;v2,它提供了相同的功能,但利用泛型来实现。v2 正在积极维护,因此我预计随着时间的推移,v1 和 v2 将不再提供功能对等。尽管如此,Go 社区在尽可能的地方都很好地采用了最新的 Go 版本,所以我不认为这会成为任何人的障碍。
在我们深入代码之前,这是 go.mod 文件的片段,只是为了突出我们正在使用 Pie 的哪个版本:
go 1.18
require github.com/elliotchance/pie/v2 v2.3.0
go 1.18 语句表示我们可以使用泛型,因为泛型是在这个版本中引入的。任何高于 1.18 的版本都将适用于我们即将看到的示例。
与泛型之前的示例一样,我们将使用 Dog 结构体和 []Dog 类型的切片。与之前的非泛型示例不同,我们不需要添加编译器指令来生成任何代码,也不需要为 []Dog 添加类型别名(尽管在实际应用中使用它仍然是一种好的实践):
type Dog struct {
Name string
Age int
}
在 main 函数中,我们将创建一个狗的切片。然后,我们将再次筛选出年龄大于 10 岁的狗。然后,我们将它们的名称映射为大写,最后按年龄返回排序后的结果:
import"github.com/elliotchance/pie/v2"
func main() {
MyDogs := []Dog{
Dog{
"Bucky",
1,
},
Dog{
"Keeno",
15,
},
Dog{
"Tala",
16,
},
Dog{
"Amigo",
7,
},
}
result := pie.Of(MyDogs).
Filter(func(d Dog) bool {
return d.Age > 10
}).Map(func(d Dog) Dog {
d.Name = strings.ToUpper(d.Name)
return d
}).
SortUsing(func(a, b Dog) bool {
return a.Age < b.Age
})
fmt.Printf("out: %v\n", result)
}
如您所见,代码与预泛型版本非常相似。然而,没有使用代码生成来实现这一点。此外,请注意 pie.Of() 确定了我们正在操作的数据类型。在预泛型版本中,这是我们必须为 []Dog 创建类型别名的原因之一——这样代码生成器就可以使用 Filter、Map、Reduce 或其他方法为正确的切片类型附加它,并用于点符号风格的函数链式调用。有了泛型,我们就不再需要这样做。一般来说,如果你想在团队中引入泛型,Pie 是一个很好的库去探索,因为熟悉点符号风格的函数调用链对于习惯了面向对象方法的开发者来说看起来很自然。如前所述,它有一套广泛的函数可以直接使用。接下来,让我们看看一个基于 Lodash 的函数式编程库。
Lodash,适用于 Go
lo (github.com/samber/lo) 是一个库,类似于 Pie,它为 Go 添加了易于使用的功能,并且目前非常受欢迎。它受到了 JavaScript 中极其流行的 Lodash 库 (github.com/lodash/lodash) 的启发,该库目前在 GitHub 上有超过 55,000 个星标,并且被广泛使用。
目前,lo 支持 38 个在切片上操作的功能,其中 16 个操作在 Map 数据类型上,还有许多方便的搜索、元组、通道和(集合)交集样式操作的功能。在这里概述所有这些功能并不实际,但如果你的问题需要操作这些常见的容器数据类型,那么在重新发明轮子之前检查这个库是否满足你的需求是个好主意。在本节中,我们将查看一个与用于 Pie 的示例类似的例子。
使用 lo 的一个示例实现
由于我们正在导入一个新的库,以下代码片段显示了我们将用于这些示例的库及其版本:
go 1.18
require (
github.com/samber/lo v1.37.0
)
为了演示这个库,我们还将使用一个 main 函数和狗的切片。在这种情况下,我们想要做以下事情。首先,我们将去除切片中的重复项,以确保切片中的每个元素都是唯一的。然后,我们将所有狗的名字转换为大写形式。这是我们将会打印的结果:
func main() {
result :=
lo.Map(lo.Uniq(MyDogs), func(d Dog, i int)
Dog {
d.Name = strings.ToUpper(d.Name)
return d
})
fmt.Printf("%v\n", result)
}
在这个小型示例中,您可以看到库的使用更像是(纯)函数式编程语言选择的一种风格,而不是面向对象代码中常见的点符号风格。我们通过将它们作为高阶函数的输入参数来链式调用函数调用。请注意,这些不是惰性求值的。在前面的示例中,首先运行 Uniq 函数,它从我们的输入切片中删除重复条目。然后运行 Map 函数并应用转换。记住,我们通过调用 d.Name = ... 来修改 Dog 结构体,但这不会修改原始数据元素。我们在这本书的前几章中对此进行了更详细的探讨。
有一个额外的功能值得指出。lo 包含了支持并发函数调用的库的子集。在 lo 的 lo/parallel 目录下有一个包,支持函数调用的并行评估。让我们重写我们的示例,但让 Map 函数以并发方式工作。(此外,请注意,这个包被称为 parallel,但讨论的是 并发代码)。
首先,这是导入语句和导入别名:
lop "github.com/samber/lo/parallel"
接下来,这是运行 Map 函数的并发代码,而 Uniq 函数仍然按顺序运行:
result :=
lop.Map(lo.Uniq(MyDogs), func(d Dog, i int)
Dog {
d.Name = strings.ToUpper(d.Name)
return d
})
fmt.Printf("%v\n", result)
这几乎不需要我们进行重构,但利用了 goroutines 来实现并发。相当不错!
为了结束这一章,让我们看看由 lo 的同一作者编写的一个库,该库包含类似于 monad 的数据结构,例如我们曾在 第五章 中探讨的 Maybe 数据类型。
Mo,为 go
Mo 是一个在 Go 中添加对类似 monad 数据结构支持的库,并且相对流行。它完全支持 Go 1.18+,因此是围绕泛型构建的。您可以在以下位置找到该包本身:github.com/samber/mo。
值得花时间探索这个库并阅读文档,特别是当您阅读这本书时,这可能已经发生了变化。本质上,它的工作方式与 第五章 中的 Maybe 实现相同,尽管在这个库中,该类型被称为 Option。我们可以创建一个可选包含值的类型,但也可以表示值的缺失。然后,这个数据类型支持转换数据或以 nil 安全的方式获取数据的函数。例如,让我们创建一个包含狗的选项:
func main() {
maybe := mo.Some(Dog{"Bucky", 1})
getOrElse := maybe.OrElse(Dog{})
fmt.Println(getOrElse)
}
这将打印以下内容:
{Bucky 1}
现在,如果我们使用这个来表示 nil 值,我们仍然可以以类型安全的方式访问它。OrElse 函数将确保使用备份作为函数调用的结果,这是调用者提供的默认值。例如,让我们在我们的 main 函数中添加以下代码:
maybe2 := mo.None[Dog]()
getOrElse2 := maybe2.OrElse(Dog{"Default", -1})
fmt.Println(getOrElse2)
输出将看起来像这样:
{Default -1}
这个库还支持其他类型,例如Future和Task。但其中一个特别有用的类型是Result类型,它或多或少类似于Maybe类型,但旨在处理值可以可选地包含错误的情况。我们将在下面的代码片段中演示这一点。首先,我们将调用Ok()函数,它使用有效的Dog对象创建Result类型。在第二种情况下,我们将使用错误而不是Dog对象来创建Result类型。在这两种情况下,我们将尝试获取并打印结果以及错误信息:
ok := mo.Ok(MyDogs[0])
result1 := ok.OrElse(Dog{})
err1 := ok.Error()
fmt.Println(result1, err1)
err := errors.New("dog not found")
ok2 := mo.ErrDog
result2 := ok2.OrElse(Dog{"Default", -1})
err2 := ok2.Error()
fmt.Println(result2, err2)
如果我们运行这个函数,我们将得到以下输出:
{Bucky 1} <nil>
{Default -1} dog not found
这表明根据Result的error值的内容,类型的行为是不同的。在第一种情况下,我们没有错误,我们得到正确的狗,错误为空。在第二种情况下,我们得到作为OrElse语句一部分提供的默认值,以及底层错误信息。
摘要
在本章中,我们探讨了实现函数式编程范式概念的库。我们首先了解了 Pie 库,这个库可以帮助用户在 Go 1.18 引入泛型之前或之后使用 Go 构建函数式编程的代码。特别是对于泛型之前的版本,我们研究了为自定义类型生成代码以获得类似泛型行为的方法。Pie 库使我们能够展示自从引入泛型以来,我们能够多么容易地创建如 Map 和 Filter 之类的函数。
然后,我们探讨了受 Lodash 启发的 Go 库lo。这个库支持在切片和映射等容器数据类型上操作的一些常见函数,但与 Pie 不同,它采用嵌套方法进行函数链式调用,而不是点符号语法。lo库为某些函数提供了并发实现,所以如果性能是一个关注点,并且并发似乎是正确的解决方案,那么检查这个库是个好主意。
最后,我们探讨了添加到 Go 中的类似 monad 的数据结构库mo。具体来说,我们探讨了Option数据结构,它与我们在第五章中创建的Maybe数据结构类似。mo还提供了一个Result类型,它是为错误处理而构建的,并允许我们在处理潜在的error值时编写更安全的代码。


浙公网安备 33010602011771号