本文将探讨如何在Go中利用函数式编程的概念。
我们将涉及引入Go 1.18中的泛型所开放的一些可能性和一些限制。函数式编程风格帮助我们编写易于理解、可维护和可测试的代码。
许多底层概念已经被广泛使用的语言的最新版本所采用,尤其是map/reduce模式或可选值的概念。
指导原则
我想强调以下是函数式编程风格的基本原则:
纯函数
顾名思义,函数式编程的核心是编写函数。如果一个函数的输出仅取决于其输入并且没有任何副作用,则该函数被认为是纯函数。这种类型的函数提供了几个好处:
- 通常很容易理解函数在做什么,因为你知道它没有任何副作用-根据定义
- 函数可以进行单元测试,而无需设置复杂的模拟
- 纯函数可以轻松地在多线程环境中使用。由于它们没有副作用,它们也不需要同步外部数据结构
具有副作用的函数被认为是不纯的。在这种情况下,副作用是指修改非本地状态(即更改全局变量,突变输入参数)或任何形式的I/O(从流或文件读取/写入,打印到控制台等)。
另一方面,副作用是程序的一个关键特征,因此函数式编程提供了一些机制来将它们与纯函数结合使用,例如I/O Monad。我们不会在本文中深入讨论。
不可变性
不可变数据结构背后的关键思想是,如果一个函数不能更改任何数据,则该函数的行为更容易预测。这与纯函数的概念相呼应,即纯函数意味着纯函数不能更改其任何输入。我们不改变数据,而是制作略微修改的数据副本。
组合
拥有纯函数的优点之一是我们可以使用这些函数并将它们组合成更复杂的(纯)函数。由于这样一个组合的每个构建块都可以作为一个单元进行测试,因此我们可以自信地推导出更高级的函数,直到最终到达所需程序的级别。
事实证明,可以想出一组有用的组合函数,因此我们可以通用地实现它们。这反过来提高了可读性,因为相同的组合函数集一遍又一遍地出现,总是具有相同的语义。
应用于Go
让我们探讨上述概念如何适用于Go语言。我们使用一个简单的例子来说明:
编写一个函数,该函数执行以下操作:
- 将未知对象的映射作为输入
- 读取此映射的一个条目
- 如果该条目是字符串,则尝试将其转换为
int
- 如果任何操作失败,则返回默认值
在惯用的Go中,这段代码可能如下所示:
func lookup(map[string]interface{}, string) (interface{}, bool)
func parseToInt(string) (int, error)
func myFunc(m map[string]interface{}, key string, defaultVal int) int {
v, ok := lookup(m, key)
if !ok {
return defaultVal
}
s, ok := v.(string)
if !ok {
return defaultVal
}
i, err := parseToInt(s)
if err != nil {
return defaultVal
}
return i
}
在Go中,可能会失败的操作会将其返回值建模为具有实际值的元组的第一个元素,而将标志或错误建模为第二个元素。但是,在Go中没有将此元组表示为顶级类型,即我们只能将其解构为两个变量,但我们无法编写接受元组作为输入的函数,以将各个步骤组合在一起。
让我们引入Option
的概念来解决这个问题。Option
是一种数据类型,它可以携带一个值或没有值的概念:
type Option[T any] struct {
Value T
Valid bool
}
使用此数据类型,我们可以编写可组合的函数来表示原始操作:
func Lookup[T any](m map[string]interface{}, key string) Option[T] {
v, ok := m[key]
if !ok {
return Option[T]{Valid: false}
}
return Option[T]{v, true}
}
func ToString[T fmt.Stringer](o Option[T]) Option[string] {
if !o.Valid {
return Option[string]{Valid: false}
}
return Option[string]{o.Value.String(), true}
}
func ParseToInt(o Option[string]) Option[int] {
if !o.Valid {
return Option[int]{Valid: false}
}
i, err := strconv.Atoi(o.Value)
if err != nil {
return Option[int]{Valid: false}
}
return Option[int]{i, true}
}
注意辅助函数使用了泛型。Lookup
方法完全通用,可以与任何类型的map
一起使用,因此它是实用库的好选择。ToString
和ParseToInt
方法将通用的Options
类型专门用于其用例。
将原始操作分离到自己的函数中的第一个优点是我们现在可以为这些函数编写单元测试,包括完整的边界检查。真正的优势在于我们将它们组合在一起。为此,我们定义可重用的组合操作:
func Map[T1 any, T2 any](f func(T1) T2) func(Option[T1]) Option[T2] {
return func(o Option[T1]) Option[T2] {
if !o.Valid {
return Option[T2]{Valid: false}
}
return Option[T2]{f(o.Value), true}
}
}
func Chain[T1 any, T2 any](f func(T1) Option[T2]) func(Option[T1]) Option[T2] {
return func(o Option[T1]) Option[T2] {
if !o.Valid {
return Option[T2]{Valid: false}
}
return f(o.Value)
}
}
特别要注意Chain
方法。它将可能包装一种类型A
的Option
转换为可能包装另一种类型B
的Option
。类型之间的关系由转换函数给出。我们的所有辅助函数都是这种特定数据类型的,它们将一个普通类型作为输入,并返回另一种类型的Option
作为输出。因此,它们立即与我们的新Chain
操作一起工作。让我们看看如何将其应用于我们的原始问题:
func myFunc(m map[string]interface{}, key string, defaultVal int) int {
return Pipe(
Lookup[interface{}](m, key),
Chain[interface{}, string](ToString),
Chain[string, int](ParseToInt),
GetOrElse(defaultVal),
)
}
这个示例的优点是它避免了重复的if
语句以进行错误处理。它显示了逻辑顺序的操作流程,并将错误处理移动到组合函数的实现中。但是,代码看起来有点复杂。
我们在这段代码中注意到两个方面:
- 每一行都有相同的结构。首先,我们创建一个描述所需操作的函数,例如
Chain(ToString)
,然后将此函数应用于值 - 每个函数的参数都是上一个函数的返回值,正式上我们不需要任何中间值
那么,我们如何以更可读和更紧凑的方式将这些函数组合在一起?
首先尝试,我们可以简单地尝试将这些函数链在一起作为嵌套函数。这是有效的,但它严重缺乏可读性,主要是因为函数的读取顺序与执行顺序不匹配。我们首先读取O.GetOrElse
,但实际上它是最后一个要调用的函数。此外,我们添加的步骤越多,阅读体验就越差。
让我们通过引入另一个实用函数Pipe
来解决这个问题。它接受初始值,然后是一系列连续应用于上一个函数输出的函数:这段代码看起来更简洁,它按照逻辑顺序显示操作,易于理解,而且没有在代码中添加显式的错误处理。我们仍然可以做得更好,因为我们注意到除了作为第一个函数的种子外,输入变量 data
并不需要显式地使用。
因此,我们不使用 Pipe
辅助函数,而是引入一个 Flow
输入函数,其签名为 func Flow(f1, f2, ...) func(T)R
,即它创建一个与它作为第一个参数接收到的函数具有相同输入的新函数,其返回值与最后一个函数的返回值相同。然后,我们的示例最终如下所示:
Monad
在开发最后一个函数的过程中,我们发现了 Options
类型的有用性及其关键操作 Of
和 Chain
。在函数式编程中,这就是我们所谓的 Monad ,它可以应用于许多其他用例。
在我们的实现中,Of
方法有时被称为 unit
、 return
或 just
,它的目的是将一个值包装成一个带有通用操作的盒子。Chain
方法也称为 bind
、 flatMap
或 mergeMap
,允许将函数应用于包装的值,同时让通用代码在结果上运行。
Golang 特性
在 Go 1.18 中引入了 generics ,这使得编写各种数据类型的代码成为可能。使用语法 type Option[A any] interface
,我们告诉编译器我们打算表示任何类型的数据的包装器,而不在编码时指定这个类型。
函数式编程方法的另一个关键方面是组合函数。定义只有一个输入参数和一个输出的函数是非常有帮助的,因为这样我们可以轻松地将一个函数的输出作为下一个函数的输入,并创建管道。为了使用这个特性,我们利用了 go 创建 高阶函数 的能力。
我们的 Chain
方法被声明为 func Chain[A, B any](f func(A)Option[B])func(Option[A])Option[B]
,即它是一个函数,它接受一个变换函数 f
作为输入,并将另一个函数作为输出返回。这个函数反过来接受一个输入,即 Option[A]
,并返回一个输出,即 Option[B]
。这种结构允许有效的函数组合。
纯函数的函数式编程概念与 Go 语言特性非常匹配。虽然 Go 不强制执行这种模式,但它允许实现。
加上对高阶函数的良好支持,它在程序员的纪律方面是写纯函数的。
限制
虽然可以使用函数式风格实现程序,但存在几种语言限制:
类型不变
Go 不支持不可变数据结构的概念。如果需要的话,程序员需要确保在函数调用过程中数据结构保持不变。
然而,这种概念的缺乏导致所有数据类型,特别是容器类型,都被认为是 不变的。如果语言支持不可变类型,我们会更喜欢纯函数的协变类型概念。
考虑以下示例:
我们可以轻松地使用 int
作为输入调用 TakeAny
函数,尽管它接受一个 any
作为输入。
但是,我们不能进行第二次调用,其中 TakeAnyArray
接受一个 []any
,而我们尝试使用一个 []int
调用它。
这完全是有道理的,因为 []int
是可修改的,如果我们允许它传递到接受 []any
的函数中,那么该函数可能会尝试将 float
或任何其他数据类型添加到数组中,这当然是行不通的。如果我们有一种方法可以告诉编译器我们只打算从数组中读取,那么将 int
数组传递进去就是可接受的,但是 情况并非如此。
请注意,原始的 int
是本质上不可变的,这就是我们可以无问题地调用 TakeAny
的原因。
该问题适用于所有容器类型,包括我们的 Option[A]
。我们知道 Option
本质上是不可变的,但是没有办法告诉编译器。
因此,如果我们有一个返回 Option[*File]
(例如 Open)的函数,我们不能直接将它链式连接到接受 Reader
的函数中,除非进行显式类型转换。
函数重载
Go 不支持函数重载,即定义具有相同名称但具有不同参数的函数。
最接近重载的是 可变参数函数 ,但这些要求可变函数参数具有相同的类型。
这个限制直接适用于我们的 Pipe
函数。理想情况下,我们想要将该函数定义为任意数量的输入,同时保持类型安全。
但是,由于函数重载的缺乏,我们需要明确为每个参数数量定义一个不同的函数。并且我们需要记住在添加和删除管道步骤时使用正确的数字后缀。但至少这给了我们类型安全性,编译器会让我们知道如果我们使用错误的参数数量调用该函数。
方法的类型参数
在上面的示例中,你可能会想知道为什么我们要编写复杂的 Pipe
函数而不是使用方法进行链接。
在上面的虚构示例中,如果 Optional
类型包含了 Chain
和其他操作作为实例方法,我们就不需要 Pipe
了。
但是,这是不可能的,因为 Chain
需要两个类型参数,一个是它所操作的 Option
类型,另一个是返回值的类型,因为我们使用变换函数来改变类型。这将需要 Chain
携带它自己的类型参数,而这是 generics spec 所禁止的。
结论
函数式编程风格提供了一种非常引人注目的编程模型,但与此同时,它在使用 Go 语言时暴露了一些缺点,尤其是因为(暂时的?)语言限制和偏离惯用编码风格。使用它是否仍然值得呢?对我而言,答案很明显——是的。纯函数、不可变数据结构和函数组合几乎自动导致可测试和干净的代码。如果编写测试很容易,写测试代码和函数就会变得像呼吸一样自然,这在未来会有所回报。
函数组合允许将大部分样板代码移到实用库中。这些库只需编写和测试一次,然后就可以在许多项目中重复使用。
由于相同的组合函数Chain
、Map
、Reduce
……一遍又一遍地出现,甚至跨越了Monads
,因此尽管代码偏离了惯用编码风格,但它很容易阅读。这只是一个浅显易懂的学习曲线,只要习惯了就好了,这是我的经验。
但为什么不使用对函数式模式有更好内置支持的语言,如Rust?选择语言不仅取决于语法和语言特性,还取决于生态系统。如今,Go是云应用程序的通用语言,因此,如果想要与大量库的庞大生态系统集成,Go是一个不错的选择。此外,工具和IDE支持也非常好,所以我们可以轻松地交叉编译到许多平台。
因此,让我们将两者的优点结合起来编写令人惊叹的代码!
译自:https://betterprogramming.pub/investigate-functional-programming-concepts-in-go-1dada09bc913
评论(0)