标签 Haskell 下的文章

告别懵圈:实战派 Gopher 的类型理论入门

本文永久链接 – https://tonybai.com/2025/10/30/type-theory-intro-for-gopher

大家好,我是Tony Bai。

你是否曾有过这样的经历:在浏览一个关于 Go 泛型或接口设计的 GitHub issue 或技术提案时,评论区里的大佬们突然开始讨论 “Sum Type”、“Product Type”、“Parametric Polymorphism” 或是 “Higher-Kinded Types”。一瞬间,你感觉自己仿佛闯入了一个学术研讨会,这些看似熟悉又陌生的词汇让你一头雾水,只想默默关掉页面。

作为一名务实的 Gopher,我们习惯于用具体的代码和设计模式来思考问题。我们关心的是接口的解耦能力、struct 的组合性、goroutine 的并发效率。这些学院派的类型理论术语,似乎离我们的日常工作很遥远。

然而,事实并非如此。这些术语并非象牙塔里的空谈,它们是计算机科学家们经过几十年沉淀,用来精确描述和分类编程语言核心特性的“通用语言”。理解它们,就像给一位经验丰富的工匠配上了一套精准的图纸和测量工具。它能让你:

  1. 更深刻地理解 Go 的设计哲学:为什么 Go 的接口如此强大?为什么 Go 1.18之前 长期以来没有泛型?为什么 int 和 int32 不能直接相加?这些背后都有类型理论的影子。
  2. 更清晰地沟通技术方案:当你能用“Product Type”来描述 struct,用“Sum Type”的思想来解释接口的用途时,你的技术沟通会变得更加精确和高效。
  3. 看懂高阶的技术讨论:无论是 Go 语言的未来演进,还是与其他语言(如 Rust, Haskell, Scala)的对比,这些术语都是绕不开的基石。

本文的灵感来源于阅读Simon Thompson教授所著《Type Theory & Functional Programming》一书时的感悟,但我们的目标并非成为类型理论的研究者。恰恰相反,我们的目标是做一个“翻译者”,将这些核心的理论概念,用我们最熟悉的 Go 语言特性和代码示例进行“转码”,彻底拉通学术殿堂与工程实践之间的鸿沟。

准备好了吗?让我们一起告别懵圈,开启这段实战派 Gopher 的类型理论入门之旅。

地基与框架 —— 到底什么是“类型系统”?

在深入具体的类型之前,我们首先需要建立一个宏观的框架。一个编程语言的类型系统 (Type System),从学术角度来说,是一套规则集合,它为程序中的每个值(value)、变量(variable)和表达式(expression)都关联一个“类型”属性。

它的核心目的非常单纯且强大:在程序造成危害(比如运行时崩溃)之前,通过检查类型的合法性来预防错误。正如 Go 的领军人物 Rob Pike 所言:类型系统旨在“让非法的状态无法表示”

为了系统性地理解它,我们可以从以下几个关键维度来对其进行分类和审视。

类型检查的时机:编译时 vs. 运行时 (Static vs. Dynamic)

这是对类型系统最基本、最重要的划分。

静态类型 (Statically Typed)

定义:类型检查在编译时完成。编译器会像一位严谨的图书管理员,在程序运行前,通读你的全部代码,检查每一个变量的赋值、每一次函数调用,确保类型在所有地方都严格匹配。如果发现问题,程序将无法通过编译。

优点
* 早期错误发现:绝大多数类型相关的 bug 在开发阶段就被扼杀在摇篮里。
* 更高的性能:编译器确切地知道每个变量的类型和内存布局,可以生成高度优化的机器码。运行时无需再花费时间去检查类型。
* 更好的工具支持和可维护性:类型本身就是最可靠的文档。IDE 能提供精准的自动补全、代码导航和安全的重构。

Go 是一门不折不扣的静态类型语言。 它的编译器是你的第一道防线。

package main

func main() {
    var i int
    // 下面这行代码会导致编译失败,而不是运行时错误
    i = "hello"
}

// go build -> ./main.go:6:4: cannot use "hello" (type untyped string) as type int in assignment

动态类型 (Dynamically Typed)

定义:类型检查发生在运行时。变量本身没有固定的类型,它可以随时指向任何类型的值。只有当代码执行到某一行,需要对一个值进行特定操作时,解释器才会检查这个值的类型是否支持该操作。

代表语言:Python, JavaScript, Ruby。

Go 中的“动态”一面:虽然 Go 语言本身是静态的,但它通过 interface{} (自 Go 1.18 起的别名 any) 提供了一种强大的机制来处理不确定的类型,这在行为上模拟了动态类型的灵活性。

一个接口值可以看作一个“箱子”,它包含了两部分信息:值的动态类型(dynamic type)和动态值(dynamic value)。

package main
import "fmt"

func main() {
    // data 的静态类型是 any,它可以持有任何类型的值
    var data any

    data = "hello, world" // 编译通过,data 的动态类型是 string
    printValue(data)

    data = 42 // 编译通过,data 的动态类型是 int
    printValue(data)

    data = true // 编译通过,data 的动态类型是 bool
    printValue(data)
}

func printValue(v any) {
    // 使用类型断言(type assertion)或类型选择(type switch)在运行时检查动态类型
    switch val := v.(type) {
    case string:
        fmt.Printf("It's a string: %s\n", val)
    case int:
        fmt.Printf("It's an integer: %d\n", val)
    default:
        fmt.Printf("It's some other type: %T\n", val)
    }
}

这种机制是 Go 实现通用数据结构和处理 JSON 等非结构化数据的基石,但代价是放弃了部分编译时的类型安全,并将检查推迟到了运行时。

类型的严格程度:强类型 vs. 弱类型 (Strong vs. Weak)

这个维度的划分标准在学术界略有争议,但通常用来描述一门语言对于不同类型间隐式转换的容忍度

强类型 (Strongly Typed)

定义:语言严格限制不同类型之间的隐式转换。当一个操作需要特定类型时,你必须提供该类型的值。如果类型不匹配,要么编译失败,要么运行时报错,语言本身不会“自作主张”地进行不安全的转换。

Go 的类型系统是出了名的“强硬”

package main

import "strconv"

func main() {
    var a int = 10
    var b float64 = 5.5

    // 编译错误:不同数值类型之间不能直接运算
    // c := a + b // invalid operation: a + b (mismatched types int and float64)

    // 必须进行显式类型转换
    c := float64(a) + b // 正确

    var i int32 = 100
    var j int64 = 200

    // 即使是不同位数的整型,也必须显式转换
    // k := i + j // invalid operation: i + j (mismatched types int32 and int64)
}

这种严格性杜绝了许多在 C/C++ 或 JavaScript 中常见的、因隐式转换导致的难以察觉的 bug,让代码行为更加可预测。

弱类型 (Weakly Typed)

定义:语言倾向于在操作中自动进行类型转换,以“尽力”让程序继续运行。

代表语言:JavaScript 是典型代表,’5′ + 1 会得到字符串 ’51′,而 ’5′ – 1 会得到数字 4。这种灵活性有时很方便,但也是 bug 的温床。

类型的等价性判断:名义类型 vs. 结构类型 (Nominal vs. Structural)

这是判断“类型 A 和类型 B 是否相同(或兼容)”的规则,也是理解 Go 接口的关键。

名义类型 (Nominal Typing)

定义:类型是否等价,取决于它们的名称。即使两个类型拥有完全相同的底层结构和字段,只要它们的类型名称不同,它们就是两个完全不同的、不兼容的类型。

Go 的核心类型(structs, named basic types)遵循名义类型系统。

package main
import "fmt"

type UserID int
type ProductID int

type Point struct {
    X, Y int
}

type Vector struct {
    X, Y int
}

func main() {
    var uid UserID = 123
    var pid ProductID = 123

    // 编译错误:尽管底层都是 int,但类型名称不同
    // if uid == pid { ... } // invalid operation: uid == pid (mismatched types UserID and ProductID)

    p := Point{1, 2}
    v := Vector{1, 2}

    // 编译错误:尽管结构完全相同,但类型名称不同
    // if p == v { ... } // invalid operation: p == v (mismatched types Point and Vector)
}

名义类型提供了非常强的意图保证。UserID 就是 UserID,它承载的业务含义与 ProductID 完全不同,编译器强制你区分它们,从而避免了将用户 ID 误用为产品 ID 的逻辑错误。

结构类型 (Structural Typing)

定义:类型是否兼容,取决于它们的结构或“形状”(它们有哪些字段、哪些方法)。只要结构满足要求,类型就是兼容的,这与它们的名称无关。这通常被称为“鸭子类型”(Duck Typing)——“如果它走起来像鸭子,叫起来也像鸭子,那么它就是一只鸭子。”

Go 的体现Go 的 interface 系统是纯粹的结构类型系统。

package main
import "fmt"

// 定义一个“会叫的”接口
type Quacker interface {
    Quack() string
}

// Duck 类型,它有一个 Quack 方法
type Duck struct{}
func (d Duck) Quack() string {
    return "Quack!"
}

// Person 类型,它也有一个 Quack 方法
type Person struct{}
func (p Person) Quack() string {
    return "I'm quacking like a duck!"
}

// 这个函数只关心传入的值是否满足 Quacker 接口的“结构”
func MakeItQuack(q Quacker) {
    fmt.Println(q.Quack())
}

func main() {
    var d Duck
    var p Person

    // Duck 和 Person 都没有显式声明 "implements Quacker"
    // 但因为它们都有 Quack() string 方法,所以它们都满足 Quacker 接口
    MakeItQuack(d) // 输出: Quack!
    MakeItQuack(p) // 输出: I'm quacking like a duck!
}

Go 的这一设计堪称神来之笔:在一个整体为名义类型的静态语言中,通过接口开辟了一块结构类型的区域,从而在不牺牲类型安全的前提下,获得了动态语言般的灵活性和强大的解耦能力。 你可以在不修改第三方库代码的情况下,让自己的类型去实现它的接口。

Go 类型系统的定位

综合以上维度,我们可以给 Go 的类型系统下一个精准的定义:

Go 是一门静态、强类型的语言。它主要采用名义类型系统来保证代码的严谨性和意图明确性,同时通过接口这一特性,创造性地引入了结构类型系统,以实现灵活、非侵入式的多态。

现在,我们已经搭建好了理解类型系统的宏观框架。接下来,让我们深入到类型的“原子世界”,看看那些让 Gopher 们“懵圈”的术语,在 Go 中究竟是什么模样。

类型的“和”与“积” —— Go 世界的 Sum & Product Type

在类型理论中,最基本的两种类型组合方式是“积”与“和”。它们就像算术中的乘法和加法,是构建更复杂类型的基础。

Product Type (积类型):A and B

学术定义:一个积类型(Product Type)的值由多个其他类型的值同时组成。如果一个类型 P 是类型 A 和类型 B 的积类型,那么 P 的一个值会同时包含一个 A 类型的值一个 B 类型的值。

这听起来很熟悉,对吗?

Go 的实现:struct

struct 是 Go 对积类型的直接且完美的实现。

// Person 类型是 string 和 int 的积类型
type Person struct {
    Name string // 包含一个 string
    Age  int    // 和一个 int
}

// p1 这个值同时持有一个 string "Alice" 和一个 int 30
var p1 Person = Person{Name: "Alice", Age: 30}

学术上,积类型最简单的形式是元组 (Tuple),例如 (string, int)。Go 不支持原生的元组语法,但 struct 在功能上是更强大的、带命名字段的元组。你甚至可以通过多返回值来模拟元组的使用:

func getPerson() (string, int) {
    return "Bob", 42
}

// name 和 age 在这里就像一个临时的元组
name, age := getPerson()

所以,下次当你在讨论中听到 Product Type,你就可以自信地在脑海里将它替换为:“哦,就是 struct 这种东西。”

Sum Type (和类型):A or B

学术定义:一个和类型(Sum Type),也叫可辨识联合 (Discriminated Union)变体 (Variant),它的值在任意时刻只能是几种可能性中的一种。如果一个类型 S 是类型 A 和类型 B 的和类型,那么 S 的一个值要么是一个 A 类型的值,要么是一个 B 类型的值,绝不可能同时是两者。

很多现代语言,如 Rust、Swift、Haskell,都有原生语法来支持和类型:

// Rust 中的 enum 就是一个和类型
enum Result<T, E> {
    Ok(T),    // 要么是成功,里面包含一个 T 类型的值
    Err(E),   // 要么是失败,里面包含一个 E 类型的值
}

Go 语言没有提供上述那样的原生和类型语法。这是 Go 设计者在语言复杂性上做出的一个明确权衡。但是,Go 开发者每天都在使用和类型的思想,只是我们用的是另一种工具——接口

一个接口类型定义了一个方法的集合。任何实现了这些方法的类型,都可以被看作是这个接口类型集合中的一员。因此,一个接口类型的变量,可以持有任何一个满足其要求的具体类型的值。这正是“A B C…”的核心思想。

让我们用一个经典的例子来具象化这个概念:一个图形应用需要处理不同的形状。

package main
import "math"

// Shape 接口定义了一个“和类型”,它可以是任何能计算面积的东西。
// 它可以是 Circle,或者是 Rectangle,或者是未来我们定义的任何其他形状。
type Shape interface {
    Area() float64
}

// --- 可能性 1: Circle ---
type Circle struct {
    Radius float64
}
func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

// --- 可能性 2: Rectangle ---
type Rectangle struct {
    Width, Height float64
}
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// 这个函数接受一个 Shape 类型的值。
// 它不关心这个值到底是 Circle 还是 Rectangle,只关心它能调用 Area() 方法。
func PrintArea(s Shape) {
    // 这时,变量 s 的值可能是 Circle 或 Rectangle 之一
    fmt.Printf("Area of %T is %0.2f\n", s, s.Area())
}

func main() {
    c := Circle{Radius: 5}
    r := Rectangle{Width: 4, Height: 3}

    PrintArea(c) // 输出: Area of main.Circle is 78.54
    PrintArea(r) // 输出: Area of main.Rectangle is 12.00
}

在这个例子里,Shape 接口扮演了和类型的角色。一个 Shape 变量的值,在任何时刻,要么是一个 Circle,要么是一个 Rectangle。

如何“辨识”具体的类型?—— type switch

和类型的一个关键特性是“可辨识”(Discriminated)。这意味着我们必须有办法知道当前的值到底是哪个具体的类型。在 Go 中,我们使用 type switch 来实现这一点。

func PrintShapeDetails(s Shape) {
    fmt.Printf("Shape details for %T:\n", s)
    switch shape := s.(type) {
    case Circle:
        // 在这个 case 分支里,编译器知道 shape 的类型是 Circle
        fmt.Printf("  It's a circle with radius %.2f\n", shape.Radius)
    case Rectangle:
        // 在这个 case 分支里,编译器知道 shape 的类型是 Rectangle
        fmt.Printf("  It's a rectangle with width %.2f and height %.2f\n", shape.Width, shape.Height)
    default:
        fmt.Println("  It's an unknown shape.")
    }
}

type switch 是处理和类型值时的“模式匹配”,它安全地拆开接口这个“箱子”,并根据里面的动态类型执行相应的逻辑。

模拟的代价:开放性与编译时检查的缺失

Go 的接口模拟与原生和类型有一个本质区别:接口是开放的,而原生和类型通常是封闭的

  • 封闭性 (Sealed/Closed):在 Rust 的例子中,Result只能是 Ok(T)中的T 或 Err(E)中的E,编译器知道所有可能性。如果你在 match(类似 switch)时漏掉了一种情况,编译器会报错。
  • 开放性 (Open):在 Go 的例子中,任何包、任何地方都可以定义一个新的类型(比如 Triangle),只要它实现了 Area() 方法,它就可以被赋值给 Shape 变量。这意味着编译器永远无法保证你的 type switch 处理了所有情况,因此 default 分支变得至关重要。

为了在 Go 中模拟一个更“封闭”的和类型,有时会使用一种技巧:在接口中定义一个私有方法。

type Shape interface {
    Area() float64
    isShape() // 私有方法
}

由于私有方法 isShape 只能在同一个包内被实现,这实际上就将 Shape 接口的实现者限制在了当前包内,从而模拟了一个封闭的和类型。这在 Go 标准库中(例如 net/url.go 中的 addr 接口)时有应用。

所以,下次当你看到 Sum Type 这个术语,你的脑海中应该浮现出这样的映射:

“哦,这是指一个值在多个类型中‘非此即彼’的概念。Go 没有原生支持它,但我们通过 interface 和 type switch 的组合,在工程实践中出色地模拟了它的核心思想。”

抽象的力量 —— Go 中的函数与多态

类型系统不仅用于组合数据,更强大的能力在于抽象行为。这主要涉及到函数类型和多态。

函数类型 (Function Types)

学术定义:从类型 A 到类型 B 的一个映射,记作 A -> B。在函数式编程和类型理论中,函数本身就是一种可以被传递、存储和返回的值,即“一等公民”。

Go 的实现:Go 完全支持一等公民函数。我们可以定义函数类型,这在 Go 代码中非常常见。

package main
import "fmt"

// 定义一个函数类型 Operator,它接受两个 int,返回一个 int
type Operator func(int, int) int

func add(a, b int) int {
    return a + b
}

func multiply(a, b int) int {
    return a * b
}

// calculate 函数接受一个 Operator 类型的函数作为参数
func calculate(a, b int, op Operator) {
    result := op(a, b)
    fmt.Printf("Result is: %d\n", result)
}

func main() {
    calculate(10, 5, add)      // 输出: Result is: 15
    calculate(10, 5, multiply) // 输出: Result is: 50
}

HTTP 中间件、策略模式等诸多设计模式在 Go 中都大量利用了函数类型。

多态 (Polymorphism)

“Polymorphism”源于希腊语,意为“多种形态”。在编程中,它指代一段代码可以处理不同类型的值的能力。类型理论通常将其分为几种。

参数多态 (Parametric Polymorphism)

学术定义:编写的代码其逻辑对于操作的值的具体类型是通用的、不相关的。函数或数据结构可以被一个或多个类型参数化。例如,一个反转列表的函数,其逻辑(交换头尾元素)与列表里存的是整数、字符串还是用户自定义结构完全无关。

Go 的实现:泛型 (Generics, Go 1.18+)

在 Go 1.18 之前,Gopher 们只能通过 interface{} 和反射来模拟参数多态,但这牺牲了类型安全和性能。泛型的引入,为 Go 提供了实现参数多态的“正统”方式。

package main
import "fmt"

// 这个函数的逻辑对任何类型 T 都是一样的
// T 是一个类型参数
func Reverse[T any](s []T) {
    for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
}

func main() {
    intSlice := []int{1, 2, 3, 4}
    Reverse(intSlice)
    fmt.Println(intSlice) // 输出: [4 3 2 1]

    stringSlice := []string{"a", "b", "c"}
    Reverse(stringSlice)
    fmt.Println(stringSlice) // 输出: [c b a]
}

当你听到 Parametric Polymorphism,你就可以直接联想到 Go 的泛型

子类型多态 (Subtype Polymorphism)

学术定义:一个函数或操作可以作用于某个类型 T,同时也能作用于 T 的所有子类型。例如,一个处理 Animal 的函数,应该也能处理 Dog 和 Cat,因为 Dog 和 Cat 都是 Animal 的子类型。

Go 的实现:接口 (Interfaces)

我们又回到了接口!在 Go 的世界里,子类型的概念正是通过接口来实现的。如果类型 T 实现了接口 I,那么 T 就可以被看作是 I 的一个“子类型”。

更准确地说,Go 实现的是结构化子类型 (Structural Subtyping)

package main
import (
    "bytes"
    "fmt"
    "io"
    "os"
)

// 这个函数接受任何满足 io.Reader 接口的类型
// os.File 是 io.Reader 的一个“子类型”
// bytes.Buffer 也是 io.Reader 的一个“子类型”
func ReadAndPrint(r io.Reader) {
    data, err := io.ReadAll(r)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(data))
}

func main() {
    // 从文件读取
    file, _ := os.Open("test.txt")
    defer file.Close()
    ReadAndPrint(file)

    // 从内存中的 buffer 读取
    buffer := bytes.NewBufferString("Hello from buffer!")
    ReadAndPrint(buffer)
}

ReadAndPrint 函数体现了子类型多态:它被编写用来处理 io.Reader 这一通用类型,但实际上它可以无缝处理 os.File、bytes.Buffer 以及任何其他未来可能出现的、满足 io.Reader 结构的类型。

Ad-hoc 多态 (Ad-hoc Polymorphism)

学术定义:也称为重载 (Overloading)。同一个函数名可以有多个不同的实现,具体调用哪个实现取决于参数的类型。例如,add(int, int) 和 add(string, string) 是两个不同的函数。

Go 不支持函数重载。Go 的哲学是“显式优于隐式”,函数签名(包括函数名、参数类型和返回值类型)是唯一的。

理论的边界 —— Go 类型系统“做不到”的事

理解一门语言,不仅要知道它能做什么,也要知道它的边界在哪里,以及为什么会有这些边界。这通常是设计者在“表达力”与“简洁性”之间做出权衡的结果。

依赖类型 (Dependent Types)

学术定义:一种高级的类型系统特性,允许类型依赖于值。这意味着类型可以由程序中的常规变量来参数化。

经典例子:定义一个“长度为 n 的向量”类型 Vector(n)。这样,Vector(3) 和 Vector(4) 就是两个完全不同的类型。编译器可以静态地保证你不会把一个长度为 3 的向量赋值给一个长度为 4 的向量变量,或者保证矩阵乘法的维度匹配。

// 伪代码,Go 并不支持
func dotProduct(n: int, v1: Vector(n), v2: Vector(n)) -> float64 {
    // ...
}

var vec3 Vector(3)
var vec4 Vector(4)
dotProduct(3, vec3, vec4) // 编译错误!vec4 的长度不是 3

Go完全不支持依赖类型。Go 的类型系统在编译时工作,而像 n 这样的值通常在运行时才知道。将运行时信息混入编译时类型检查会极大地增加语言和编译器的复杂性。Go 选择了简洁,将这类检查(如切片长度)的责任交给了程序员,通过 len() 函数和运行时 panic 来保障。

值得一提的是,Go 的数组类型 [N]T 具有依赖类型的“影子”。例如,[3]int 和 [4]int 是不同的类型,因为它们的类型定义依赖于值 3 和 4。但这并非真正的依赖类型,因为数组的长度 N 必须是一个编译时常量,而不能是一个运行时变量。这个限制正是 Go 的数组与依赖类型的本质区别,也是 Go 在追求更强类型安全与保持语言简洁性之间做出的一种工程权衡。

高阶类型 (Higher-Kinded Types, HKTs)

这是一个在函数式编程和高级类型系统讨论中频繁出现的术语,也是理解 Go 泛型设计边界的关键所在。乍一听可能有些吓人,但我们可以通过类比来轻松理解它。

通俗解释:类型的“阶”

想象一下我们熟悉的函数:

  • 一阶函数:操作“值”。例如,func add(a, b int) int 接受 int 值,返回 int 值。
  • 高阶函数:操作“函数”。例如,func apply(f func(int) int, v int) int 接受一个函数 f 作为参数。

现在,我们把这个概念“提升”到类型层面:

  • 一阶类型 (或称普通类型):就是一个具体的类型,比如 int, string, struct{}。在类型理论中,它们的“种类”(Kind) 被记为 *。
  • 高阶类型 (Higher-Kinded Types):不是一个完整的类型,而是一个“类型的模板”或“类型构造器”(Type Constructor)。它接受一个或多个普通类型作为参数,然后“构造”出一个新的普通类型。

    • []T 就是一个类型构造器。[] 本身不是类型,你必须给它一个类型(如 int),才能得到一个完整的类型 []int。它的“种类”可以记为 * -> * (接受一个类型,返回一个类型)。
    • 同理,map[K]V 也是一个类型构造器,它的“种类”是 * -> * -> * (接受两个类型,返回一个类型)。
    • chan T 也是 * -> *。

高阶类型系统,就是指一门语言的泛型系统能够对类型构造器本身进行抽象的能力。换句话说,泛型参数不仅可以是 T(代表一个普通类型),还可以是 F(代表一个类型构造器,如 [] 或 chan)。

Go 的现状:不支持高阶类型

Go 的泛型系统被设计为只处理一阶类型。这意味着 Go 的类型参数 [T any] 只能代表一个完整的类型

  • T 可以是 int。
  • T 也可以是 []int。
  • 但 T 不能是 [] 本身。

让我们通过一个经典的 Map 函数的例子来具体说明这一点。我们的目标是写一个通用的 Map 函数,它能将一个容器里的所有元素通过一个函数进行转换,并返回一个包含新元素的同类容器

Go 能做到的:为每种容器编写独立的泛型函数

由于 Go 不支持 HKTs,我们必须为 slice、channel 或其他任何我们想支持的容器类型,分别编写一个泛型 Map 函数。

// 为 slice 实现的 Map
func SliceMap[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

// 为 channel 实现的 Map (简化版)
func ChanMap[T, U any](ch <-chan T, f func(T) U) <-chan U {
    result := make(chan U)
    go func() {
        defer close(result)
        for v := range ch {
            result <- f(v)
        }
    }()
    return result
}

注意,SliceMap 和 ChanMap 的核心逻辑思想是一致的,但因为容器的操作方式(创建、遍历、添加元素)不同,且 Go 无法抽象“容器”这个概念,我们不得不重复编写。

Go 做不到的:一个统一所有容器的 Map 函数(伪代码)

如果 Go 支持高阶类型,我们就可以梦想编写一个 UniversalMap 函数。下面的代码使用了 Go 的语法风格,但它在 Go 中是完全无法编译的,它仅仅是为了展示 HKTs 的思想。

// ----------------------------------------------------
// !! 警告:以下是 HKTs 思想的伪代码,无法在 Go 中编译 !!
// ----------------------------------------------------

// 这里的 type F[T] any 是一种虚构的语法,
// 意在声明“F 是一个接受单一类型参数的类型构造器”。
func UniversalMap[type F[T] any, T, U any](container F[T], f func(T) U) F[U] {
    // 这段函数体在 Go 中是无法实现的,因为:
    // 1. 如何创建一个 F[U] 类型的新容器?make(F[U]) 语法无效。
    // 2. 如何遍历一个抽象的 F[T] 容器?range 关键字只认识内置类型。
    // 3. 如何向 F[U] 中添加一个元素?是 append 还是 <- 发送?

    panic("This is pseudo-code demonstrating what HKTs would enable.")
}

func main() {
    ints := []int{1, 2, 3}
    intChan := make(chan int)

    // 在一个支持 HKTs 的理想世界里,我们可以这样调用:
    // strings := UniversalMap(ints, func(i int) string { ... })      // 期望返回 []string
    // stringChan := UniversalMap(intChan, func(i int) string { ... }) // 期望返回 chan string
}

这段伪代码清晰地揭示了 Go 泛型的边界:

  1. 语法限制:Go 没有定义 [type F[T] any] 这样的语法来表示“一个类型构造器”作为类型参数。
  2. 实现限制:即使语法允许,Go 缺乏一个通用的接口来描述“容器”的基本操作(如 map, flatMap 等)。支持 HKTs 的语言(如 Haskell, Scala)通常会提供一套名为 Functor, Monad 的“类型类”或“特质”(traits) 来定义这些通用操作,程序员可以为自己的容器类型(比如自定义的 Tree[T])实现这些接口。

为什么 Go 选择不支持 HKTs?

这是一个深思熟虑的设计决策。Go 语言的核心哲学之一是简洁性可读性。高阶类型的概念虽然强大,但它引入了更高层次的抽象,极大地增加了语言的复杂性和程序员的心智负担。对于 Go 团队来说,为 slice 和 chan 等几种常见类型编写独立的泛型函数,这种适度的代码重复,相比于引入整个 HKTs 体系所带来的复杂性,是一个更值得接受的权衡。

所以,当你听到 Higher-Kinded Types,你可以这样理解:“它是一种更强大的泛型,可以对像 []T 中的 [] 这样的‘类型模板’本身进行参数化,但 Go 为了保持简洁而没有支持它。因此在 Go 中,我们需要为不同的容器类型(如 slice, channel)编写各自的泛型工具函数。”

小结:从“懵圈”到“通透”

我们从令人困惑的 GitHub issue 讨论出发,踏上了一段连接类型理论与 Go 语言实践的旅程。现在,让我们回顾一下我们的“翻译”成果,将那些抽象的术语牢牢地锚定在 Go 的具体实现上:

  • 类型系统框架:我们确立了 Go 的定位——一个静态、强类型的系统,它以名义类型为基础保证代码的严谨性,同时通过接口这一卓越设计,巧妙地融合了结构类型的灵活性。

  • Product Type (积类型):这个概念不再神秘,它就是我们日常工作中构建复合数据的基石——struct

  • Sum Type (和类型):我们揭示了 Go 是如何通过接口type switch 这一组合拳,优雅地模拟出和类型的核心思想(“A 或 B”)。我们最熟悉的 error 接口,便是这一思想在 Go 生态中最无处不在的体现。

  • Parametric Polymorphism (参数多态):我们看到,Go 1.18+ 的泛型为其提供了原生的、类型安全的支持,让我们得以编写出与具体类型无关的通用算法和数据结构。

  • Subtype Polymorphism (子类型多态):这再次指向了 Go 接口的强大之处。它基于结构化子类型,构建了一个非侵入式、高度解耦的多态模型,这是 Go 强大组合能力的核心源泉。

  • 理论的边界 (Dependent Types & HKTs):我们不仅理解了这些高级特性是什么,更重要的是,通过具体的伪代码示例,我们清晰地看到了 Go 泛型的局限性——它只能参数化完整的类型,而无法抽象类型构造器(如 [] 或 chan)。我们明白了,这些“做不到”并非语言的缺陷,而是 Go 团队在追求简洁性、可读性和工程实用性方面做出的深思熟虑的设计权衡

掌握这些术语,并不仅仅是为了在技术讨论中显得“专业”。更重要的是,它为我们提供了一个更深刻、更系统的视角来审视我们每天使用的工具。它解释了 Go 为什么是现在这个样子,它的优势在哪里,它的取舍又在哪里。

希望这篇文章能成为你工具箱里的一件利器。当你下一次再遇到那些“学院派”术语时,你将不再“懵圈”,而是能够会心一笑,轻松地将它们映射到你熟悉的 Go 世界中,从而更加自信地去创造、去构建、去解决实际的工程问题。

毕竟,对于实战派 Gopher 而言,任何理论的最终价值,都在于它能否帮助我们写出更好、更稳健、更易于维护的代码。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


想系统学习Go,构建扎实的知识体系?

我的新书《Go语言第一课》是你的首选。源自2.4万人好评的极客时间专栏,内容全面升级,同步至Go 1.24。首发期有专属五折优惠,不到40元即可入手,扫码即可拥有这本300页的Go语言入门宝典,即刻开启你的Go语言高效学习之旅!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

通过Go示例理解函数式编程思维

本文永久链接 – https://tonybai.com/2024/08/11/understand-functional-programming-in-go

一个孩子要尝试10次、20次才肯接受一种新的食物,我们接受一种新的范式,大概不会比这个简单。– 郭晓刚 《函数式编程思维》译者

函数式编程(Functional Programming, 简称fp)是一种编程范式,与命令式编程(Imperative Programming)、面向对象编程(OOP)、泛型编程(Generics Programming)、逻辑编程(logic Programming)等是一类的概念。

注:尽管面向对象范式引入了新的编程思想和技术,但它本质上与命令式编程一样,都关注程序的状态和如何通过改变状态来控制程序的执行流程,因此,OOP仍然属于命令式编程的一个分支。OOP可以看作是命令式编程的一种扩展和补充,它增强了代码的模块化、复用性和可维护性。在接下来,我会统一使用命令式编程范式来指代它们。

但几十年的编程语言的演进和实践已经证明:函数式编程并非银弹,它有优势,更有不足。从编程社区的实际反映来看,纯函数式编程语言(比如:CLispHaskell、Scala、Clojure、Erlang等)始终处于小众地位。此外,即便很多主流命令式编程语言在近些年融入了一些函数式编程的语法特性,采用函数式风格的代码依旧比例极低,且不易被广泛接受。许多程序员在面对复杂的状态管理和副作用时,依然倾向于使用传统的命令式编程风格(包括OOP)。。

注:Go就原生提供了一些支持函数式范式编程的语法特性,比如:函数是一等公民(first-class)、高阶函数、闭包函数迭代器以及泛型等。

造成这种局面的原因众说纷纭,但我认为有如下几个:

首先从人类这个物种的大脑的认知和思维方式来看,命令式编程更接近于人类的自然思维方式,其逻辑与人类解决问题时的逻辑思维相似,即都是以步骤的形式理解问题,且有明确的控制流:命令式语言的控制结构(如条件语句、选择语句和循环)使得程序的执行路径清晰可见,符合人类的直觉理解,这也使得命令式语言更容易被人类大脑所掌握。

其次,命令式编程强调状态的变化,程序员可以直接看到和控制变量的变化,这与人类处理现实世界事物的方式相似。

在上面原因的驱使下,久而久之,程序员便形成习惯与传统,有了积淀,便可以促进命令式编程语言在教育和产业中的广泛应用,使得大多数程序员习惯于这种编程方式(间接挤压了函数式编程的使用空间)。进而使得命令式语言有更丰富的学习资源和社区支持,程序员也更容易找到帮助和示例。

也就是说,命令式编程范式占据主流的根本原因是人类的大脑本身就是命令式的,而不是函数式的。不过也有极少数大脑是函数式思维的,比如发明了TLA+这门形式化建模和验证语言Leslie Lamport老先生

那么问题来了!既然学习函数式编程思维是违反人类大脑直觉的,且较为困难,那为什么还是有很多人学习函数式编程思维,并在实际开发中应用函数式编程范式呢?关于这个问题,我们可以从两方面来看。

从主观上说,程序员经常有探索新技术和新范式的内在动力,这种好奇心驱使他们尝试函数式编程,也就是我们俗称的“玩腻了,尝尝鲜儿”。并且,许多程序员视学习函数式编程为一种智力挑战,一种来自舒适区之外的挑战,这种挑战能带来成就感和个人成长。此外,在竞争激烈的IT行业,掌握多种编程范式可以使得个人技能多样化,增加个人的职业竞争力。

从客观上看,函数式编程也确实能帮助程序员提高抽象思维和系统设计能力,这种能力的提升不仅限于函数式编程,还能应用到其他编程范式中。并且,函数式编程为程序员提供了一个新的解决问题的视角和方法,特别是在处理并发和并行计算、复杂数据转换和流处理方面。

学习函数式编程范式,并不是说抛弃命令式范式(或其他范式),而是融合,从主流编程语言对函数式编程的语法特性的支持也可窥见这般。

那么,到底什么是函数式编程范式?它与命令式范式对比又有怎么样的差异与优劣呢?在这篇文章中,我就来说说我的体会,并辅以Go示例来帮助大家理解。

1. 思维差异:命令式编程 vs. 函数式编程

在看过很多函数式编程的资料后(见文后的参考资料一节),我问了自己一个问题:面对同一个实际的问题,用命令式编程范式和用函数式编程范式的核心思维差异在哪里?为此,我基于现实世界的一个典型问题模型(数据输入 -> 数据处理 -> 处理结果输出),并根据自己的理解画了下面两幅图:


命令式编程范式的思维


函数式编程范式的思维

我们先来描述一下上面两幅图中的数据处理流程:

  • 命令式编程:通过I/O操作获取数据,然后解码为自定义类型进行处理,再编码为自定义类型以便I/O操作输出。处理过程中使用函数、带方法的类型和控制流结构(如for、if、switch等)。

  • 函数式编程:通过带有副作用的操作(如I/O操作)获取数据,然后解码数据放入通用数据结构(如列表、元组、映射)进行处理,再放入通用数据结构以便通过副作用操作输出。处理过程中会使用纯函数、高阶函数以及它们的函数组合。

基于上述流程的说明,我们可以看出两种范式核心关注点的差异:

  • 命令式编程范式:更关注类型的封装、类型间的耦合关系、行为集合的抽象(接口)以及对数据在类型实例间的传递的显式控制(if/for/switch)。
  • 函数式编程范式:弱化类型的概念,使用通用数据结构,专注于通过纯函数/高阶函数、不可变数据和函数组合来实现对数据的处理逻辑。“控制流”更加隐含,比如会通过递归、模式匹配和惰性求值等方式实现。建立专门的抽象来应对与真实世界交互时的带有副作用(side effect)的操作。

下面我们通过一个具体的问题来大致体会一下不同编程泛型在解决问题的实现上的思维差异。这个问题很简单:编写一个程序从input.txt文件中读取数字(每行一个数字),将每个数字乘以2,然后将结果写入output.txt文件中。

我们先来用命令式编程范式实现:

// fp-in-go/double/go/main.go

// NumberData represents the input data
type NumberData struct {
    numbers []int
}

// ProcessedData represents the processed output data
type ProcessedData struct {
    numbers []int
}

// NewNumberData creates and returns a new NumberData instance
func NewNumberData() *NumberData {
    return &NumberData{numbers: []int{}}
}

// AddNumber adds a number to NumberData
func (nd *NumberData) AddNumber(num int) {
    nd.numbers = append(nd.numbers, num)
}

// Process doubles all numbers in NumberData and returns ProcessedData
func (nd *NumberData) Process() ProcessedData {
    processed := ProcessedData{numbers: make([]int, len(nd.numbers))}
    for i, num := range nd.numbers {
        processed.numbers[i] = num * 2
    }
    return processed
}

// FileProcessor handles file operations and data processing
type FileProcessor struct {
    inputFile  string
    outputFile string
}

// NewFileProcessor creates and returns a new FileProcessor instance
func NewFileProcessor(input, output string) *FileProcessor {
    return &FileProcessor{
        inputFile:  input,
        outputFile: output,
    }
}

// ReadAndDeserialize reads data from input file and deserializes it into NumberData
func (fp *FileProcessor) ReadAndDeserialize() (*NumberData, error) {
    file, err := os.Open(fp.inputFile)
    if err != nil {
        return nil, fmt.Errorf("error opening input file: %w", err)
    }
    defer file.Close()

    data := NewNumberData()
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        num, err := strconv.Atoi(scanner.Text())
        if err != nil {
            return nil, fmt.Errorf("error converting to number: %w", err)
        }
        data.AddNumber(num)
    }

    if err := scanner.Err(); err != nil {
        return nil, fmt.Errorf("error reading input file: %w", err)
    }

    return data, nil
}

// SerializeAndWrite serializes ProcessedData and writes it to output file
func (fp *FileProcessor) SerializeAndWrite(data ProcessedData) error {
    file, err := os.Create(fp.outputFile)
    if err != nil {
        return fmt.Errorf("error creating output file: %w", err)
    }
    defer file.Close()

    writer := bufio.NewWriter(file)
    defer writer.Flush()

    for _, num := range data.numbers {
        _, err := writer.WriteString(fmt.Sprintf("%d\n", num))
        if err != nil {
            return fmt.Errorf("error writing to output file: %w", err)
        }
    }

    return nil
}

// Process orchestrates the entire data processing workflow
func (fp *FileProcessor) Process() error {
    // Read and deserialize input data
    inputData, err := fp.ReadAndDeserialize()
    if err != nil {
        return err
    }

    // Process data
    processedData := inputData.Process()

    // Serialize and write output data
    err = fp.SerializeAndWrite(processedData)
    if err != nil {
        return err
    }

    return nil
}

func main() {
    processor := NewFileProcessor("input.txt", "output.txt")
    if err := processor.Process(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
    fmt.Println("Processing completed successfully.")
}

这段代码十分容易理解,在这段代码中,我们建立了三个类型:NumberData、ProcessedData和FileProcessor。前两个分别代表解码后的输入数据和编码前的输出数据,FileProcessor则是封装了文件操作和数据处理的逻辑的自定义类型。这段代码将文件I/O、数据处理和主要流程控制分离到不同的方法中。在读取和写入过程中,数据经历了字符串 -> NumberData -> ProcessedData -> 字符串的转换过程,同时数据也是在不同类型的方法间传递和变换状态

接下来我们再来看看函数式范式版本,Go虽然提供了一些函数式编程的基础支持,比如一等公民的函数、支持高阶函数、闭包等,但一些像monad、monoid等高级概念还需要手工实现。IBM开源了一个Go的函数式编程基础库fp-go,这里就借用fp-go的便利实现上面的同等功能,我们看看风格上有何不同:

// fp-in-go/double/fp-go/main.go

package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"

    "github.com/IBM/fp-go/either"
    "github.com/IBM/fp-go/ioeither"
)

// 读取文件内容
func readFile(filename string) ioeither.IOEither[error, string] {
    return ioeither.TryCatchError(func() (string, error) {
        content, err := os.ReadFile(filename)
        return string(content), err
    })
}

// 将字符串转换为数字列表
func parseNumbers(content string) either.Either[error, []int] {
    numbers := []int{}
    scanner := bufio.NewScanner(strings.NewReader(content))
    for scanner.Scan() {
        num, err := strconv.Atoi(scanner.Text())
        if err != nil {
            return either.Left[[]int](err)
        }
        numbers = append(numbers, num)
    }
    return either.Right[error](numbers)
}

// 将数字乘以2
func multiplyBy2(numbers []int) []int {
    result := make([]int, len(numbers))
    for i, num := range numbers {
        result[i] = num * 2
    }
    return result
}

// 将结果写入文件
func writeFile(filename string, content string) ioeither.IOEither[error, string] {
    return ioeither.TryCatchError(func() (string, error) {
        return "", os.WriteFile(filename, []byte(content), 0644)
    })
}

func main() {
    program := ioeither.Chain(func(content string) ioeither.IOEither[error, string] {
        return ioeither.FromEither(
            either.Chain(func(numbers []int) either.Either[error, string] {
                multiplied := multiplyBy2(numbers)
                result := []string{}
                for _, num := range multiplied {
                    result = append(result, strconv.Itoa(num))
                }
                return either.Of[error](strings.Join(result, "\n"))
            })(parseNumbers(content)),
        )
    })(readFile("input.txt"))

    program = ioeither.Chain(func(content string) ioeither.IOEither[error, string] {
        return writeFile("output.txt", content)
    })(program)

    result := program()
    err := either.ToError(result)

    if err != nil {
        fmt.Println("Program failed:", err)
    } else {
        fmt.Println("Program completed successfully")
    }
}

相对于前面使用命令式范式风格的代码,这段函数式范式的代码理解起来就要难上不少。

不过,这段代码很好地诠释了函数式编程中的函数组合理念,我们看到函数被当作值来传递和使用。例如,在ioeither.Chain中,我们传递了匿名函数作为参数,这体现了函数式编程中函数作为一等公民的概念。multiplyBy2函数是一个纯函数的例子,它没有副作用,对于相同的输入总是产生相同的输出。这种纯函数更容易测试和推理。

代码中最明显的函数组合例子是在main函数中,我们使用ioeither.Chain来组合多个函数操作。并且在这里,我们将文件读取、内容处理和文件写入操作串联在一起,形成一个更大的操作。而ioeither.Chain和either.Chain又都是高阶函数的例子,它们接受其他函数作为参数并返回新的函数。Either和IOEither类型也是函数式编程中用于错误处理的主流方式,允许我们以更函数式的方式处理错误,将错误处理集成到函数组合中。

很多人好奇如果用纯函数式编程语言实现这个示例会是什么样子的,下面我就贴一段Haskell语言的代码,大家简单了解一下,这里就不对代码进行解释了:

// fp-in-go/double/fp-haskell/Main.hs

import System.IO
import Control.Monad (when)
import Text.Read (readMaybe)
import Data.Maybe (catMaybes)

-- Define a custom type for the result
data DoubledNumbers = DoubledNumbers { doubledNumbers :: [Int] } deriving (Show)

-- Function to read numbers from a file
readNumbers :: FilePath -> IO (Either String [Int])
readNumbers filePath = do
    content <- readFile filePath
    let numbers = catMaybes (map readMaybe (lines content))
    return $ if null numbers
             then Left "No valid numbers found."
             else Right numbers

-- Function to write result to a file
writeResult :: FilePath -> DoubledNumbers -> IO (Either String ())
writeResult filePath result = do
    let resultString = unlines (map show (doubledNumbers result))
    writeFile filePath resultString
    return $ Right ()

-- Function to double the numbers
doubleNumbers :: [Int] -> DoubledNumbers
doubleNumbers numbers = DoubledNumbers { doubledNumbers = map (* 2) numbers }

main :: IO ()
main = do
    -- Read numbers from input.txt
    readResult <- readNumbers "input.txt"
    case readResult of
        Left err -> putStrLn $ "Error: " ++ err
        Right numbers -> do
            let result = doubleNumbers numbers
            -- Write result to output.txt
            writeResultResult <- writeResult "output.txt" result
            case writeResultResult of
                Left err -> putStrLn $ "Error: " ++ err
                Right () -> putStrLn "Successfully written the result to output.txt."

注:安装ghc后,执行ghc –make Main就可以将上面Main.hs编译为一个可执行程序。更多关于haskell编译器的信息可以到haskell官网查看。

从上面的示例我们大致也能感受到两种范式在思维层面的差异,正如Robert Martin在《函数式设计》一书中说道的那样:函数式程序更倾向于铺设调节数据流转换的管道结构,而可变的命令式程序更倾向于迭代地处理一个个类型对象

我们很难在一个例子中体现出函数式编程的所有概念和思维特点,接下来,我们就来逐个说说函数式编程范式中的要素,你也可以对应前面的图中的内容,反复感受函数式编程的思维特点。

2. 函数式编程的要素

面向对象的编程通过封装不确定因素来使代码能被人理解,而函数式编程通过尽量减少不确定因素来使代码能被人理解。—— Michael Feathers 《修改代码的艺术》一书作者

函数式编程建立在几个核心要素之上,这些要素共同构成了函数式编程的基础。让我们逐一探讨这些要素。

2.1 纯函数 (Pure Functions)

纯函数是函数式编程的基石。一个纯函数具有以下特性:

  • 对于相同的输入,总是产生相同的输出;
  • 不会产生副作用(不会修改外部状态);
  • 不依赖外部状态。

例如,前面fp-go示例中的multiplyBy2就是一个纯函数:

func multiplyBy2(numbers []int) []int {
    result := make([]int, len(numbers))
    for i, num := range numbers {
        result[i] = num * 2
    }
    return result
}

这个函数总是为相同的输入返回相同的结果,并且不会修改任何外部状态。

2.2 不可变性 (Immutability)

Robert Martin在《函数式设计》一书为函数式编程下一个理想的定义:没有赋值语句的编程。实质是其强调了不可变性在函数式编程范式中的重要意义。在没有赋值语句的情况下,代码通常基于对原状态的计算而得到新的状态,而对原状态没有任何修改。

在Go语言中,由于不支持不可变变量(很多语言用val关键字来声明不可变变量,但Go并不支持),我们通常通过复制对象来实现不可变性,这可以帮助我们避免状态变化带来的复杂性,但也因为复制而增加了内存开销和性能成本。

// 定义一个不可变的结构体
type Point struct {
    x, y int
}

// 创建一个新的 Point,模拟不可变性
func NewPoint(x, y int) Point {
    return Point{x, y}
}

// 移动Point的方法,返回一个新的Point
func (p Point) Move(dx, dy int) Point {
    return NewPoint(p.x+dx, p.y+dy)
}

2.3 高阶函数 (Higher-Order Functions)与函数组合(Function Composition)

Go语言的一个内置特性让它具备了使用函数式编程范式的前提,那就是在Go中,函数是一等公民。这意味着函数可以像其他类型变量一样,被赋值、传参和返回。

而接受其他函数作为参数或返回函数的函数,被称为高阶函数,这也是函数式编程的基石,如下面的applyOperation函数就是一个高阶函数:

func applyOperation(x int, operation func(int) int) int {
    return operation(x)
}

func double(x int) int {
    return x * 2
}

result := applyOperation(5, double) // 结果为10

而有了对高阶函数的支持,我们才能运用函数式思维中的核心思维:函数组合,来铺设调节数据流转换的管道结构:

// fp-in-go/high-order-func/main.go

package main

import (
    "fmt"
)

// 定义一个类型为函数的别名
type IntTransformer func(int) int

// 将多个转换函数组合成一个管道
func pipe(value int, transformers ...IntTransformer) int {
    for _, transformer := range transformers {
        value = transformer(value)
    }
    return value
}

// 定义一些转换函数
func addOne(x int) int {
    return x + 1
}

func square(x int) int {
    return x * x
}

func main() {
    // 使用管道处理数据
    result := pipe(3, addOne, square)
    fmt.Println("Result:", result) // 输出 Result: 16
}

这个示例中的pipe函数接受一个初始值和多个转换函数,并将其串联执行。main函数调用pipe函数,将addOne和square两个转换函数连接起来并执行输出结果。

前面那个使用fp-go编写的示例中,使用ioeither.Chain构建的program也是一个函数调用组合。

此外,链式调用也是一种在日常开发中常见的函数组合的使用形式,它融合了命令式的类型和函数式编程的函数组合,特别适用于集合类型数据的处理,通过链式调用,可以以更简洁和直观的方式进行数据转换和处理。下面是一个基于泛型实现的通用的链式调用(filter -> map -> reduce)的示例:

// fp-in-go/func-composition/main.go

package main

import "fmt"

// Collection 接口定义了通用的集合操作
type Collection[T any] interface {
    Filter(predicate func(T) bool) Collection[T]
    Map(transform func(T) T) Collection[T]
    Reduce(initialValue T, reducer func(T, T) T) T
}

// SliceCollection 是基于切片的集合实现
type SliceCollection[T any] struct {
    data []T
}

// NewSliceCollection 创建一个新的 SliceCollection
func NewSliceCollection[T any](data []T) *SliceCollection[T] {
    return &SliceCollection[T]{data: data}
}

// Filter 实现了 Collection 接口的 Filter 方法
func (sc *SliceCollection[T]) Filter(predicate func(T) bool) Collection[T] {
    result := make([]T, 0)
    for _, item := range sc.data {
        if predicate(item) {
            result = append(result, item)
        }
    }
    return &SliceCollection[T]{data: result}
}

// Map 实现了 Collection 接口的 Map 方法
func (sc *SliceCollection[T]) Map(transform func(T) T) Collection[T] {
    result := make([]T, len(sc.data))
    for i, item := range sc.data {
        result[i] = transform(item)
    }
    return &SliceCollection[T]{data: result}
}

// Reduce 实现了 Collection 接口的 Reduce 方法
func (sc *SliceCollection[T]) Reduce(initialValue T, reducer func(T, T) T) T {
    result := initialValue
    for _, item := range sc.data {
        result = reducer(result, item)
    }
    return result
}

// SetCollection 是基于 map 的集合实现
type SetCollection[T comparable] struct {
    data map[T]struct{}
}

// NewSetCollection 创建一个新的 SetCollection
func NewSetCollection[T comparable]() *SetCollection[T] {
    return &SetCollection[T]{data: make(map[T]struct{})}
}

// Add 向 SetCollection 添加元素
func (sc *SetCollection[T]) Add(item T) {
    sc.data[item] = struct{}{}
}

// Filter 实现了 Collection 接口的 Filter 方法
func (sc *SetCollection[T]) Filter(predicate func(T) bool) Collection[T] {
    result := NewSetCollection[T]()
    for item := range sc.data {
        if predicate(item) {
            result.Add(item)
        }
    }
    return result
}

// Map 实现了 Collection 接口的 Map 方法
func (sc *SetCollection[T]) Map(transform func(T) T) Collection[T] {
    result := NewSetCollection[T]()
    for item := range sc.data {
        result.Add(transform(item))
    }
    return result
}

// Reduce 实现了 Collection 接口的 Reduce 方法
func (sc *SetCollection[T]) Reduce(initialValue T, reducer func(T, T) T) T {
    result := initialValue
    for item := range sc.data {
        result = reducer(result, item)
    }
    return result
}

// ToSlice 实现了 Collection 接口的 ToSlice 方法
func (sc *SetCollection[T]) ToSlice() []T {
    result := make([]T, 0, len(sc.data))
    for item := range sc.data {
        result = append(result, item)
    }
    return result
}

func main() {
    // 使用 SliceCollection
    numbers := NewSliceCollection([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
    result := numbers.
        Filter(func(n int) bool { return n%2 == 0 }).
        Map(func(n int) int { return n * 2 }).
        Reduce(0, func(acc, n int) int { return acc + n })
    fmt.Println(result) // 输出: 60

    // 使用 SetCollection
    set := NewSetCollection[int]()
    for _, n := range []int{1, 2, 2, 3, 3, 3, 4, 5} {
        set.Add(n)
    }
    uniqueSum := set.
        Filter(func(n int) bool { return n > 2 }).
        Map(func(n int) int { return n * n }).
        Reduce(0, func(acc, n int) int { return acc + n })
    fmt.Println(uniqueSum) // 输出: 50 (3^2 + 4^2 + 5^2)
}

这段代码定义的泛型接口类型Collection包含三个方法:

  • Filter:根据条件过滤集合中的元素。
  • Map:对集合中的每个元素应用转换函数。
  • Reduce:对集合中的元素进行归约操作,比如求和。

其中Filtre、Map都是返回集合自身,这样便允许实现Collection接口的集合类型(如上面的SetCollection和SliceCollection)使用链式调用,代码看起来也十分易于理解。

2.4 递归(Recursion)

递归是函数式编程中常用的控制结构,常用来替代循环。例如下面是计算阶乘的函数实现:

func factorial(n int) int {
    if n <= 1 {
        return 1
    }
    return n * factorial(n-1)
}

递归的优点十分明显,代码简洁,易于理解(相对于循环),特别适合处理分解问题(如树结构、图遍历等)。但不足也很突出,比如可能导致栈溢出(尤其是对那些不支持尾递归优化的语言,比如Go),特别是对于较大的输入。此外,由于每次递归调用都需要创建新栈帧,维护栈状态,递归会有额外的性能开销。调试递归函数也可能比循环更复杂,因为需要跟踪多个函数调用。

2.5 惰性求值 (Lazy Evaluation)

惰性求值是指延迟计算表达式的值,直到真正需要它的时候。这样可以避免不必要的计算并有效管理内存,特别是在处理大集合或无限集合时。下面是用惰性求值实现迭代集合元素的示例:

注:Go原生并不支持惰性求值的语法,但我们可以使用闭包来模拟。

// fp-in-go/lazy-evaluation/lazy-range/main.go

package main

import "fmt"

func lazyRange(start, end int) func() (int, bool) {
    current := start
    return func() (int, bool) {
        if current >= end {
            return 0, false
        }
        result := current
        current++
        return result, true
    }
}
func main() {
    next := lazyRange(1, 5)
    for {
        value, hasNext := next()
        if !hasNext {
            break
        }
        fmt.Println(value)
    }
}

我们看到这段代码通过惰性求值方式生成从1到4的数字,避免了预先生成整个范围的集合元素,节省了内存,并避免了不必要的计算。

我们再来看一个用惰性求值生成前N个斐波那契数列的示例:

// fp-in-go/lazy-evaluation/fibonacci/main.go

package main

import (
    "fmt"
)

// Fibonacci 返回一个生成无限斐波那契数列的函数
func Fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}

func main() {
    fib := Fibonacci()
    for i := 0; i < 10; i++ { // 打印前10个斐波那契数
        fmt.Println(fib())
    }
}

我们看到Fibonacci函数返回一个闭包,每次调用时生成下一个斐波那契数,这样我们在需要时生成下一个斐波那契数,而无需生成所有。

虽然函数式编程强调纯函数和不可变性,但在实际应用中,我们不可避免地需要处理副作用,如I/O操作、数据库交互等。接下来,我们就来看看在函数式编程范式中是如何处理带有副作用的操作的。

3. 函数式编程对副作用操作的处理

3.1 理解副作用

在函数式编程中,副作用是指函数或表达式在执行过程中对其周围环境产生的任何可观察到的变化。这些变化包括但不限于:

  • 修改全局变量或静态局部变量
  • 修改函数参数
  • 执行I/O操作(读写文件、网络通信等)
  • 抛出异常或错误
  • 调用其他具有副作用的函数

副作用使得程序的行为变得难以预测和测试,因为函数的输出不仅依赖于其输入,还依赖于程序的状态和外部环境。函数式编程通过最小化副作用来提高程序的可预测性和可测试性。

3.2 Monad: 函数式编程中处理副作用的核心抽象

在函数式编程中,Monad是一种用于处理副作用的核心抽象。它提供了一种结构化的方式来处理计算中的状态、异常、输入输出等副作用,使得程序更加模块化和可组合。

在范畴论中,Monad被定义为一个自函子(endofunctor)加上两个自然变换(有点抽象了):

  • return (也称为unit):将一个值封装到Monad中。
  • bind (也称为flatMap或>>=):将一个Monad中的值应用到一个函数中,并返回一个新的Monad。

注:要入门范畴论,可以参考《Category Theory for Programmers》这本书。

Monad可以通过以下策略来处理副作用:

  • 延迟执行:将副作用操作封装在Monad中,但不立即执行,这样可以将副作用推迟到程序的边缘。
  • 显式表示:使副作用成为类型系统的一部分,迫使开发者显式地处理这些效果。
  • 组合性:提供了一种方式来组合包含副作用的操作,而不破坏函数的纯粹性。
  • 错误处理:提供了一种统一的方式来处理可能失败的操作。
  • 状态管理:允许在一系列操作中传递和修改状态,而不需要使用可变变量。

在实际应用中,我们可以根据具体需求选择使用不同的Monad实现。每种Monad都有其适用场景,比如:

  • 使用Option(Maybe) Monad处理可能缺失的值,避免空指针异常。
  • 使用Result(Either) Monad 处理可能失败的操作,提供更丰富的错误信息。
  • 使用IO Monad封装所有的I/O操作,将副作用推迟到程序的边缘。

接下来,我们就结合Go示例来逐一探讨这三种Monad实现。

3.3 Option (Maybe)

Option 用于表示一个值可能存在或不存在,避免了使用null或undefined带来的问题。

// fp-in-go/side-effect/option/main.go

package main

import "fmt"

type Option[T any] struct {
    value   T
    present bool
}

func Some[T any](x T) Option[T] {
    return Option[T]{value: x, present: true}
}

func None[T any]() Option[T] {
    return Option[T]{present: false}
}

func (o Option[T]) Bind(f func(T) Option[T]) Option[T] {
    if !o.present {
        return None[T]()
    }
    return f(o.value)
}

// 使用示例
func safeDivide(a, b int) Option[int] {
    if b == 0 {
        return None[int]()
    }
    return Some(a / b)
}

func main() {
    result := Some(10).Bind(func(x int) Option[int] {
        return safeDivide(x, 2)
    })
    fmt.Println(result) // {5 true}

    result = Some(10).Bind(func(x int) Option[int] {
        return safeDivide(x, 0)
    })
    fmt.Println(result) // {0 false}
}

这段示例程序定义了一个Option结构体:包含一个值和一个表示值是否存在的布尔变量。Some和None函数是Option的创建函数,Some函数:返回一个包含值的Option。None函数返回一个不包含值的Option。Bind方法对Option中的值应用一个函数,如果值不存在则返回None。

3.4 Result (Either)

Result可用于处理可能产生错误的操作,它比Option提供了更多的信息,它可以可以携带错误信息。

// fp-in-go/side-effect/result/main.go

package main

import (
    "fmt"
    "os"
    "strings"
)

type Result[T any] struct {
    value T
    err   error
    isOk  bool
}

func Ok[T any](value T) Result[T] {
    return Result[T]{value: value, isOk: true}
}

func Err[T any](err error) Result[T] {
    return Result[T]{err: err, isOk: false}
}

func (r Result[T]) Bind(f func(T) Result[T]) Result[T] {
    if !r.isOk {
        return Err[T](r.err)
    }
    return f(r.value)
}

// 使用示例
func readFile(filename string) Result[string] {
    content, err := os.ReadFile(filename)
    if err != nil {
        return Err[string](err)
    }
    return Ok(string(content))
}

func processContent(content string) Result[string] {
    // 处理内容...
    return Ok(strings.ToUpper(content))
}

func main() {
    result := readFile("input.txt").Bind(processContent)
    fmt.Println(result) // {HELLO, GOLANG <nil> true}
    result = readFile("input1.txt").Bind(processContent)
    fmt.Println(result) // { 0xc0000a0420 false}
}

这段示例程序定义了一个Result结构体:包含一个值、一个错误信息和一个表示操作是否成功的布尔变量。Ok和Err函数是Result的创建函数,Ok函数返回一个成功的Result。Err函数返回一个失败的Result。Bind方法对成功的Result中的值应用一个函数,如果操作失败则返回错误。

在示例中,我们分别用读取input.txt和不存在的input1.txt来演示成功和错误的两个情况,具体输出结果见上面代码中的注释。

3.5 IO Monad

IO Monad用于封装所有的带有副作用的输入/输出操作,使得这些操作在类型系统中可见,并且可以被推迟执行。

// fp-in-go/side-effect/io-monad/main.go

package main

import (
    "fmt"
    "os"
    "strings"
)

// IO represents an IO operation that, when run, produces a value of type any or an error
type IO struct {
    run func() (any, error)
}

// NewIO creates a new IO monad
func NewIO(f func() (any, error)) IO {
    return IO{run: f}
}

// Bind chains IO operations, allowing for type changes
func (io IO) Bind(f func(any) IO) IO {
    return NewIO(func() (any, error) {
        v, err := io.run()
        if err != nil {
            return nil, err
        }
        return f(v).run()
    })
}

// Map transforms the value inside IO
func (io IO) Map(f func(any) any) IO {
    return io.Bind(func(v any) IO {
        return NewIO(func() (any, error) {
            return f(v), nil
        })
    })
}

// Pure lifts a value into the IO context
func Pure(x any) IO {
    return NewIO(func() (any, error) { return x, nil })
}

// ReadFile is an IO operation that reads a file
func ReadFile(filename string) IO {
    return NewIO(func() (any, error) {
        content, err := os.ReadFile(filename)
        if err != nil {
            return nil, fmt.Errorf("failed to read file: %w", err)
        }
        return string(content), nil
    })
}

// WriteFile is an IO operation that writes to a file
func WriteFile(filename string, content string) IO {
    return NewIO(func() (any, error) {
        err := os.WriteFile(filename, []byte(content), 0644)
        if err != nil {
            return nil, fmt.Errorf("failed to write file: %w", err)
        }
        return true, nil
    })
}

// Print is an IO operation that prints to stdout
func Print(x any) IO {
    return NewIO(func() (any, error) {
        fmt.Println(x)
        return x, nil
    })
}

func main() {
    // Example: Read a file, transform its content, and write it back
    program := ReadFile("input.txt").
        Map(func(v any) any {
            return strings.ToUpper(v.(string))
        }).
        Bind(func(v any) IO {
            return WriteFile("output.txt", v.(string))
        }).
        Bind(func(v any) IO {
            success := v.(bool)
            if success {
                return Pure("File processed successfully")
            }
            return Pure("Failed to process file")
        }).
        Bind(func(v any) IO {
            return Print(v)
        })

    // Run the IO operation
    result, err := program.run()
    if err != nil {
        fmt.Printf("An error occurred: %v\n", err)
    } else {
        fmt.Printf("Program completed: %s\n", result)
    }
}

这个示例提供了一个非泛型版本的IO Monad的Go实现,它允许我们链式组合带有副作用的IO操作,同时保持了一定程度的类型安全(尽管需要类型断言)。在实际使用中,你完全不用自己实现IO Monad,可以直接使用IBM/fp-go中的ioeither,就像本文初那个示例那样。

4. 小结

到这里,关于函数式编程思维的入门介绍就告一段落了!

通过上面的介绍,我们看到函数式编程提供了一种不同于传统命令式编程的思维方式。它强调不可变性、纯函数和函数的组合,为数据流的处理搭建管道,这些特性使得代码更易于理解、测试和并行化。然而,函数式编程也带来了一些挑战,如处理副作用和状态管理的复杂性和难于理解。

学习函数式编程不仅可以扩展我们的编程技能,还能帮助我们以新的方式思考问题和设计解决方案。正如《函数式编程思维》一书中译者所说,接受一种新的编程范式可能需要时间和耐心,但最终会带来新的见解和能力。

在实际应用中,纯粹的函数式编程并不常见,更常见的是将函数式编程的概念和技术与其他编程范式(主要就是命令式范式)相结合。

Go语言虽然不是一个纯函数式语言,但它提供了足够的特性来支持函数式编程风格,如一等公民的函数、闭包和高阶函数等。

最后要记住,编程范式是工具,而不是教条。好的程序员应该能够根据具体问题和场景,灵活地选择和组合不同的编程范式,以创造出最优雅、高效的解决方案。

本文涉及的源码可以在这里下载 – https://github.com/bigwhite/experiments/blob/master/fp-in-go

本文部分源代码由Claude 3.5 sonnet和GPT-4o生成。

5. 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats