发布于 2026-01-05 1 阅读
0

Go语言中7个简单易学的函数式编程技巧

Go语言中7个简单易学的函数式编程技巧

原文发表于deepu.tech

函数式编程(FP)目前非常火爆,很多程序员都在使用它,但它并非万能灵药。与其他编程范式/风格一样,函数式编程也有其优缺点,每个人对不同的范式都有不同的偏好。如果你是一名 Go 开发者,并且想要尝试函数式编程,不用担心,你无需学习 Haskell 或 Clojure 等面向函数式编程的语言(甚至 Scala 或 JavaScript,尽管它们并非纯函数式编程语言),因为 Go 已经为你提供了所需的一切,这篇文章正是为你准备的。

如果您正在寻找Java函数式编程方面的知识,那么请查看这篇文章。

我不会详细讲解函数式编程的所有概念,而是会重点介绍 Go 语言中符合函数式编程概念的实现方式。我也不会讨论函数式编程的优缺点。


什么是函数式编程?

根据维基百科,

函数式编程是一种编程范式——一种构建计算机程序结构和元素的风格——它将计算视为数学函数的求值,并避免改变状态和可变数据。

因此,在函数式编程中,有两条非常重要的规则。

  • 禁止数据变更:这意味着数据对象创建后不应再被更改。
  • 避免隐式状态:应避免隐藏/隐式状态。在函数式编程中,状态并非被消除,而是变得可见且显式。

这意味着:

  • 无副作用:函数或操作不应改变其作用域之外的任何状态。也就是说,函数应该只向调用者返回一个值,而不应影响任何外部状态。这意味着程序更容易理解。
  • 纯函数:函数式代码是幂等的。函数应该只根据传入的参数返回值,并且不应该影响(产生副作用)或依赖于全局状态。这样的函数对于相同的参数总是产生相同的结果。

除此之外,还有一些函数式编程概念可以应用于 Go 语言,我们将在后面详细介绍。

使用函数式编程并非非此即彼,你始终可以将函数式编程的概念与 Go 语言中的面向对象或命令式编程概念相结合。无论你使用何种编程范式或语言,只要有可能,你都可以利用函数式编程的优势。而这正是我们接下来要看到的。


Go语言中的函数式编程

Golang 是一种多范式语言,所以让我们看看如何在 Go 中应用上面提到的一些函数式编程概念。

一类函数和高阶函数

一等函数(函数作为一等公民)意味着你可以将函数赋值给变量,将函数作为参数传递给另一个函数,或者从另一个函数返回一个函数。Go 支持这种特性,因此闭包、柯里化和高阶函数等概念的编写变得非常简单。

只有当一个函数接受一个或多个函数作为参数,或者返回另一个函数作为结果时,该函数才能被视为高阶函数。

在 Go 语言中,这很容易做到。

func main() {
    var list = []string{"Orange", "Apple", "Banana", "Grape"}
    // we are passing the array and a function as arguments to mapForEach method.
    var out = mapForEach(list, func(it string) int {
        return len(it)
    })
    fmt.Println(out) // [6, 5, 6, 5]

}

// The higher-order-function takes an array and a function as arguments
func mapForEach(arr []string, fn func(it string) int) []int {
    var newArray = []int{}
    for _, it := range arr {
        // We are executing the method passed
        newArray = append(newArray, fn(it))
    }
    return newArray
}
Enter fullscreen mode Exit fullscreen mode

在 Go 中,闭合和咖喱化也是可能的。

// this is a higher-order-function that returns a function
func add(x int) func(y int) int {
    // A function is returned here as closure
    // variable x is obtained from the outer scope of this method and memorized in the closure
    return func(y int) int {
        return x + y
    }
}

func main() {

    // we are currying the add method to create more variations
    var add10 = add(10)
    var add20 = add(20)
    var add30 = add(30)

    fmt.Println(add10(5)) // 15
    fmt.Println(add20(5)) // 25
    fmt.Println(add30(5)) // 35
}
Enter fullscreen mode Exit fullscreen mode

Go 标准库中也内置了许多高阶函数。此外,还有一些函数式风格的库,例如这个这个,它们在 Go 中提供了类似 MapReduce 的函数式方法。

纯函数

正如我们之前看到的,纯函数应该只根据传入的参数返回值,而不应该影响或依赖于全局状态。在 Go 语言中,这很容易实现。

这很简单,下面这个纯函数就是一个纯函数。对于给定的输入,它总是返回相同的输出,其行为高度可预测。如有需要,我们可以安全地缓存该方法。

func sum(a, b int) int {
    return a + b
}
Enter fullscreen mode Exit fullscreen mode

如果在这个函数中添加一行额外的代码,其行为将变得不可预测,因为它现在会产生影响外部状态的副作用。

var holder = map[string]int{}

func sum(a, b int) int {
    c := a + b
    holder[fmt.Sprintf("%d+%d", a, b)] = c
    return c
}
Enter fullscreen mode Exit fullscreen mode

所以尽量保持函数简洁明了。

递归

函数式编程更倾向于使用递归而非循环。让我们来看一个计算一个数的阶乘的例子。

在传统的迭代方法中:

func factorial(num int) int {
    result := 1
    for ; num > 0; num-- {
        result *= num
    }
    return result
}

func main() {
    fmt.Println(factorial(20)) // 2432902008176640000
}
Enter fullscreen mode Exit fullscreen mode

同样的方法也可以用递归来实现,如下所示,递归是函数式编程中常用的方法。

func factorial(num int) int {
    if num == 0 {
        return 1
    }
    return num * factorial(num-1)
}
func main() {
    fmt.Println(factorial(20)) // 2432902008176640000
}
Enter fullscreen mode Exit fullscreen mode

递归方法的缺点在于,它通常比迭代方法慢(我们追求的是代码简洁性和可读性),并且由于每次函数调用都需要保存为一个栈帧,因此可能会导致栈溢出错误。为了避免这种情况,尾递归是更优的选择,尤其是在递归次数过多时。在尾递归中,递归调用是函数执行的最后一步,因此编译器无需保存函数的栈帧。大多数编译器可以像优化迭代代码一样优化尾递归代码,从而避免性能损失。遗憾的是,Go 编译器并不进行这种优化。

现在使用尾递归,同一个函数可以写成如下形式,但是 Go 并没有优化它,尽管有一些变通方法,但它在基准测试中表现得更好。

func factorialTailRec(num int) int {
    return factorial(1, num)
}

func factorial(accumulator, val int) int {
    if val == 1 {
        return accumulator
    }
    return factorial(accumulator*val, val-1)
}

func main() {
    fmt.Println(factorialTailRec(20)) // 2432902008176640000
}
Enter fullscreen mode Exit fullscreen mode

我用这三种方法进行了一些基准测试,结果如下,正如你所看到的,循环仍然是性能最好的,其次是尾递归。

goos: linux
goarch: amd64
BenchmarkFactorialLoop-12           100000000           11.7 ns/op         0 B/op          0 allocs/op
BenchmarkFactorialRec-12            30000000            52.9 ns/op         0 B/op          0 allocs/op
BenchmarkFactorialTailRec-12        50000000            44.2 ns/op         0 B/op          0 allocs/op
PASS
ok      _/home/deepu/workspace/deepu105.github.io/temp  5.072s
Success: Benchmarks passed.
Enter fullscreen mode Exit fullscreen mode

为了提高 Go 代码的可读性和不可变性,可以考虑使用递归,但如果性能至关重要或者迭代次数非常大,则应使用标准循环。

懒惰评估

惰性求值或非严格求值是指延迟表达式的求值,直到需要时才进行求值。通常,Go 语言采用严格/立即求值,但对于像 `a`&&和 `b` 这样的操作数,||则采用惰性求值。我们可以使用高阶函数、闭包、goroutine 和通道来模拟惰性求值。

以 Go 语言为例,它会立即对所有内容进行求值。

func main() {
    fmt.Println(addOrMultiply(true, add(4), multiply(4)))  // 8
    fmt.Println(addOrMultiply(false, add(4), multiply(4))) // 16
}

func add(x int) int {
    fmt.Println("executing add") // this is printed since the functions are evaluated first
    return x + x
}

func multiply(x int) int {
    fmt.Println("executing multiply") // this is printed since the functions are evaluated first
    return x * x
}

func addOrMultiply(add bool, onAdd, onMultiply int) int {
    if add {
        return onAdd
    }
    return onMultiply
}
Enter fullscreen mode Exit fullscreen mode

这将产生以下输出,我们可以看到两个函数始终都会执行。

executing add
executing multiply
8
executing add
executing multiply
16
Enter fullscreen mode Exit fullscreen mode

我们可以使用高阶函数将其重写为惰性求值版本。

func add(x int) int {
    fmt.Println("executing add")
    return x + x
}

func multiply(x int) int {
    fmt.Println("executing multiply")
    return x * x
}

func main() {
    fmt.Println(addOrMultiply(true, add, multiply, 4))
    fmt.Println(addOrMultiply(false, add, multiply, 4))
}

// This is now a higher-order-function hence evaluation of the functions are delayed in if-else
func addOrMultiply(add bool, onAdd, onMultiply func(t int) int, t int) int {
    if add {
        return onAdd(t)
    }
    return onMultiply(t)
}
Enter fullscreen mode Exit fullscreen mode

输出结果如下,我们可以看到只有必需的函数被执行了。

executing add
8
executing multiply
16
Enter fullscreen mode Exit fullscreen mode

还有其他方法可以实现,例如使用 Sync 和 Futures 或者使用 Channel 和 goroutine 在 Go 中进行惰性求值通常并不值得增加代码复杂度,但如果相关函数处理量很大,那么惰性求值就绝对值得。

类型系统

Go 语言拥有强大的类型系统和相当不错的类型推断能力。与其他函数式编程语言相比,它唯一缺少的是类似 case class 和模式匹配这样的功能。

参考透明度

摘自维基百科:

函数式程序没有赋值语句,也就是说,函数式程序中变量的值一旦定义就不会改变。这消除了任何副作用的可能性,因为任何变量都可以在执行的任何时刻被替换为其实际值。因此,函数式程序是引用透明的。

遗憾的是,Go 语言中严格限制数据修改的方法并不多,但通过使用纯函数,并利用我们之前介绍的其他概念显式地避免数据修改和重新赋值,是可以实现的。Go 默认按值传递变量,切片和映射除外。因此,应尽可能避免按引用(使用指针)传递它们。

例如,以下代码会改变外部状态,因为我们是通过引用传递参数,因此无法确保引用透明性。

func main() {
    type Person struct {
        firstName string
        lastName  string
        fullName  string
        age       int
    }
    var getFullName = func(in *Person) string {
        in.fullName = in.firstName + in.lastName // data mutation
        return in.fullName
    }

    john := Person{
        "john", "doe", "", 30,
    }

    fmt.Println(getFullName(&john)) // johndoe
    fmt.Println(john) // {john doe johndoe 30}
}
Enter fullscreen mode Exit fullscreen mode

如果我们按值传递参数,即使在函数内部意外修改了传递的数据,我们也能确保引用透明性。

func main() {
    type Person struct {
        firstName string
        lastName  string
        fullName  string
        age       int
    }
    var getFullName = func(in Person) string {
        in.fullName = in.firstName + in.lastName
        return in.fullName
    }

    john := Person{
        "john", "doe", "", 30,
    }

    fmt.Println(getFullName(john))
    fmt.Println(john)
}
Enter fullscreen mode Exit fullscreen mode

当传递的参数是映射或切片时,我们不能依赖这种方法。

数据结构

在使用函数式编程技术时,建议使用函数式数据类型,例如栈、映射和队列。
因此,在函数式编程中,映射作为数据存储方式比数组或哈希集更合适。


结论

这只是为那些尝试在 Go 语言中应用一些函数式编程技巧的人准备的入门介绍。Go 语言的功能远不止于此,而且随着下一个主要版本中泛型的加入,实现起来会更加便捷。正如我之前所说,函数式编程并非万能灵药,但它提供了许多有用的技巧,能够让代码更易于理解、维护和测试。它可以与命令式和面向对象编程风格完美共存。事实上,我们都应该尽可能地融合各种编程风格的优点。


希望这对您有所帮助。如果您有任何疑问或认为我遗漏了什么,请留言。

如果您喜欢这篇文章,请点赞或留言。

你可以在TwitterLinkedIn上关注我。

文章来源:https://dev.to/deepu105/7-easy-functioning-programming-techniques-in-go-3idp