Skip to content
Go
地道代码风格

地道代码风格

前面各章回答"语法和 API 是什么",这一章回答:gopher 实际怎么写。命名怎么起、包怎么切、错误怎么传、并发怎么组织、老写法怎么换成新版 Go 的地道形态——本章把散在 05-函数与方法.md、06-接口与泛型.md、07-错误处理.md、08-并发编程.md 里的风格知识收拢成可执行规则,每条配"反例 → 推荐"短示例。

基线为 Go 1.27(本地 go1.27.0 windows/amd64 实测)。凡 1.21+ 新特性写法均括注起始版本。


1. 命名:名字是给读代码的人的

总纲只有一条:代码被阅读的次数远多于被编写,所有命名规则都从这里推出。

// 反例
package common_utils                    // 下划线、复数、"垃圾抽屉"职责
type UserInfo struct { user_name string; UserId int }  // 下划线风格 + 初始主义大小写不一致
func (u *User) GetName() string         // 不必要的 Get 前缀
// 包内定义 type HTTPClient —— 调用点变成 http.HTTPClient,前缀重复(stutter)

// 推荐
package user                            // 短、小写、单数、名词
var ErrNameEmpty = errors.New("user: name is empty")
type Record struct {
    Name   string
    UserID int                           // ID/URL/HTTP/JSON 保持全大写
}
func (r *Record) DisplayName() string    // 访问器用名词,不加 Get

逐条展开背后的理由:

  • 包名是调用点的前缀,所以按 pkg.Type 读出来的效果起名。net/http 的类型叫 Client 而不是 HTTPClient;user.User 是坏味道,多半应叫 user.Record 或把包改名。
  • 变量名长度与作用域成正比:for i := range 10 三行内一个字母足够;handler 参数 w/r 是全社区约定;跨越几十行、包级的标识符写全(customerID)。作用域越大、离定义点越远,名字越要能自我说明。
  • Getter 不加 Get;例外是方法带副作用、做校验或返回 (T, error) 时(cache.Get(key) (value, error)),此时 Get 在提示"这不是纯读取"。
  • 接口名用 -er 后缀(Reader、Closer、Formatter):小接口是 Go 的默认形态(见 06-接口与泛型.md),一个方法一个 -er 恰好;方法多了改用能力/角色名(ReadWriter、Pipeline)。
  • 导出即承诺:导出标识符是 API,修改是破坏性的。拿不准先不导出,以后放宽容易、收回难。
  • 其他约定:错误变量统一 ErrXxx;相关常量用 const ( ... ) 分组;不用匈牙利命名(strName);下划线只用于"有意忽略"(_ = f.Close())。

注意:以上是经验规则加理由(社区通行共识),不是编译器强制。被工具强制的只有导出/非导出可见性。


2. 包与项目布局:按抽象切,不按类型词切

myapp/
  go.mod
  cmd/myapp/main.go      // 入口只做装配:构造依赖、注册路由、启动,几十行
  internal/
    store/               // 一个包回答一个问题
    auth/
    user/

internal/ 是编译器强制的项目私有区:外部模块导不进来,比"先导出再靠文档劝退"可靠,新项目默认从它起步。

分层包名(service/、handler/、model/、utils/)是反模式:model 与 service 互相引用天然容易成环;handler 包是 N 个互不相关业务的堆叠,改一个功能要在跨层几个包之间跳;utils 更是没有承诺任何抽象。按抽象切包(user、store、billing),判断标准:给这个包写文档第一句时,名词短语里不含"和、以及、各种"。

循环依赖的三种标准解法,按侵入性从小到大:

  1. 把双方共同依赖的类型抽到第三个包(最常用);
  2. 接口由消费方定义,依赖倒置——billing 不 import store,自己声明 type Store interface { Chargeable(ctx, id) (float64, error) } 注入进来。只依赖行为,测试随手塞 fake,这是优先考虑的解法;
  3. 把触发循环的那个函数挪到某一侧。

对外想要稳定 API 时,实现散在 internal/ 包,外面留一个薄门面(client.New() 包住 client/internal/*);少量再导出(var ErrX = internal.ErrX)无妨,别用类型别名把三个概念伪装成一个包。


3. 注释与文档:doc comment 是一门语言

// 反例:go doc 里看不出第一句是"是什么"
// 一个线程安全的队列实现,内部用两个切片。
type Queue struct{ ... }

// 推荐:标识符开头,一句话定性;实现细节另起段落
// Queue is a thread-safe FIFO queue.
//
// The zero Queue is empty and ready to use.
type Queue struct{ ... }

这不是美观问题:go doc、pkg.go.dev、IDE 悬停都取 doc comment 第一句当摘要,以标识符开头让任何位置的展示都独立成句。

  • 包注释只写一处(惯例 doc.go 或主文件):同包多文件都写会被 go doc 拼接成重复噪音。包注释回答"这个包干什么 + 典型用法",标识符注释逐条说明。
  • 注释解释为什么,不解释做了什么:i++ // i 加 1 是噪音;retries = min(retries, 5) // 供应商 SLA 里 5 次后必然限流,见 issue #412 才是信息。代码表达怎么做,注释记录约束从哪来、删掉会怎样。
  • TODO 格式:// TODO(姓名缩写): 内容,带责任人才催得动,裸 TODO 评审时通常被要求补 issue 链接。
  • 导出标识符没有 doc comment 等于 API 文档缺页;写不出注释本身是信号——你还没想清楚它是什么。行内注释只用 //,/* */ 只配出现在注释掉的死代码里(然后被 lint 和评审同时嫌弃)。

4. 函数与方法:早返回、小函数、诚实的签名

// 反例:金字塔嵌套,正常路径被挤到最深层
func process(d *Doc) error {
    if d != nil {
        if d.Valid {
            if err := save(d); err == nil {
                return publish(d)
            }
            return ErrInvalid
        }
        return ErrInvalid
    }
    return ErrNilDoc
}

// 推荐:guard clauses 逐个挡回坏情况,主路径始终在 0 缩进
func process(d *Doc) error {
    if d == nil {
        return ErrNilDoc
    }
    if !d.Valid {
        return ErrInvalid
    }
    if err := save(d); err != nil {
        return fmt.Errorf("process: %w", err)
    }
    return publish(d)
}

Go 没有 try,早返回是减少认知负担的刚需:if err != nil { return ... } 连续排下来,主路径一眼可追踪。配套尺度:函数超过几十行先问"哪段能独立命名并测试"。配套铁律:错误永远是最后一个返回值——go vet、errcheck、IDE quick fix 都按这个位置工作;多个独立失败用 errors.Join(1.20),别发明 (err1, err2, result)。

// 反例:调用点 render(doc, true) —— true 什么意思?
func render(doc *Doc, html bool) error

// 推荐一:行为拆分,把成本在定义处一次付清
func renderHTML(doc *Doc) error
func renderMarkdown(doc *Doc) error

// 推荐二:开关多于两个时用选项结构体或 functional options(见第 10 节)

接受接口,返回结构体:参数收最窄的接口(io.Reader 什么都能喂、测试好塞 fake),返回值给具体类型(调用方拿到全部能力)。返回接口只在"故意隐藏实现"或"返回一族实现之一"时合理。

接收者选择规则与对照表见 05-函数与方法.md,结论复述:有可变状态、体积大、含锁 → 指针接收者;小型不可变值 → 值接收者;同一类型不混用。另有一条语义规则常被忽略——值接收者的方法改不动 receiver:

// 反例:调用方的 s.items 纹丝不动(append 的结果丢在副本上)
func (s Stack) Push(v int) { s.items = append(s.items, v) }

// 推荐:有状态变更就用指针 receiver
func (s *Stack) Push(v int) { s.items = append(s.items, v) }

5. 错误惯用法:包装带上下文,处理只处理一次

%w 与 %v:链能不能被检查

// 反例:%v 只格式化,errors.Is/As 到此断链
return fmt.Errorf("open config: %v", err)

// 推荐:%w 保留原始错误,调用方仍可 errors.Is(err, fs.ErrNotExist)
return fmt.Errorf("open config %s: %w", path, err)

包装只补"这一层在干什么 + 关键参数",冒号分段:loadUser: invalid id 404: user not found。每层重复同一句话是日志的事,不是错误链的事。

哨兵还是自定义类型

调用方只需知道"哪一种失败"→ 导出哨兵 var ErrNameEmpty = errors.New(...);失败携带结构化数据(ID、字段名)→ 自定义类型 + Error()(指针方法)。两者可共存:自定义类型实现 Unwrap() error 返回哨兵,Is 与 As 都工作。展开见 07-错误处理.md。

检查与聚合

var nfe *NotFoundError
if errors.As(err, &nfe) { ... }                       // Go 1.13 起标准三步

if v, ok := errors.AsType[*NotFoundError](err); ok {   // Go 1.26+:省掉声明取地址
    log.Printf("not found: id=%d", v.ID)
}

scratch 实测:As got id: 404、AsType got id: 404。多个独立失败一起上报,1.20 起 fmt.Errorf 支持多个 %w,或 errors.Join:

multi := fmt.Errorf("batch failed: %w / %w", errA, errB) // 1.20+
errors.Is(multi, errA) && errors.Is(multi, errB)          // 实测 true / true

旧手册"只能用一个 %w"的说法作废。

typed-nil 陷阱

// 反例:调用方 if err != nil 判成"有错"
var e *NotFoundError            // nil 指针
var errIf error = e
// 实测 errIf == nil → false

// 推荐:返回 error 接口的函数,只 return 字面量 nil 或真实错误值

一句话记住:接口值的 nil 与否看"类型+值"两个槽,指针槽为 nil 但类型槽非空就是非 nil 接口。

Handle once 与 panic 边界

// 反例:中间层既打日志又返回错误,顶层再打 —— 一个错误 N 行日志
log.Printf("read %s: %v", path, err)
return nil, err

// 推荐:中间层只加上下文上抛;顶层(main/handler)统一记录或转成 4xx
return nil, fmt.Errorf("load config: %w", err)

“处理"指改变控制流:降级、重试、转响应、退出。打日志不改变走向,和返回错误是同一件事的两份记账,选一份。库代码连日志都不打。panic 只留给"继续执行必然损坏状态"的编程错误,业务失败、I/O 失败都不是 panic 的理由(框架 recovery 是兜底不是 return 的替身)。

注意:1.27 的 go vet printf 检查会标记 fmt.Errorf("...: %w", p) 且 p 为 *E、E 自身实现 error 的写法——包指针多半破坏 errors.Is/断言语义。


6. 泛型使用尺度:消重复才值得

第一问永远是"不用泛型,这段代码要写几份?"。只有一份的抽象是炫技;两份以上同逻辑重复才上泛型。约束写行为的最小集合,不写类型清单的穷举:

// 反例:只有一个场景,约束堆了一屏
type Box[T int | int8 | int16 | int32 | int64 | uint | ...] struct{ v T }

// 推荐
type Number interface{ ~int | ~int64 | ~float64 }

type Stats struct { sum float64; count int }

// Add 带自己的类型参数(Go 1.27 泛型方法):容器存 float64,入参各数值类型都能收
func (s *Stats) Add[T Number](vals ...T) {
    for _, v := range vals {
        s.sum += float64(v)
        s.count++
    }
}

实测 s.Add(1, 2, 3) 后再 s.Add(2.5, 4.5),输出 stats sum=13.0 count=5 avg=2.60。1.27 泛型方法的使用尺度:类型参数属于"这次操作"就写在方法上;定义"容器元素类型"仍写在类型上(Stack[T])。接口方法不允许有类型参数(实测编译错误 interface method must have no type parameters),泛型方法也不能用来满足接口方法。语法细节见 06-接口与泛型.md。

comparable 慎用

type Holder struct{ Payload any }
func index[T comparable](vals []T) map[T]int { ... }

index([]Holder{{Payload: []int{1}}})  // 编译通过,运行时 panic

含 any 字段的类型静态满足 comparable(接口可比较),装进不可哈希的动态值后做 map key 当场炸。实测捕获 panic 原文:runtime error: hash of unhashable type []int。规则:comparable 只在确实做 ==/map key 时用,并确认调用方保证动态类型可哈希;有 cmp.Compare/cmp.Equal 可用的场合优先 cmp。

泛型容器的地道细节

type Stack[T any] struct{ items []T }
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }

func (s *Stack[T]) Pop() (T, bool) {     // 零值 + ok:不 panic、不包指针
    var zero T
    if len(s.items) == 0 {
        return zero, false
    }
    v := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return v, true
}

var st Stack[string] 零值可用(文档写一句 “The zero Stack is empty”),比强制造 New[T]() 更 Go;工厂只在初始化需要参数/校验时存在。实测 popped: b true。


7. 并发惯用法:所有权清晰,channel 就不乱

“不要通过共享内存来通信"的实际含义:数据所有权随消息转移——channel 把指针交给 worker 后,发送方不再碰它。用一把大锁护一堆共享可变状态等于没做并发设计。锁不是罪:保护单个结构体内部状态时 sync.Mutex 比 channel 直白(选型见 08-并发编程.md)。

关闭责任:发送方(或多 sender 时的唯一协调者)close,接收方一律 v, ok := <-ch 或 range ch 感知结束。接收方 close 是 bug(下一次发送 panic: send on closed channel);多 sender 先 wg.Wait() 等全部退场再由协调者统一 close。缓冲默认值是 0:无缓冲是同步交接,有缓冲解耦节奏;拿不准就无缓冲——它把"对方没准备好"暴露成可见阻塞,缓冲掩盖的背压会在更远的地方爆。只两类场景加缓冲地道:结果收集、宁丢不堵的事件(配 select/default)。

生产形态 worker pool(scratch 实测,-race 通过):

func worker(ctx context.Context, id int, jobs <-chan int, out chan<- string) {
    for n := range jobs {                  // 接收方靠 range ok=false 自然退出
        select {
        case <-ctx.Done():
            return                         // 取消优先:放弃工作
        default:
        }
        out <- fmt.Sprintf("worker %d processed %d", id, n)
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    jobs := make(chan int)
    out := make(chan string, 100)          // 结果收集:允许积压

    var wg sync.WaitGroup
    for w := 1; w <= 3; w++ {              // 1.22 起循环变量逐轮独立,闭包捕获安全
        wg.Go(func() { worker(ctx, w, jobs, out) }) // Go 1.25 WaitGroup.Go
    }
    for i := 1; i <= 6; i++ {
        jobs <- i
    }
    close(jobs)   // 唯一生产者关闭
    wg.Wait()     // 等所有 sender 退场
    close(out)    // 此刻无残余发送者,安全
    for r := range out {
        fmt.Println(r)
    }
}

实测输出(交错顺序每次略有不同):

worker 3 processed 2
worker 2 processed 3
worker 1 processed 1

fan-in 合流同理:N 个上游各自 close,协调方等齐再转发、再关出口。

WaitGroup.Go(1.25)删掉样板:wg.Add(1); go func(){ defer wg.Done(); f() }() → wg.Go(f),错开配对从源头消失。go fix 的 waitgroupgo modernizer(1.27)可自动重写——scratch 实测 go fix ./fixdemo 把旧代码改写为(for 也顺带现代化):

for i := range 3 {
    wg.Go(func() {
        print(i)
    })
}

context 的规矩:ctx context.Context 恒为第一参数;不要塞进 struct 字段(http.Request 这类跨生命周期的除外);取消原因传调用方用 WithCancelCause/Cause(1.20)。补一句:1.23 起 timer channel 恒为无缓冲同步,1.27 已永久移除 asynctimerchan GODEBUG。

锁与原子选型:一小段状态 → Mutex(defer Unlock,耗时操作挪锁外);单个数字/指针 → atomic.Int64/atomic.Pointer[T](1.19 类型化形态);一次性初始化 → sync.Once,要返回值用 OnceValues(1.21)。互斥锁嵌 struct 而非指针字段,含锁类型不可复制(go vet copylocks 会报)。并发收敛/取消聚合一句带过:golang.org/x/sync/errgroup。数据竞争零容忍:本地与 CI 一律 go test -race。


8. 集合与字符串:预分配、nil 语义、少拷贝

// 反例:边 append 边扩容反复拷贝;循环 += 整串 O(n²) 拷贝
var out []string
s := ""
for _, r := range rows { out = append(out, ...); s += r.Name }

// 推荐:容量已知给够;变长聚合用 strings.Builder
out := make([]string, 0, len(rows))         // 长度 0 容量 n,append 语义不变
idx := make(map[string]int, len(keys))      // map 同理给 hint
var b strings.Builder
for _, part := range parts { b.WriteString(part) }

实测:upper: [CAROL ALICE BOB]、built: x,x,x,x,x。两三个片段的短拼接直接 +(编译器折叠成一次拷贝),Builder 是给循环用的。

nil slice 是设计不是缺陷:var empty []int 的 len、range、append 全部安全(实测 append 后 len: 1)。Go 故意不区分"空"与 nil:返回空结果 return nil, nil 地道,调用方判 len(s) == 0。唯一陷阱是 JSON:nil slice 编成 null、空 slice 编成 [],契约敏感就初始化为 []Item{}。map 相反:nil map 可读不可写,忘 make 直接 panic。

切割与比较:

key, val, ok := strings.Cut(line, "=")             // 1.18:按第一个 "="
stem, ext, has := strings.CutLast("a.b.c", ".")    // 1.27:最后一个 → 实测 "a.b","c",true
if strings.EqualFold(a, "Go") { ... }              // 别先 ToLower 再 ==
key := "id=" + strconv.Itoa(id)                    // 反例 fmt.Sprintf("id=%d", id)

CutLast 返回三个值,多值调用不能塞进单值上下文(如当作 fmt.Sprintf 的一个实参),要显式解构。单值转换走 strconv,fmt.Sprintf 背着格式解析与反射,热路径里是几十倍开销差;多值格式化、错误消息这类"人读"场景仍放心用 fmt。

拷贝意识:string(b)/[]byte(s) 各拷一次;编译器在只读等安全场景做 CopyOnWrite 免拷贝,但别赌——长期持有或要修改就显式 bytes.Clone。容器操作优先 slices/maps(1.21):slices.Sort、slices.IndexFunc、maps.Keys(1.23 起返回 iter.Seq 迭代器,升级注意语义变化)。对照见 04-复合类型.md。


9. 现代 Go 改写速查表

评审旧代码、做升级 PR 按这张表改。括号为起始版本;标 go fix 的可自动重写(1.27 modernizers:atomictypes、embedlit、slicesbackward、unsafefuncs、waitgroupgo;fmtappendf 因风格争议被移除)。

旧写法 地道写法 起始
ioutil.ReadAll / ioutil.ReadDir io.ReadAll / os.ReadDir 1.16
interface{} any 1.18
atomic.AddInt64(&x, 1) x atomic.Int64; x.Add(1)(go fix atomictypes) 1.19
for i := 0; i < n; i++ for i := range n;min/max/clear 内建 1.21
手写遍历/排序/去重 slices / maps 包 1.21
闭包里 v := v 防共享 直接依赖逐轮独立循环变量 1.22
rand.Intn(需播种) math/rand/v2(全局自动播种) 1.22
手写回调迭代 for x := range seq(iter.Seq) 1.23
单 %w 包一个错误 多 %w / errors.Join 1.20
wg.Add(1)+go defer wg.Done() wg.Go(f)(go fix waitgroupgo,实测过) 1.25
errors.New + As 三步模板 errors.AsType[T](err) 1.26
x := int64(300); p := &x p := new(int64(300)) 1.26
第三方 UUID 库 顶层 uuid 包(NewV7 等,无 error 返回) 1.27
encoding/json 性能顾虑 老 API 行为由 v2 支撑(GOEXPERIMENT=nojsonv2 可回退) 1.27

升级 PR 第一遍 go fix ./pkg 机械改写,第二遍按本表人工复审,最后 go vet。


10. 配置与选项:flag、环境变量、functional options

二进制入口 flag 就够:flag.String("addr", ":8080", "listen address") + flag.Parse();要子命令(myapp serve)再上独立 flag.FlagSet 或第三方 CLI。部署相关配置(端口、DSN、开关)优先环境变量——Kubernetes/CI 的实际注入形态就是 env;区分"未设置"与"空串"用 os.LookupEnv。viper 类全家桶的代价是隐式优先级链和启动成本,中小项目 std 够用。

// 反例:在 init() 里读 env 装配全局 —— 测试拿不到第二套配置
// 推荐:装配放 main,配置作参数传入

选项多于两个、调用点常只改其中一两个时,functional options 才发光:

type Server struct {
    addr    string
    timeout time.Duration
}
type Option func(*Server)

func WithAddr(addr string) Option     { return func(s *Server) { s.addr = addr } }
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }

func NewServer(opts ...Option) *Server {
    s := &Server{addr: ":8080", timeout: 5 * time.Second} // 先填缺省
    for _, opt := range opts {
        opt(s)                                            // 再逐项覆盖
    }
    return s
}

实测:NewServer(WithAddr(":9090"), WithTimeout(2*time.Second)) 输出 server: :9090 2s。参数本来就两三个的,直接多写参数或用选项结构体(见第 4 节)。变参与方法细节见 05-函数与方法.md。


11. 测试风格:表驱动、helper、以及 1.25+ 的时间机器

表驱动是默认形态:

func TestParseKey(t *testing.T) {
    tests := []struct {
        name   string
        in     string
        wantK  string
        wantOK bool
    }{
        {"normal", "key=value", "key", true},
        {"empty value", "key=", "key", true},
        {"no separator", "keyvalue", "", false},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {   // 子测试:-run TestParseKey/normal 单独复现
            gotK, ok := parseKey(tt.in)
            if gotK != tt.wantK || ok != tt.wantOK {
                t.Errorf("parseKey(%q) = (%q, %v), want (%q, %v)",
                    tt.in, gotK, ok, tt.wantK, tt.wantOK)
            }
        })
    }
}

三个固定件:t.Run 给每条 case 名字;t.Errorf 报告不中断、t.Fatal 中断(初始化失败用后者);错误消息同时含期望与实际。helper 与清理:断言 helper 第一行写 t.Helper(),否则失败行号全指向 helper;注册逆序清理用 t.Cleanup,临时目录用 t.TempDir()(测试完自动删)。fixtures/golden 文件放 testdata/(go build ./... 自动忽略该目录),golden 对比配一个 -update 标志重新生成。

网络与时序:常规集成测试用 httptest.NewServer(loopback 真端口)。1.27 新增 httptest.NewTestServer(t testing.TB, handler) *Server——内存假网络,配 testing/synctest 用;注意其 srv.URL 要等第一次 Client()/Start() 才填充,惯用形态先拿 client:

srv := httptest.NewTestServer(t, handler)
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/ping")

testing/synctest(1.25 GA)在隔离"泡泡"里跑被测代码:泡泡内虚拟时钟起点 2000-01-01 00:00 UTC,泡泡内所有 goroutine 都持久阻塞时时钟才整体前进。当前 API 为 Test/Wait/Sleep(Sleep 系 1.27 新增 = time.Sleep + Wait;1.24 实验期的 synctest.Run 已不存在)。测超时节奏:

func TestSleepAdvance(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        out := make(chan int)
        go func() {
            time.Sleep(10 * time.Hour)
            out <- 42
        }()
        synctest.Sleep(10 * time.Hour) // 虚拟 10 小时,墙上时间约 0ms
        if got := <-out; got != 42 {
            t.Errorf("got %d, want 42", got)
        }
    })
}

本章示例 go test -race -v 实测:--- PASS: TestSleepAdvance (0.00s)——十小时超时逻辑毫秒级验证完。其余一句:解析器/输入校验可上 testing.F.Fuzz(语料在 testdata/fuzz);覆盖率 go test -cover(并发配 -covermode=atomic),是体检指标不是 KPI;对外库常用 package foo_test 黑盒写法。更多见 09-标准库速览.md。


12. 性能与内存意识:不微优化,但要会避免白给

前提:先测量再优化(pprof 定位热点,见 09-标准库速览.md)。以下才是"不测也能确定不亏"的写法。

逃逸分析一瞥:go build -gcflags=-m 的实测输出:

./main.go:30:9: &User{...} escapes to heap

结论的实用形态:返回 *User 导致逃逸是设计代价的一部分,别为省一次堆分配把 API 扭成 out-param;只有热路径(每请求百万次)的小对象值得回看能否值传递。判断方法是 -m 看一眼,不是背规则。

切片共享底层数组:data[i:j]、append 复用、bytes.Buffer.Bytes() 都可能共享底层数组,小切片长期持有会扣住整个大 buffer 不回收——谁活得比谁长,就 append([]byte(nil), b[:10]...) 或 bytes.Clone 断一刀。

sync.Pool 的边界:只在"构造贵、生命周期恰好一次操作内、并发复用率高"的地方上(典型:每请求 buffer);Get 判 nil 重建、Put 前必须重置状态。GC 周期会清空它,拿它当跨请求对象缓存池是常见误用(详见 08-并发编程.md)。字符串 interning:取值集合有限且高频出现的字符串(状态名、指标标签)集中定义成导出常量或 intern 到一张表,省重复分配,同指针时 == 还走快路径。

预分配与这两条其实是同一件事:Go 的免费性能来自提前告诉 runtime “你要多大、你要活多久”。排查 goroutine 泄漏用 1.27 转正的 goroutineleak profile(实测 pprof.Lookup("goroutineleak") 非 nil),端点 /debug/pprof/goroutineleak,比翻全量 goroutine dump 快一个数量级。


13. 工具即风格:三条命令的事,不值得争论

  • gofmt 不参与风格讨论:它是法律不是建议。编辑器开保存即格式化(gopls 默认),CI 里 test -z "$(gofmt -l .)"。1.27 gofmt 修了列对齐逻辑,旧文件重排可能产生纯空白 diff,升级工具链时留意。
  • go vet 必跑:printf 格式串、copylocks、lostcancel 都是真 bug 级问题。1.27 起 go test 默认附带 stdversion 检查——用了比模块 go 指令更新的标准库符号直接报,实测错误样式:uuid.New requires go1.27 or later (file is go1.26)。
  • go fix 做机械现代化:见第 9 节表,waitgroupgo/atomictypes/embedlit/slicesbackward/unsafefuncs 可自动改写(fmtappendf 已移除)。
  • lint 生态一句:golangci-lint 聚合 errcheck/staticcheck/revive 一族,在 vet 之上补风格一致性;规则集宜少宜精——改一条 lint 配置比改一条命名习惯容易得多。

实战建议

一页自检清单,评审 PR 逐条过。

命名与包

  • 包名短、小写、单数;pkg.Type 读起来不 stutter、不重复包名
  • 变量名长度配作用域;初始主义大小写一致(ID/URL/HTTP)
  • 导出标识符有以标识符开头的 doc comment;拿不准的先不导出
  • 没有 util/common/model 式按类型词切的包;循环依赖优先"消费方定义接口”

错误处理

  • 每层只加自己的上下文,%w 保链;错误恒为最后一个返回值
  • 哨兵 ErrXxx 配 errors.Is,结构化数据配自定义类型 + errors.As/AsType(1.26+)
  • handle once:一个错误只在一处被记录/转换;库代码不打日志不 panic
  • 返回 error 的函数只 return nil 或真实错误,不返回 nil 指针

并发

  • 每个 channel 能说出唯一关闭者;接收方一律 ok/range 感知结束
  • 无缓冲默认起步,加缓冲能说出理由
  • ctx 恒为第一参数;取消路径走 ctx.Done()
  • wg.Go(1.25+)替代 Add/Done 样板;测试与 CI 开 -race

测试与工具

  • 新逻辑表驱动 + t.Run;helper 有 t.Helper(),资源有 t.Cleanup/t.TempDir
  • 超时/重试节奏用 synctest(1.25+)测,不拿真挂钟赌;网络测试 1.27 起可用 httptest.NewTestServer 假网络
  • 提交前 gofmt + vet 全绿;老代码升级先 go fix 再按第 9 节人工改
  • 性能疑点用 pprof 回答,泄漏查 /debug/pprof/goroutineleak(1.27)

地道风格的本质是可预期:任何人打开任何一个 gopher 的包,命名、错误流、并发收尾的读法都一样。写代码时多代入"三个月后第一次读这段的人”,比背规则更持久。