引言
本页面收集了在 Go 代码审查过程中常见的评论,以便通过简短的引用指向详细的解释。这是常见风格问题的列表,而不是全面的风格指南。
您可以将其视为《Effective Go》的补充。
有关测试的额外评论,请参见Go 测试注释。
Google 发布了一份更详细的Go 风格指南。
请在编辑此页面之前讨论更改,即使是小的更改。很多人有自己的意见,这里不是编辑战的地方。
Gofmt
在代码上运行gofmt,以自动修复大部分机械风格问题。几乎所有公开的 Go 代码都使用gofmt。本文档的其余部分讨论非机械风格点。
另一种选择是使用goimports,它是gofmt的超集,额外添加(和移除)必要的导入行。
注释句子
参见https://go.dev/doc/effective_go##commentary。记录声明的注释应为完整的句子,即使这看起来有些冗余。这种方法使它们在提取到 godoc 文档时格式良好。注释应以描述对象的名称开头,并以句号结尾:
// Request表示运行命令的请求。
type Request struct { ...
// Encode将req的JSON编码写入w。
func Encode(w io.Writer, req *Request) { ...
上下文
context.Context类型的值携带安全凭证、追踪信息、截止日期和取消信号,跨越 API 和进程边界。Go 程序在从传入的 RPC 和 HTTP 请求到传出的请求的整个函数调用链中显式传递上下文。
大多数使用上下文的函数应将其作为第一个参数:
func F(ctx context.Context, /* 其他参数 */) {}
永不特定于请求的函数可以使用context.Background(),但在认为不需要传递上下文时,也倾向于传递上下文。默认情况是传递上下文;只有激流勇进,只有在有充分理由认为替代方法是错误的情况下才直接使用context.Background()。
不要将上下文成员添加到结构体类型;相反,将ctx参数添加到需要传递上下文的每个方法上。唯一的例外是必须匹配标准库或第三方库中接口签名的方法。
不要创建自定义上下文类型或在函数签名中使用除Context之外的其他接口。
如果需要传递应用程序数据,请将其放入参数、接收者、全局变量中,或者,如果确实属于那里,放入上下文值中。
上下文是不可变的,因此将相同的ctx传递给共享相同截止日期、取消信号、凭证、父追踪等的多个调用是安全的。
复制
为避免意外别名,复制来自其他包的结构体时要小心。例如,bytes.Buffer类型包含一个[]byte切片。如果复制一个Buffer,副本中的切片可能与原始数组别名,导致后续方法调用产生意外效果。
一般来说,如果类型T的方法与指针类型*T相关联,则不要复制该类型的值。
加密随机数
不要使用math/rand或math/rand/v2包生成密钥,即使是临时的。用Time.Nanoseconds()种子,只有少量熵。相反,使用crypto/rand.Reader。如果需要文本,使用crypto/rand.Text,或者将随机字节编码为encoding/hex或encoding/base64。
import (
"crypto/rand"
"fmt"
)
func Key() string {
return rand.Text()
}
声明空切片
声明空切片时,优先选择:
var t []string
而不是:
t := []string{}
前者声明一个nil切片值,而后者是非nil但长度为零的切片。它们在功能上是等价的——它们的len和cap都是零——但nil切片是首选风格。
注意,在某些有限情况下,首选非nil但长度为零的切片,例如在编码 JSON 对象时(nil切片编码为null,而[]string{}编码为 JSON 数组[])。
在设计接口时,避免区分nil切片和非nil、长度为零的切片,因为这可能导致微妙的编程错误。
有关 Go 中nil的更多讨论,请参见 Francesc Campoy 的演讲理解 Nil。
文档注释
所有顶层、导出的名称都应有文档注释,非平凡的未导出类型或函数声明也是如此。有关注释约定的更多信息,请参见https://go.dev/doc/effective_go##commentary。
不要使用 Panic
参见https://go.dev/doc/effective_go##errors。不要将panic用于正常错误处理。使用error和多返回值。
错误字符串
错误字符串不应大写(除非以专有名词或首字母缩写开头)或以标点符号结尾,因为它们通常在其他上下文之后打印。即使用fmt.Errorf("something bad")而不是fmt.Errorf("Something bad"),以便log.Printf("Reading %s: %v", filename, err)在消息中间不会出现多余的大写字母。这不适用于日志记录,因为日志是隐式面向行的,不会在其他消息中组合。
示例
在添加新包时,包含预期使用的示例:一个可运行的Example,或一个展示完整调用序列的简单测试。
有关可测试Example()函数的更多信息,请参见##。
协程生命周期
在生成协程时,明确说明它们何时退出,或者是否退出。
协程可能因在通道发送或接收上阻塞而泄漏:即使它们阻塞的通道不可达,垃圾回收器也不会终止协程。
即使协程没有泄漏,在不再需要时让它们保持运行可能导致其他微妙且难以诊断的问题。关闭通道上的发送会导致panic。在“结果不再需要”后修改仍在使用的输入仍可能导致数据竞争。让协程无限期运行可能导致不可预测的内存使用。
尽量保持并发代码简单,以便协程生命周期显而易见。如果这不可行,记录协程何时以及为何退出。
处理错误
参见https://go.dev/doc/effective_go##errors。不要使用_变量丢弃错误。如果函数返回错误,请检查它以确保函数成功。处理错误、返回错误,或在真正特殊的情况下使用panic。
导入
除非为了避免名称冲突,否则避免重命名导入;好的包名称不需要重命名。如果发生冲突,优先重命名最本地或项目特定的导入。
导入按组组织,组之间有空行。标准库包始终在第一组。
package main
import (
"fmt"
"hash/adler32"
"os"
"github.com/foo/bar"
"rsc.io/goversion/version"
)
goimports会为您完成此操作。
空导入
仅因其副作用而导入的包(使用import _ "pkg"语法)只能在程序的main包或需要它们的测试中导入。
点导入
点导入形式在测试中可能有用,因为循环依赖关系无法成为被测试包的一部分:
package foo_test
import (
"bar/testutil" // 也导入了 "foo"
. "foo"
)
在这种情况下,测试文件不能在foo包中,因为它使用了bar/testutil,而bar/testutil导入了foo。因此,我们使用import .形式,让文件假装是foo包的一部分,尽管它不是。除此之外,不要在程序中使用import .。它使程序难以阅读,因为不清楚像Quux这样的名称是当前包的顶级标识符还是导入包中的标识符。
带内错误
在 C 及类似语言中,函数通常返回-1或null等值以表示错误或缺失结果:
// Lookup返回key的值,如果key没有映射,则返回""。
func Lookup(key string) string
不检查带内错误值可能导致错误:
Parse(Lookup(key)) // 返回“parse failure for value”而不是“no value for key”
Go 支持多返回值提供了更好的解决方案。函数不应要求客户端检查带内错误值,而应返回一个额外的值以指示其他返回值的有效性。此返回值可以是error,或在不需要解释时为布尔值。它应为最后一个返回值。
// Lookup返回key的值,如果key没有映射,则ok=false。
func Lookup(key string) (value string, ok bool)
这可以防止调用者错误使用结果:
Parse(Lookup(key)) // 编译时错误
并鼓励更健壮且可读的代码:
value, ok := Lookup(key)
if !ok {
return fmt.Errorf("no value for %q", key)
}
return Parse(value)
此规则适用于导出的函数,但对未导出的函数也很有用。
当它们是函数的有效结果时,返回值如nil、""、0和-1是可以的,即调用者无需与其他值不同地处理它们。
一些标准库函数,如strings包中的函数,返回带内错误值。这极大地简化了字符串操作代码,但需要程序员更加勤奋。一般来说,Go 代码应为错误返回额外的值。
错误流缩进
尽量保持正常代码路径的最小缩进,并首先处理错误处理。这提高了代码的可读性,使正常路径可以快速视觉扫描。例如,不要写:
if err != nil {
// 错误处理
} else {
// 正常代码
}
相反,写:
if err != nil {
// 错误处理
return // 或 continue 等
}
// 正常代码
如果if语句有初始化语句,例如:
if x, err := f(); err != nil {
// 错误处理
return
} else {
// 使用 x
}
则可能需要将短变量声明移到自己的行:
x, err := f()
if err != nil {
// 错误处理
return
}
// 使用 x
首字母缩写
名称中的首字母缩写或缩写词(如“URL”或“NATO”)具有一致的大小写。例如,“URL”应显示为“URL”或“url”(如“urlPony”或“URLPony”),绝不能是“Url”。例如:ServeHTTP而不是ServeHttp。对于具有多个初始化“单词”的标识符,例如使用xmlHTTPRequest或XMLHTTPRequest。
此规则也适用于“ID”,当它表示“标识符”时(几乎所有情况都不是“id”作为“自我”、“超我”),因此写appID而不是appId。
协议缓冲区编译器生成的代码免于此规则。人工编写的代码比机器生成的代码有更高的标准。
接口
Go 接口通常属于使用接口类型值的包,而不是实现这些值的包。实现包应返回具体(通常是指针或结构体)类型:这样,可以在实现中添加新方法而无需广泛重构。
不要在 API 的实现方定义接口“用于模拟”;相反,设计 API 以便可以使用真实实现的公共 API 进行测试。
不要在使用之前定义接口:没有实际使用的示例,很难看出接口是否必要,更不用说它应该包含哪些方法。
package consumer // consumer.go
type Thinger interface { Thing() bool }
func Foo(t Thinger) string { … }
package consumer // consumer_test.go
type fakeThinger struct{ … }
func (t fakeThinger) Thing() bool { … }
…
if Foo(fakeThinger{…}) == "x" { … }
// 不要这样做!!!
package producer
type Thinger interface { Thing() bool }
type defaultThinger struct{ … }
func (t defaultThinger) Thing() bool { … }
func NewThinger() Thinger { return defaultThinger{ … } }
相反,返回具体类型并让消费者模拟生产者实现。
package producer
type Thinger struct{ … }
func (t Thinger) Thing() bool { … }
func NewThinger() Thinger { return Thinger{ … } }
行长度
Go 代码没有严格的行长度限制,但避免过长的行。同样,不要为了保持短行而添加换 换行,当长行更可读时。例如,如果函数调用或函数声明中途换行,通常是因为参数过多或变量名过长。长行通常与长名称相关,摆脱长名称会有很大帮助。
换句话说,根据你所写内容的语义换行(作为一般规则),而不是因为行的长度。如果你发现这产生了过长的行,那么更改名称或语义,你可能会得到一个好的结果。
这实际上与函数应该有多长的建议完全相同。没有“函数永远不要超过 N 行”的规则,但肯定有函数过长和过短重复的函数,解决方案是更改函数边界,而不是开始计数行。
混合大小写
参见https://go.dev/doc/effective_go##mixed-caps。即使这违反了其他语言的惯例。例如,未导出的常量是maxLength,而不是MaxLength或MAX_LENGTH。
另见首字母缩写。
命名结果参数
考虑在 godoc 中的显示效果。像这样的命名结果参数:
func (n *Node) Parent1() (node *Node) {}
func (n *Node) Parent2() (node *Node, err error) {}
在 godoc 中会显得重复;最好使用:
func (n *Node) Parent1() *Node {}
func (n *Node) Parent2() (*Node, error) {}
另一方面,如果函数返回两个或三个相同类型的参数,或者结果的含义从上下文中不清楚,在某些情况下添加名称可能有用。不要仅仅为了避免在函数内部声明变量而命名结果参数;这以不必要的 API 冗长为代价换取了小的实现简洁性。
func (f *Foo) Location() (float64, float64, error)
不如以下清晰:
// Location返回f的纬度和经度。
// 负值分别表示南和西。
func (f *Foo) Location() (lat, long float64, err error)
如果函数只有几行,裸返回是可以的。一旦函数达到中等大小,明确指定返回值。推论:仅仅因为可以启用裸返回就命名结果参数是不值得的。文档的清晰度总是比在函数中节省一两行更重要。
最后,在某些情况下,需要命名结果参数以在延迟闭包中更改它。这是始终可以的。
裸返回
没有参数的返回语句返回命名的返回值。这被称为“裸”返回。
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return
}
参见命名结果参数。
包注释
像所有要由 godoc 呈现的注释一样,包注释必须紧邻包子句,没有空行。
// Package math 提供基本的常数和数学函数。
package math
/*
Package template 实现用于生成文本输出的数据驱动模板,
例如HTML。
....
*/
package template
对于“package main”注释,在二进制名称之后(如果它出现在开头,可能会大写),其他样式的注释是可以的。例如,对于目录seedgen中的package main,你可以写:
// Binary seedgen ...
package main
或
// Command seedgen ...
package main
或
// Program seedgen ...
package main
或
// The seedgen command ...
package main
或
// The seedgen program ...
package main
或
// Seedgen ...
package main
这些是示例,合理的变体也是可以接受的。
注意,以小写字母开头的句子不是包注释的可接受选项,因为这些是公开可见的,应使用正确的英语书写,包括将句子的第一个单词大写。当二进制名称是第一个单词时,即使命令行调用不严格匹配拼写,也需要将其大写。
有关注释约定的更多信息,请参见https://go.dev/doc/effective_go##commentary。
包命名
对你包中名称的所有引用都将使用包名称,因此你可以从标识符中省略该名称。例如,如果你在chubby包中,你不需要类型ChubbyFile,客户端将写为chubby.ChubbyFile。相反,将类型命名为File,客户端将写为chubby.File。避免使用无意义的包名称,如util、common、misc、api、types和interfaces。参见https://go.dev/doc/effective_go##package-names和https://go.dev/blog/package-names获取更多信息。
传递值
不要仅为了节省几个字节而将指针作为函数参数传递。如果函数仅通过*x引用其参数x,那么该参数不应是指针。常见的例子包括传递字符串指针(*string)或接口值指针(*io.Reader)。在这两种情况下,值本身是固定大小的,可以直接传递。这条建议不适用于大的结构体,甚至可能变大的小型结构体。
接收者名称
方法的接收者名称应反映其身份;通常类型的一两个字母缩写就足够了(例如Client的“c”或“cl”)。不要使用通用的名称,如“me”、“this”或“self”,这些是面向对象语言中赋予方法特殊含义的标识符。在 Go 中,方法的接收者只是另一个参数,因此应相应命名。其名称无需像方法参数那样具有描述性,因为其作用显而易见,且没有文档目的。它可以非常短,因为它几乎出现在类型的每个方法的每一行;熟悉允许简洁。也要保持一致:如果你在一个方法中将接收者称为“c”,不要在另一个方法中称为“cl”。
接收者类型
选择在方法上使用值接收者还是指针接收者可能很困难,尤其是对新的 Go 程序员来说。如有疑问,使用指针,但在某些情况下,值接收者有意义,通常是出于效率考虑,例如小型不变结构体或基本类型的值。一些有用的指导原则:
- 如果接收者是
map、func或chan,不要使用指向它们的指针。如果接收者是切片,且方法不会重新切片或重新分配切片,也不要使用指向它的指针。 - 如果方法需要修改接收者,接收者必须是指针。
- 如果接收者是包含
sync.Mutex或类似同步字段的结构体,接收者必须是指针,以避免复制。 - 如果接收者是大的结构体或数组,指针接收者更有效。如何算大?假设它相当于将所有元素作为方法的参数传递。如果感觉太大,对接收者来说也太大。
- 函数或方法(无论是在并发中还是从此方法调用时)可以修改接收者吗?值类型在调用方法时会创建接收者的副本,因此外部更新不会应用于此接收者。如果更改必须在原始接收者中可见,接收者必须是指针。
- 如果接收者是结构体、数组或切片,且其任何元素是指向可能被修改的内容的指针,优先选择指针接收者,因为这会使意图对读者更清晰。
- 如果接收者是小的数组或自然是值类型的结构体(例如
time.Time类型),没有可变字段和指针,或者只是简单的基本类型,如int或string,值接收者有意义。值接收者可以减少可能生成的垃圾量;如果值传递给值方法,可以使用栈上的副本而不是在堆上分配。(编译器会尽量避免这种分配,但并非总是成功。)不要出于此原因选择值接收者类型,而不进行性能分析。 - 不要混合接收者类型。为所有可用方法选择指针或结构体类型。
- 最后,如有疑问,使用指针接收者。
同步函数
优先选择同步函数——直接返回其结果或在返回之前完成任何回调或通道操作的函数——而不是异步函数。
同步函数将协程限制在调用内,使其生命周期更容易推理,并避免泄漏和数据竞争。它们也更容易测试:调用者可以传递输入并检查输出,而无需轮询或同步。
如果调用者需要更多并发性,他们可以通过从单独的协程调用函数轻松添加。但在调用者端移除不必要的并发性非常困难——有时甚至是不可能的。
有用的测试失败
测试应以有用的消息失败,说明出了什么问题,输入是什么,实际得到什么,预期是什么。编写一堆assertFoo辅助函数可能很诱人,但要确保你的辅助函数产生有用的错误消息。假设调试你的失败测试的人不是你,也不是你的团队。典型的 Go 测试失败如下:
if got != tt.want {
t.Errorf("Foo(%q) = %d; want %d", tt.in, got, tt.want) // 或 Fatalf,如果测试在此之后无法继续
}
注意,这里的顺序是实际值 != 预期值,消息也使用该顺序。一些测试框架鼓励反过来写:0 != x,expected 0, got x等。Go 不这样做。
如果这看起来像是打字很多,你可能想编写一个表驱动测试。
另一种在不同输入使用测试辅助函数时消除测试失败歧义的常见技术是将每个调用者包装在不同的TestFoo函数中,因此测试以该名称失败:
func TestSingleValue(t *testing.T) { testHelper(t, []int{80}) }
func TestNoValues(t *testing.T) { testHelper(t, []int{}) }
无论如何,你有责任为未来调试你的代码的人提供有用的失败消息。
变量命名
Go 中的变量名应短而非长。对于作用域有限的局部变量尤其如此。优先选择c而不是lineCount。优先选择i而不是sliceIndex。
基本规则:名称使用的距离其声明越远,名称必须越具有描述性。对于方法接收者,一两个字母就足够。常见变量如循环索引和读取器可以是单个字母(i、r)。更不寻常的事物和全局变量需要更具描述性的名称。
另见 Google Go 风格指南中的更详细讨论https://google.github.io/styleguide/go/decisions##variable-names。
