ᕕ( ᐛ )ᕗ Jimyag's Blog

使用 Go 1.25 和 Go 1.26 编写更简洁、更易维护的 Go 代码

Go 1.25 和 Go 1.26 的发布为 Go 开发者带来了许多实用的改进。如果仅看语法表面,很容易误解这两次发布的影响。Go 1.25 侧重于语言规范的简化,而 Go 1.26 则引入了实用的语言级变更(如 new(expr) 和自引用泛型约束),并彻底重构了 go fix 工具链,使得团队能够更加轻松地将旧代码库升级到现代化的 Go 风格。

本文将深入分析这些特性的实际应用场景,探讨如何利用它们编写更整洁、更易维护的 Go 代码。


语言版本声明与支持

在使用 Go 1.26 及以上的新语法前,请确保在 go.mod 中声明正确的最低 Go 语言版本:

1
2
3
module example.com/service

go 1.26

如果某个特定文件只能在 Go 1.26 或更高版本中编译,可以使用 Build Tags 进行控制:

1
2
3
//go:build go1.26

package config

这不仅是文档说明。Go 工具链会基于模块和文件的语言版本声明,决定允许使用哪些语法和自动化优化。


Go 1.25:没有新语法,但有更清爽的语言模型

Go 1.25 移除了语言规范中关于“核心类型”(Core Types)的定义。这看似是一项学术层面的微调,但它简化了开发者理解和讲授泛型代码的逻辑。

在 Go 1.25 之前,Go 规范通过“核心类型”这一中间概念来描述泛型类型参数的操作规则,从而使一些约束条件解释起来比较晦涩。Go 1.25 直接使用通俗易懂的表述定义了这些规则。

对现有代码的影响

该变动对现有代码没有任何破坏性影响。在旧版规范中合法的 Go 代码在 Go 1.25 中依然合法;反之,旧版下不合法的代码也不会因为规范的去“核心类型化”而变得合法。

对开发者的意义

其主要收益体现在代码可读性、编译器诊断的清晰度以及语言未来的平滑演进上。

例如,下面的约束接口在限制参数类型时非常有用:

1
2
3
4
5
6
7
8
9
package textutil

type BytesOrString interface {
	~[]byte | ~string
}

func Len[T BytesOrString](v T) int {
	return len(v)
}

在 Go 1.25 之后,向其他开发者解释这段泛型代码时无需引入复杂的“核心类型”概念。我们可以直接根据实际行为表述:

Len 接收任何底层类型为 []bytestring 的类型,且 len 函数对该集合中的所有类型都是有效操作。

泛型 API 文档的最佳实践

在编写泛型约束的文档注释时,建议避开晦涩的规范术语,转而使用面向实际行为的描述。

不推荐的做法(带有晦涩的规范概念):

1
2
3
4
// Sliceable accepts values whose core type supports slicing.
type Sliceable interface {
	~[]byte | ~string
}

推荐的做法(面向实际行为):

1
2
3
4
5
// Sliceable accepts byte slices and strings, including named types
// whose underlying type is []byte or string.
type Sliceable interface {
	~[]byte | ~string
}

这正是 Go 1.25 带来的代码质量启示:泛型 API 应该围绕开发者可执行的操作进行描述,而不是强调编译器或语言规范的内部实现细节。


Go 1.26:利用 new(expr) 摆脱指针辅助函数

Go 1.26 最实用的新语法非 new(expr) 莫属。

在此之前,内置的 new 函数只能接收类型作为参数,并返回指向该类型零值的指针:

1
p := new(int) // *int, pointing to 0

在 Go 1.26 中,new 函数可以直接接收一个表达式作为参数,并返回一个指向已用该表达式初始化的新分配变量的指针:

1
p := new(42) // *int, pointing to 42

这消除在处理 JSON、Protobuf、配置解析、API 客户端及单元测试时编写的大量小指针辅助函数(如 IntPtrStrPtr 等)。

场景 1:无辅助函数的可选 JSON 字段

在 Go 中,我们通常使用指针类型来区分 JSON 负载中字段的“未传”与“传入零值”。

在 Go 1.26 之前,我们需要显式声明辅助函数以保持代码紧凑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package api

import "encoding/json"

type CreateUserRequest struct {
	Email       string  `json:"email"`
	DisplayName *string `json:"display_name,omitempty"`
	RetryCount  *int    `json:"retry_count,omitempty"`
}

func strptr(v string) *string { return &v }
func intptr(v int) *int       { return &v }

func encode() ([]byte, error) {
	return json.Marshal(CreateUserRequest{
		Email:       "[email protected]",
		DisplayName: strptr("Developer"),
		RetryCount:  intptr(3),
	})
}

在 Go 1.26 中,可以直接行内初始化,无任何样板代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package api

import "encoding/json"

type CreateUserRequest struct {
	Email       string  `json:"email"`
	DisplayName *string `json:"display_name,omitempty"`
	RetryCount  *int    `json:"retry_count,omitempty"`
}

func encode() ([]byte, error) {
	return json.Marshal(CreateUserRequest{
		Email:       "[email protected]",
		DisplayName: new("Developer"),
		RetryCount:  new(3),
	})
}

new(expr) 帮助我们精简了辅助函数,保证了初始化的连贯性,并使调用者的意图非常清晰。它在以下场景极为适用:

  • JSON 请求/响应结构体初始化
  • Protobuf 可选字段包装
  • SDK 的请求构建器
  • 数据驱动测试(Table-driven Tests)
  • 配置结构体初始化

场景 2:让测试数据更加干净

指针辅助函数往往会给测试代码带来不必要的噪音。

以前的做法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package billing

import "testing"

type Plan struct {
	Name         string
	TrialDays    *int
	DiscountCode *string
	AutoRenew    *bool
}

func intp(v int) *int          { return &v }
func stringp(v string) *string { return &v }
func boolp(v bool) *bool       { return &v }

func TestPlanValidation(t *testing.T) {
	plan := Plan{
		Name:         "growth",
		TrialDays:    intp(14),
		DiscountCode: stringp("SPRING"),
		AutoRenew:    boolp(true),
	}

	if err := Validate(plan); err != nil {
		t.Fatal(err)
	}
}

Go 1.26 之后的做法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package billing

import "testing"

type Plan struct {
	Name         string
	TrialDays    *int
	DiscountCode *string
	AutoRenew    *bool
}

func TestPlanValidation(t *testing.T) {
	plan := Plan{
		Name:         "growth",
		TrialDays:    new(14),
		DiscountCode: new("SPRING"),
		AutoRenew:    new(true),
	}

	if err := Validate(plan); err != nil {
		t.Fatal(err)
	}
}

升级到 Go 1.26 后,可以全局搜索并使用 new(expr) 替换诸如 ptrstrptrstringPtrintpboolPtrToPtr 等自定义函数。

场景 3:直接返回计算值指针,减少临时变量

new(expr) 也支持计算表达式,而不仅仅是硬编码字面量。

以前的做法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package users

import (
	"encoding/json"
	"time"
)

type Person struct {
	Name string `json:"name"`
	Age  *int   `json:"age,omitempty"`
}

func yearsSince(t time.Time) int {
	return int(time.Since(t).Hours() / (365.25 * 24))
}

func personJSON(name string, born time.Time) ([]byte, error) {
	age := yearsSince(born)
	return json.Marshal(Person{
		Name: name,
		Age:  &age,
	})
}

Go 1.26 之后的做法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package users

import (
	"encoding/json"
	"time"
)

type Person struct {
	Name string `json:"name"`
	Age  *int   `json:"age,omitempty"`
}

func yearsSince(t time.Time) int {
	return int(time.Since(t).Hours() / (365.25 * 24))
}

func personJSON(name string, born time.Time) ([]byte, error) {
	return json.Marshal(Person{
		Name: name,
		Age:  new(yearsSince(born)),
	})
}

这避免了仅仅为了取地址而声明一个无关的临时变量。

new(expr) 使用准则

建议在能够清晰体现所有权和可选性时,推荐使用 new(expr)

1
2
3
4
5
request := CreateInvoiceRequest{
	CustomerID: customerID,
	DueDays:    new(30),
	Memo:       new("Created by billing worker"),
}

但是,当表达式本身极其复杂或伴随副作用时,请谨慎使用:

1
process(new(expensiveComputation(a, b, c)))

这会把昂贵的计算和内存分配逻辑隐藏在入参位置,降低了代码可读性。此时使用命名变量并对其进行显式取地址操作依然是更好的选择:

1
2
result := expensiveComputation(a, b, c)
process(&result)

一条实用的指导线: 对于字面量、简单转换或纯小表达式,优先使用 new(expr);当表达式值得命名或需要断点调试时,坚持使用命名变量。


Go 1.26:解除自引用泛型约束与 F-Bounded Polymorphism

Go 1.26 解除了先前规范的一项限制:泛型约束(或接口类型)现在可以在自己的类型参数列表中直接引用自身。

这允许我们优雅地编写类似 F-Bounded Polymorphism(F-Bounded 多态,也称为递归通用类型约束)的模式。

什么是 F-Bounded Polymorphism?

F-Bounded Polymorphism 是一种用于约束泛型类型参数的机制,它强制要求方法签名中消耗或返回的类型必须与实现该约束的具体类型本身完全一致。

为了更好地展示这一机制的实际意义,我们以一个“宠物配对”的场景为例。我们希望在类型系统中强制要求:猫只能与猫配对,狗只能与狗配对,而不能跨物种乱配。

阶段 1:传统的非泛型接口

如果我们使用传统的面向对象接口来实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package pet

type Partner interface {
	Pair(other Partner) Partner
}

type Cat struct{}

func (c Cat) Pair(other Partner) Partner {
	return Cat{}
}

type Dog struct{}

func (d Dog) Pair(other Partner) Partner {
	return Dog{}
}

在这种设计下,接口 Partner 接收任何实现了该接口的类型。这意味着我们可以编译通过类似 myCat.Pair(myDog) 的代码。因为猫和狗的配对在业务逻辑上是无效的,我们不得不在方法内部使用类型断言 other.(Cat) 并在转换失败时抛出运行时 panic。这把本该在编译期解决的缺陷推迟到了运行期。

阶段 2:Go 1.26 之前的普通泛型接口

为了解决参数类型过于松散的问题,在早期的 Go 泛型版本中,我们可能会引入一个类型参数:

1
2
3
4
5
package pet

type Partner[T any] interface {
	Pair(other T) T
}

现在,Cat 实现了 Partner[Cat],而 Dog 实现了 Partner[Dog]。这成功阻止了 myCat.Pair(myDog) 的编译,因为猫的 Pair 只能接受 Cat 类型的参数。

但是,这里的类型约束仍然存在一个致命漏洞:泛型约束参数是 any。如果我们声明了一个没有任何配对特征的 Car 结构体,某人可能会写出如下的代码:

1
2
3
4
5
6
7
8
9
package pet

type Car struct{}

type Cat struct{}

func (c Cat) Pair(other Car) Car {
	return Car{}
}

这里 Cat 错误地实现了 Partner[Car](猫与汽车配对),但这不仅逻辑说不通,而且也打破了“配对方法应该返回自身类型”的初衷。更为严重的是,在编写接受 Partner 作为参数的通用算法(如 func Match[T Partner[T]](items []T))时,由于 any 不具备任何方法约束,我们无法保证 T 真的实现了正确的配对属性。

阶段 3:Go 1.26 引入的自引用泛型接口

在 Go 1.26 中,解除自引用泛型约束限制后,我们可以将定义修改为:

1
2
3
4
5
package pet

type Partner[T Partner[T]] interface {
	Pair(other T) T
}

这个约束用清晰的逻辑限制了两个层面的规则:

  1. 配对的方法入参 other 的类型必须是 T(同类型配对,猫配猫,狗配狗)。
  2. 类型 T 自身也必须实现 Partner[T] 接口本身(这就形成了一个闭环,确保 T 必须是一个有效的“宠物”,而不能是无关的 Car)。

现在,编译器能完美地保证在编译期拦截所有跨类型混用行为,并消除运行时的类型转换。


Go 1.26 如何通过自引用约束解决此问题

自引用泛型约束在各种需要类型对等约束的业务逻辑中非常适用。例如,在数学和算法设计中常见的加法操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type Adder[A Adder[A]] interface {
	Add(A) A
}

func Sum[A Adder[A]](values []A) A {
	if len(values) == 0 {
		var zero A
		return zero
	}

	result := values[0]
	for _, v := range values[1:] {
		result = result.Add(v)
	}
	return result
}

在 Go 1.26 之前,Adder[A Adder[A]] 这种自引用定义会导致编译失败。现在,此限制被完全移除了。

场景 4:强类型领域对象

假设我们需要定义一个表示货币的类型,在执行加法时,我们希望通过类型安全约束避免混用不同的货币类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package money

type Addable[T Addable[T]] interface {
	Add(T) T
}

func Sum[T Addable[T]](items []T) T {
	var total T
	for _, item := range items {
		total = total.Add(item)
	}
	return total
}

type EUR struct {
	Cents int64
}

func (e EUR) Add(other EUR) EUR {
	return EUR{Cents: e.Cents + other.Cents}
}

type USD struct {
	Cents int64
}

func (u USD) Add(other USD) USD {
	return USD{Cents: u.Cents + other.Cents}
}

在实际调用中:

1
2
3
4
5
6
7
func Example() {
	totalEUR := Sum([]EUR{{100}, {250}, {399}})
	_ = totalEUR

	totalUSD := Sum([]USD{{1000}, {500}})
	_ = totalUSD
}

该模式完美做到了编译期类型检查,避免了混合币种相加的低级错误:

1
2
// This does not compile, preventing cross-currency bugs at compile time.
// _ = EUR{100}.Add(USD{100})

这个约束用清晰的机制表达了算法的核心诉求:“给我一个类型,它能将另一个相同类型的值相加,并返回相同的类型。” 这比接受松散的 any 并在运行时执行类型断言更加安全。

场景 5:泛型向量与矩阵 API

自引用约束也非常适合用于向量、矩阵和几何算法中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package vector

type Vector[V Vector[V]] interface {
	Add(V) V
	Scale(float64) V
}

func Lerp[V Vector[V]](a, b V, t float64) V {
	return a.Scale(1 - t).Add(b.Scale(t))
}

type Vec2 struct {
	X, Y float64
}

func (v Vec2) Add(other Vec2) Vec2 {
	return Vec2{X: v.X + other.X, Y: v.Y + other.Y}
}

func (v Vec2) Scale(k float64) Vec2 {
	return Vec2{X: v.X * k, Y: v.Y * k}
}

泛型函数 Lerp 不需要了解 Vec2 的具体细节,只需关心 Vector 约束规定的强类型运算规则。

场景 6:链式调用与不可变 API

当方法需要修改并返回类型相同的实例时,此模式也十分方便:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package query

type Filterable[Q Filterable[Q]] interface {
	Where(condition string, args ...any) Q
	Limit(n int) Q
}

func FirstPage[Q Filterable[Q]](q Q) Q {
	return q.Limit(50)
}

type UserQuery struct {
	where []string
	args  []any
	limit int
}

func (q UserQuery) Where(condition string, args ...any) UserQuery {
	q.where = append(q.where, condition)
	q.args = append(q.args, args...)
	return q
}

func (q UserQuery) Limit(n int) UserQuery {
	q.limit = n
	return q
}

这对于不可变的 Query Builder 链式调用非常有效,因为每个方法返回的都是具体的 Builder 类型,而不是松散抽象的接口。


Go 1.26 工具链:使用全新的 go fix 升级代码

Go 1.26 重构了 go fix 指令,大幅优化了代码现代化迁移体验。它新增了多个内置的自动化修复工具,例如将老旧的指针辅助函数一键升级为 new(expr) 语法。

升级实践

首先,确认当前 Git 工作树是干净的:

1
git status

接着预览升级带来的差异(不写入文件):

1
go fix -diff ./...

应用修改:

1
go fix ./...

最后运行完整测试:

1
go test ./...

如果是在庞大的商业项目中,可以通过参数分批进行升级:

1
2
go fix -newexpr ./...
go test ./...

进行一次干净的提交:

1
2
git add .
git commit -m "Modernize pointer helpers with Go 1.26 new expressions"

由于某些现代化升级可能触发连锁优化的可能,在第一次升级完成后,建议再次运行 go fix -diff ./...,确认无任何改动即可收尾。

利用 //go:fix inline 实现库 API 的无缝升级

Go 1.26 在 go fix 中集成了一个源码级的内联处理器。第三方库作者或者平台架构组可以通过给废弃方法打上 //go:fix inline 标记,帮助下层业务库调用方自动重构迁移至新 API。

假设底层的旧包配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package oldconfig

import "example.com/platform/config"

// Load reads configuration from path.
// Deprecated: use config.LoadFile.
//go:fix inline
func Load(path string) (*config.Config, error) {
	return config.LoadFile(path)
}

业务方的旧调用代码为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package app

import "example.com/platform/oldconfig"

func boot() error {
	cfg, err := oldconfig.Load("service.yaml")
	if err != nil {
		return err
	}
	_ = cfg
	return nil
}

当业务方运行 go fix 时,其代码会被自动化更新并移除对旧方法的依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package app

import "example.com/platform/config"

func boot() error {
	cfg, err := config.LoadFile("service.yaml")
	if err != nil {
		return err
	}
	_ = cfg
	return nil
}

//go:fix inline 适用范围

该技术适用于无任何行为变更的简单演进:

  • 废弃包重定向
  • 方法重命名
  • 参数顺序调整的包装函数
  • 常量迁移至新包
  • 类型别名重引用

注意: 请勿将此方案用于需要业务决策或包含复杂语义变化的升级场景中。


团队落地与演进规划

以下是团队在落地 Go 1.26 过程中的推荐演进流程:

  1. 升级 CI 工具链:首先在构建与测试流水线中将 Go 环境升级至 Go 1.26。
  2. 保持兼容版本:在团队尚未决定使用新语法前,维持 go.mod 中原有的最低 Go 版本设置。
  3. 跑通既有测试:确保在 Go 1.26 下所有原有单元测试与静态检查完美通过。
  4. 提升声明版本:将 go.mod 中的语言版本更新为 go 1.26
  5. 本地预览修复效果:在本地执行 go fix -diff ./... 审计差异。
  6. 分步应用迁移规则:首先对最无风险的 newexpr 进行修改。
  7. 完整性回归:运行单元测试、Linter、Fuzzing 测试和集成测试。
  8. 原子化提交:确保机械性的代码升级(go fix 产出)与日常的手动业务改动划分在不同的 Commit 中,降低 Review 时的心智负担。

代码审查(Code Review)检查清单

在评审相关的升级 Pull Request 时,审查人应当关注以下要点:

  • 目标 Module 或特定文件是否正确配置了 go 1.26 版本声明?
  • 转换完 new(expr) 后,原有的旧辅助函数(如 strptr 等)是否已彻底变成无用代码(Dead Code)?如果是,是否已在同一 PR 中清理?
  • 原有的辅助函数是否属于对外部暴露的公共 API?如果是,请不要随意删除,应标记为 Deprecated。
  • 代码中是否存在对自动生成的代码(Generated Code)进行修改的情况?通常应当排除此类文件。
  • 对于使用了 new(expr) 的 JSON 编解码场景,是否维持了原有的 nil 与“零值”区别的语义?
  • 新增的自引用约束(如 F-Bounded Generics)是真正提升了代码质量,还是引入了不必要的架构复杂度?
  • 对性能敏感(Performance-critical)的逻辑段在现代化后,是否有 Benchmark 证明性能没有发生退化?

结语

Go 1.25 通过去除“核心类型”概念,为泛型的理解和文档书写提供了更直接的支撑,而 Go 1.26 则进一步提升了日常编码的舒适度。

通过 new(expr),我们可以完全告别过去为了给字面量指针赋值而编写的成百上千行样板函数;通过自引用泛型约束,强类型的复杂领域运算得以通过编译期防线完成自愈;再配合强大的 go fix 现代化机制,历史项目能在保证稳定性的前提下快速融入现代 Go 的演进潮流。


参考资源

#Go #Golang #Go Release Notes #Generics #Go Fix #New Expression