Sign in

Go 1.26 里,泛型已经不是“新特性”,而是写好 Go 的基本功

你要是经历过早些年的 Go 开发,大概率都懂那种烦躁。

明明是同一段逻辑、同一个循环、同一种处理方式,只因为这次拿到的是 []int,下次拿到的是 []string,你就得老老实实再抄一遍。代码没变,脑子也没变,变的只有类型。那种机械重复,真的很消磨人。

大家当然也不是没挣扎过。

最常见的“聪明办法”,就是上 interface{},也就是今天更常写的 any。表面上看,它好像让函数“通用”了;可实际上,你是把编译期的安全感,换成了运行时的侥幸。类型断言一漏,线上 panic 就来了。那种感觉很像走在一层打蜡过头的地板上:你知道大概率能走过去,但心里始终不踏实。

后来,事情终于变了。

Go 1.18 在 2022 年 3 月正式发布,把泛型带进了语言本体;而到了 Go 1.26,这已经不是“新鲜玩具”,而是写现代 Go 时很自然的一部分。Go 官方也在 1.26 中继续完善泛型能力,比如允许泛型类型在自己的类型参数列表中引用自身,这让一些更复杂的数据结构写起来顺手了不少。 (Go)

所以问题来了:Go 泛型到底在干什么?它又是怎么把那些重复劳动,从你手里接过去的?

核心概念:Go 泛型到底在帮你做什么?

把它想简单一点:

泛型就是让你在定义函数或数据结构时,先别把类型写死,而是留一个“占位符”。等真正调用时,再告诉编译器,这里到底是 intstring,还是某个自定义结构体。

它很像先造一个架子,至于上面放书、放光盘,还是放你那一堆攒了很多年的 Gopher 周边,等用的时候再说。

在 Go 里,泛型最关键的两块是:类型参数 和 类型约束

1. 类型参数:先留个位置,类型稍后再定

类型参数写在函数名或类型名后面的方括号里。

func Identity[T any](value T) T {
    return value
}

这里的 T 就是类型参数。它的意思很直接:先别急着问我是什么类型,等你真正调用这个函数时,我再告诉你。

这个例子虽然简单,但它很准确地说明了泛型的基本动作:把“类型”也变成函数签名的一部分

顺带一提,Go 1.26 对泛型类型系统又往前推了一步。官方博客明确提到,泛型类型现在可以在自己的类型参数列表里引用自身,这让某些递归式、互相关联的数据结构实现起来更自然。这个改动不花哨,但很实用。 (Go)

2. 类型约束:泛型不是放飞自我,而是“带规则地通用”

如果只有类型参数,没有约束,泛型很快就会变成一锅粥。

Go 这点其实拿捏得很克制。它没有让泛型变成“什么都行”,而是通过约束来告诉编译器:允许哪些类型进来,进来之后又能做哪些操作。

最常见的几种:

any:什么都可以,但你在函数体里也几乎不能假设它支持什么操作。

comparable:只能是能用 == 和 != 比较的类型,这对 map 键之类的场景特别顺手。

自定义接口(类型集合):这才是泛型真正有意思的地方。你可以自己规定,哪些类型被允许。

type Numeric interface {
    int | int64 | float64
}

func Sum[T Numeric](a, b T) T {
    return a + b
}

这里的 Numeric 就是一个类型集合,意思是:T 只能是 intint64 或 float64 之一。也正因为约束已经告诉编译器“这些都是数值类型”,所以 + 才能合法使用。

这种设计很像 Go 一贯的风格:不给你无限魔法,但把真正有用的能力交给你,而且让编译器继续替你守住边界。 (Go)

一个很值钱的小细节:~ 真不是装饰符

写约束时,很多人一开始会忽略 ~。但它其实很有用。

例如:

type IntegerLike interface {
    ~int
}

这表示不仅接受原生 int,也接受底层类型是 int 的自定义类型,比如:

type UserID int

这样你既保留了领域类型的表达力,又不会把泛型函数写得过死。这一点在项目规模上来后,会特别舒服。官方规范对这种“underlying type” 的约束写法有明确定义。 (Go)

用例一:slices 包,终于不用再重复造轮子了

泛型真正改变日常开发体验的地方,不只是“你能写泛型函数”了,而是 Go 标准库终于开始大规模给你现成工具了。

从 Go 1.21 开始,标准库新增了 slices 和 maps 包,专门提供基于泛型的常用操作。也就是说,那些过去你总得自己写一遍、团队里复制来复制去的小工具,如今很多已经官方内置了。 (Go)

这件事的意义其实很大。因为泛型一旦进了标准库,开发者的心态就会变:它不再是“高阶技巧”,而是“正常写法”。

过去你可能会有这种痛苦:

func FilterInts(s []int, f func(int) bool) []int { /* ... */ }
func FilterStrings(s []string, f func(string) bool) []string { /* ... */ }

逻辑一样,只有类型不同,写起来真像在抄作业。

如果标准库没有你想要的某个工具,比如函数式风格里常见的 Map,那你也可以很优雅地自己补一个,而且只写一次就够了。

func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 {
    result := make([]T2, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

这类函数一旦写好,后面不管你处理的是整数、字符串、时间、结构体,还是一堆业务模型,调用方式都一样,而且全程受编译器保护。

这种体验,说白了就是:终于不用再为了“类型不同”而重复劳动了

用例二:数据结构终于可以“按逻辑复用”,而不是“按类型复制”

另一个经典痛点,是数据结构。

队列就是队列,栈就是栈。无论里面放的是用户、任务、订单,还是别的什么东西,它们的行为逻辑并不会因为元素类型变了就跟着变。过去 Go 没泛型时,想把这些结构做得真正通用,几乎绕不开 interface{},然后你就得接受运行时断言、可读性下降、IDE 推断变弱这一整套副作用。

泛型出现之后,这件事终于顺了。

下面这个栈的例子,我顺手帮你把格式和细节整理了一下,代码会更接近真实项目里的写法:

package main

import "fmt"

type Stack[T any] struct {
    elements []T
}

func NewStack[T any]() *Stack[T] {
    return &Stack[T]{
        elements: make([]T, 0),
    }
}

func (s *Stack[T]) Push(element T) {
    s.elements = append(s.elements, element)
}

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.elements) == 0 {
        var zero T
        return zero, false
    }

    last := len(s.elements) - 1
    element := s.elements[last]
    s.elements = s.elements[:last]
    return element, true
}

func main() {
    type User struct {
        Name string
    }

    userStack := NewStack[User]()
    userStack.Push(User{Name: "Alice"})
    userStack.Push(User{Name: "Bob"})

    if popped, ok := userStack.Pop(); ok {
        fmt.Printf("Popped User: %s\n", popped.Name)
    }
}

这里最舒服的地方在于:

你弹出来的就是 User,而不是一个模糊的 any;IDE 能直接补全字段;编译器也能替你提前发现问题。那种“写完还得小心翼翼断言一次”的不安感,基本就没有了。

说到底,泛型并没有让这些数据结构“更高级”,它只是终于让它们回到了它们本来该有的样子:逻辑复用,但不牺牲类型安全

再往前一步:什么时候该用泛型,什么时候别硬上?

这里我得说一句很 Go 的话:泛型确实好用,但别上头。

它是工具,不是信仰。

Go 的魅力一直在于克制。很多时候,代码能写得更抽象,不等于代码就更好。泛型要用在“能明显减少重复、又不牺牲可读性”的地方;如果只是为了显得自己会写抽象,那就容易把一段本来很直接的代码,拧成一根别人读不懂的麻花。

几个比较稳的习惯,我建议保留:

先看标准库。
真的,先看 slicesmaps,很多事官方已经给你写好了。别还没翻文档,就先起手一个自定义泛型工具箱。 (Go Packages)

能让编译器推断,就别手写类型参数。
比如这两种写法:

result := Map[int, int](mySlice, Double)

result := Map(mySlice, Double)

多数时候后者更自然。Go 的类型推断能力本来就是拿来省事的,不用白不用。官方也专门写过类型推断的说明。 (Go)

约束别写太死。
该用 ~ 的地方用上,让自定义领域类型也能吃到泛型函数的好处,而不是被排除在外。 (Go)

如果函数永远只处理字符串,那就写字符串函数。
别为了“理论上可泛化”就强行上泛型。Go 仍然是那个重可读性、重直接表达的语言,这点没变。

最后收个尾:泛型不是炫技,它只是让 Go 终于补上了那块空白

回头看,Go 1.18 把泛型带进来,确实是个分水岭;到了 Go 1.21,slices 和 maps 进入标准库,泛型开始真正影响日常写法;而 Go 1.26 又继续把这套能力往更成熟的方向推。现在的现实已经很清楚:泛型不是可选的“新语法”,而是现代 Go 工具箱里该有的一把趁手扳手。 (Go)

它最实在的价值,不是什么“学会了一个高级概念”,而是:

你终于不用再为类型差异,去复制那些一模一样的逻辑;
你终于不用再靠 interface{} 和类型断言赌线上运气;
你终于可以把复用、可读性、类型安全这三件事,同时拿在手里。

真要开始上手,也别搞太大。

挑一个你项目里反复出现的小工具函数,比如 ContainsIndexOfMap,先改一个。你很快就会发现,泛型最迷人的地方从来不是“厉害”,而是它把那些原本很烦、很碎、很机械的重复劳动,安安静静地收掉了。

这才是它最值钱的地方。