标签 软件工程 下的文章

告别懵圈:实战派 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语言高效学习之旅!


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

超越时间的智慧:重读那些定义了现代软件开发的经典文章

本文永久链接 – https://tonybai.com/2025/10/04/the-software-essays-that-shaped-me

大家好,我是Tony Bai。

二十年前,一位年轻的程序员在还未踏入职场时,便开始沉浸于软件开发的博客文章与深刻思考之中。二十年后,他已成为一名资深工程师,回首望去,成千上万的文字中,只有寥寥数篇真正沉淀下来,如基石般塑造了他的思维方式和职业生涯。

这份由 Michael Lynch 精心筛选出的“思想塑造清单”,本身就是一次对软件工程领域永恒智慧的巡礼。清单中的每一篇文章,都如同一个思想的火种,点燃了关于工程文化、代码哲学、乃至技术选型的深刻辩论。

今天,也让我们重新打开这些经典,逐一剖析其中的智慧,看看它们在瞬息万变的当下,能为我们——尤其是追求简约与高效的 Go 开发者——带来怎样历久弥新的启示。

1. Joel 测试:衡量开发者幸福感的 12 条黄金标准

(“The Joel Test: 12 Steps to Better Code” by Joel Spolsky, 2000)

Joel Spolsky 的这 12 个问题,与其说是对代码质量的测试,不如说是一面镜子,映照出一家公司是否真正尊重开发者的时间和心智。二十多年过去了,这些问题依然是衡量一个工程团队成熟度的“试金石”。

  1. Do you use source control? (你用源码控制吗?)
  2. Can you make a build in one step? (你能一步构建吗?)
  3. Do you make daily builds? (你每天都构建吗?)
  4. Do you have a bug database? (你有 Bug 数据库吗?)
  5. Do you fix bugs before writing new code? (你先修 Bug 再写新代码吗?)
  6. Do you have an up-to-date schedule? (你有最新的排期吗?)
  7. Do you have a spec? (你有需求规格说明吗?)
  8. Do programmers have quiet working conditions? (程序员有安静的工作环境吗?)
  9. Do you use the best tools money can buy? (你用钱能买到的最好工具吗?)
  10. Do you have testers? (你有测试人员吗?)
  11. Do new candidates write code during their interview? (新候选人在面试时会写代码吗?)
  12. Do you do hallway usability testing? (你做“走廊可用性测试”吗?)

虽然“每日构建”在今天已被“持续集成”(CI) 所取代,“Bug 数据库”也演变成了 Jira 或 Linear,但其精神内核——减少摩擦、自动化、系统化地管理混乱——从未过时。对于 Go 开发者而言,go build 的一步构建、go test 的内置测试、以及强大的静态分析工具链,都是对“Joel 测试”精神的现代回应。当你评估一个团队或项目时,不妨在心中过一遍这 12 个问题,它的得分,往往比任何花哨的技术栈更能说明问题。

2. 解析,而非验证:用类型系统构建“安全默认”的代码

(“Parse, don’t validate” by Alexis King, 2019)

这篇文章的核心论点,对于任何一个使用静态类型语言(如 Go)的开发者来说,都具有革命性的意义:“每当你验证一段数据时,你应该将它转换成一个新的类型。”

传统(脆弱的)做法:

// 每次使用前,都得记得调用它
func validateUsername(username string) error { ... }

这种做法的问题在于,它将验证的责任推给了开发者。你必须在代码的每一个角落,都记得去调用 validateUsername,一旦遗漏,就可能导致安全漏洞或数据损坏。

“解析,而非验证”的哲学:

// 定义一个全新的、无法被随意创建的类型
type Username string

// 唯一的入口:一个“解析”函数,它在内部执行验证
func ParseUsername(raw string) (Username, error) {
    if err := validate(raw); err != nil {
        return "", err
    }
    return Username(raw), nil
}

// 后续的业务逻辑,只接受这个被“祝福”过的类型
func GreetUser(u Username) { ... }

这种模式利用类型系统,将安全检查从一种“需要开发者时刻牢记的纪律”,转变为一种“由编译器强制执行的保证”。一旦你有了一个 Username 类型的变量,你就拥有了一个不可辩驳的证明——它必然是合法的。这在 Go 中极易实现,通过创建新的具名类型,我们可以轻松地在代码中构建起一道道安全的“防火墙”,让非法状态根本没有机会存在

3. 无银弹:正视软件开发的“本质复杂性”

(“No Silver Bullet” by Fred Brooks, 1986)

这篇来自《人月神话》作者的经典文章,将软件开发工作划分为两个核心部分:

  • 本质复杂性 (Essential Complexity):与问题领域本身固有的、不可简化的复杂逻辑作斗争。例如,设计一套复杂的保险计价公式。
  • 偶然复杂性 (Accidental Complexity):与工具、环境和实现细节作斗争。例如,处理内存泄漏、等待编译、配置构建系统。

Brooks 的核心论点是:过去几十年软件开发效率的巨大提升,主要来自于对“偶然复杂性”的削减。但无论工具如何发展,我们永远无法消除“本质复杂性”。因此,不存在任何能够带来数量级生产力提升的“银弹”

这篇文章是对抗技术领域“炒作周期”的最佳解毒剂。无论是微服务、Serverless、还是当下的 AI,它们在很大程度上解决的都是“偶然复杂性”。Go 语言的诞生,其核心目标——极快的编译速度、简单的并发模型、自动的垃圾回收——本身就是对 C++ 等语言“偶然复杂性”的一次宣战。

Brooks 的理论让我们保持清醒:即使 AI 能为我们编写代码,但定义需求、设计系统、测试复杂交互这些“本质复杂性”的工作,依然是人类工程师不可替代的价值所在。

4. 选择的代价:为用户做明智的决定

(“Choices” by Joel Spolsky, 2000)

Joel Spolsky 敏锐地指出:“你每提供一个选项,就是在要求用户做一次决策。” 过多的选择,尤其是那些用户并不具备足够信息来做出的选择,会中断用户的心流,带来挫败感。

他以 Windows 98 中一个荒谬的帮助搜索设置为例,痛斥了将底层技术决策(如“最小化数据库大小”或“最大化搜索能力”)推给普通用户的设计懒政。

这个原则不仅适用于 GUI,更适用于我们编写的任何 API 和命令行工具。当你的函数需要一大堆配置参数时,问问自己:

  • 这些选项真的都是必需的吗?
  • 我是否可以根据大多数场景,提供一个明智的、开箱即用的默认行为?
  • 对于必须暴露的选项,我能否通过 Go 的选项模式 (Options Pattern) 来组织它们,让简单的使用保持简单,让复杂的配置成为可能?

一个优秀的 API 设计者,应该是一个“仁慈的独裁者”,敢于为用户承担决策的责任,只在真正必要时,才将选择的权力交还给他们。

5. 兼容性是为用户,而非为程序

(“Application compatibility layers are there for the customer, not for the program” by Raymond Chen, 2010)

Raymond Chen 用一个尖刻的比喻,讽刺了那些期望操作系统为他们的旧软件提供无限向后兼容性的开发者。然而,文章作者 Michael Lynch 反思后认为,这个比喻的背后,其实蕴含着一个更深刻的用户行为洞察:用户永远会选择阻力最小的路径

如果你发现用户在以一种“错误”但“有效”的方式使用你的系统(比如,依赖一个 Bug 来实现某个功能),那么你的责任不是嘲笑他们,而是去理解他们为何这么做,并提供一条更简单、更正确的路径来引导他们。

这条规则对我们如今进行API设计也是大有借鉴意义的,这意味着我们需要时刻保持同理心。如果你发布了一个有 Bug 的 v1 版本,并且发现大量用户已经围绕这个 Bug 构建了他们的系统,那么在 v2 版本中,简单地“修复”这个 Bug 可能会导致大规模的破坏。

一个更负责任的做法可能是:

  1. 在 v2 中提供一个新的、行为正确的 API。
  2. 保留 v1 的旧 API,但将其标记为废弃,并在文档中清晰地解释其错误行为和迁移路径。
  3. (在 Go 1.26+ 中)甚至可以利用 //go:fix 指令,为用户提供自动化的迁移工具。

6. 不要在测试中引入逻辑

(“Don’t Put Logic in Tests” by Erik Kuefler, 2014)

我们通常被教导要在生产代码中遵循 DRY (Don’t Repeat Yourself) 原则。但 Erik Kuefler 指出,将这一原则盲目地应用到测试代码中,可能是一场灾难。

糟糕的测试:

// 为了“ DRY ”,我们拼接了 URL
assertEquals(baseUrl + "/u/0/photos", nav.getCurrentUrl());

这段代码隐藏了一个微小的 Bug(多了一个斜杠),因为它需要读者在脑中进行一次字符串拼接运算才能发现问题。

优秀的测试:

// 清晰、直白,一眼就能看出期望的结果
assertEquals("http://plus.google.com//u/0/photos", nav.getCurrentUrl());

虽然存在字符串冗余,但它的意图是一目了然的。

测试代码的首要目标是清晰性,而非优雅或无冗余。测试代码没有它自己的测试,验证其正确性的唯一方式就是人工审查。因此,一段好的测试,应该像一篇优秀的规格说明文档,让任何一个读者都能毫不费力地理解它在断言什么。在 Go 的表驱动测试 (Table-Driven Tests) 中,这一点体现得尤为重要:绝大多数情况下,输入和期望的输出应该被清晰地、并排地列出,而不是通过复杂的辅助函数动态生成。

7. 一点原生 JavaScript 就能做很多事

(“A little bit of plain Javascript can do a lot” by Julia Evans, 2020)

Julia Evans 曾分享了她从一个坚定的“前端框架拥护者”转变为“原生 JavaScript 爱好者”的心路历程。在饱受了 Angular, React, Vue 等框架带来的依赖问题和复杂性的折磨后,她决定尝试只用原生 JavaScript(现代的 ES2018 标准)来构建一个 Web 界面。

结果令她震惊:没有框架、没有构建步骤、没有 Node.js,她依然能完成 90% 的工作,而开发体验的“头痛程度”只有 5%。当出现运行时错误时,她看到的不再是经过压缩、转换的“天书”,而是她自己写的、清晰可辨的代码。

这篇文章是对现代软件开发中“框架至上”文化的一次有力反思。它提醒我们,在引入任何一个大型框架或库之前,都应该先问自己:我真的需要这个吗?标准库或语言本身的能力是否已经足够?

对于 Go 开发者而言,这种思想更是与语言的哲学不谋而合。Go 拥有一个极其强大的标准库(特别是 net/http),在许多场景下,你完全不需要引入像 Gin 或 Echo 这样的 Web 框架,就能构建出高性能、可维护的 Web 服务。

Julia 的经历鼓励我们,要敢于挑战对框架的“路径依赖”,重新审视并信任我们手中工具(无论是 JavaScript 还是 Go 标准库)的内建能力。有时候,最简单的解决方案,恰恰就在我们眼前。

8. 选择无聊的技术

(“Choose Boring Technology” by Dan McKinley, 2015)

这篇经典文章的标题本身,就是其全部智慧的浓缩。Dan McKinley 警告我们,在启动一个新项目时,要警惕那些闪亮、前沿、充满炒作的新技术的诱惑。

  • 新技术:有未知的 Bug 和弱点,当你遇到问题时,社区可能还没有解决方案,你将孤立无援。
  • “无聊”的技术(如 Postgres, Java, Go):虽然有其自身的问题,但经过数十年(或多年)的实战检验,它们几乎所有可能遇到的问题,都有成熟的、有据可查的解决方案。

McKinley 提出了一个有趣的模型:每个公司都有三枚“创新代币” (innovation tokens)。如果你想在一个项目中使用一项未经充分验证的新技术,你就必须花掉一枚代币。请明智地使用它们。

Go 语言本身,在许多方面,已经成为了“无聊技术”的典范。它稳定、向后兼容、拥有强大的标准库和成熟的生态。当我们进行技术选型时,应该问自己:我们当前的核心问题,真的需要一个全新的、我们团队不熟悉的“闪亮新事物”来解决吗?还是说,用我们已经精通的“无聊”工具,就足以应对挑战?选择“无聊”,往往是通往项目成功最可靠的路径。

9. 我把自己锁在了数字生活之外

(“I’ve locked myself out of my digital life” by Terence Eden, 2022)

这篇文章以一个引人入胜的思想实验开场:如果一道闪电击中了你的房子,摧毁了你所有的电子设备,你将如何恢复你的数字生活?

作者 Terence Eden 意识到,尽管他有密码管理器、硬件密钥和多重备份,但所有这些安全措施的“入口”,都依赖于他手边的某个设备。如果所有设备同时被毁,他将无法访问密码管理器,也无法使用硬件密钥,从而陷入一个无法恢复的死循环。

这个故事迫使读者思考一个被我们常常忽略的问题:我们的灾难恢复计划,是否本身就依赖于那些可能会在灾难中一同消失的东西?

这篇文章的教训,超越了个人数字安全,直指系统设计的核心——韧性 (Resilience)避免单点故障

当我们设计一个分布式系统时,我们是否考虑过最坏的情况?

  • 我们的备份恢复流程,是否依赖于某个中心化的、可能会一同宕机的认证服务?
  • 我们的配置中心如果不可用,应用是否能以一种“降级”但仍可用的模式启动?
  • 在多云或混合云部署中,我们的跨区域故障转移方案,是否隐藏了对某个单一 DNS 提供商或证书颁发机构的隐式依赖?

Terence 的故事提醒我们,真正的系统韧性,不仅仅是拥有备份和冗余,更是要反复审视和测试我们的恢复路径,确保在极端情况下,我们不会发现自己“被锁在门外”。

10. Bonus:Brad Fitzpatrick 论输入验证的“咆哮”

(Brad Fitzpatrick on parsing user input, 2009)

最后,是一段来自 Go 社区大神、Memcached 和 LiveJournal 的创造者 Brad Fitzpatrick 的“咆哮”,这段话源于一本访谈录《Coders at Work》。当被问及软件工程的伦理时,他将矛头直指糟糕的输入验证:

“我希望每个人在他们的信用卡表单上都能保持一致,让我TMD能输入空格或连字符。计算机很擅长移除那些狗屎。别告诉我该如何格式化我的数字。”

这段充满激情的“粗口”,完美地概括了一个核心的用户体验原则:宽进严出 (Be liberal in what you accept, be conservative in what you produce)

作为 API 或 UI 的设计者,我们的责任是尽可能地减轻用户的负担。计算机是用来处理繁琐、重复性工作的。如果用户输入了一个带空格的电话号码,或者一个全角的逗号,我们的程序应该默默地、智能地将其清理和格式化,而不是粗暴地拒绝并抛出一个错误。

Fitzpatrick 的“咆哮”时刻提醒着我们:每一次当你设计一个输入字段时,都要站在用户的角度思考,并记住那句话——“计算机很擅长移除那些狗屎。”

小结:构建衡量“好”与“坏”的永恒坐标系

从 Joel Spolsky 对工程文化的拷问,到 Fred Brooks 对复杂性的深刻剖析;从 Alexis King 对类型安全的精妙论证,到 Dan McKinley 对技术选型的务实忠告…… 当我们跟随 Michael Lynch 的脚步,完成这次跨越四十年的思想巡礼后,我们收获的远不止是一份“书单”。

技术浪潮来了又去,今天我们手中的工具,明天可能就会过时。但这些围绕着“人”的根本原则——清晰性、简单性、健壮性、同理心、风险意识——却是永恒的。它们是区分一名普通的“代码实现者”与一位真正的“软件工程师”的分水岭。

这份清单,最终为我们构建的,是一个内心深处的、用以衡量“好”与“坏”的永恒坐标系。在未来的职业生涯中,无论面对何种炫目的新技术或棘手的工程问题,这个坐标系都将指引我们,做出更明智、更持久、也更具价值的决策。

资料链接:https://refactoringenglish.com/blog/software-essays-that-shaped-me


你的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语言第一课 Go语言进阶课 AI原生开发工作流实战 从 0 开始构建 Agent Harness 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