Go 风格决策
https://google.github.io/styleguide/go/decisions
注意: 这是概述 Google Go 风格的系列文档之一。本文档是**规范性的但非权威性的**,从属于核心风格指南。更多信息请参见概述。
关于
本文档包含旨在统一和提供标准指导、解释和示例的风格决策,用于 Go 可读性导师给出的建议。
本文档并非详尽无遗,将随时间增长。在核心风格指南与此处给出的建议矛盾的情况下,风格指南优先,本文档应相应更新。
请参见概述 获取完整的 Go 风格文档集。
以下章节已从风格决策移至指南的其他部分:
-
MixedCaps:参见 guide#mixed-caps
-
格式化:参见 guide#formatting
-
行长度:参见 guide#line-length
命名
有关命名的总体指导,请参见核心风格指南中的命名部分。以下部分对命名中的特定领域提供进一步的说明。
下划线
Go 中的名称通常不应包含下划线。此原则有三个例外:
- 仅由生成代码导入的包名可以包含下划线。有关如何选择多词包名的更多细节,请参见包名。
*_test.go文件中的 Test、Benchmark 和 Example 函数名可以包含下划线。- 与操作系统或 cgo 互操作的低级库可以重用标识符,如
syscall中所做的那样。这在大多数代码库中预计非常罕见。
注意: 源代码的文件名不是 Go 标识符,不必遵循这些约定。它们可以包含下划线。
包名
在 Go 中,包名必须简洁,只使用小写字母和数字(例如 k8s、oauth2)。多词包名应保持不间断且全部小写(例如 tabwriter 而非 tabWriter、TabWriter 或 tab_writer)。
避免选择可能被常用局部变量名遮蔽的包名。例如,usercount 是比 count 更好的包名,因为 count 是一个常用的变量名。
Go 包名不应该有下划线。如果你需要导入一个名称中包含下划线的包(通常来自生成的或第三方代码),则必须在导入时将其重命名为适合在 Go 代码中使用的名称。
此规则的例外是,仅由生成代码导入的包名可以包含下划线。具体示例包括:
-
使用
_test后缀表示仅测试包的导出 API 的单元测试(testing包称这些为”黑盒测试” )。例如,包linkedlist必须在名为linkedlist_test的包中定义其黑盒单元测试(不是linked_list_test) -
使用下划线和
_test后缀表示指定功能或集成测试的包。例如,链表服务集成测试可以命名为linked_list_service_test -
使用
_test后缀表示包级文档示例
避免使用 util、utility、common、helper、model、testhelper 等无信息量的包名,这些名称会诱使包的用户在导入时重命名它。参见:
当导入的包被重命名时(例如 import foopb "path/to/foo_go_proto"),包的本地名称必须遵循上述规则,因为本地名称决定了文件中如何引用包中的符号。如果给定的导入在多个文件中被重命名,特别是在相同或相邻的包中,应尽可能在任何地方使用相同的本地名称以保持一致性。
另请参见:Go 博客关于包名的文章 。
接收器名称
接收器 变量名必须:
- 短(通常一到两个字母长度)
- 类型本身的缩写
- 一致地应用于该类型的每个接收器
- 不是下划线;如果未使用则省略名称
| 长名称 | 更好的名称 |
|---|---|
func (tray Tray) | func (t Tray) |
func (info *ResearchInfo) | func (ri *ResearchInfo) |
func (this *ReportWriter) | func (w *ReportWriter) |
func (self *Scanner) | func (s *Scanner) |
常量名
常量名必须像 Go 中的所有其他名称一样使用 MixedCaps。(导出的 常量以大写开头,未导出的常量以小写开头。)即使这打破了其他语言中的惯例也是如此。常量名不应是其值的派生,而应解释该值表示什么。
// Good:
const MaxPacketSize = 512
const (
ExecuteBit = 1 << iota
WriteBit
ReadBit
)不要使用非 MixedCaps 常量名或带有 K 前缀的常量。
// Bad:
const MAX_PACKET_SIZE = 512
const kMaxBufferSize = 1024
const KMaxUsersPergroup = 500根据常量的角色而非其值来命名常量。如果一个常量除了它的值之外没有角色,那么将它定义为常量是不必要的。
// Bad:
const Twelve = 12
const (
UserNameColumn = "username"
GroupColumn = "group"
)首字母缩写词(Initialisms)
名称中作为首字母缩写词或缩写的词(例如 URL 和 NATO)应使用相同的大小写。URL 应表示为 URL 或 url(如 urlPony 或 URLPony),而不是 Url。一般规则是,标识符(例如 ID 和 DB)也应以类似其在英语散文中的用法进行大写。
- 在包含多个首字母缩写词的名称中(例如
XMLAPI,因为它包含XML和API),给定首字母缩写词中的每个字母应具有相同的大小写,但名称中的每个首字母缩写词不需要具有相同的大小写。 - 在包含小写字母的首字母缩写词的名称中(例如
DDoS、iOS、gRPC),首字母缩写词应按照标准散文中的形式出现,除非你需要为了导出性 更改首字母。在这些情况下,整个首字母缩写词应为相同大小写(例如ddos、IOS、GRPC)。
| 英语用法 | 作用域 | 正确写法 | 不正确写法 |
|---|---|---|---|
| XML API | 导出的 | XMLAPI | XmlApi, XMLApi, XmlAPI, XMLapi |
| XML API | 未导出的 | xmlAPI | xmlapi, xmlApi |
| iOS | 导出的 | IOS | Ios, IoS |
| iOS | 未导出的 | iOS | ios |
| gRPC | 导出的 | GRPC | Grpc |
| gRPC | 未导出的 | gRPC | grpc |
| DDoS | 导出的 | DDoS | DDOS, Ddos |
| DDoS | 未导出的 | ddos | dDoS, dDOS |
| ID | 导出的 | ID | Id |
| ID | 未导出的 | id | iD |
| DB | 导出的 | DB | Db |
| DB | 未导出的 | db | dB |
| Txn | 导出的 | Txn | TXN |
Getter 方法
函数和方法名不应使用 Get 或 get 前缀,除非底层概念使用了”get”一词(例如 HTTP GET)。优先直接以名词开头,例如使用 Counts 而非 GetCounts。
如果函数涉及执行复杂计算或远程调用,可以使用 Compute 或 Fetch 等不同的词代替 Get,以向读者表明该函数调用可能需要时间并可能阻塞或失败。
变量名
一般的经验法则是,名称的长度应与其作用域的大小成正比,与该作用域内的使用次数成反比。在文件作用域创建的变量可能需要多个词,而作用域限定在单个内部块中的变量可能只是一个词甚至只是一两个字符,以保持代码清晰并避免多余的信息。
以下是一个大致的基准。这些数值指南不是严格的规则。根据上下文、清晰性和简洁性应用判断。
- 小作用域是执行一两个小操作的地方,大约 1-7 行。
- 中等作用域是几个小操作或一个大操作,大约 8-15 行。
- 大作用域是一个或几个大操作,大约 15-25 行。
- 非常大的作用域是跨越超过一页的任何内容(大约超过 25 行)。
一个在小作用域内可能完全清晰的名称(例如,用于计数器的 c)在较大的作用域中可能不够充分,需要澄清以提醒读者它的用途。一个有很多变量的作用域,或者变量代表相似的值或概念,可能需要比作用域暗示的更长的变量名。
概念的具体性也有助于保持变量名的简洁。例如,假设只有一个数据库在使用,像 db 这样通常为非常小的作用域保留的短变量名,即使作用域很大也可能完全清晰。在这种情况下,基于作用域大小,单词 database 可能是可接受的,但不是必需的,因为 db 是该词非常常见的缩写,几乎没有其他解释。
局部变量的名称应反映它包含的内容以及在当前上下文中如何使用,而不是值的来源。例如,最佳局部变量名通常与结构体或 protocol buffer 字段名不同。
一般来说:
- 像
count或options这样的单词名称是一个好的起点。 - 可以添加额外的词来消除相似名称的歧义,例如
userCount和projectCount。 - 不要仅仅为了节省输入而省略字母。例如
Sandbox优于Sbx,特别是对于导出的名称。 - 从大多数变量名中省略类型和类型类词语。
- 对于数字,
userCount是比numUsers或usersInt更好的名称。 - 对于切片,
users是比userSlice更好的名称。 - 如果作用域中有两个版本的值,包含类型限定符是可接受的,例如你可能有一个存储在
ageString中的输入,并使用age表示解析后的值。
- 对于数字,
- 省略从周围上下文中明确的词语。例如,在
UserCount方法的实现中,名为userCount的局部变量可能是多余的;count、users甚至c同样可读。
单字母变量名
单字母变量名可以是减少重复的有用工具,但也可能使代码不必要地晦涩。将它们的使用限制在完整单词明显且出现完整单词会重复的场景中。
一般来说:
- 对于方法接收器变量,优先使用一个或两个字母的名称。
- 使用熟悉的变量名表示常见类型通常很有帮助:
r表示io.Reader或*http.Requestw表示io.Writer或http.ResponseWriter
- 单字母标识符作为整数循环变量是可以接受的,特别是用于索引(例如
i)和坐标(例如x和y)。 - 当作用域很短时,缩写可以作为可接受的循环标识符,例如
for _, n := range nodes { ... }。
重复
一段 Go 源代码应避免不必要的重复。一个常见的来源是重复的名称,通常包含不必要的词语或重复其上下文或类型。如果相同或类似的代码段在近距离内多次出现,代码本身也可能不必要地重复。
重复的命名可以有多种形式,包括:
包名 vs. 导出符号名
在命名导出符号时,包名始终在包外可见,因此两者之间的冗余信息应该减少或消除。如果一个包只导出一个类型且以包本身命名,如果需要构造函数,其规范名称为 New。
示例: 重复的名称 -> 更好的名称
widget.NewWidget->widget.Newwidget.NewWidgetWithName->widget.NewWithNamedb.LoadFromDatabase->db.Loadgoatteleportutil.CountGoatsTeleported->gtutil.CountGoatsTeleported或goatteleport.Countmyteampb.MyTeamMethodRequest->mtpb.MyTeamMethodRequest或myteampb.MethodRequest
变量名 vs. 类型
编译器始终知道变量的类型,在大多数情况下,读者也可以通过变量的使用方式清楚地知道变量的类型。只有当变量的值在同一作用域中出现两次时,才有必要澄清变量的类型。
| 重复的名称 | 更好的名称 |
|---|---|
var numUsers int | var users int |
var nameString string | var name string |
var primaryProject *Project | var primary *Project |
如果值以多种形式出现,可以用额外的词(如 raw 和 parsed)或底层表示来澄清:
// Good:
limitRaw := r.FormValue("limit")
limit, err := strconv.Atoi(limitRaw)// Good:
limitStr := r.FormValue("limit")
limit, err := strconv.Atoi(limitStr)外部上下文 vs. 局部名称
包含其周围上下文信息的名称通常会在没有益处的情况下产生额外噪音。包名、方法名、类型名、函数名、导入路径甚至文件名都可以提供自动限定其中所有名称的上下文。
// Bad:
// 在包 "ads/targeting/revenue/reporting" 中
type AdsTargetingRevenueReport struct{}
func (p *Project) ProjectName() string// Good:
// 在包 "ads/targeting/revenue/reporting" 中
type Report struct{}
func (p *Project) Name() string// Bad:
// 在包 "sqldb" 中
type DBConnection struct{}// Good:
// 在包 "sqldb" 中
type Connection struct{}// Bad:
// 在包 "ads/targeting" 中
func Process(in *pb.FooProto) *Report {
adsTargetingID := in.GetAdsTargetingID()
}// Good:
// 在包 "ads/targeting" 中
func Process(in *pb.FooProto) *Report {
id := in.GetAdsTargetingID()
}重复通常应在符号用户的上下文中评估,而不是孤立地评估。例如,以下代码有很多在某些情况下可能没问题但在上下文中多余的名称:
// Bad:
func (db *DB) UserCount() (userCount int, err error) {
var userCountInt64 int64
if dbLoadError := db.LoadFromDatabase("count(distinct users)", &userCountInt64); dbLoadError != nil {
return 0, fmt.Errorf("failed to load user count: %s", dbLoadError)
}
userCount = int(userCountInt64)
return userCount, nil
}相反,从上下文或用法中清楚的名称信息通常可以省略:
// Good:
func (db *DB) UserCount() (int, error) {
var count int64
if err := db.Load("count(distinct users)", &count); err != nil {
return 0, fmt.Errorf("failed to load user count: %s", err)
}
return int(count), nil
}注释
关于注释的约定(包括注释什么、使用什么风格、如何提供可运行示例等)旨在支持阅读公共 API 文档的体验。更多信息请参见 Effective Go 。
最佳实践文档中关于文档约定的部分进一步讨论了这一点。
最佳实践: 在开发和代码审查期间使用文档预览来查看文档和可运行示例是否有用,是否按你期望的方式呈现。
提示: Godoc 使用很少的特殊格式;列表和代码片段通常应该缩进以避免换行。除了缩进外,通常应该避免装饰。
注释行长度
Go 中注释没有固定的行长度限制。
长注释行应该换行,以确保在不自动换行注释行的工具中源代码可读。如果不确定在哪里换行,80 或 100 列是常见的选择。但这不是硬性截止值;在某些情况下,断开长文字文本是有害的。不要求特定的列宽进行换行。目标是在文件内保持一致。
参见 Go 博客上这篇关于文档的文章 了解更多关于注释的内容。
# Good:
// This is a comment paragraph.
// The length of individual lines doesn't matter in Godoc;
// but the choice of wrapping makes it easy to read on narrow screens.
//
// Don't worry too much about the long URL:
// https://supercalifragilisticexpialidocious.example.com:8080/Animalia/Chordata/Mammalia/Rodentia/Geomyoidea/Geomyidae/
//
// Similarly, if you have other information that is made awkward
// by too many line breaks, use your judgment and include a long line
// if it helps rather than hinders.避免将大量文本放在一行中的注释,这是糟糕的阅读体验。
# Bad:
// This is a comment paragraph. While some code editors and viewers will wrap the paragraph for the reader, others will display a very long line that will overflow most windows and require users to scroll horizontally. In addition, even on a screen capable of displaying the entire line, it is easier to read a narrower paragraph than very wide one.
//
// Don't worry too much about the long URL:
// https://supercalifragilisticexpialidocious.example.com:8080/Animalia/Chordata/Mammalia/Rodentia/Geomyoidea/Geomyidae/文档注释
所有顶层导出的名称都必须有文档注释,未导出的类型或函数声明如果行为或含义不明显也应该有。这些注释应该是完整的句子,以被描述对象的名称开头。名称前可以加冠词(“a”、“an”、“the”)使其更自然。
// Good:
// A Request represents a request to run a command.
type Request struct { ...
// Encode writes the JSON encoding of req to w.
func Encode(w io.Writer, req *Request) { ...文档注释出现在 Godoc 中,并由 IDE 展示,因此应该为使用该包的任何人编写。
文档注释适用于紧随其后的符号,或者如果出现在结构体中,则适用于字段组。
// Good:
// Options configure the group management service.
type Options struct {
// General setup:
Name string
Group *FooGroup
// Dependencies:
DB *sql.DB
// Customization:
LargeGroupThreshold int // 可选;默认值:10
MinimumMembers int // 可选;默认值:2
}最佳实践: 如果你为未导出的代码编写了文档注释,请遵循与导出代码相同的惯例(即以未导出的名称开头注释)。这使得以后只需在注释和代码中将未导出的名称替换为新导出的名称即可轻松导出。
注释语句
作为完整句子的注释应该像标准英语句子一样大写和加标点。(作为例外,如果其他方面是清楚的,可以用未大写的标识符名称开头句子。这种情况最好只在段落的开头使用。)
作为句子片段的注释对标点或大写没有这样的要求。
文档注释应始终是完整的句子,因此应始终大写和加标点。简单的行尾注释(特别是对于结构体字段)可以是假设字段名为主语的简单短语。
// Good:
// A Server handles serving quotes from the collected works of Shakespeare.
type Server struct {
// BaseDir points to the base directory under which Shakespeare's works are stored.
//
// The directory structure is expected to be the following:
// {BaseDir}/manifest.json
// {BaseDir}/{name}/{name}-part{number}.txt
BaseDir string
WelcomeMessage string // 用户登录时显示
ProtocolVersion string // 与传入请求进行检查
PageLength int // 打印时每页行数(可选;默认值:20)
}示例
包应清楚地记录其预期用法。尝试提供可运行示例 ;示例出现在 Godoc 中。可运行示例属于测试文件,而不是生产源文件。参见此示例(Godoc 、源码 )。
如果提供可运行示例不可行,可以在代码注释中提供示例代码。与注释中的其他代码和命令行片段一样,它应该遵循标准格式约定。
命名返回参数
命名参数时,考虑函数签名在 Godoc 中的外观。函数名称本身和结果参数的类型通常足够清楚。
// Good:
func (n *Node) Parent1() *Node
func (n *Node) Parent2() (*Node, error)如果函数返回两个或更多相同类型的参数,添加名称会很有用。
// Good:
func (n *Node) Children() (left, right *Node, err error)如果调用者必须对特定的结果参数采取行动,命名它们可以帮助暗示行动是什么:
// Good:
// WithTimeout returns a context that will be canceled no later than d duration
// from now.
//
// The caller must arrange for the returned cancel function to be called when
// the context is no longer needed to prevent a resource leak.
func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func())在上面的代码中,取消是调用者必须采取的特定行动。但是,如果结果参数只写为 (Context, func()),那么”取消函数”的含义就不清楚了。
当名称产生不必要的重复时,不要命名结果参数。
// Bad:
func (n *Node) Parent1() (node *Node)
func (n *Node) Parent2() (node *Node, err error)不要为了避免在函数内部声明变量而命名结果参数。这种做法以次要的实现简洁为代价,导致不必要的 API 冗长。
裸返回 只在小函数中可以接受。一旦是中等大小的函数,就要明确你的返回值。同样,不要仅仅因为它能让你使用裸返回就命名结果参数。清晰性始终比在函数中节省几行代码更重要。
如果结果参数的值必须在延迟闭包中更改,命名结果参数始终是可以接受的。
提示: 类型在函数签名中通常比名称更清晰。 GoTip #38: Functions as Named Types 演示了这一点。
在上面的
WithTimeout中,真实代码在结果参数列表中使用CancelFunc而不是原始的func(),只需很少的努力就能记录。
包注释
包注释必须紧接在包声明上方,注释和包名之间没有空行。示例:
// Good:
// Package math provides basic constants and mathematical functions.
//
// This package does not guarantee bit-identical results across architectures.
package math每个包必须有一个包注释。如果一个包由多个文件组成,其中恰好一个文件应该有包注释。
main 包的注释有略微不同的形式,其中 BUILD 文件中 go_binary 规则的名称取代了包名。
// Good:
// The seed_generator command is a utility that generates a Finch seed file
// from a set of JSON study configs.
package main只要二进制名称与 BUILD 文件中的完全一致,其他注释风格也是可以的。当二进制名称是第一个词时,即使它与命令行调用的拼写不严格匹配,也需要大写。
// Good:
// Binary seed_generator ...
// Command seed_generator ...
// Program seed_generator ...
// The seed_generator command ...
// The seed_generator program ...
// Seed_generator ...提示:
-
示例命令行调用和 API 使用可以是有用的文档。对于 Godoc 格式,缩进包含代码的注释行。
-
如果没有明显的主文件或包注释异常长,可以将文档注释放在名为
doc.go的文件中,该文件只包含注释和包声明。 -
可以使用多行注释代替多个单行注释。这在文档包含可能从源文件复制粘贴有用的部分时特别有用,例如二进制程序的示例命令行和模板示例。
// Good: /* The seed_generator command is a utility that generates a Finch seed file from a set of JSON study configs. seed_generator *.json | base64 > finch-seed.base64 */ package template -
面向维护者的适用于整个文件的注释通常放在导入声明之后。这些不会在 Godoc 中显示,不受上述包注释规则的约束。
导入
导入重命名
包导入通常不应被重命名,但在某些情况下必须重命名或者重命名可以提高可读性。
导入包的本地名称必须遵循包名指导,包括禁止使用下划线和大写字母。尽量一致地为相同的导入包使用相同的本地名称。
导入的包必须被重命名以避免与其他导入的名称冲突。(推论是好的包名不应需要重命名。)在名称冲突的情况下,优先重命名最局部或项目特定的导入。
生成的 protocol buffer 包必须被重命名以从其名称中移除下划线,其本地名称必须有 pb 后缀。更多信息请参见 proto 和 stub 最佳实践。
// Good:
import (
foosvcpb "path/to/package/foo_service_go_proto"
)最后,导入的非自动生成的包可以在其名称不具信息量(例如 util 或 v1)时被重命名。请谨慎这样做:如果包使用处周围的代码提供了足够的上下文,就不要重命名包。如果可能,优先使用更合适的名称重构包本身。
// Good:
import (
core "github.com/kubernetes/api/core/v1"
meta "github.com/kubernetes/apimachinery/pkg/apis/meta/v1beta1"
)如果你需要导入一个名称与你想使用的常用局部变量名冲突的包(例如 url、ssh),并且你希望重命名该包,首选方式是使用 pkg 后缀(例如 urlpkg)。注意,可以用局部变量遮蔽一个包;这种重命名只在这样的变量在作用域内时仍需要使用该包的情况下才是必要的。
导入分组
导入应按以下组排列,按顺序:
-
标准库包
-
其他(项目和第三方)包
-
Protocol Buffer 导入(例如
fpb "path/to/foo_go_proto") -
用于副作用 的导入(例如
_ "path/to/package")
// Good:
package main
import (
"fmt"
"hash/adler32"
"os"
"github.com/dsnet/compress/flate"
"golang.org/x/text/encoding"
"google.golang.org/protobuf/proto"
foopb "myproj/foo/proto/proto"
_ "myproj/rpc/protocols/dial"
_ "myproj/security/auth/authhooks"
)空白导入(import _)
仅为其副作用而导入的包(使用语法 import _ "package")只能在 main 包中导入,或在需要它们的测试中导入。
这类包的一些示例包括:
-
图像处理代码中的 image/jpeg
避免在库包中使用空白导入,即使库间接依赖它们。将副作用导入限制在 main 包中有助于控制依赖关系,并使编写依赖不同导入的测试成为可能,而不会产生冲突或浪费的构建成本。
此规则的唯一例外是:
-
你可以使用空白导入来绕过 nogo 静态检查器 中禁止导入的检查。
-
你可以在使用
//go:embed编译器指令的源文件中使用 embed 包的空白导入。
提示: 如果你创建的库包在生产环境中间接依赖于副作用导入,请记录预期用法。
点导入(import .)
import . 形式是一种语言特性,允许将另一个包导出的标识符带入当前包而无需限定。更多信息请参见语言规范 。
不要在 Google 代码库中使用此特性;它使得更难判断功能来自何处。
// Bad:
package foo_test
import (
"bar/testutil" // 也导入了 "foo"
. "foo"
)
var myThing = Bar() // Bar 定义在包 foo 中;无需限定。// Good:
package foo_test
import (
"bar/testutil" // 也导入了 "foo"
"foo"
)
var myThing = foo.Bar()错误
返回错误
使用 error 来表示函数可能失败。按照惯例,error 是最后一个结果参数。
// Good:
func Good() error { /* ... */ }返回 nil 错误是表示一个否则可能失败的操作成功的惯用方式。如果函数返回错误,调用者必须将所有非错误返回值视为未指定,除非另有明确文档说明。通常,非错误返回值是其零值,但这不能假设。
// Good:
func GoodLookup() (*Result, error) {
// ...
if err != nil {
return nil, err
}
return res, nil
}返回错误的导出函数应使用 error 类型返回。具体的错误类型容易出现微妙的 bug:一个具体的 nil 指针可以被包装到接口中,从而变成非 nil 值(参见关于此主题的 Go FAQ 条目 )。
// Bad:
func Bad() *os.PathError { /*...*/ }提示: 接受 context.Context 参数的函数通常应返回 error,以便调用者可以确定函数运行期间上下文是否被取消。
错误字符串
错误字符串不应大写(除非以导出名称、专有名词或首字母缩写词开头),不应以标点结尾。这是因为错误字符串通常在打印给用户之前出现在其他上下文中。
// Bad:
err := fmt.Errorf("Something bad happened.")// Good:
err := fmt.Errorf("something bad happened")另一方面,完整显示消息(日志记录、测试失败、API 响应或其他 UI)的风格取决于具体情况,但通常应该大写。
// Good:
log.Infof("Operation aborted: %v", err)
log.Errorf("Operation aborted: %v", err)
t.Errorf("Op(%q) failed unexpectedly; err=%v", args, err)处理错误
遇到错误的代码应该对如何处理它做出深思熟虑的选择。使用 _ 变量丢弃错误通常不合适。如果函数返回错误,执行以下操作之一:
- 立即处理和解决错误。
- 将错误返回给调用者。
- 在特殊情况下,调用
log.Fatal或(如果绝对必要)panic。
注意: log.Fatalf 不是标准库的 log。参见日志记录。
在罕见的情况下,忽略或丢弃错误是合适的(例如调用文档说明永远不会失败的 (*bytes.Buffer).Write),应有随附的注释解释为什么这是安全的。
// Good:
var b *bytes.Buffer
n, _ := b.Write(p) // 永远不返回非 nil 错误有关错误处理的更多讨论和示例,请参见 Effective Go 和最佳实践。
带内错误
在 C 和类似语言中,函数通常返回 -1、null 或空字符串等值来表示错误或缺失的结果。这被称为带内错误处理。
// Bad:
// Lookup returns the value for key or -1 if there is no mapping for key.
func Lookup(key string) int未能检查带内错误值可能导致 bug,并可能将错误归因于错误的函数。
// Bad:
// 以下行返回 Parse 对输入值失败的错误,
// 而实际上失败的是 missingKey 没有映射。
return Parse(Lookup(missingKey))Go 对多返回值的支持提供了更好的解决方案(参见 Effective Go 关于多返回值的部分 )。不需要客户端检查带内错误值,函数应该返回一个额外的值来指示其他返回值是否有效。此返回值可以是错误,或者在不需要解释时是布尔值,并且应该是最后一个返回值。
// Good:
// Lookup returns the value for key or ok=false if there is no mapping for key.
func Lookup(key string) (value string, ok bool)此 API 防止调用者错误地编写 Parse(Lookup(key)),因为 Lookup(key) 有 2 个输出,会导致编译时错误。
以这种方式返回错误鼓励更健壮和明确的错误处理:
// Good:
value, ok := Lookup(key)
if !ok {
return fmt.Errorf("no value for %q", key)
}
return Parse(value)一些标准库函数,如 strings 包中的函数,返回带内错误值。这在以程序员需要更多勤勉为代价大大简化了字符串操作代码。一般来说,Google 代码库中的 Go 代码应该为错误返回额外的值。
缩进错误流
在继续处理其余代码之前处理错误。这通过使读者能够快速找到正常路径来提高代码的可读性。同样的逻辑适用于任何测试条件然后以终止条件结束的块(例如 return、panic、log.Fatal)。
如果终止条件不满足则运行的代码应该出现在 if 块之后,不应缩进在 else 子句中。
// Good:
if err != nil {
// 错误处理
return // 或 continue 等
}
// 正常代码// Bad:
if err != nil {
// 错误处理
} else {
// 由于缩进看起来不正常的正常代码
}提示: 如果你在多行代码中使用一个变量,通常不值得使用带初始化器的
if风格。在这些情况下,通常更好的做法是将声明移出并使用标准的if语句:// Good: x, err := f() if err != nil { // 错误处理 return } // 使用 x 的大量代码 // 跨越多行// Bad: if x, err := f(); err != nil { // 错误处理 return } else { // 使用 x 的大量代码 // 跨越多行 }
参见 Go Tip #1: Line of Sight 和 TotT: Reduce Code Complexity by Reducing Nesting 了解更多细节。
语言
字面量格式化
Go 有非常强大的复合字面量语法 ,可以在一个表达式中表达深度嵌套的复杂值。在可能的情况下,应该使用这种字面量语法而不是逐字段构建值。gofmt 对字面量的格式化通常相当好,但有一些额外的规则来保持这些字面量的可读性和可维护性。
字段名
结构体字面量必须为当前包外定义的类型指定字段名。
-
包含来自其他包的类型的字段名。
// Good: // https://pkg.go.dev/encoding/csv#Reader r := csv.Reader{ Comma: ',', Comment: '#', FieldsPerRecord: 4, }结构体中字段的位置和完整的字段集合(省略字段名时都需要正确)通常不被视为结构体公共 API 的一部分;指定字段名是避免不必要耦合所需的。
// Bad: r := csv.Reader{',', '#', 4, false, false, false, false} -
对于包内局部类型,字段名是可选的。
// Good: okay := Type{42} also := internalType{4, 2}如果字段名使代码更清晰,仍然应该使用,这样做也非常常见。例如,一个有大量字段的结构体几乎总是应该用字段名初始化。
// Good: okay := StructWithLotsOfFields{ field1: 1, field2: "two", field3: 3.14, field4: true, }
匹配的花括号
花括号对的闭合部分应该始终出现在与开始花括号具有相同缩进量的行上。单行字面量自然具有此属性。当字面量跨越多行时,维护此属性使字面量的花括号匹配与常见的 Go 语法构造(如函数和 if 语句)的花括号匹配保持一致。
此领域最常见的错误是将闭合花括号放在多行结构体字面量中值的同一行上。在这些情况下,该行应以逗号结尾,闭合花括号应出现在下一行。
// Good:
good := []*Type{{Key: "value"}}// Good:
good := []*Type{
{Key: "multi"},
{Key: "line"},
}// Bad:
bad := []*Type{
{Key: "multi"},
{Key: "line"}}// Bad:
bad := []*Type{
{
Key: "value"},
}紧凑花括号
消除切片和数组字面量中花括号之间的空白(即”紧凑”它们)仅在以下两点都为真时才允许。
- 缩进匹配
- 内部值也是字面量或 proto 构建器(即不是变量或其他表达式)
// Good:
good := []*Type{
{ // 非紧凑
Field: "value",
},
{
Field: "value",
},
}// Good:
good := []*Type{{ // 正确的紧凑
Field: "value",
}, {
Field: "value",
}}// Good:
good := []*Type{
first, // 不能紧凑
{Field: "second"},
}// Good:
okay := []*pb.Type{pb.Type_builder{
Field: "first", // Proto 构建器可以紧凑以节省垂直空间
}.Build(), pb.Type_builder{
Field: "second",
}.Build()}// Bad:
bad := []*Type{
first,
{
Field: "second",
}}重复的类型名
重复的类型名可以从切片和映射字面量中省略。这有助于减少杂乱。显式重复类型名的合理场合是处理项目中不常见的复杂类型时,或者重复的类型名在相距很远的行上可以提醒读者上下文时。
// Good:
good := []*Type{
{A: 42},
{A: 43},
}// Bad:
repetitive := []*Type{
&Type{A: 42},
&Type{A: 43},
}// Good:
good := map[Type1]*Type2{
{A: 1}: {B: 2},
{A: 3}: {B: 4},
}// Bad:
repetitive := map[Type1]*Type2{
Type1{A: 1}: &Type2{B: 2},
Type1{A: 3}: &Type2{B: 4},
}提示: 如果你想在结构体字面量中移除重复的类型名,可以运行 gofmt -s。
零值字段
当不会因此失去清晰性时,可以从结构体字面量中省略零值 字段。
精心设计的 API 通常为增强可读性而使用零值构造。例如,从以下结构体中省略三个零值字段可以将注意力集中在唯一指定的选项上。
// Bad:
import (
"github.com/golang/leveldb"
"github.com/golang/leveldb/db"
)
ldb := leveldb.Open("/my/table", &db.Options{
BlockSize: 1<<16,
ErrorIfDBExists: true,
// 这些字段都是它们的零值。
BlockRestartInterval: 0,
Comparer: nil,
Compression: nil,
FileSystem: nil,
FilterPolicy: nil,
MaxOpenFiles: 0,
WriteBufferSize: 0,
VerifyChecksums: false,
})// Good:
import (
"github.com/golang/leveldb"
"github.com/golang/leveldb/db"
)
ldb := leveldb.Open("/my/table", &db.Options{
BlockSize: 1<<16,
ErrorIfDBExists: true,
})表驱动测试中的结构体通常受益于显式字段名,特别是当测试结构体不简单时。这允许作者在字段与测试用例无关时完全省略零值字段。在零值对于理解测试用例是必要的情况下,如测试零或 nil 输入,应指定字段名。
简洁
tests := []struct {
input string
wantPieces []string
wantErr error
}{
{
input: "1.2.3.4",
wantPieces: []string{"1", "2", "3", "4"},
},
{
input: "hostname",
wantErr: ErrBadHostname,
},
}显式
tests := []struct {
input string
wantIPv4 bool
wantIPv6 bool
wantErr bool
}{
{
input: "1.2.3.4",
wantIPv4: true,
wantIPv6: false,
},
{
input: "1:2::3:4",
wantIPv4: false,
wantIPv6: true,
},
{
input: "hostname",
wantIPv4: false,
wantIPv6: false,
wantErr: true,
},
}Nil 切片
对于大多数目的,nil 和空切片之间没有功能差异。内置函数如 len 和 cap 在 nil 切片上按预期工作。
// Good:
import "fmt"
var s []int // nil
fmt.Println(s) // []
fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 0
for range s {...} // 空操作
s = append(s, 42)
fmt.Println(s) // [42]如果你将空切片声明为局部变量(特别是如果它可以是返回值的来源),优先使用 nil 初始化以减少调用者出现 bug 的风险。
// Good:
var t []string// Bad:
t := []string{}不要创建强制客户端区分 nil 和空切片的 API。
// Good:
// Ping pings its targets.
// Returns hosts that successfully responded.
func Ping(hosts []string) ([]string, error) { ... }// Bad:
// Ping pings its targets and returns a list of hosts
// that successfully responded. Can be empty if the input was empty.
// nil signifies that a system error occurred.
func Ping(hosts []string) []string { ... }在设计接口时,避免区分 nil 切片和非 nil 零长度切片,因为这可能导致微妙的编程错误。这通常通过使用 len 检查空性而不是 == nil 来实现。
此实现将 nil 和零长度切片都视为”空”:
// Good:
// describeInts describes s with the given prefix, unless s is empty.
func describeInts(prefix string, s []int) {
if len(s) == 0 {
return
}
fmt.Println(prefix, s)
}而不是将这种区分作为 API 的一部分:
// Bad:
func maybeInts() []int { /* ... */ }
// describeInts describes s with the given prefix; pass nil to skip completely.
func describeInts(prefix string, s []int) {
// 此函数的行为会根据 maybeInts() 在"空"情况下返回什么
// (nil 或 []int{})而意外地改变。
if s == nil {
return
}
fmt.Println(prefix, s)
}
describeInts("Here are some ints:", maybeInts())参见带内错误获取进一步讨论。
缩进混淆
如果引入换行会使行的其余部分与缩进的代码块对齐,则避免引入换行。如果无法避免,请留一个空格来分隔块中的代码和换行行。
// Bad:
if longCondition1 && longCondition2 &&
// 条件 3 和 4 与 if 内部的代码具有相同的缩进。
longCondition3 && longCondition4 {
log.Info("all conditions met")
}有关具体指南和示例,请参见以下部分:
函数格式化
函数或方法声明的签名应保持在一行上以避免缩进混淆。
函数参数列表可能是 Go 源文件中最长的行。然而,它们先于缩进变化,因此很难以不让后续行看起来像函数体一部分的混乱方式换行:
// Bad:
func (r *SomeType) SomeLongFunctionName(foo1, foo2, foo3 string,
foo4, foo5, foo6 int) {
foo7 := bar(foo1)
// ...
}有关缩短本来会有很多参数的函数调用处的几个选项,请参见最佳实践。
通常可以通过提取局部变量来缩短行。
// Good:
local := helper(some, parameters, here)
good := foo.Call(list, of, parameters, local)同样,函数和方法调用不应仅基于行长度来分隔。
// Good:
good := foo.Call(long, list, of, parameters, all, on, one, line)// Bad:
bad := foo.Call(long, list, of, parameters,
with, arbitrary, line, breaks)尽可能避免向特定函数参数添加内联注释。相反,使用选项结构体或向函数文档添加更多细节。
// Good:
good := server.New(ctx, server.Options{Port: 42})// Bad:
bad := server.New(
ctx,
42, // Port
)如果 API 无法更改或局部调用不寻常(无论调用是否太长),如果换行有助于理解调用,始终允许添加换行。
// Good:
canvas.RenderHeptagon(fillColor,
x0, y0, vertexColor0,
x1, y1, vertexColor1,
x2, y2, vertexColor2,
x3, y3, vertexColor3,
x4, y4, vertexColor4,
x5, y5, vertexColor5,
x6, y6, vertexColor6,
)注意上面示例中的行不是在特定列边界处换行,而是基于顶点坐标和颜色进行分组。
函数内的长字符串字面量不应仅为了行长度而断开。对于包含此类字符串的函数,可以在字符串格式之后添加换行,并在下一行或后续行提供参数。关于换行应该放在哪里的决定最好基于输入的语义分组,而不是纯粹基于行长度。
// Good:
log.Warningf("Database key (%q, %d, %q) incompatible in transaction started by (%q, %d, %q)",
currentCustomer, currentOffset, currentKey,
txCustomer, txOffset, txKey)// Bad:
log.Warningf("Database key (%q, %d, %q) incompatible in"+
" transaction started by (%q, %d, %q)",
currentCustomer, currentOffset, currentKey, txCustomer,
txOffset, txKey)条件和循环
if 语句不应换行;多行 if 子句可能导致缩进混淆。
// Bad:
// 第二个 if 语句与 if 块内的代码对齐,导致缩进混淆。
if db.CurrentStatusIs(db.InTransaction) &&
db.ValuesEqual(db.TransactionKey(), row.Key()) {
return db.Errorf(db.TransactionError, "query failed: row (%v): key does not match transaction key", row)
}如果不需要短路行为,可以直接提取布尔操作数:
// Good:
inTransaction := db.CurrentStatusIs(db.InTransaction)
keysMatch := db.ValuesEqual(db.TransactionKey(), row.Key())
if inTransaction && keysMatch {
return db.Error(db.TransactionError, "query failed: row (%v): key does not match transaction key", row)
}也可能有其他可以提取的局部变量,特别是当条件已经重复时:
// Good:
uid := user.GetUniqueUserID()
if db.UserIsAdmin(uid) || db.UserHasPermission(uid, perms.ViewServerConfig) || db.UserHasPermission(uid, perms.CreateGroup) {
// ...
}// Bad:
if db.UserIsAdmin(user.GetUniqueUserID()) || db.UserHasPermission(user.GetUniqueUserID(), perms.ViewServerConfig) || db.UserHasPermission(user.GetUniqueUserID(), perms.CreateGroup) {
// ...
}包含闭包或多行结构体字面量的 if 语句应确保花括号匹配以避免缩进混淆。
// Good:
if err := db.RunInTransaction(func(tx *db.TX) error {
return tx.Execute(userUpdate, x, y, z)
}); err != nil {
return fmt.Errorf("user update failed: %s", err)
}// Good:
if _, err := client.Update(ctx, &upb.UserUpdateRequest{
ID: userID,
User: user,
}); err != nil {
return fmt.Errorf("user update failed: %s", err)
}同样,不要尝试在 for 语句中插入人为的换行。如果没有优雅的方式重构,你总是可以让行保持长:
// Good:
for i, max := 0, collection.Size(); i < max && !collection.HasPendingWriters(); i++ {
// ...
}不过,通常有方法:
// Good:
for i, max := 0, collection.Size(); i < max; i++ {
if collection.HasPendingWriters() {
break
}
// ...
}switch 和 case 语句也应保持在一行上。
// Good:
switch good := db.TransactionStatus(); good {
case db.TransactionStarting, db.TransactionActive, db.TransactionWaiting:
// ...
case db.TransactionCommitted, db.NoTransaction:
// ...
default:
// ...
}// Bad:
switch bad := db.TransactionStatus(); bad {
case db.TransactionStarting,
db.TransactionActive,
db.TransactionWaiting:
// ...
case db.TransactionCommitted,
db.NoTransaction:
// ...
default:
// ...
}如果行过长,缩进所有 case 并用空行分隔它们以避免缩进混淆:
// Good:
switch db.TransactionStatus() {
case
db.TransactionStarting,
db.TransactionActive,
db.TransactionWaiting,
db.TransactionCommitted:
// ...
case db.NoTransaction:
// ...
default:
// ...
}在将变量与常量比较的条件中,将变量值放在等号运算符的左侧:
// Good:
if result == "foo" {
// ...
}而不是常量在前的不太清晰的表述(“尤达风格条件” ):
// Bad:
if "foo" == result {
// ...
}复制
为了避免意外的别名和类似 bug,从另一个包复制结构体时要小心。例如,像 sync.Mutex 这样的同步对象不能被复制。
bytes.Buffer 类型包含一个 []byte 切片,作为对小字符串的优化,还有一个小的字节数组,切片可能引用该数组。如果你复制一个 Buffer,副本中的切片可能会别名指向原始中的数组,导致后续方法调用产生令人惊讶的效果。
一般来说,如果类型 T 的方法与指针类型 *T 相关联,则不要复制类型 T 的值。
// Bad:
b1 := bytes.Buffer{}
b2 := b1调用接受值接收器的方法可以隐藏复制。当你编写 API 时,如果你的结构体包含不应被复制的字段,通常应该接受和返回指针类型。
这些是可以接受的:
// Good:
type Record struct {
buf bytes.Buffer
// 其他字段省略
}
func New() *Record {...}
func (r *Record) Process(...) {...}
func Consumer(r *Record) {...}但这些通常是错误的:
// Bad:
type Record struct {
buf bytes.Buffer
// 其他字段省略
}
func (r Record) Process(...) {...} // 复制了 r.buf
func Consumer(r Record) {...} // 复制了 r.buf此指导也适用于复制 sync.Mutex。
不要 panic
不要使用 panic 进行正常的错误处理。相反,使用 error 和多返回值。参见 Effective Go 关于错误的部分 。
在 package main 和初始化代码中,对于应终止程序的错误(例如无效配置),考虑使用 log.Exit,因为在许多这些情况下,堆栈跟踪对读者没有帮助。请注意,log.Exit 调用 os.Exit,任何延迟函数都不会运行。
对于表示”不可能”条件的错误,即应该总是在代码审查和/或测试期间被捕获的 bug,函数可以合理地返回错误或调用 log.Fatal。
另请参见何时可以 panic。
注意: log.Fatalf 不是标准库的 log。参见日志记录。
Must 函数
在失败时停止程序的设置辅助函数遵循命名约定 MustXYZ(或 mustXYZ)。一般来说,它们应该只在程序启动的早期被调用,而不是在像用户输入这样优先使用正常 Go 错误处理的场合。
这在仅在包初始化时 调用以初始化包级变量的函数中经常出现(例如 template.Must 和 regexp.MustCompile )。
// Good:
func MustParse(version string) *Version {
v, err := Parse(version)
if err != nil {
panic(fmt.Sprintf("MustParse(%q) = _, %v", version, err))
}
return v
}
// 包级"常量"。如果我们想使用 `Parse`,就必须在 `init` 中设置值。
var DefaultVersion = MustParse("1.2.3")同样的约定可以用于仅停止当前测试(使用 t.Fatal)的测试辅助函数。这样的辅助函数在创建测试值时通常很方便,例如在表驱动测试的结构体字段中,因为返回错误的函数不能直接分配给结构体字段。
// Good:
func mustMarshalAny(t *testing.T, m proto.Message) *anypb.Any {
t.Helper()
any, err := anypb.New(m)
if err != nil {
t.Fatalf("mustMarshalAny(t, m) = %v; want %v", err, nil)
}
return any
}
func TestCreateObject(t *testing.T) {
tests := []struct{
desc string
data *anypb.Any
}{
{
desc: "my test case",
// 直接在表驱动测试用例中创建值。
data: mustMarshalAny(t, mypb.Object{}),
},
// ...
}
// ...
}在这两种情况下,此模式的价值在于辅助函数可以在”值”上下文中调用。这些辅助函数不应在难以确保错误会被捕获的地方或在应该检查错误的上下文中调用(例如在许多请求处理程序中)。对于常量输入,这允许测试容易确保 Must 参数格式正确,对于非常量输入,允许测试验证错误是否正确处理或传播。
在测试中使用 Must 函数时,通常应该标记为测试辅助函数并在错误时调用 t.Fatal(参见测试辅助函数中的错误处理了解更多考虑)。
当普通错误处理可行时(包括经过一些重构),不应使用它们:
// Bad:
func Version(o *servicepb.Object) (*version.Version, error) {
// 应返回错误而不是使用 Must 函数。
v := version.MustParse(o.GetVersionString())
return dealiasVersion(v)
}Goroutine 生命周期
当你启动 goroutine 时,明确它们何时或是否退出。
Goroutine 可以因阻塞在通道发送或接收上而泄漏。即使没有其他 goroutine 有对该通道的引用,垃圾收集器也不会终止阻塞在通道上的 goroutine。
即使 goroutine 没有泄漏,在不再需要时让它们继续运行也可能导致其他微妙且难以诊断的问题。向已关闭的通道发送会导致 panic。
// Bad:
ch := make(chan int)
ch <- 42
close(ch)
ch <- 13 // panic在”结果不再需要后”修改仍在使用的输入可能导致数据竞争。让 goroutine 无限期运行可能导致不可预测的内存使用。
并发代码应该编写成使 goroutine 的生命周期是明显的。通常这意味着将同步相关的代码限制在函数的作用域内,并将逻辑分解为同步函数。如果并发性仍然不明显,记录 goroutine 何时以及为什么退出是很重要的。
遵循上下文使用最佳实践的代码通常有助于使这一点清晰。它通常使用 context.Context 管理:
// Good:
func (w *Worker) Run(ctx context.Context) error {
var wg sync.WaitGroup
// ...
for item := range w.q {
// process 最迟在上下文取消时返回。
wg.Add(1)
go func() {
defer wg.Done()
process(ctx, item)
}()
}
// ...
wg.Wait() // 防止生成的 goroutine 比此函数存活更久。
}上述的其他变体使用原始信号通道如 chan struct{}、同步变量、条件变量 等。重要的是 goroutine 的结束对后续维护者来说是明显的。
相比之下,以下代码对其生成的 goroutine 何时结束很随意:
// Bad:
func (w *Worker) Run() {
// ...
for item := range w.q {
// process 在完成时返回,如果它曾完成的话,可能不会干净地
// 处理状态转换或 Go 程序本身的终止。
go process(item)
}
// ...
}此代码看起来可能没问题,但有几个潜在问题:
-
代码在生产中可能有未定义的行为,程序可能不会干净地终止,即使操作系统释放了资源。
-
由于代码的不确定生命周期,代码难以进行有意义的测试。
-
代码可能如上所述泄漏资源。
另请参见:
- 永远不要在不知道它如何停止的情况下启动 goroutine
- Rethinking Classical Concurrency Patterns: 幻灯片 、视频
- When Go programs end
- 文档约定:上下文
接口(Interfaces)
在实际需要存在之前,避免创建接口。关注所需的行为而不是仅仅是像”service”或”repository”之类的抽象命名模式。
设计小接口以便更容易实现和组合(GoTip #78: Minimal Viable Interfaces )。适当地记录接口,包括它们的契约、边界情况和预期的错误。如果接口仅在包内部使用,保持接口类型未导出。
接口的消费者应该定义它(而不是实现接口的包),确保它只包含它们实际使用的方法。生产者包可以在接口是产品(一个公共协议)时导出接口,以防止接口重复定义膨胀。
有一个格言:函数应该接受接口作为参数但返回具体类型(GoTip #49: Accept Interfaces, Return Concrete Types )。返回具体类型允许调用者访问该特定实现的每个公共方法和字段,而不仅仅是在预选接口中定义的方法子集。调用者仍然可以将该具体结果传递给任何期望接口的其他函数。有时返回接口对于封装是可以接受的(例如 error 接口),某些构造如命令、链接、工厂和策略 模式也是如此。
关于接口的更深入讨论存在于最佳实践的接口部分。
泛型(Generics)
泛型(正式称为”类型参数 ”)在满足你的业务需求时允许使用。在许多应用中,使用现有语言特性(切片、映射、接口等)的传统方法同样有效,而不需要增加复杂性,所以要警惕过早使用。参见最小机制的讨论。
引入使用泛型的导出 API 时,确保它有适当的文档。强烈鼓励包含有启发性的可运行示例。
不要仅仅因为你正在实现一个不关心其成员元素类型的算法或数据结构就使用泛型。如果实际上只有一种类型被实例化,先不使用泛型让你的代码在那个类型上工作。以后添加多态性会比移除被发现不必要的抽象更直接。
不要使用泛型来发明领域特定语言(DSL)。特别是,避免引入错误处理框架,这可能给读者带来重大负担。相反,优先使用已建立的错误处理实践。对于测试,特别要警惕引入导致不太有用的测试失败的断言库或框架。
一般来说:
- 编写代码,不要设计类型 。来自 Robert Griesemer 和 Ian Lance Taylor 的 GopherCon 演讲。
- 如果你有几个共享有用统一接口的类型,考虑使用该接口来建模解决方案。可能不需要泛型。
- 否则,不要依赖
any类型和过多的类型开关 ,考虑使用泛型。
另请参见:
- Using Generics in Go ,Ian Lance Taylor 的演讲
- Go 网页上的泛型教程
按值传递
不要仅仅为了节省几个字节而将指针作为函数参数传递。如果函数只以 *x 的形式读取其参数 x,那么参数不应该是指针。常见的实例包括传递指向字符串的指针(*string)或指向接口值的指针(*io.Reader)。在这两种情况下,值本身是固定大小的,可以直接传递。
此建议不适用于大型结构体,甚至不适用于可能增大的小结构体。特别是,protocol buffer 消息通常应通过指针而不是值来处理。指针类型满足 proto.Message 接口(被 proto.Marshal、protocmp.Transform 等接受),且 protocol buffer 消息可能相当大,并且通常随时间增长。
接收器类型
方法接收器 可以像普通函数参数一样以值或指针传递。两者之间的选择取决于方法应该属于哪个方法集 。
正确性胜过速度或简单性。 有些情况下你必须使用指针值。在其他情况下,为大型类型选择指针,或者如果你不确定代码将如何增长则作为面向未来的做法,对简单的普通旧数据 使用值。
以下列表详细说明了每种情况:
-
如果接收器是切片且方法不会重新切片或重新分配切片,使用值而不是指针。
// Good: type Buffer []byte func (b Buffer) Len() int { return len(b) } -
如果方法需要改变接收器,接收器必须是指针。
// Good: type Counter int func (c *Counter) Inc() { *c++ } // See https://pkg.go.dev/container/heap. type Queue []Item func (q *Queue) Push(x Item) { *q = append([]Item{x}, *q...) } -
如果接收器是包含不能安全复制的字段的结构体,使用指针接收器。常见的例子是
sync.Mutex和其他同步类型。// Good: type Counter struct { mu sync.Mutex total int } func (c *Counter) Inc() { c.mu.Lock() defer c.mu.Unlock() c.total++ }提示: 检查类型的 Godoc 以了解是否可以安全复制。
-
如果接收器是”大”结构体或数组,指针接收器可能更高效。传递结构体等同于将其所有字段或元素作为参数传递给方法。如果那看起来太大而不能按值传递,指针是一个好的选择。
-
对于将与其他修改接收器的函数并发调用或运行的方法,如果这些修改不应对你的方法可见,使用值;否则使用指针。
-
如果接收器是结构体或数组,其任何元素是指向可能被修改的东西的指针,优先使用指针接收器以使可变性的意图对读者清晰。
// Good: type Counter struct { m *Metric } func (c *Counter) Inc() { c.m.Add(1) } -
如果接收器是内置类型 ,如整数或字符串,不需要被修改,使用值。
// Good: type User string func (u User) String() { return string(u) } -
如果接收器是映射、函数或通道,使用值而不是指针。
// Good: // See https://pkg.go.dev/net/http#Header. type Header map[string][]string func (h Header) Add(key, value string) { /* omitted */ } -
如果接收器是”小”数组或结构体,自然是值类型,没有可变字段和指针,值接收器通常是正确的选择。
// Good: // See https://pkg.go.dev/time#Time. type Time struct { /* omitted */ } func (t Time) Add(d Duration) Time { /* omitted */ } -
当有疑问时,使用指针接收器。
作为一般准则,优先使一个类型的方法要么全部是指针方法,要么全部是值方法。
注意: 关于向函数传递值还是指针是否会影响性能,有很多错误信息。编译器可以选择在栈上传递值的指针以及在栈上复制值,但在大多数情况下,这些考虑不应超过代码的可读性和正确性。当性能确实重要时,在决定一种方法优于另一种方法之前,使用真实的基准测试来分析两种方法非常重要。
switch 和 break
不要在 switch 子句的末尾使用没有目标标签的 break 语句;它们是多余的。与 C 和 Java 不同,Go 中的 switch 子句自动 break,需要 fallthrough 语句来实现 C 风格的行为。如果你想阐明空子句的目的,使用注释而不是 break。
// Good:
switch x {
case "A", "B":
buf.WriteString(x)
case "C":
// 在 switch 语句外处理
default:
return fmt.Errorf("unknown value: %q", x)
}// Bad:
switch x {
case "A", "B":
buf.WriteString(x)
break // 这个 break 是多余的
case "C":
break // 这个 break 是多余的
default:
return fmt.Errorf("unknown value: %q", x)
}注意: 如果
switch子句在for循环内,在switch中使用break不会退出外围的for循环。for { switch x { case "A": break // 退出 switch,不是循环 } }要跳出外围循环,在
for语句上使用标签:loop: for { switch x { case "A": break loop // 退出循环 } }
同步函数
同步函数直接返回其结果,并在返回之前完成任何回调或通道操作。优先使用同步函数而非异步函数。
同步函数将 goroutine 局限在调用内。这有助于推理它们的生命周期,避免泄漏和数据竞争。同步函数也更容易测试,因为调用者可以传递输入并检查输出,而不需要轮询或同步。
如果必要,调用者可以通过在单独的 goroutine 中调用函数来添加并发。然而,在调用者侧移除不必要的并发是相当困难的(有时不可能的)。
另请参见:
类型别名
使用类型定义,type T1 T2,来定义新类型。使用类型别名,type T1 = T2,来引用现有类型而不定义新类型。类型别名很少见;它们的主要用途是帮助将包迁移到新的源代码位置。不需要时不要使用类型别名。
使用 %q
Go 的格式化函数(fmt.Printf 等)有一个 %q 动词,可以在双引号内打印字符串。
// Good:
fmt.Printf("value %q looks like English text", someText)优先使用 %q 而不是手动等效操作,使用 %s:
// Bad:
fmt.Printf("value \"%s\" looks like English text", someText)
// 也避免用单引号手动包装字符串:
fmt.Printf("value '%s' looks like English text", someText)在面向人类的输出中推荐使用 %q,其中输入值可能为空或包含控制字符。静默的空字符串可能很难注意到,但 "" 作为这样的情况清楚地凸显出来。
使用 any
Go 1.18 引入了 any 类型作为 interface{} 的别名 。因为它是别名,any 在许多情况下等价于 interface{},在其他情况下可以通过显式转换轻松互换。优先在新代码中使用 any。
常用库
Flags
Google 代码库中的 Go 程序使用标准 flag 包的内部变体。它有类似的接口但与内部 Google 系统良好互操作。Go 二进制程序中的 flag 名称应优先使用下划线分隔单词,但持有 flag 值的变量应遵循标准 Go 名称风格(混合大小写)。具体来说,flag 名称应使用蛇形命名法,变量名应使用等效的驼峰命名法。
// Good:
var (
pollInterval = flag.Duration("poll_interval", time.Minute, "Interval to use for polling.")
)// Bad:
var (
poll_interval = flag.Int("pollIntervalSeconds", 60, "Interval to use for polling in seconds.")
)Flags 必须仅在 package main 或等效包中定义。
通用包应使用 Go API 进行配置,而不是通过命令行接口;不要让导入库作为副作用导出新的 flags。也就是说,优先使用显式函数参数或结构体字段赋值,或者在最严格的审查下很少使用导出的全局变量。在极其罕见的必须打破此规则的情况下,flag 名称必须清楚地表明它配置的包。
如果你的 flags 是全局变量,将它们放在自己的 var 组中,位于 imports 部分之后。
关于创建带子命令的复杂 CLI 的最佳实践有额外讨论。
另请参见:
- Tip of the Week #45: Avoid Flags, Especially in Library Code
- Go Tip #10: Configuration Structs and Flags
- Go Tip #80: Dependency Injection Principles
日志记录
Google 代码库中的 Go 程序使用标准 log 包的变体。它有类似但更强大的接口,并与内部 Google 系统良好互操作。该库的开源版本可作为 package glog 获得,开源 Google 项目可以使用它,但本指南中始终称之为 log。
注意: 对于异常程序退出,此库使用 log.Fatal 带堆栈跟踪终止,使用 log.Exit 不带堆栈跟踪停止。标准库中没有 log.Panic 函数。
提示: log.Info(v) 等同于 log.Infof("%v", v),其他日志级别同样如此。当没有格式化需求时,优先使用非格式化版本。
另请参见:
上下文(Contexts)
context.Context 类型的值携带安全凭证、跟踪信息、截止时间和取消信号跨越 API 和进程边界。与在 Google 代码库中使用线程本地存储的 C++ 和 Java 不同,Go 程序在整个函数调用链中从传入的 RPC 和 HTTP 请求到传出的请求显式传递上下文。
当传递给函数或方法时,context.Context 始终是第一个参数。
func F(ctx context.Context /* 其他参数 */) {}例外情况有:
-
在 HTTP 处理程序中,上下文来自
req.Context()。 -
在流式 RPC 方法中,上下文来自流。
使用 gRPC 流的代码从生成的服务器类型中的
Context()方法访问上下文,该类型实现了grpc.ServerStream。参见 gRPC 生成代码文档 。 -
在测试函数中(例如
TestXXX、BenchmarkXXX、FuzzXXX),上下文来自(testing.TB).Context()。 -
在其他入口函数中(参见下面的此类函数示例),使用
context.Background()。- 在二进制目标中:
main - 在通用代码和库中:
init
- 在二进制目标中:
注意: 调用链中间的代码很少需要创建自己的基础上下文使用
context.Background()。始终优先从调用者获取上下文,除非它是错误的上下文。
context.Context 在函数中排第一的约定也适用于测试辅助函数。
// Good:
func readTestFile(ctx context.Context, t *testing.T, path string) string {}不要将上下文成员添加到结构体类型。相反,向需要传递上下文的类型上的每个方法添加上下文参数。唯一的例外是签名必须与标准库或 Google 控制之外的第三方库中的接口匹配的方法。这种情况非常罕见,应在实现和可读性审查之前与 Google Go 风格邮件列表讨论。
注意: Go 1.24 添加了 (testing.TB).Context() 方法。在测试中,优先使用 (testing.TB).Context() 而非 context.Background() 来提供测试使用的初始 context.Context。辅助函数、环境或测试替身设置以及从测试函数体调用的其他需要上下文的函数应该显式传递上下文。
Google 代码库中需要在父上下文取消后运行后台操作的代码可以使用内部包进行分离。关注 issue #40221 了解开源替代方案的讨论。
由于上下文是不可变的,将相同的上下文传递给共享相同截止时间、取消信号、凭证、父跟踪等的多个调用是可以的。
另请参见:
自定义上下文
不要创建自定义上下文类型或在函数签名中使用 context.Context 以外的接口。此规则没有例外。
想象一下如果每个团队都有自定义上下文。从包 p 到包 q 的每个函数调用都必须确定如何将 p.Context 转换为 q.Context,对于所有包对 p 和 q。这对人类来说是不切实际和容易出错的,并且使得添加上下文参数的自动重构几乎不可能。
如果你有要传递的应用数据,将它放在参数中、接收器中、全局变量中,或者如果它真正属于那里则放在 Context 值中。创建你自己的上下文类型是不可接受的,因为它破坏了 Go 团队使 Go 程序在生产中正常工作的能力。
crypto/rand
不要使用 math/rand 包来生成密钥,即使是临时的。如果未设置种子,生成器是完全可预测的。使用 time.Nanoseconds() 作为种子,只有几位的熵。相反,使用 crypto/rand 的 Reader,如果你需要文本,打印为十六进制或 base64。
// Good:
import (
"crypto/rand"
// "encoding/base64"
// "encoding/hex"
"fmt"
// ...
)
func Key() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
log.Fatalf("Out of randomness, should never happen: %v", err)
}
return fmt.Sprintf("%x", buf)
// 或 hex.EncodeToString(buf)
// 或 base64.StdEncoding.EncodeToString(buf)
}注意: log.Fatalf 不是标准库的 log。参见日志记录。
有用的测试失败
应该能够在不阅读测试源代码的情况下诊断测试的失败。测试应该以有用的消息失败,详细说明:
- 什么导致了失败
- 什么输入导致了错误
- 实际结果
- 预期结果
以下是实现此目标的具体约定。
断言库
不要创建”断言库”作为测试辅助工具。
断言库是尝试在测试中结合验证和生成失败消息的库(尽管相同的陷阱也适用于其他测试辅助工具)。有关测试辅助工具和断言库之间区别的更多信息,请参见最佳实践。
// Bad:
var obj BlogPost
assert.IsNotNil(t, "obj", obj)
assert.StringEq(t, "obj.Type", obj.Type, "blogPost")
assert.IntEq(t, "obj.Comments", obj.Comments, 2)
assert.StringNotEq(t, "obj.Body", obj.Body, "")断言库往往要么过早停止测试(如果 assert 调用 t.Fatalf 或 panic),要么遗漏关于测试正确部分的相关信息:
// Bad:
package assert
func IsNotNil(t *testing.T, name string, val any) {
if val == nil {
t.Fatalf("Data %s = nil, want not nil", name)
}
}
func StringEq(t *testing.T, name, got, want string) {
if got != want {
t.Fatalf("Data %s = %q, want %q", name, got, want)
}
}复杂的断言函数通常不提供有用的失败消息和测试函数内存在的上下文。太多的断言函数和库导致开发者体验碎片化:我应该使用哪个断言库,它应该发出什么样的输出格式等?碎片化产生不必要的混乱,特别是对于负责修复潜在下游破坏的库维护者和大规模更改的作者。相反,不要为测试创建领域特定语言,使用 Go 本身。
断言库通常提取出比较和相等性检查。优先使用标准库如 cmp 和 fmt:
// Good:
var got BlogPost
want := BlogPost{
Comments: 2,
Body: "Hello, world!",
}
if !cmp.Equal(got, want) {
t.Errorf("Blog post = %v, want = %v", got, want)
}对于更多领域特定的比较辅助工具,优先返回值或错误,可以在测试的失败消息中使用,而不是传递 *testing.T 并调用其错误报告方法:
// Good:
func postLength(p BlogPost) int { return len(p.Body) }
func TestBlogPost_VeritableRant(t *testing.T) {
post := BlogPost{Body: "I am Gunnery Sergeant Hartman, your senior drill instructor."}
if got, want := postLength(post), 60; got != want {
t.Errorf("Length of post = %v, want %v", got, want)
}
}最佳实践: 如果 postLength 不简单,直接独立测试它是有意义的,独立于任何使用它的测试。
另请参见:
- 相等性比较和 diff
- 打印 diff
- 有关测试辅助工具和断言辅助工具之间区别的更多信息,请参见最佳实践
- Go FAQ 关于测试框架 的部分及其有意的缺失
标识函数
在大多数测试中,失败消息应包含失败的函数名称,即使从测试函数的名称看来似乎很明显。具体来说,你的失败消息应该是 YourFunc(%v) = %v, want %v 而不仅仅是 got %v, want %v。
标识输入
在大多数测试中,如果输入较短,失败消息应包含函数输入。如果输入的相关属性不明显(例如因为输入很大或不透明),你应该用被测试内容的描述命名测试用例,并将描述作为错误消息的一部分打印。
Got 在 want 之前
测试输出应包含函数返回的实际值,在打印预期值之前。打印测试输出的标准格式是 YourFunc(%v) = %v, want %v。在你会写”actual”和”expected”的地方,分别优先使用”got”和”want”。
对于 diff,方向性不太明显,因此包含一个键来帮助解释失败很重要。参见打印 diff 部分。无论你在失败消息中使用什么 diff 顺序,你都应该将其作为失败消息的一部分明确指出,因为现有代码在排序方面不一致。
完整结构比较
如果你的函数返回一个结构体(或任何具有多个字段的数据类型,如切片、数组和映射),避免编写对结构体进行手工逐字段比较的测试代码。相反,构造你期望函数返回的数据,并使用深度比较直接比较。
注意: 如果你的数据包含模糊测试意图的不相关字段,则不适用此规则。
如果你的结构体需要近似(或等效类型的语义)相等性比较,或者它包含无法比较相等性的字段(例如某个字段是 io.Reader),使用 cmpopts 选项(如 cmpopts.IgnoreInterfaces)调整 cmp.Diff 或 cmp.Equal 比较可能满足你的需求(示例 )。
如果你的函数返回多个返回值,你不需要在比较之前将它们包装在结构体中。只需单独比较返回值并打印它们。
// Good:
val, multi, tail, err := strconv.UnquoteChar(`\"Fran & Freddie's Diner\"`, '"')
if err != nil {
t.Fatalf(...)
}
if val != `"` {
t.Errorf(...)
}
if multi {
t.Errorf(...)
}
if tail != `Fran & Freddie's Diner"` {
t.Errorf(...)
}比较稳定的结果
避免比较可能依赖于你不拥有的包的输出稳定性的结果。相反,测试应该比较语义上相关的、稳定的且对依赖变化有抵抗力的信息。对于返回格式化字符串或序列化字节的功能,通常不安全地假设输出是稳定的。
例如,json.Marshal 可以改变(并且过去已经改变)它发出的具体字节。对 JSON 字符串执行字符串相等性的测试可能在 json 包改变其序列化字节时会中断。相反,更健壮的测试应该解析 JSON 字符串的内容并确保它在语义上等同于某些预期的数据结构。
继续执行
测试应该尽可能长时间地继续执行,即使在失败之后也是如此,以便在单次运行中打印出所有失败的检查。这样,修复失败测试的开发者不必在修复每个 bug 后重新运行测试来找到下一个 bug。
优先调用 t.Error 而非 t.Fatal 来报告不匹配。当比较函数输出的几个不同属性时,对每个比较使用 t.Error。
// Good:
gotMean, gotVariance, err := MyDistribution(input)
if err != nil {
t.Fatalf("MyDistribution(%v) returned unexpected error: %v", input, err)
}
if diff := cmp.Diff(wantMean, gotMean); diff != "" {
t.Errorf("MyDistribution(%v) returned unexpected difference in mean value (-want +got):\n%s", input, diff)
}
if diff := cmp.Diff(wantVariance, gotVariance); diff != "" {
t.Errorf("MyDistribution(%v) returned unexpected difference in variance value (-want +got):\n%s", input, diff)
}调用 t.Fatal 主要在报告意外条件(如错误或输出不匹配)且后续失败将无意义甚至误导调查者时有用。
对于表驱动测试,考虑使用子测试并使用 t.Fatal 而不是 t.Error 加 continue。另请参见 GoTip #25: Subtests: Making Your Tests Lean 。
最佳实践: 有关何时应该使用 t.Fatal 的更多讨论,请参见最佳实践。
相等性比较和 diff
== 运算符使用语言定义的比较 评估相等性。标量值(数字、布尔值等)基于其值进行比较,但只有某些结构体和接口可以这样比较。指针基于它们是否指向同一个变量进行比较,而不是基于它们指向的值的相等性。
cmp 包可以比较 == 不适当处理的更复杂数据结构,如切片。使用 cmp.Equal 进行相等性比较,使用 cmp.Diff 获取对象之间的人类可读 diff。
// Good:
want := &Doc{
Type: "blogPost",
Comments: 2,
Body: "This is the post body.",
Authors: []string{"isaac", "albert", "emmy"},
}
if !cmp.Equal(got, want) {
t.Errorf("AddPost() = %+v, want %+v", got, want)
}作为通用比较库,cmp 可能不知道如何比较某些类型。例如,它只能在传递 protocmp.Transform 选项时比较 protocol buffer 消息。
// Good:
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("Foo() returned unexpected difference in protobuf messages (-want +got):\n%s", diff)
}尽管 cmp 包不是 Go 标准库的一部分,但它由 Go 团队维护,并且应该随时间产生稳定的相等性结果。它是用户可配置的,应该满足大多数比较需求。
现有代码可能使用以下较旧的库,为了一致性可以继续使用:
pretty产生美观的差异报告。但是,它有意地认为具有相同视觉表示的值相等。特别是,pretty不会捕获 nil 切片和空切片之间的差异,不敏感于具有相同字段的不同接口实现,并且可以使用嵌套映射作为与结构体值比较的基础。它还在产生 diff 之前将整个值序列化为字符串,因此不适合比较大值。默认情况下,它比较未导出的字段,这使得它对依赖中实现细节的更改敏感。因此,不适合在 protobuf 消息上使用pretty。
新代码优先使用 cmp,在实际可行时值得考虑更新旧代码以使用 cmp。
旧代码可能使用标准库 reflect.DeepEqual 函数来比较复杂结构。不应使用 reflect.DeepEqual 检查相等性,因为它对未导出字段和其他实现细节的更改敏感。使用 reflect.DeepEqual 的代码应更新为上述库之一。
注意: cmp 包是为测试而非生产使用设计的。因此,当它怀疑比较执行不正确时可能会 panic,以便为用户提供如何改进测试使其不那么脆弱的指导。鉴于 cmp 倾向于 panic,它不适合在生产中使用,因为虚假的 panic 可能是致命的。
详细程度
适用于大多数 Go 测试的传统失败消息是 YourFunc(%v) = %v, want %v。但是,有些情况可能需要更多或更少的细节:
- 执行复杂交互的测试也应描述交互。例如,如果同一个
YourFunc被多次调用,标识哪个调用使测试失败。如果了解系统的任何额外状态很重要,在失败输出中包含它(或至少在日志中)。 - 如果数据是具有大量样板的复杂结构体,可以在消息中只描述重要部分,但不要过度遮蔽数据。
- 设置失败不需要相同级别的细节。如果测试辅助函数填充 Spanner 表但 Spanner 挂了,你可能不需要包含你打算存储在数据库中的测试输入。
t.Fatalf("Setup: Failed to set up test database: %s", err)通常足够解决问题。
提示: 在开发期间触发失败模式。审查失败消息的外观以及维护者能否有效处理失败。
有一些清晰再现测试输入和输出的技巧:
打印 diff
如果你的函数返回大输出,当测试失败时,阅读失败消息的人可能很难找到差异。与其打印返回的值和期望的值,不如生成 diff。
对于这样的值,cmp.Diff 是首选的计算 diff 方式,特别是对于新的测试和新代码,但也可以使用其他工具。参见相等性类型了解每个函数的优缺点指导。
你可以使用 diff 包来比较多行字符串或字符串列表。你可以将其用作其他类型 diff 的构建块。
在失败消息中添加一些文字解释 diff 的方向。
-
当你使用
cmp、pretty和diff包时,diff (-want +got)这样的格式是好的(如果你将(want, got)传递给函数),因为你添加到格式字符串中的-和+将匹配 diff 行开头实际出现的-和+。如果你将(got, want)传递给函数,正确的键应该是(-got +want)。 -
messagediff包使用不同的输出格式,因此使用它时(如果你将(want, got)传递给函数),消息diff (want -> got)是合适的,因为箭头的方向将匹配”修改”行中箭头的方向。
diff 将跨越多行,因此你应该在打印 diff 之前打印换行符。
测试错误语义
当单元测试使用字符串比较或使用普通 cmp 来检查是否为特定输入返回特定类型的错误时,如果将来这些错误消息被重新措辞,你可能会发现你的测试变得脆弱。由于这有可能将你的单元测试变成变更检测器(参见 TotT: Change-Detector Tests Considered Harmful ),不要使用字符串比较来检查函数返回的错误类型。但是,使用字符串比较来检查来自被测包的错误消息满足某些属性是允许的,例如它包含参数名称。
Go 中的错误值通常有一个面向人眼的组件和一个面向语义控制流的组件。测试应只测试可以可靠观察的语义信息,而不是面向人类调试的显示信息,因为这通常会在未来更改。有关构造具有语义含义的错误的指导,请参见关于错误的最佳实践。如果语义信息不足的错误来自你控制之外的依赖,考虑向所有者提交 bug 以帮助改进 API,而不是依赖解析错误消息。
在单元测试中,通常只关心是否发生了错误。如果是这样,只测试错误是否在预期错误时非 nil 就足够了。如果你想测试错误是否在语义上匹配其他错误,则考虑使用 errors.Is 或 cmp 配合 cmpopts.EquateErrors。
注意: 如果测试使用
cmpopts.EquateErrors但其所有wantErr值都是nil或cmpopts.AnyError,则使用cmp是不必要的机制。通过将 want 字段设为bool来简化代码。然后你可以使用简单的!=比较。// Good: err := f(test.input) if gotErr := err != nil; gotErr != test.wantErr { t.Errorf("f(%q) = %v, want error presence = %v", test.input, err, test.wantErr) }
另请参见 GoTip #13: Designing Errors for Checking 。
测试结构
子测试
标准 Go 测试库提供了定义子测试 的功能。这在设置和清理、控制并行性和测试过滤方面提供了灵活性。子测试可能很有用(特别是对于表驱动测试),但使用它们不是强制的。另请参见 Go 博客关于子测试的文章 。
子测试不应依赖其他用例的执行来成功或获取初始状态,因为子测试应该能够使用 go test -run flags 或 Bazel 测试过滤 表达式单独运行。
子测试名称
命名你的子测试,使其在测试输出中可读,并在命令行上对使用测试过滤的用户有用。当你使用 t.Run 创建子测试时,第一个参数用作测试的描述性名称。为确保测试结果对阅读日志的人来说清晰可读,选择在转义后仍然有用和可读的子测试名称。将子测试名称更多地视为函数标识符而不是散文描述。
测试运行器将空格替换为下划线,并转义不可打印字符。为确保测试日志和源代码之间的准确关联,建议避免在子测试名称中使用这些字符。
如果你的测试数据受益于更长的描述,考虑将描述放在单独的字段中(可能使用 t.Log 打印或与失败消息一起打印)。
子测试可以使用 Go 测试运行器 的 flags 或 Bazel 测试过滤 单独运行,因此选择描述性且容易输入的名称。
警告: 斜杠字符在子测试名称中特别不友好,因为它们对测试过滤有特殊含义 。
# Bad: # 假设 TestTime 和 t.Run("America/New_York", ...) bazel test :mytest --test_filter="Time/New_York" # 不运行任何测试! bazel test :mytest --test_filter="Time//New_York" # 正确,但很尴尬。
要标识函数的输入,将它们包含在测试的失败消息中,而不是在子测试名称中,因为在那里它们不会被测试运行器转义。
// Good:
func TestTranslate(t *testing.T) {
data := []struct {
name, desc, srcLang, dstLang, srcText, wantDstText string
}{
{
name: "hu=en_bug-1234",
desc: "regression test following bug 1234. contact: cleese",
srcLang: "hu",
srcText: "cigarettát és egy öngyújtót kérek",
dstLang: "en",
wantDstText: "cigarettes and a lighter please",
}, // ...
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
got := Translate(d.srcLang, d.dstLang, d.srcText)
if got != d.wantDstText {
t.Errorf("%s\nTranslate(%q, %q, %q) = %q, want %q",
d.desc, d.srcLang, d.dstLang, d.srcText, got, d.wantDstText)
}
})
}
}以下是一些需要避免的示例:
// Bad:
// 太冗长。
t.Run("check that there is no mention of scratched records or hovercrafts", ...)
// 斜杠在命令行上造成问题。
t.Run("AM/PM confusion", ...)另请参见 Go Tip #117: Subtest Names 。
表驱动测试
当许多不同的测试用例可以使用类似的测试逻辑进行测试时,使用表驱动测试。
- 当测试函数的实际输出是否等于预期输出时。例如,
fmt.Sprintf的许多测试或下面的最小代码片段。 - 当测试函数的输出是否始终符合相同的不变量集时。例如,
net.Dial的测试。
以下是表驱动测试的最小结构。如果需要,你可以使用不同的名称或添加额外的设施,如子测试或设置和清理函数。始终记住有用的测试失败。
// Good:
func TestCompare(t *testing.T) {
compareTests := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "a", -1},
{"abc", "abc", 0},
{"ab", "abc", -1},
{"abc", "ab", 1},
{"x", "ab", 1},
{"ab", "x", -1},
{"x", "a", 1},
{"b", "x", -1},
// 测试 runtime·memeq 的分块实现
{"abcdefgh", "abcdefgh", 0},
{"abcdefghi", "abcdefghi", 0},
{"abcdefghi", "abcdefghj", -1},
}
for _, test := range compareTests {
got := Compare(test.a, test.b)
if got != test.want {
t.Errorf("Compare(%q, %q) = %v, want %v", test.a, test.b, got, test.want)
}
}
}注意:上面示例中的失败消息遵循了标识函数和标识输入的指导。没有必要用数字标识行。
当某些测试用例需要使用与其他测试用例不同的逻辑进行检查时,编写多个测试函数是合适的,如 GoTip #50: Disjoint Table Tests 中所解释的。
当附加的测试用例很简单(例如基本错误检查)且不在表测试的循环体中引入条件化的代码流时,将该用例包含在现有测试中是允许的,但要小心使用这样的逻辑。今天简单的东西可以随着时间有机增长为不可维护的东西。
你可以将表驱动测试与多个测试函数结合使用。例如,当测试函数的输出是否与预期输出完全匹配以及函数是否对无效输入返回非 nil 错误时,编写两个独立的表驱动测试函数是最佳方法:一个用于正常的非错误输出,一个用于错误输出。
数据驱动的测试用例
表测试行有时可能变得复杂,行值决定了测试用例内的条件行为。测试用例之间重复带来的额外清晰性对于可读性是必要的。
标识行
不要使用测试表中测试的索引来替代命名你的测试或打印输入。没有人想要翻阅你的测试表并计数条目来弄清楚哪个测试用例失败了。
// Bad:
tests := []struct {
input, want string
}{
{"hello", "HELLO"},
{"wORld", "WORLD"},
}
for i, d := range tests {
if strings.ToUpper(d.input) != d.want {
t.Errorf("Failed on case #%d", i)
}
}向你的测试结构体添加测试描述,并在失败消息中打印。使用子测试时,子测试名称应有效地标识行。
重要: 即使 t.Run 限定了输出和执行的范围,你也必须始终标识输入。表测试行名称必须遵循子测试命名指导。
测试辅助函数
测试辅助函数是执行设置或清理任务的函数。在测试辅助函数中发生的所有失败都预期是环境的失败(而不是被测代码的失败)—— 例如当测试数据库无法启动因为此机器上没有更多空闲端口时。
如果你传递 *testing.T,调用 t.Helper 以将测试辅助函数中的失败归因于调用辅助函数的行。此参数应在 context 参数之后(如果存在),在任何剩余参数之前。
// Good:
func TestSomeFunction(t *testing.T) {
golden := readFile(t, "testdata/golden-result.txt")
// ... 针对 golden 的测试 ...
}
// readFile returns the contents of a data file.
// It must only be called from the same goroutine as started the test.
func readFile(t *testing.T, filename string) string {
t.Helper()
contents, err := runfiles.ReadFile(filename)
if err != nil {
t.Fatal(err)
}
return string(contents)
}当它会模糊测试失败与导致失败的条件之间的联系时,不要使用此模式。具体来说,关于断言库的指导仍然适用,不应使用 t.Helper 来实现这样的库。
提示: 有关测试辅助工具和断言辅助工具之间区别的更多信息,请参见最佳实践。
虽然上面提到的是 *testing.T,但大部分建议对基准测试和模糊测试辅助函数也是一样的。
测试包
同包测试
测试可以定义在与被测代码相同的包中。
要在同一包中编写测试:
- 将测试放在
foo_test.go文件中 - 对测试文件使用
package foo - 不要显式导入被测包
# Good:
go_library(
name = "foo",
srcs = ["foo.go"],
deps = [
...
],
)
go_test(
name = "foo_test",
size = "small",
srcs = ["foo_test.go"],
library = ":foo",
deps = [
...
],
)同包测试可以访问包中未导出的标识符。这可以实现更好的测试覆盖率和更简洁的测试。请注意,测试中声明的任何示例将不会有用户在其代码中需要的包名。
不同包的测试
在某些情况下,在与被测代码相同的包中定义测试并不总是合适甚至可能。在这些情况下,使用带有 _test 后缀的包名。这是包名”无下划线”规则的例外。例如:
-
如果集成测试没有明显它属于的库
// Good: package gmailintegration_test import "testing" -
如果在同一包中定义测试会导致循环依赖
// Good: package fireworks_test import ( "fireworks" "fireworkstestutil" // fireworkstestutil 也导入 fireworks )
使用 testing 包
Go 标准库提供了 testing 包。这是 Google 代码库中 Go 代码唯一允许使用的测试框架。特别是,不允许使用断言库和第三方测试框架。
testing 包提供了编写良好测试的最小但完整的功能集:
- 顶层测试
- 基准测试
- 可运行示例
- 子测试
- 日志记录
- 失败和致命失败
这些设计为与核心语言特性(如复合字面量 和带初始化器的 if 语法)协同工作,使测试作者能够编写清晰、可读、可维护的测试。
非决策
风格指南不能为所有事项列出正面的规定,也不能列出它不持意见的所有事项。也就是说,以下是一些可读性社区之前讨论过但未达成共识的事项。
- 使用零值初始化局部变量。
var i int和i := 0是等价的。另请参见初始化最佳实践 。 - 空复合字面量 vs.
new或make。&File{}和new(File)是等价的。map[string]bool{}和make(map[string]bool)也是如此。另请参见复合声明最佳实践 。 - cmp.Diff 调用中 got、want 参数的顺序。保持局部一致,并在失败消息中包含图例。
errors.Newvsfmt.Errorf用于非格式化字符串。errors.New("foo")和fmt.Errorf("foo")可以互换使用。
如果有特殊情况需要再次讨论,可读性导师可能会发表可选评论,但一般来说,作者可以自由选择在给定情况下他们喜欢的风格。
自然地,如果风格指南未涵盖的任何内容确实需要更多讨论,作者可以随时提问 —— 无论是在特定的审查中,还是在内部消息板上。