https://ggdocs.cn/styleguide/go/decisions
注意: 这是概述 Google Go 代码风格 的一系列文档的一部分。本文档是规范性的,但不是 权威性的,并且服从于核心风格指南。有关更多信息,请参见概述。
本文档包含样式决策,旨在统一并为 Go 可读性导师提供的建议提供标准指导、解释和示例。
本文档并非详尽无遗,并且会随着时间的推移而增长。如果核心风格指南与此处给出的建议相矛盾,则以风格指南为准,并且应相应地更新本文档。
有关完整的 Go 代码风格文档集,请参见概述。
以下部分已从样式决策移至指南的另一部分
MixedCaps: 请参阅 guide#mixed-caps
Formatting: 请参阅 guide#formatting
Line Length: 请参阅 guide#line-length
有关命名的总体指导,请参阅 核心风格指南 中的命名部分。以下各节提供了对命名中特定领域的进一步说明。
通常,Go 中的名称不应包含下划线。此原则有三个例外
*_test.go
文件中的 Test、Benchmark 和 Example 函数名称可能包含下划线。syscall
中所做的那样。 在大多数代码库中,这种情况预计会非常罕见。注意: 源代码的文件名不是 Go 标识符,不必遵循这些约定。它们可以包含下划线。
Go 包名称应简短,并且仅包含小写字母。 由多个单词组成的包名称应保持不间断的小写形式。 例如,包 tabwriter
不应命名为 tabWriter
、TabWriter
或 tab_writer
。
避免选择容易被常用局部变量名遮蔽的包名称。 例如,usercount
比 count
更好,因为 count
是常用的变量名。
Go 包名称不应包含下划线。 如果您需要导入名称中确实包含下划线的包(通常来自生成的代码或第三方代码),则必须在导入时将其重命名为适合在 Go 代码中使用的名称。
例外情况是,仅由生成的代码导入的包名称可能包含下划线。 具体示例包括
为仅执行包的导出 API 的单元测试使用 _test
后缀(包 testing
将其称为 “黑盒测试”)。 例如,包 linkedlist
必须在其名为 linkedlist_test
的包中定义其黑盒单元测试(而不是 linked_list_test
)
为指定功能或集成测试的包使用下划线和 _test
后缀。 例如,链接列表服务集成测试可以命名为 linked_list_service_test
为 包级别的文档示例使用 _test
后缀
避免使用信息量不足的包名称,如 util
、utility
、common
、helper
、models
等,这些名称会诱使用户在导入时重命名。 请参阅
当导入的包被重命名时(例如 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"
)
名称中作为首字母缩略词或缩略语的单词(例如,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 |
函数和方法名称不应使用 Get
或 get
前缀,除非底层概念使用“get”一词(例如,HTTP GET)。 最好直接使用名词开始命名,例如使用 Counts
而不是 GetCounts
。
如果函数涉及执行复杂的计算或执行远程调用,则可以使用不同的单词(如 Compute
或 Fetch
)代替 Get
,以向读者明确表明该函数调用可能需要时间并且可能阻塞或失败。
一般来说,名称的长度应与其作用域的大小成正比,与其在该作用域内使用的次数成反比。在文件作用域内创建的变量可能需要多个单词,而作用域限定在单个内部块中的变量可能只需要一个单词,甚至一两个字符,以保持代码清晰并避免无关信息。
以下是一个粗略的基准。这些数字指导原则不是严格的规则。请根据上下文、清晰度和简洁性进行判断。
在小作用域内可能完全清晰的名称(例如,c
作为计数器)在较大的作用域内可能不够用,需要进行澄清,以提醒读者其在代码中的进一步用途。如果作用域内存在许多变量,或者变量代表相似的值或概念,则可能需要比作用域建议的更长的变量名。
概念的特异性也有助于保持变量名称的简洁。例如,假设只使用一个数据库,像 db
这样通常为非常小的作用域保留的短变量名,即使作用域非常大,也可能仍然非常清晰。在这种情况下,单个单词 database
根据作用域的大小可能是可以接受的,但不是必需的,因为 db
是该单词的一个非常常见的缩写,几乎没有其他解释。
局部变量的名称应反映其包含的内容以及其在当前上下文中的使用方式,而不是该值的来源。例如,通常情况下,最佳局部变量名与结构体或协议缓冲区字段名不同。
总的来说
count
或 options
这样的单字名称是一个好的起点。userCount
和 projectCount
。Sandbox
优于 Sbx
,特别是对于导出的名称。userCount
比 numUsers
或 usersInt
更好。users
比 userSlice
更好。ageString
中的输入,并使用 age
作为解析后的值。UserCount
方法的实现中,名为 userCount
的局部变量可能是多余的;count
、users
,甚至 c
也同样可读。单字母变量名可以成为最大限度减少重复的有用工具,但也可能使代码不必要地晦涩难懂。将其使用限制在完整单词很明显,并且重复出现以代替单字母变量的情况下。
总的来说
r
用于 io.Reader
或 *http.Request
w
用于 io.Writer
或 http.ResponseWriter
i
)和坐标(例如,x
和 y
)。for _, n := range nodes { ... }
。Go 源代码应避免不必要的重复。一个常见的来源是重复的名称,这些名称通常包含不必要的单词或重复其上下文或类型。如果相同或相似的代码段在非常接近的位置多次出现,则代码本身也可能是不必要地重复。
重复命名可以有多种形式,包括
在命名导出的符号时,包的名称始终在您的包外部可见,因此应减少或消除两者之间的冗余信息。如果一个包只导出一个类型,并且该类型以包本身命名,则构造函数的规范名称是 New
(如果需要)。
示例:重复名称 -> 更好名称
widget.NewWidget
->widget.New
widget.NewWidgetWithName
->widget.NewWithName
db.LoadFromDatabase
->db.Load
goatteleportutil.CountGoatsTeleported
->gtutil.CountGoatsTeleported
或goatteleport.Count
myteampb.MyTeamMethodRequest
->mtpb.MyTeamMethodRequest
或myteampb.MethodRequest
编译器始终知道变量的类型,并且在大多数情况下,读者也可以通过变量的使用方式清楚地了解变量的类型。只有当变量的值在同一作用域内出现两次时,才需要澄清变量的类型。
重复名称 | 更好的名称 |
---|---|
var numUsers int |
var users int |
var nameString string |
var name string |
var primaryProject *Project |
var primary *Project |
如果该值以多种形式出现,则可以使用 raw
和 parsed
等额外单词或使用底层表示形式来澄清。
// Good:
limitStr := r.FormValue("limit")
limit, err := strconv.Atoi(limitStr)
// Good:
limitRaw := r.FormValue("limit")
limit, err := strconv.Atoi(limitRaw)
包含来自其周围上下文的信息的名称通常会产生额外的噪音,而没有好处。包名称、方法名称、类型名称、函数名称、导入路径,甚至文件名都可以提供上下文,自动限定其中的所有名称。
// Bad:
// In package "ads/targeting/revenue/reporting"
type AdsTargetingRevenueReport struct{}
func (p *Project) ProjectName() string
// Good:
// In package "ads/targeting/revenue/reporting"
type Report struct{}
func (p *Project) Name() string
// Bad:
// In package "sqldb"
type DBConnection struct{}
// Good:
// In package "sqldb"
type Connection struct{}
// Bad:
// In package "ads/targeting"
func Process(in *pb.FooProto) *Report {
adsTargetingID := in.GetAdsTargetingID()
}
// Good:
// In package "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 使用的特殊格式很少;列表和代码片段通常应缩进以避免换行。除缩进外,通常应避免修饰。
确保即使在窄屏幕上也可以从源代码中读取注释。
当注释太长时,建议将其包装成多个单行注释。如果可能,目标是使注释在 80 列宽的终端上也能很好地读取,但这并不是一个硬性截止值;Go 中对注释没有固定的行长度限制。例如,标准库通常选择基于标点符号来分隔注释,这有时会使单行更接近 60-70 个字符的标记。
有很多现有代码中的注释长度超过 80 个字符。不应将此指南用作在可读性审查中更改此类代码的理由(请参见一致性),但鼓励团队在其他重构中抓住机会更新注释以遵循此指南。本指南的主要目标是确保所有 Go 可读性导师在提出建议时都提出相同的建议。
有关注释的更多信息,请参见 The Go Blog 上关于使用 Godoc 编写 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. The length of individual lines doesn't matter in
Godoc;
// but the choice of wrapping causes jagged lines on narrow screens or in code
review,
// which can be annoying, especially when in a comment block that will wrap
repeatedly.
//
// 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 // optional; default: 10
MinimumMembers int // optional; default: 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 // displayed when user logs in
ProtocolVersion string // checked against incoming requests
PageLength int // lines per page when printing (optional; default: 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())
在上面的代码中,取消(cancellation)是调用者必须采取的特定操作。但是,如果结果参数仅写为 (Context, func())
,则“取消函数”的含义将不明确。
当名称产生不必要的重复时,请不要使用命名结果参数。
// Bad:
func (n *Node) Parent1() (node *Node)
func (n *Node) Parent2() (node *Node, err error)
不要为了避免在函数内部声明变量而命名结果参数。这种做法会导致不必要的 API 冗长,却仅仅节省了少量的实现代码。
裸返回 仅在小型函数中是可以接受的。一旦函数达到中等大小,就应该明确地指定返回的值。 同样,不要仅仅因为命名结果参数可以让你使用裸返回就去命名它。清晰始终比在函数中节省几行代码更重要。
如果结果参数的值必须在 deferred closure 中更改,那么命名它总是可以接受的。
提示: 在函数签名中,类型通常比名称更清晰。 GoTip #38: 将函数作为命名类型 演示了这一点。
在上面的
WithTimeout
中,实际代码在结果参数列表中使用了CancelFunc
而不是原始的func()
,并且几乎不需要文档说明。
包注释必须紧挨着 package 子句出现,注释和包名之间没有空行。示例
// 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 格式,请缩进包含代码的注释行。
如果没有明显的primary文件,或者包注释非常长,则可以将文档注释放在名为 doc.go
的文件中,其中只包含注释和 package 子句。
可以使用多行注释来代替多个单行注释。 如果文档包含可能从源文件中复制和粘贴的部分,例如示例命令行(对于二进制文件)和模板示例,这尤其有用。
// 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
供维护者使用且适用于整个文件的注释通常放在 import 声明之后。 这些注释不会在 Godoc 中显示,也不受上述关于包注释的规则的约束。
通常不应该重命名包导入,但在某些情况下必须重命名,或者重命名可以提高可读性。
导入包的本地名称必须遵循关于包命名的指导,包括禁止使用下划线和大写字母。 尽量做到一致,始终对同一个导入包使用相同的本地名称。
必须重命名导入的包,以避免与其他导入发生名称冲突。(由此推论,好的包名称不应需要重命名。)如果发生名称冲突,请优先重命名最本地或项目特定的导入。
生成的协议缓冲区包必须重命名以删除名称中的下划线,并且它们的本地名称必须带有 pb
后缀。 有关更多信息,请参阅 [proto 和 stub 最佳实践]。
// Good:
import (
fspb "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
)。 请注意,可以用局部变量隐藏包; 只有在仍然需要在该变量的作用域中使用该包时,才需要进行此重命名。
导入应分为两组
标准库包
其他(项目和 vendored)包
// 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"
)
如果您想要一个单独的组,则可以将项目包拆分为多个组,只要这些组具有一定的含义即可。 这样做的常见原因
示例
// 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"
)
注意: goimports 工具不支持维护可选组 - 除了标准库和 Google 导入之间的强制分离之外的拆分。其他导入子组需要作者和审阅者注意以保持符合规范的状态。
也是 AppEngine 应用程序的 Google 程序应该为 AppEngine 导入使用一个单独的组。
Gofmt 负责按导入路径对每个组进行排序。 但是,它不会自动将导入分成组。 流行的 goimports 工具结合了 Gofmt 和导入管理,根据上述决策将导入分成组。 可以让 goimports 完全管理导入排列,但是当修改文件时,其导入列表必须在内部保持一致。
import _
)仅为其副作用而导入的包(使用语法 import _ "package"
)只能在 main 包中或需要它们的测试中使用。
此类包的一些示例包括
image/jpeg 在图像处理代码中
避免在库包中使用空导入,即使该库间接依赖于它们。 将副作用导入限制到 main 包有助于控制依赖关系,并使得编写依赖于不同导入的测试成为可能,而不会发生冲突或浪费构建成本。
以下是此规则的唯一例外
您可以使用空导入来绕过 nogo 静态检查器中对不允许的导入的检查。
您可以在使用 //go:embed
编译器指令的源文件中使用 embed 包的空导入。
提示: 如果您创建一个库包,该库包在生产环境中间接依赖于副作用导入,请记录预期的用法。
import .
)import .
形式是一种语言特性,允许将从另一个包导出的标识符带到当前包中而无需限定。 有关更多信息,请参阅语言规范。
请勿在 Google 代码库中使用此功能; 它使得更难辨别功能来自何处。
// Bad:
package foo_test
import (
"bar/testutil" // also imports "foo"
. "foo"
)
var myThing = Bar() // Bar defined in package foo; no qualification needed.
// Good:
package foo_test
import (
"bar/testutil" // also imports "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
类型返回它们。 具体错误类型容易出现细微的错误:具体的 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
不是标准库日志。请参阅[#logging]。
在极少数情况下,忽略或丢弃错误是合适的(例如,调用 (*bytes.Buffer).Write
,该函数文档说明永远不会失败),应附带注释解释为什么这样做是安全的。
// Good:
var b *bytes.Buffer
n, _ := b.Write(p) // never returns a non-nil error
有关错误处理的更多讨论和示例,请参阅 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
未能检查带内错误值可能导致错误,并将错误归因于错误的函数。
// Bad:
// The following line returns an error that Parse failed for the input value,
// whereas the failure was that there is no mapping for missingKey.
return Parse(Lookup(missingKey))
Go 对多个返回值的支持提供了一个更好的解决方案(请参阅 Effective Go 中关于多个返回值的章节)。函数不应要求客户端检查带内错误值,而应返回一个附加值来指示其其他返回值是否有效。此返回值可以是 error,也可以是不需要解释时的布尔值,并且应该是最终的返回值。
// 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 {
// error handling
return // or continue, etc.
}
// normal code
// Bad:
if err != nil {
// error handling
} else {
// normal code that looks abnormal due to indentation
}
提示: 如果您使用的变量超过几行代码,通常不值得使用带有初始化器的
if
样式。在这些情况下,最好移出声明并使用标准if
语句// Good: x, err := f() if err != nil { // error handling return } // lots of code that uses x // across multiple lines
// Bad: if x, err := f(); err != nil { // error handling return } else { // lots of code that uses x // across multiple lines }
有关更多详细信息,请参阅 Go 提示 #1:视线范围 和 TotT:通过减少嵌套来降低代码复杂性。
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"},
}
仅当以下两个条件都为真时,才允许删除切片和数组字面量的大括号之间的空格(又名“紧凑”它们)。
// Good:
good := []*Type{
{ // Not cuddled
Field: "value",
},
{
Field: "value",
},
}
// Good:
good := []*Type{{ // Cuddled correctly
Field: "value",
}, {
Field: "value",
}}
// Good:
good := []*Type{
first, // Can't be cuddled
{Field: "second"},
}
// Good:
okay := []*pb.Type{pb.Type_builder{
Field: "first", // Proto Builders may be cuddled to save vertical space
}.Build(), pb.Type_builder{
Field: "second",
}.Build()}
// Bad:
bad := []*Type{
first,
{
Field: "second",
}}
可以从切片和 map 字面量中省略重复的类型名称。这有助于减少混乱。显式重复类型名称的一个合理场合是处理项目中不常见的复杂类型时,当重复的类型名称位于相距很远的行上时,可以提醒读者上下文。
// 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,
// These fields all have their zero values.
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
和空切片之间没有功能差异。内置函数(如 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 {...} // no-op
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) {
// The behavior of this function unintentionally changes depending on what
// maybeInts() returns in 'empty' cases (nil or []int{}).
if s == nil {
return
}
fmt.Println(prefix, s)
}
describeInts("Here are some ints:", maybeInts())
有关更多讨论,请参阅带内错误。
如果引入换行符会将行的其余部分与缩进的代码块对齐,请避免引入换行符。如果这是不可避免的,请留一个空格以将块中的代码与换行的行分开。
// Bad:
if longCondition1 && longCondition2 &&
// Conditions 3 and 4 have the same indentation as the code within the 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:
// The second if statement is aligned with the code within the if block, causing
// indentation confusion.
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" {
// ...
}
而不是使用常量在前的,不太清晰的表述方式(“Yoda 风格条件语句”)
// Bad:
if "foo" == result {
// ...
}
为了避免意外的别名和类似的错误,复制来自其他包的结构体时要小心。例如,诸如 sync.Mutex
这样的同步对象绝不能被复制。
bytes.Buffer
类型包含一个 []byte
切片,并且为了优化小字符串,还包含一个小字节数组,切片可能会引用该数组。 如果复制了 Buffer
,副本中的切片可能会与原始对象中的数组别名化,导致后续的方法调用产生令人惊讶的效果。
一般来说,如果类型 T
的方法与指针类型 *T
关联,则不要复制类型 T
的值。
// Bad:
b1 := bytes.Buffer{}
b2 := b1
调用带有值接收器的方法会隐藏复制操作。 当您编写 API 时,如果您的结构体包含不应复制的字段,您通常应该接受和返回指针类型。
这些是可以接受的
// Good:
type Record struct {
buf bytes.Buffer
// other fields omitted
}
func New() *Record {...}
func (r *Record) Process(...) {...}
func Consumer(r *Record) {...}
但这些通常是错误的
// Bad:
type Record struct {
buf bytes.Buffer
// other fields omitted
}
func (r Record) Process(...) {...} // Makes a copy of r.buf
func Consumer(r Record) {...} // Makes a copy of r.buf
此指导也适用于复制 sync.Mutex
。
不要使用 panic
进行正常的错误处理。 请改用 error
和多个返回值。 请参阅Effective Go 关于错误的章节。
在 package main
和初始化代码中,可以考虑使用 log.Exit
处理应该终止程序的错误(例如,无效的配置),因为在许多情况下,堆栈跟踪对读者没有帮助。 请注意,log.Exit
会调用 os.Exit
,并且任何延迟函数都不会运行。
对于指示“不可能”的条件的错误,即在代码审查和/或测试期间应始终捕获的错误,函数可以合理地返回错误或调用 log.Fatal
。
另请参阅 何时可以使用 panic。
注意: log.Fatalf
不是标准库日志。请参阅[#logging]。
设置在失败时停止程序的辅助函数遵循命名约定 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
}
// Package level "constant". If we wanted to use `Parse`, we would have had to
// set the value in `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",
// Creating values directly within table driven test cases.
data: mustMarshalAny(t, mypb.Object{}),
},
// ...
}
// ...
}
在这两种情况下,这种模式的价值在于可以在“值”上下文中调用助手。不应在难以确保捕获错误的地方或应检查错误的地方(例如,在许多请求处理程序中)调用这些助手。对于常量输入,这允许测试轻松确保 Must
参数格式良好,并且对于非常量输入,它允许测试验证错误是否已正确处理或传播。
在测试中使用 Must
函数时,通常应将其标记为测试助手并在出错时调用 t.Fatal
(参见测试助手中的错误处理 以获得更多相关信息)。
当普通错误处理 可能时,不应使用它们(包括一些重构)
// Bad:
func Version(o *servicepb.Object) (*version.Version, error) {
// Return error instead of using Must functions.
v := version.MustParse(o.GetVersionString())
return dealiasVersion(v)
}
当您生成 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 returns at latest when the context is cancelled.
wg.Add(1)
go func() {
defer wg.Done()
process(ctx, item)
}()
}
// ...
wg.Wait() // Prevent spawned goroutines from outliving this function.
}
上述还有其他变体,它们使用原始信号通道(如 chan struct{}
)、同步变量、 条件变量 等。 重要的是,goroutine 的结束对于后续维护者来说是显而易见的。
相反,以下代码对生成的 goroutine 何时完成漠不关心
// Bad:
func (w *Worker) Run() {
// ...
for item := range w.q {
// process returns when it finishes, if ever, possibly not cleanly
// handling a state transition or termination of the Go program itself.
go process(item)
}
// ...
}
此代码看起来可能没问题,但存在几个潜在问题
该代码在生产环境中可能具有未定义的行为,即使操作系统释放了资源,程序也可能无法干净地终止。
由于代码的不确定生命周期,该代码难以进行有意义的测试。
该代码可能会泄漏资源,如上所述。
另请参阅
Go 接口通常属于使用接口类型值的包,而不是实现接口类型的包。 实现包应返回具体(通常是指针或结构体)类型。 这样,可以在实现中添加新方法,而无需进行广泛的重构。 有关更多详细信息,请参阅GoTip #49: 接受接口,返回具体类型。
不要从使用接口的 API 导出接口的 测试替身实现。 相反,设计 API,以便可以使用公共 API测试 实际实现。 有关更多详细信息,请参阅GoTip #42:编写测试存根。 即使使用实际实现不可行,也可能没有必要引入完全覆盖真实类型中所有方法的接口; 消费者可以创建一个仅包含所需方法的接口,如GoTip #78:最小可行接口中所示。
要测试使用 Stubby RPC 客户端的包,请使用真实的客户端连接。 如果无法在测试中运行真实的服务器,则 Google 的内部做法是使用内部 rpctest 包(即将推出!)获得与本地 测试替身的真实客户端连接。
不要在使用之前定义接口 (参见 TotT:代码健康:消除 YAGNI 气味)。 如果没有实际的使用示例,很难看出是否真的需要接口,更不用说它应该包含哪些方法了。
如果包的用户不需要为接口类型参数传递不同的类型,请不要使用接口类型的参数。
不要导出包的用户不需要的接口。
TODO: 编写更深入的关于接口的文档并在此处链接。
// Good:
package consumer // consumer.go
type Thinger interface { Thing() bool }
func Foo(t Thinger) string { ... }
// Good:
package consumer // consumer_test.go
type fakeThinger struct{ ... }
func (t fakeThinger) Thing() bool { ... }
...
if Foo(fakeThinger{...}) == "x" { ... }
// Bad:
package producer
type Thinger interface { Thing() bool }
type defaultThinger struct{ ... }
func (t defaultThinger) Thing() bool { ... }
func NewThinger() Thinger { return defaultThinger{ ... } }
// Good:
package producer
type Thinger struct{ ... }
func (t Thinger) Thing() bool { ... }
func NewThinger() Thinger { return Thinger{ ... } }
允许使用泛型(正式名称为“类型参数”),只要它们满足您的业务需求即可。 在许多应用程序中,使用现有语言功能(切片、映射、接口等)的传统方法同样有效,而不会增加复杂性,因此请警惕过早使用。 请参阅关于最小机制的讨论。
引入使用泛型的导出 API 时,请确保对其进行适当的文档说明。 强烈建议包括激励性的可运行的示例。
不要仅仅因为您正在实现一种算法或数据结构而使用泛型,该算法或数据结构不关心其成员元素的类型。 如果实际上只有一个类型被实例化,请首先让您的代码在不使用任何泛型的情况下对该类型起作用。 与删除发现不必要的抽象相比,稍后添加多态性将很简单。
不要使用泛型来发明特定于领域的语言 (DSL)。 特别是,避免引入可能给读者带来重大负担的错误处理框架。 而是首选已建立的错误处理实践。 对于测试,请特别警惕引入导致不太有用的测试失败的断言库或框架。
总的来说
any
类型和过多的 类型切换,不如考虑使用泛型。另请参阅
在 Go 中使用泛型,Ian Lance Taylor 的演讲
泛型教程,在 Go 的网页上
不要仅仅为了节省几个字节而将指针作为函数参数传递。如果一个函数自始至终只将参数 x
读取为 *x
,那么该参数不应该是指针。常见的例子包括传递字符串的指针 (*string
) 或接口值的指针 (*io.Reader
)。在这两种情况下,值本身的大小都是固定的,可以直接传递。
此建议不适用于大型结构体,甚至是不确定大小是否会增加的小型结构体。特别是,协议缓冲区消息通常应通过指针而不是值来处理。指针类型满足 proto.Message
接口(被 proto.Marshal
, protocmp.Transform
等接受),并且协议缓冲区消息可能非常大,而且随着时间的推移往往会变得更大。
方法接收者可以像常规函数参数一样以值或指针的形式传递。两者之间的选择取决于该方法应该成为哪个 方法集 的一部分。
正确性胜过速度或简单性。 在某些情况下,您必须使用指针值。在其他情况下,对于大型类型,或者如果您不确定代码将如何增长,请选择指针作为面向未来的选择,对于简单的 普通旧数据,请使用值。
下面的列表更详细地说明了每种情况
如果接收者是一个切片,并且该方法不重新切片或重新分配切片,则使用值而不是指针。
// 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) }
如果接收者是一个 map、函数或通道,则使用值而不是指针。
// 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
子句会自动中断,并且需要 fallthrough
语句才能实现 C 样式的行为。 如果你想澄清空子句的目的,请使用注释而不是 break
。
// Good:
switch x {
case "A", "B":
buf.WriteString(x)
case "C":
// handled outside of the switch statement
default:
return fmt.Errorf("unknown value: %q", x)
}
// Bad:
switch x {
case "A", "B":
buf.WriteString(x)
break // this break is redundant
case "C":
break // this break is redundant
default:
return fmt.Errorf("unknown value: %q", x)
}
注意: 如果
switch
子句位于for
循环内,则在switch
中使用break
不会退出封闭的for
循环。for { switch x { case "A": break // exits the switch, not the loop } }
要跳出封闭循环,请在
for
语句上使用标签loop: for { switch x { case "A": break loop // exits the loop } }
同步函数直接返回其结果,并在返回之前完成任何回调或通道操作。 优先使用同步函数而不是异步函数。
同步函数将 goroutine 保持在调用中。 这有助于推理它们的生命周期,并避免泄漏和数据竞争。 同步函数也更容易测试,因为调用者可以传递输入并检查输出,而无需轮询或同步。
如有必要,调用者可以通过在单独的 goroutine 中调用该函数来添加并发性。 但是,在调用者端删除不必要的并发性非常困难(有时是不可能的)。
另请参阅
使用类型定义 type T1 T2
定义一个新类型。使用类型别名,type T1 = T2
,引用现有类型而不定义新类型。类型别名很少使用;它们的主要用途是帮助将包迁移到新的源代码位置。在不需要时不要使用类型别名。
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)
// Avoid manually wrapping strings with single-quotes too:
fmt.Printf("value '%s' looks like English text", someText)
建议在面向人类的输出中使用 %q
,其中输入值可能为空或包含控制字符。很难注意到一个静默的空字符串,但 ""
清楚地突出显示为一个空字符串。
Go 1.18 引入了 any
类型作为 别名 到 interface{}
。 因为它是一个别名,any
在许多情况下等同于 interface{}
,而在其他情况下,可以通过显式转换轻松互换。 在新代码中优先使用 any
。
Google 代码库中的 Go 程序使用 标准 flag
包 的内部变体。 它具有类似的接口,但可以与 Google 内部系统很好地互操作。 Go 二进制文件中的标志名称应优先使用下划线来分隔单词,尽管保存标志值的变量应遵循标准的 Go 命名风格 (混合大小写)。 具体来说,标志名称应采用蛇形命名法,变量名称应采用等效的驼峰命名法。
// 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.")
)
标志必须仅在 package main
或等效项中定义。
通用软件包应使用 Go API 进行配置,而不是通过命令行界面进行配置;不要让导入库导出新的标志作为副作用。也就是说,优先使用显式函数参数或结构字段赋值,或者较少地使用受最严格审查的导出全局变量。在极少数情况下,必须打破此规则,标志名称必须清楚地表明它配置的包。
如果您的标志是全局变量,请将它们放在自己的 var
组中,放在导入部分之后。
有关创建具有子命令的 复杂 CLI 的最佳实践还有更多讨论。
另请参阅
Google 代码库中的 Go 程序使用标准 log
包的变体。 它具有类似但更强大的接口,并且可以与 Google 内部系统很好地互操作。 此库的开源版本可用作 包 glog
,并且开源 Google 项目可以使用它,但本指南始终将其称为 log
。
注意: 对于异常的程序退出,此库使用 log.Fatal
中止并显示堆栈跟踪,并使用 log.Exit
停止而不显示堆栈跟踪。 标准库中没有 log.Panic
函数。
提示: log.Info(v)
等同于 log.Infof("%v", v)
,其他日志级别也是如此。 当您没有格式化操作时,请优先使用非格式化版本。
另请参阅
context.Context
类型的值在 API 和进程边界之间传递安全凭证、跟踪信息、截止日期和取消信号。 与 C++ 和 Java 不同,Google 代码库使用线程本地存储,Go 程序沿整个函数调用链显式传递上下文,从传入的 RPC 和 HTTP 请求到传出的请求。
当传递给函数或方法时,context.Context
始终是第一个参数。
func F(ctx context.Context /* other arguments */) {}
例外情况是
req.Context()
。在流式 RPC 方法中,上下文来自流。
使用 gRPC 流的代码从生成的服务器类型中的 Context()
方法访问上下文,该方法实现 grpc.ServerStream
。 请参阅 gRPC 生成的代码文档。
在入口点函数中(请参见下面的此类函数的示例),使用 context.Background()
,或者对于测试,使用 tb.Context()
。
main
init
TestXXX
、BenchmarkXXX
、FuzzXXX
注意:在调用链中间的代码通常极少需要使用
context.Background()
创建自己的基础上下文。除非上下文不正确,否则始终首选从调用方获取上下文。您可能会遇到服务器库(Google 的 Go 服务器框架中 Stubby、gRPC 或 HTTP 的实现),它们为每个请求构建一个新的上下文对象。这些上下文会立即填充来自传入请求的信息,以便在传递给请求处理程序时,上下文附加的值已通过网络边界从客户端调用方传播到该上下文。此外,这些上下文的生命周期限定于请求的生命周期:当请求完成时,上下文将被取消。
除非您正在实现服务器框架,否则您不应在库代码中使用
context.Background()
创建上下文。相反,如果存在可用的现有上下文,则首选使用下面提到的上下文分离。如果您认为需要在入口点函数之外使用context.Background()
,请在提交实现之前与 Google Go 风格邮件列表讨论。
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 程序在生产中正常工作的能力。
不要使用包 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)
// or hex.EncodeToString(buf)
// or base64.StdEncoding.EncodeToString(buf)
}
注意: log.Fatalf
不是标准库日志。请参阅[#logging]。
应该能够在不读取测试源代码的情况下诊断测试的失败。测试应该以详细说明的有用消息失败
下面概述了实现此目标的具体约定。
不要创建“断言库”作为测试的辅助工具。
断言库是尝试在测试中组合验证和生成失败消息的库(尽管同样的缺陷也可能适用于其他测试辅助工具)。有关测试辅助工具和断言库之间的区别的更多信息,请参见 最佳实践。
// 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
不是微不足道的,那么直接测试它,独立于任何使用它的测试,将是有意义的。
另请参阅
在大多数测试中,失败消息应包括失败的函数的名称,即使从测试函数的名称来看这似乎很明显。具体来说,您的失败消息应该是 YourFunc(%v) = %v, want %v
而不仅仅是 got %v, want %v
。
在大多数测试中,如果函数输入很短,则失败消息应包括这些输入。如果输入的有关属性不明显(例如,因为输入很大或不透明),则应使用对正在测试的内容的描述来命名您的测试用例,并将该描述作为错误消息的一部分打印出来。
测试输出应包括函数返回的实际值,然后再打印期望的值。打印测试输出的标准格式是 YourFunc(%v) = %v, want %v
。如果您要写“actual”和“expected”,请分别首选使用单词“got”和“want”。
对于差异,方向性不太明显,因此包含一个密钥以帮助解释失败非常重要。请参阅 关于打印差异的部分。无论您在失败消息中使用哪种差异顺序,都应将其明确地指示为失败消息的一部分,因为现有代码在排序方面不一致。
如果您的函数返回一个结构(或任何具有多个字段的数据类型,如切片、数组和映射),请避免编写执行结构的手工编码字段比较的测试代码。相反,构造您期望函数返回的数据,并使用 深度比较 直接比较。
注意: 如果您的数据包含模糊测试意图的无关字段,则这不适用。
如果您的结构需要进行近似(或等效的语义)相等性比较,或者它包含无法进行相等性比较的字段(例如,如果其中一个字段是 io.Reader
),则调整 cmp.Diff
或 cmp.Equal
比较,使用 cmpopts
选项(例如 cmpopts.IgnoreInterfaces
)可能满足您的需求(示例)。
如果您的函数返回多个返回值,则无需在比较之前将它们包装在一个结构中。只需单独比较返回值并打印它们即可。
// 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 字符串的内容,并确保它在语义上等同于某个预期的数据结构。
为了在一次运行中打印出所有失败的检查,测试应该尽可能地继续进行,即使在失败之后也是如此。 这样,修复失败测试的开发人员不必在修复每个错误后重新运行测试来查找下一个错误。
对于报告不匹配,建议使用 t.Error
而不是 t.Fatal
。 当比较函数输出的几个不同属性时,对每个比较使用 t.Error
。
当后续比较失败将没有意义时,调用 t.Fatal
主要用于报告意外的错误情况。
对于表格驱动的测试,请考虑使用子测试,并使用 t.Fatal
而不是 t.Error
和 continue
。 另请参阅 GoTip #25: Subtests: Making Your Tests Lean。
最佳实践: 有关何时应使用 t.Fatal
的更多讨论,请参阅 最佳实践。
==
运算符使用 语言定义的比较来评估相等性。 标量值(数字、布尔值等)基于它们的值进行比较,但只有一些结构体和接口可以以这种方式进行比较。 指针基于它们是否指向同一个变量进行比较,而不是基于它们指向的值的相等性。
cmp
包可以比较 ==
无法适当处理的更复杂的数据结构,例如切片。 使用 cmp.Equal
进行相等性比较,并使用 cmp.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
选项,它才能比较协议缓冲区消息。
// 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 切片和空切片之间的差异,对具有相同字段的不同接口实现不敏感,并且可以将嵌套映射用作与结构体值进行比较的基础。 它还会将整个值序列化为字符串,然后再生成差异,因此不适合比较大的值。 默认情况下,它会比较未导出的字段,这使其对依赖项中实现细节的更改很敏感。 因此,不适合在 protobuf 消息上使用 pretty
。对于新代码,建议使用 cmp
,并且在可行的情况下,值得考虑更新旧代码以使用 cmp
。
较旧的代码可能使用标准库 reflect.DeepEqual
函数来比较复杂结构。 不应使用 reflect.DeepEqual
来检查相等性,因为它对未导出的字段和其他实现细节的更改很敏感。 应将使用 reflect.DeepEqual
的代码更新为上述库之一。
注意: cmp
包是为测试而设计的,而不是为生产用途而设计的。 因此,当它怀疑比较执行不正确时,它可能会发生 panic,以向用户提供有关如何改进测试以使其不易出错的说明。 鉴于 cmp 倾向于发生 panic,这使其不适合在生产中使用的代码,因为错误的 panic 可能是致命的。
传统的失败消息适用于大多数 Go 测试,为 YourFunc(%v) = %v, want %v
。 但是,有些情况下可能需要更多或更少的细节
YourFunc
,请确定哪个调用使测试失败。 如果了解系统的任何额外状态很重要,请将其包含在失败输出中(或至少在日志中)。t.Fatalf("Setup: Failed to set up test database: %s", err)
通常足以解决问题。提示: 使您的失败模式在开发期间触发。 检查失败消息的外观以及维护人员是否可以有效地处理失败。
有一些技术可以清楚地重现测试输入和输出
如果您的函数返回大量输出,则当您的测试失败时,读取失败消息的人可能难以找到差异。 不要打印返回值和想要的值,而是制作差异。
为了计算此类值的差异,首选 cmp.Diff
,特别是对于新测试和新代码,但也可以使用其他工具。 有关每个函数的优缺点的指导,请参阅 相等性的类型。
您可以使用 diff
包来比较多行字符串或字符串列表。 您可以将其用作其他类型差异的构建块。
在您的失败消息中添加一些文本,解释差异的方向。
当您使用 cmp
、pretty
和 diff
包(如果您将 (want, got)
传递给该函数)时,类似于 diff (-want +got)
的内容很好,因为您添加到格式字符串中的 -
和 +
将与实际出现在差异行开头的 -
和 +
匹配。 如果您将 (got, want)
传递给该函数,则正确的键将是 (-got +want)
。
messagediff
包使用不同的输出格式,因此当您使用它时(如果您将 (want, got)
传递给该函数),消息 diff (want -> got)
是合适的,因为箭头的方向将与“modified”行中的箭头的方向匹配。
差异将跨越多行,因此您应该在打印差异之前打印一个换行符。
当单元测试执行字符串比较或使用 vanilla cmp
来检查是否为特定输入返回了特定类型的错误时,您可能会发现如果将来重新措辞任何这些错误消息,您的测试将会变得脆弱。 由于这有可能将您的单元测试变成一个变更检测器(请参阅 TotT:变更检测器测试被认为是有害的),因此不要使用字符串比较来检查您的函数返回的错误类型。 但是,允许使用字符串比较来检查来自被测包的错误消息是否满足某些属性,例如,它是否包含参数名称。
Go 中的错误值通常具有一个供人眼使用的组件和一个用于语义控制流的组件。 测试应仅尝试测试可以可靠观察到的语义信息,而不是旨在用于人类调试的显示信息,因为这通常会发生未来的更改。 有关构建具有语义含义的错误的指导,请参阅 有关错误的最佳实践。 如果来自您无法控制的依赖项的错误没有足够的语义信息,请考虑向所有者提交一个错误报告以帮助改进 API,而不是依赖于解析错误消息。
在单元测试中,通常只关心是否发生了错误。 如果是这样,那么当您期望出现错误时,仅测试该错误是否为非 nil 就足够了。 如果您想测试该错误在语义上是否与其他错误匹配,请考虑使用 errors.Is
或 cmp
以及 cmpopts.EquateErrors
。
注意: 如果测试使用
cmpopts.EquateErrors
,但其所有wantErr
值要么是nil
,要么是cmpopts.AnyError
,则使用cmp
是 不必要的机制。 通过将 want 字段设为bool
来简化代码。 然后,您可以使用与!=
的简单比较。// Good: err := f(test.input) gotErr := err != nil if gotErr != test.wantErr { t.Errorf("f(%q) = %v, want error presence = %v", test.input, err, test.wantErr) }
另请参阅 GoTip #13: 设计可检查的错误。
标准的 Go 测试库提供了一个工具来 定义子测试。这允许在设置和清理、控制并行性和测试过滤方面的灵活性。子测试可能很有用(特别是对于表驱动的测试),但使用它们不是强制性的。另请参阅 关于子测试的 Go 博客文章。
子测试不应依赖于其他用例的执行来获得成功或初始状态,因为预期子测试可以使用 go test -run
标志或使用 Bazel 测试过滤器表达式单独运行。
命名您的子测试,使其在测试输出中可读,并且在命令行上对测试过滤的用户有用。当您使用 t.Run
创建子测试时,第一个参数用作测试的描述性名称。为确保测试结果对于阅读日志的人来说是清晰易懂的,请选择在转义后仍保持有用且可读的子测试名称。将子测试名称更多地视为函数标识符,而不是散文描述。
测试运行器会将空格替换为下划线,并转义非打印字符。为了确保测试日志和源代码之间的准确关联,建议避免在子测试名称中使用这些字符。
如果您的测试数据可以从更长的描述中受益,请考虑将描述放在单独的字段中(可能使用 t.Log
或与失败消息一起打印)。
可以使用 Go 测试运行器或 Bazel 测试过滤器的标志单独运行子测试,因此请选择描述性名称,这些名称也易于键入。
警告:斜杠字符在子测试名称中尤其不友好,因为它们具有 测试过滤器的特殊含义。
# Bad: # Assuming TestTime and t.Run("America/New_York", ...) bazel test :mytest --test_filter="Time/New_York" # Runs nothing! bazel test :mytest --test_filter="Time//New_York" # Correct, but awkward.
要标识函数的输入,请将其包含在测试的失败消息中,测试运行器不会转义这些消息。
// 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:
// Too wordy.
t.Run("check that there is no mention of scratched records or hovercrafts", ...)
// Slashes cause problems on the command line.
t.Run("AM/PM confusion", ...)
另请参阅 Go Tip #117: 子测试名称。
当可以使用类似的测试逻辑测试许多不同的测试用例时,使用表驱动测试。
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},
// test runtime·memeq's chunked implementation
{"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: 不相交表测试 中所述。
当额外的测试用例很简单(例如,基本错误检查)并且不会在表测试的循环体中引入条件化的代码流时,允许将该用例包含在现有测试中,但要小心使用这样的逻辑。今天开始简单的事情可能会有机地发展成难以维护的东西。
例如
func TestDivide(t *testing.T) {
tests := []struct {
dividend, divisor int
want int
wantErr bool
}{
{
dividend: 4,
divisor: 2,
want: 2,
},
{
dividend: 10,
divisor: 2,
want: 5,
},
{
dividend: 1,
divisor: 0,
wantErr: true,
},
}
for _, test := range tests {
got, err := Divide(test.dividend, test.divisor)
if (err != nil) != test.wantErr {
t.Errorf("Divide(%d, %d) error = %v, want error presence = %t", test.dividend, test.divisor, err, test.wantErr)
}
// In this example, we're only testing the value result when the tested function didn't fail.
if err != nil {
continue
}
if got != test.want {
t.Errorf("Divide(%d, %d) = %d, want %d", test.dividend, test.divisor, got, test.want)
}
}
}
测试代码中更复杂的逻辑,例如基于测试设置中条件差异(通常基于表测试输入参数)的复杂错误检查,在表的每个条目都有基于输入的专门逻辑时,可能难以理解。如果测试用例具有不同的逻辑但设置相同,则单个测试函数中的一系列子测试可能更具可读性。测试助手也可能有助于简化测试设置,以保持测试主体的可读性。
您可以将表驱动测试与多个测试函数结合使用。例如,当测试函数的输出与预期输出完全匹配,并且该函数为无效输入返回非 nil 错误时,编写两个单独的表驱动测试函数是最好的方法:一个用于正常的非错误输出,一个用于错误输出。
表测试行有时会变得复杂,行值决定了测试用例中的条件行为。测试用例之间重复带来的额外清晰度对于可读性是必要的。
// Good:
type decodeCase struct {
name string
input string
output string
err error
}
func TestDecode(t *testing.T) {
// setupCodex is slow as it creates a real Codex for the test.
codex := setupCodex(t)
var tests []decodeCase // rows omitted for brevity
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output, err := Decode(test.input, codex)
if got, want := output, test.output; got != want {
t.Errorf("Decode(%q) = %v, want %v", test.input, got, want)
}
if got, want := err, test.err; !cmp.Equal(got, want) {
t.Errorf("Decode(%q) err %q, want %q", test.input, got, want)
}
})
}
}
func TestDecodeWithFake(t *testing.T) {
// A fakeCodex is a fast approximation of a real Codex.
codex := newFakeCodex()
var tests []decodeCase // rows omitted for brevity
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output, err := Decode(test.input, codex)
if got, want := output, test.output; got != want {
t.Errorf("Decode(%q) = %v, want %v", test.input, got, want)
}
if got, want := err, test.err; !cmp.Equal(got, want) {
t.Errorf("Decode(%q) err %q, want %q", test.input, got, want)
}
})
}
}
在下面的反例中,请注意在用例设置中很难区分每个测试用例使用了哪种类型的 Codex
。(突出显示的部分违反了来自 TotT: 数据驱动陷阱! 的建议。)
// Bad:
type decodeCase struct {
name string
input string
codex testCodex
output string
err error
}
type testCodex int
const (
fake testCodex = iota
prod
)
func TestDecode(t *testing.T) {
var tests []decodeCase // rows omitted for brevity
for _, test := tests {
t.Run(test.name, func(t *testing.T) {
var codex Codex
switch test.codex {
case fake:
codex = newFakeCodex()
case prod:
codex = setupCodex(t)
default:
t.Fatalf("Unknown codex type: %v", codex)
}
output, err := Decode(test.input, codex)
if got, want := output, test.output; got != want {
t.Errorf("Decode(%q) = %q, want %q", test.input, got, want)
}
if got, want := err, test.err; !cmp.Equal(got, want) {
t.Errorf("Decode(%q) err %q, want %q", test.input, got, want)
}
})
}
}
不要使用测试表中测试的索引来代替命名测试或打印输入。没有人愿意浏览您的测试表并计算条目,以找出哪个测试用例失败。
// 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
以将测试助手中的失败归因于调用该助手的行。如果存在,此参数应位于上下文参数之后,并在任何剩余参数之前。
// Good:
func TestSomeFunction(t *testing.T) {
golden := readFile(t, "testdata/golden-result.txt")
// ... tests against 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 also imports fireworks
)
testing
Go 标准库提供 testing
包。这是 Google 代码库中允许用于 Go 代码的唯一测试框架。特别是,不允许使用断言库和第三方测试框架。
testing
包提供了一组最小但完整的功能,用于编写良好的测试
它们旨在与核心语言特性(如 复合字面量 和 if-with-initializer 语法)协同工作,以使测试作者能够编写[清晰、可读且可维护的测试]。
样式指南不能枚举所有事项的积极规定,也不能枚举它不提供意见的所有事项。也就是说,以下是一些可读性社区之前讨论过但未达成共识的事情。
var i int
和 i := 0
是等价的。另请参阅 初始化最佳实践。new
或 make
. &File{}
和 new(File)
是等价的。map[string]bool{}
和 make(map[string]bool)
也是等价的。另请参见 复合声明的最佳实践。errors.New
vs fmt.Errorf
。 errors.New("foo")
和 fmt.Errorf("foo")
可以互换使用。如果出现特殊情况导致它们再次出现,可读性指导者可能会添加可选的注释,但通常作者可以自由选择他们在给定情况下喜欢的样式。
当然,如果样式指南未涵盖的任何内容需要更多讨论,欢迎作者提问 – 无论是在具体的审查中,还是在内部留言板上。