JetBrains go-modern-guidelines:一份给 AI 的 Go 现代写法清单,人也该对照查一遍
用 AI 写 Go 有个通病:你让它找切片里有没有某个值,它给你写 found := false 加一个 for 循环;让它处理错误,它写 err == os.ErrNotExist。这些写法没错,但都不是 2025 年的写法。
JetBrains 的 go-modern-guidelines 就是冲着这个来的。名义上是给 AI coding agent 用的 skill,实际内容是一份覆盖 Go 1.0 到 Go 1.27 的「现代 Go 写法」清单。人也可以拿来自查。
它解决什么问题
两个直接动机:
- 训练数据滞后。模型训练截止后的新特性它没见过,自然写不出来。比如 Go 1.26 的
errors.AsType,训练数据里根本没有,你不能指望模型自己发明出来。 - 频率偏置。训练语料里
for i := 0; i看示例。
要求本机 Go 1.25+,老版本靠 GOTOOLCHAIN=auto 自动切换工具链。
仓库里有三个设计细节,比清单本身更值得抄:
- 不允许 agent 用 grep/head 截断 list 输出。模型为了省 token 常会只读开头几行,但新版指南排前面,尾部往往是被过滤后剩下的「旧但仍适用」的条目。截断会漏掉这些。
- 指南与目标版本冲突、或明确改变行为时,agent 可以跳过,但跳之前必须
explain。跳过要给出理由,不能默默忽略。 - 版本绑定。不是甩一份 35 条总表让 agent 自己判断,而是 CLI 按项目实际版本过滤。这比 system prompt 里写「请先检查 Go 版本」可靠得多——把规则嵌进工具,而不是靠模型自觉。
挑几条 Critical/High 过一遍
下面每条标注所需 Go 版本,以及 go fix 里有没有对应 modernizer([x] 有,[ ] 没有)。判断依据来自仓库 FEATURES.md。
errors.Is / errors.As(Go 1.13,Critical,[ ])
// before
if err == os.ErrNotExist { /* ... */ }
if pe, ok := err.(*os.PathError); ok { /* ... */ }
// after
if errors.Is(err, os.ErrNotExist) { /* ... */ }
var pe *os.PathError
if errors.As(err, &pe) { /* ... */ }
直接比较 == 只对「错误就是同一个哨兵值」成立;一旦错误被 fmt.Errorf("...: %w", err) 包装过,== 和类型断言都会失效。errors.Is/As 会穿透整个包装链。
这条 go fix 还没有对应 analyzer,只能手动改。
errors.Join(Go 1.20,High,[ ])
// before
var errs []error
if a != nil { errs = append(errs, a) }
if b != nil { errs = append(errs, b) }
if len(errs) > 0 {
return fmt.Errorf("multiple errors: %v", errs)
}
return nil
// after
return errors.Join(a, b)
关键是 errors.Join 保留错误结构,errors.Is/As 能继续穿透;fmt.Errorf("...%v", errs) 会把列表拍平成一条普通错误。全部参数为 nil 时 errors.Join 返回 nil。
interface{} → any(Go 1.18,Critical,[x])
// before
func Parse(s interface{}) {}
// after
func Parse(s any) {}
any 就是 interface{} 的别名,纯语法糖。go fix 能自动改,语义完全等价。唯一要注意的是别让代码库里两种写法并存。
slices.Contains(Go 1.21,Critical,[x])
// before
found := false
for _, v := range s {
if v == needle {
found = true
break
}
}
// after
found := slices.Contains(s, needle)
go fix 有 modernizer。如果循环体里还要用下标,slices.Index 更合适;只是判存在,Contains 干净得多。
内建 min/max(Go 1.21,High,[x])
// before
var result int
if a < b { result = a } else { result = b }
// after
result := min(a, b)
要求参数是有序类型且类型一致。go fix 能替换简单 case,但浮点 NaN 的行为和手写 if 有差异,替换前查一下 Go spec。
slices.SortFunc(Go 1.21,High,[x])
// before
sort.Slice(users, func(i, j int) bool {
return users[i].Name < users[j].Name
})
// after
slices.SortFunc(users, func(a, b User) int {
return cmp.Compare(a.Name, b.Name)
})
注意 slices.SortFunc 不保证稳定。原来用 sort.SliceStable 的代码,要换的是 slices.SortStableFunc,别只改个函数名。
maps.Keys / maps.Values(Go 1.23,High,[ ])
// before
var keys []K
for k := range m {
keys = append(keys, k)
}
// after
keys := slices.Collect(maps.Keys(m))
迭代顺序和手写 range 一样是随机的,所以原本对 keys 做了 sort.Strings 的代码,替换后排序不能省。这条还没进 go fix。
strings.CutPrefix(Go 1.20,High,[x])
// before
if strings.HasPrefix(s, "prefix:") {
s = strings.TrimPrefix(s, "prefix:")
/* ... */
}
// after
if rest, ok := strings.CutPrefix(s, "prefix:"); ok {
/* ... */
}
HasPrefix + TrimPrefix 扫两遍字符串,CutPrefix 一遍搞定,而且直接把切完的值带出来了。go fix 有对应 modernizer。
strings.SplitSeq(Go 1.24,High,[x])
// before
parts := strings.Split(s, ",")
for _, part := range parts {
use(part)
}
// after
for part := range strings.SplitSeq(s, ",") {
use(part)
}
SplitSeq 返回迭代器,不分配中间切片。适用条件是:parts 后续不再被多次遍历或按下标访问。如果还要索引,先 slices.Collect 也省不了多少。
range over int(Go 1.22,Critical,[x])
// before
for i := 0; i < n; i++ { /* ... */ }
// after
for i := range n { /* ... */ }
少写两个分号,更重要的是不容易写错边界。只适用于「从 0 到 n-1、步长 1」的循环;步长、逆序、起始值不是 0 的,还得写三部分循环。
循环闭包捕获(Go 1.22,High,[x])
// before(Go ≤ 1.21)
for _, v := range items {
v := v
go func() { use(v) }()
}
// after(Go 1.22+)
for _, v := range items {
go func() { use(v) }()
}
Go 1.22 起 range 循环的迭代变量是每轮新建的,不再有捕获同一变量的坑。这个语义变化由 go.mod 里的 go 版本控制——即使编译工具链是 1.25,go.mod 声明 go 1.21 也不会生效。
cmp.Or(Go 1.22,High,[ ])
// before
r := a
if r == "" {
r = b
}
if r == "" {
r = c
}
// after
r := cmp.Or(a, b, c)
返回第一个非零值。注意它判断的是「零值」,不是「nil」。如果 a 是 0 但你原本想表达的是「a 有值」,cmp.Or 会给出错误结果。这条也没有进 go fix。
t.Context()(Go 1.24,High,[x])
// before
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// after
ctx := t.Context()
测试里 90% 的 WithCancel + defer cancel 只是为了不泄漏 goroutine。t.Context() 直接给你一个随测试结束自动取消的 context。如果测试中途要手动取消(比如模拟超时),还是得自己 WithCancel。
什么时候别照着抄
替换前先搞清楚「语言版本」和「标准库 API」是两回事:
- 语言特性(
range over int、循环变量语义、any)由go.mod的 go 指令控制。go.mod 还写着go 1.21,即使本机工具链是 Go 1.25,for i := range n一样编译不过。 - 标准库新包(
slices、maps)由实际编译工具链提供。CI 的 Go 版本够新就能用,go.mod 版本不影响。
行为差异也要过一遍:
slices.SortFunc不保证稳定,sort.SliceStable的用户别直接换。cmp.Or判零值,0和空字符串这些合法值会被跳过。errors.Is/As有遍历错误链的开销,纯热路径上每次==能解决的话,不一定换。maps迭代顺序随机,任何依赖顺序的隐藏逻辑都会在替换后暴露出来。
readability 取舍同样存在:for i := range n 写不了步长和逆序,硬套反而难看;SplitSeq 的迭代器只能消费一次,后续还要按索引取 parts[i] 就别改。现代写法不等于所有场景的正确答案。
哪些已经在 go fix 里
上面 13 条里,go fix 的 modernize analyzer 已覆盖:efaceany、slicescontains、minmax、sortslice、stringscutprefix、stringsseq、rangeint、forvar、testingcontext。
还没有自动化的:erris、errorsjoin、mapkeysvalues、cmpor。
仓库 35 条里还有 waitgroup、bloop、omitzero、httpmux 等条目,素材里没给 API 细节,我没实测,不展开。
这个项目最值得借鉴的不是某一条写法,而是「按项目版本过滤 + 输出不可截断 + 跳过必须解释」这套约束。让 AI 记住规则,不如让规则长在工具里——这一点人做 code review 时也用得上。