go语言代码规范

go语言代码规范

指南篇

编码风格原则

  • 清晰:代码的目的和原理对读者来说是清晰的
  • 简单:代码以最简单的方式完成其目标
  • 简明:代码具有较高的信噪比
  • 可维护性:编写的代码可以很容易维护
  • 一致:代码与广泛的谷歌代码库风格一致

清晰

清晰主要是通过有效的命名、有用的注释和有效的代码组织来实现的。

清晰与否要从代码的读者角度来看,而不是从代码的作者的角度来看。代码的易读性比易写性更重要。

代码的清晰性有两个不同的方面:

  • 代码实际上在做什么?
  • 为什么代码在做它所做的事?
代码实际上在做什么
  • 使用更具描述性的变量名
  • 添加额外的注释
  • 用空白和注释来分隔代码
  • 将代码重构为独立的函数/方法,使其更加模块化。
为什么代码在做它所做的事

代码的原理往往是通过变量、函数、方法或包的名称来充分传达的。如果这些元素名称无法做到这点,那么添加注释就会变得很重要。当代码包含读者可能不熟悉的细微差别时,解释“为什么”将变得尤其重要。

注释最好是解释为什么要做某事,而不是解释代码在做什么。

简单

  • 从上到下都易于阅读
  • 不假设你已经知道它的工作原理
  • 不假设你能记住前面所有的代码
  • 没有不必要的抽象层次
  • 在平凡代码中没有引起人们注意的名字
  • 让读者清楚地了解价值和决策的传播情况
  • 有注释,解释为什么,而不是代码在做什么,以避免将来出现偏差
  • 有独立的文档
  • 拥有有用的错误和有用的失败测试用例
  • 通常与“故作聪明的”代码相互排斥

要控制代码的复杂性

简明

简明的Go代码具有很高的信噪比。它很容易分辨出相关的细节,而命名和结构则可以引导读者了解这些细节。

理解和使用常见的代码结构和地道用法对于保持高信噪比也很重要。例如,下面这个代码块在错误处理中非常常见,读者可以很快理解这个代码块的意图:

// Good:
if err := doSomething(); err != nil {
    // ...
}

如果代码看起来与此非常相似,但却有细微的不同,读者可能不会注意到这种变化。在这样的情况下,值得故意“提高”错误检查的信号,我们可以通过添加一个注释来引起注意。

// Good:
if err := doSomething(); err == nil { // if NO error
    // ...
}

可维护

  • 易于被未来的程序员正确修改
  • 具有结构化的API,使其能够优雅地扩展
  • 清楚它所做的假设,并选择与问题结构相对应的抽象,而不是与代码的结构相对应。
  • 避免不必要的耦合,不包含未用到的功能特性。
  • 拥有一个全面的测试套件,以确保承诺的行为得到维护以及重要的逻辑是正确的,并且在测试失败的情况下为开发人员提供清晰、可操作的诊断

可维护的代码还可以避免将重要的细节隐藏在容易被忽视的地方。

一致

一致性的代码是指在更广泛的代码库中,在一个团队的代码中或一个包的范围内,甚至在一个文件中,看起来、感觉和行为都类似的代码。

决定篇

receiver命名

Receiver变量的名称必须满足下面要求:

  • 短(通常为一或两个字母的长度)。
  • 类型本身的缩略语。
  • 统一应用于该类型的每一个Receiver。
长名字 更好的名字
func (tray Tray) func (t Tray)
func (this *ReportWriter) func (w *ReportWriter)

常量命名

常量名称必须像Go中的其他名称一样使用驼峰命名法(MixedCaps)(导出的常量以大写字母开始,而未导出的常量以小写字母开始)。常量名称不应该是其值的衍生物,而应该解释其值所表示的内容:

// Good:
const MaxPacketSize = 512 // 译注:不应命名为FiveHundredTwelve

const (
    ExecuteBit = 1 << iota
    WriteBit
    ReadBit
)

根据常量的作用来命名常量,而不是根据它们的值。如果一个常量除了它的值之外没有作用,那么就没有必要把它定义为一个常量。

// Bad:
const Twelve = 12

const (
    UserNameColumn = "username"
    GroupColumn    = "group"
)

首字母缩略词

名称中的单词如果是首字母缩略词或缩略语(例如,URL和NATO)应该使用相同的大小写命名。URL应该使用URL或url(如urlPony,或URLPony),而不是Url。这也适用于ID,当它是“标识符”的缩写时使用appID而不是appId。

  • 在有多个首字母缩略词的名字中(例如XMLAPI,它包含XML和API两个首字母缩略词),每个首字母缩略词中的字母都应该具有一致的大小写,但名字中的多个首字母缩略词不需要有相同的大小写。
  • 在包含小写字母的首字母缩略词名称中(例如DDoS、iOS、gRPC),首字母应该保持其在缩略词中原有的样子,除非你需要为了导出该名称而改变第一个字母。在这些情况下,整个首字母缩略词中的字母应该采用相同的大小写(例如,ddos, IOS, GRPC)。

Getter命名

函数和方法名称不应该使用Get或get前缀,除非底层概念使用“get”一词(例如HTTP GET)。我们倾向于直接用那个要Get的事物名词进行命名,例如使用Counts而不是GetCounts。

如果函数涉及到执行复杂的计算或执行远程调用,可以使用不同的词,如Compute或Fetch来代替Get,以使读者清楚地知道函数调用可能需要时间,并可能阻塞或失败。

变量名命名

一般的经验法则是,名字的长度应该与它使用的范围大小成正比,与它在该范围内使用的次数成反比。一个在文件范围内创建的变量,其名称可能需要由多个单词组成,而一个在单个内部代码块范围内的变量可能只需要用一个单词命名,甚至只有一两个字符,以保持代码的清晰和避免无关的信息。

单字母命名

单字母变量名可以是一个有用的工具,可以最大限度地减少重复,但这类变量名也可能使代码出现不必要地不透明。把它们的使用限制在其全词含义很明显的情况下,而且如果用全词来代替单字母变量,就会出现重复的情况。

一般来说:

  • 对于一个方法接收器变量,最好使用一个或两个字母的名字。
  • 对常见的类型使用熟悉的变量名通常是有帮助的。
  • 单字母标识符作为整数循环变量是可以接受的,特别是对于索引(如i)和坐标(如x和y)。
  • 当范围很小时,缩写可以成为可接受的循环标识符,例如,for _, n := range nodes { … }。

重复

Go源代码应该避免不必要的重复。这方面的一个常见来源是重复的名称,它往往包括不必要的单词或重复其上下文或类型。如果相同或类似的代码段在很近的地方多次出现,代码本身也会出现不必要的重复。

包 vs. 导出符号的名称

重复的名字 更好的名字
widget.NewWidget widget.New
widget.NewWidgetWithName widget.NewWithName
db.LoadFromDatabase db.Load

变量名称 vs. 类型

重复的名字 更好的名字
var numUsers int var users int
var nameString string var name string

如果该值以多种形式出现,可以用一个额外的词来澄清,如raw和parsed,或者用底层表示法:

// Good:
limitStr := r.FormValue("limit")
limit, err := strconv.Atoi(limitStr)

// Good:
limitRaw := r.FormValue("limit")
limit, err := strconv.Atoi(limitRaw)

外部上下文 vs. 本地名称

包含周围上下文信息的名字往往不仅没有带来好处,还会产生额外的噪音。包名、方法名、类型名、函数名、导入路径、甚至文件名都可以提供自动限定其中所有名称的上下文信息。

// 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:
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
}

具名返回值参数

如果一个函数返回两个或更多相同类型的参数,为返回值参数添加名称可能会很有用:

// 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()),就会不清楚“取消函数”是什么意思。

不要为了避免在函数中声明一个变量而给返回值参数命名,这种做法收获的仅仅是很小的实现简洁性,但却会导致不必要的API冗长。

只有在小型函数中才可以接受裸返回(naked return)。一旦是一个中等规模的函数,就要显式地带着返回值一起返回。同样地,不要因为可以使用裸返回就给返回值参数命名。清晰性总是比在你的函数中节省几行字更重要。

导入

重命名导入包

import只应该在为避免与其他import的名称冲突时才进行重命名(一个推论是,好的包名不应该需要重命名)。在名字冲突的情况下,最好对本地的或项目特定的包进行重命名。包的本地名称(别名)必须遵循包命名的指导,包括禁止使用下划线和大写字母。

// Good:
import (
    fspb "path/to/package/foo_service_go_proto"
)

如果导入的软件包名称没有任何有用的标识信息(例如,package v1),应该将其重新命名为包括之前路径成分的名字。重命名必须与其他导入相同软件包的本地文件一致,包括版本号。

// Good:
import (
    core "github.com/kubernetes/api/core/v1"
    meta "github.com/kubernetes/apimachinery/pkg/apis/meta/v1beta1"
)

如果您需要导入一个包,其名称与你想使用的常见局部变量名称相冲突(例如 url, ssh),并且你希望重命名该包,首选的方法是使用pkg后缀(例如urlpkg)。注意,一个本地变量可以遮蔽一个包;但只有当这样的变量在同一范围内时,且该包仍然需要被使用时,这种重命名才是必要的。

分组导入

  • 标准库包
  • 其他包(项目和vendor包)
// 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"
)

错误

返回错误

使用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 { /*...*/ }

错误处理

在代码中遇到错误时应该慎重选择如何处理它。通常情况下,使用“_”空变量来丢弃错误是不合适的。如果一个函数返回一个错误,请做以下其中一个:

  • 立即处理并解决该错误。
  • 将错误返回给调用者。
  • 在特殊情况下,调用log.Fatal或(如果绝对必要)panic。

缩进错误流程

在继续进行你的代码的其余部分之前,先处理错误。这可以提高代码的可读性,使读者能够迅速找到正常的路径。这个逻辑同样适用于任何测试一个条件是否为终止条件的代码块(例如,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-with-initializer风格。在这种情况下,通常最好将声明移出,使用标准的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有一个非常强大的复合字面值语法,可以用一个表达式来表达深度嵌套的复杂值。在可能的情况下,应该使用这种字面值语法,而不是逐个字段地赋值。一般来说,gofmt对字面值的格式化是非常好的,但是还有一些额外的规则来保持这些字面值的可读性和可维护性。

字段名

结构体中字段的位置和字段的完整集合(当字段名被省略时,这两者都是有必要搞清楚的)通常不被认为是结构体的公共API的一部分;需要指定字段名以避免不必要的耦合。

// Bad:
// https://pkg.go.dev/encoding/csv#Reader
r := csv.Reader{',', '#', 4, false, false, false, false}

如果要使代码更清晰,还是应该使用字段名,而且这样做是非常普遍的。例如,一个有大量字段的结构体几乎都应该用字段名来初始化。

// Good:
okay := StructWithLotsOfFields{
  field1: 1,
  field2: "two",
  field3: 3.14,
  field4: true,
}
匹配括号

一对大括号的最后一半应该总是应该出现在缩进量与开头的大括号相同的一行中。单行字面值也必须有这个属性。当字面值跨越多行时,保持这一属性可以使字面值的大括号匹配与函数和if语句等常见Go语法结构的大括号匹配相同。

这方面最常见的错误是在多行结构体字面值中把收尾括号和值放在同一行。在这种情况下,该行应以逗号结束,收尾括号应出现在下一行。

// Good:
good := []*Type{{Key: "value"}}

// Good:
good := []*Type{
    {Key: "multi"},
    {Key: "line"},
}
// Bad:
bad := []*Type{
    {Key: "multi"},
    {Key: "line"}}

    // Bad:
bad := []*Type{
    {
        Key: "value"},
}
重复的类型名

重复的类型名可以从切片和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},
}

空切片

在大多数情况下,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切片和非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)
}

而不是依靠区别nil和零长度的切片来作为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")
}

函数的格式化

函数或方法的声明应该在同一行防止缩进混淆,方法的参数列表可能会很长,参数进行换行的时候很有可能会被看成方法体的一部分

// Bad:
func (r *SomeType) SomeLongFunctionName(foo1, foo2, foo3 string,
    foo4, foo5, foo6 int) {
    foo7 := bar(foo1)
    // ...
}

方法的调用也不应该被换行

// 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
)

在函数中的长字符串字数不应该因为行长的原因而被打断。对于包含此类字符串的函数,可以在字符串格式之后添加一个换行符,参数可以在下一行或后续行提供。关于断行的位置,最好是根据输入的语义分组来决定,而不是单纯地根据行的长度。

// 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:
    // ...
}

goroutine的生命周期

当你创建新的goroutines时,要明确它们何时或是否会退出。

goroutine可能因阻塞在channel的发送或接收操作上而导致泄漏。垃圾收集器不会终止一个goroutine,即使阻塞它的channel已经是不可到达的了。

即使goroutine没有泄漏,当它们不再被需要时,让它们继续存活也会导致其他微妙的、难以诊断的问题。在一个已经关闭的channel上��行发送操作会导致panic。

// Bad:
ch := make(chan int)
ch <- 42
close(ch)
ch <- 13 // panic

编写并发代码时应该明确goroutine的生命周期。通常情况下,这意味着将同步相关的代码限制在一个函数的范围内,并将逻辑分解到同步函数中。如果并发性仍然不明显,那么记录下goroutine退出的时间和原因是很重要的。

遵循围绕Context使用的最佳实践的代码通常有助于明确这一点。传统上,它是用context.Context来管理的。

// Good:
func (w *Worker) Run(ctx context.Context) error {
    // ...
    for item := range w.q {
        // process returns at latest when the context is cancelled.
        go process(ctx, item)
    }
    // ...
}

以上还有其他变种,使用原始信号channel,如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)
    }
    // ...
}

以上还有其他变种,使用原始信号channel,如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)
    }
    // ...
}

传值

不要仅仅为了节省几个字节而把指针作为函数参数传递。如果一个函数自始至终只是以*x的形式对它的参数x进行了读操作,那么这个参数就不应该被设计成一个指针。常见的例子包括传递一个字符串的指针(string)或一个接口值的指针(io.Reader)。在这两种情况下,值本身是一个固定的大小,可以直接传递。

这个建议并不适用于大型结构体,甚至是可能增大的小型结构体。特别是,protocol buffer消息一般应通过指针而不是值来处理。指针类型满足proto.Message接口(由proto.Marshal、protocmp.Transform等接受),而protocol buffer消息可能相当大,并且经常��着时间的推移而变大。

下面的列表进一步详细说明了每种情况:

  • 如果接收器是一个切片,并且该方法没有做reslice操作或重新分配切片,则使用一个值而不是一个指针。
// Good:
type Buffer []byte

func (b Buffer) Len() int { return len(b) }
  • 如果方法需要修改receiver参数,那么receiver必须用指针类型
// 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...) }
  • 如果receiver是一个包含不能安全复制的字段的结构体,请使用一个指针类型receiver。常见的例子是sync.Mutex和其他同步类型。
// Good:
type Counter struct {
    mu    sync.Mutex
    total int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.total++
}
  • 如果receiver是一个”大”结构体或数组,指针类型接收器可能更有效率。传递一个结构体相当于把它的所有字段或元素作为参数传递给方法。如果这看起来太大,无法通过数值传递,那么指针是一个不错的选择。

  • 对于将调用或与其他修改receiver的函数并发运行的方法,如果这些修改不应该对你的方法可见,则使用一个值;否则使用一个指针。

  • 如果receiver是一个结构体或数组,其任何元素都是指向可能被修改的东西的指针,那么最好使用指针类型接收器,以使读者清楚地了解可修改的意图。

// Good:
type Counter struct {
    m *Metric
}

func (c *Counter) Inc() {
    c.m.Add(1)
}
  • 如果接收器是一个Go内置的类型,如整数或字符串,不需要被修改,则使用一个值类型。
// Good:
type User string

func (u User) String() { return string(u) }
  • 如果接收器是一个map、函数或channel,使用一个值而不是一个指针。
// 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 */ }
  • 如果不确定,那就使用指针类型receiver

作为一般的指导原则,最好使一个���型的方法要么所有都是指针方法,要么所有都是值方法。

注意:关于向函数传递值或指针是否会影响性能,有很多错误的信息。编译器可以选择向堆栈中的值传递指针以及复制堆栈中的值,但在大多数情况下,这些考虑的优先级不应该超过代码的可读性和正确性。当性能确实重要时,在决定一种方法优于另一种方法之前,用一个实际的基准测试对两种方法进行分析是很重要的。

switch和break

不要在switch子句的末尾使用没有目标标签的break语句,它们是多余的。与C和Java不同,Go中的switch子句会自动跳出,我们需要显式使用fallthrough语句来实现C风格的行为。如果你想澄清一个空case子句的目的,请使用注释而不是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
    }
  }

最佳实践篇

命名

函数和方法名

避免重复

当选择一个函数或方法的名字时,需要考虑该名字将被使用的上下文环境。请考虑以下建议,以避免在调用时出现过多的重复:

  • 以下内容一般可以从函数和方法的名字中省略。
    • 输入和输出的类型(当不存在冲突的时候)
    • 方法的接收器的类型
    • 输入或输出是否是一个指针
  • 对于函数,不要重复包的名称。
// Bad:
package yamlconfig

func ParseYAMLConfig(input string) (*Config, error)

// Good:
package yamlconfig

func Parse(input string) (*Config, error)

对于方法,不要重复方法接收器的名称。

// Bad:
func (c *Config) WriteConfigTo(w io.Writer) (int64, error)

// Good:
func (c *Config) WriteTo(w io.Writer) (int64, error)

不要重复作为参数传递的变量的名称。

// Bad:
func OverrideFirstWithSecond(dest, source *Config) error

// Good:
func Override(dest, source *Config) error

不要重复返回值的名称和类型。

// Bad:
func TransformYAMLToJSON(input *Config) *jsonconfig.Config

// Good:
func Transform(input *Config) *jsonconfig.Config

当有必要区分相似名称的函数时,名字中可以包含额外的信息:

// Good:
func (c *Config) WriteTextTo(w io.Writer) (int64, error)
func (c *Config) WriteBinaryTo(w io.Writer) (int64, error)
命名惯例

在为函数和方法选择名称时,还有一些常见的惯例。

返回某事物的函数通常被赋予类似名词的名字。

// Good:
func (c *Config) JobName(key string) (value string, ok bool)

这方面的一个推论是,函数和方法名称应该避免使用Get前缀。

// Bad:
func (c *Config) GetJobName(key string) (value string, ok bool)

做某事的函数被赋予类似动词的名称。

// Good:
func (c *Config) WriteDetail(w io.Writer) (int64, error)

只因所涉及的类型而不同的相同的函数在名称的末尾包括类型的名称。

// Good:
func ParseInt(input string) (int, error)
func ParseInt64(input string) (int64, error)
func AppendInt(buf []byte, value int) []byte
func AppendInt64(buf []byte, value int64) []byte

如果有一个明确的 “主要 “版本,该版本的名称中可以省略类型。

// Good:
func (c *Config) Marshal() ([]byte, error)
func (c *Config) MarshalText() (string, error)

遮蔽(shadowing)

注意:本解释使用了两个非正式的术语,重踏(stomping)和遮蔽。它们并不是Go语言规范中的正式概念。

像许多编程语言一样,Go有可变的变量:向一个变量赋值会改变其值。

// Good:
func abs(i int) int {
    if i < 0 {
        i *= -1
    }
    return i
}

当使用带有:=操作符的短变量声明时,在某些情况下不会创建一个新的变量。我们可以把这称为重踏。当不再需要原来的值时,这样做是可以的。

// Good:
// innerHandler is a helper for some request handler, which itself issues
// requests to other backends.
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
    // Unconditionally cap the deadline for this part of request handling.
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()
    ctxlog.Info("Capped deadline in inner request")

    // Code here no longer has access to the original context.
    // This is good style if when first writing this, you anticipate
    // that even as the code grows, no operation legitimately should
    // use the (possibly unbounded) original context that the caller provided.

    // ...
}

不过要小心在新的作用域中使用短的变量声明:这将引入一个新的变量。我们可以把这称为对原始变量的遮蔽。代码块结束后,代码中的变量将指向原来的变量。下面是一个有条件地缩短deadline的错误尝试。

// Bad:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
    // Attempt to conditionally cap the deadline.
    if *shortenDeadlines {
        ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
        defer cancel()
        ctxlog.Info(ctx, "Capped deadline in inner request")
    }

    // BUG: "ctx" here again means the context that the caller provided.
    // The above buggy code compiled because both ctx and cancel
    // were used inside the if statement.

    // ...
}

一个正确版本的代码可能是这样的:

// Good:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
    if *shortenDeadlines {
        var cancel func()
        // Note the use of simple assignment, = and not :=.
        ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
        defer cancel()
        ctxlog.Info(ctx, "Capped deadline in inner request")
    }
    // ...
}

在我们称之为重踏的情况下,因为没有新的变量,所以被分配的类型必须与原始变量的类型相匹配。而遮蔽则是引入一个全新的实体,所以它可以有不同的类型。有意的遮蔽可以是一种有用的做法,但如果从提高代码清晰度角度考虑,你总是可以使用一个新的名字。

除了非常小的范围之外,使用与标准包同名的变量并不是一个好主意,因为这使得该包的函数和值无法被访问。反过来说,在为你的包挑选名字时,要避免使用那些可能需要重命名导入包的名字,或者在客户端造成对其他好的变量名字的遮蔽。

// Bad:
func LongFunction() {
    url := "https://example.com/"
    // Oops, now we can't use net/url in code below.
}

Util包

Go包在包声明中指定了一个名称,与导入路径分开。包的名称比路径更重要,因为它的可读性。

Go包的名字应该与包所提供的内容相关。仅仅将一个包命名为util、helper、common或类似的名字通常是一个糟糕的选择(不过可以作为名字的一部分)。没有信息的名字会使代码更难阅读,而且如果使用的范围太广,很可能会造成不必要的导入冲突。

相反,要考虑到调用时会是什么样子。

// Good:
db := spannertest.NewDatabaseFromFile(...)

_, err := f.Seek(0, io.SeekStart)

b := elliptic.Marshal(curve, x, y)

即使不知道导入列表(cloud.google.com/go/spanner/spannertest、io和crypto/elliptic),你也能大致知道这些包的作用。如果没那么关注命名,这些名字可能是:

// Bad:
db := test.NewDatabaseFromFile(...)

_, err := f.Seek(0, common.SeekStart)

b := helper.Marshal(curve, x, y)

错误处理

在Go中,错误是值;它们由代码产生,也由代码消费。错误可以:

  • 转化为诊断信息,显示给人
  • 由维护者使用
  • 由终端用户解释

处理(产生或消耗)错误的代码应该小心翼翼。忽略或盲目地传播错误的返回值可能是很诱人的。然而,我们总是应该考虑的是,函数调用栈中的当前函数是否是最适合处理该错误的那一个。这是一个很大的话题,很难给出明确的建议。使用你的判断,但要记住以下的考量。

  • 当创建一个错误值时,决定是否给它任何结构。
  • 当处理一个错误时,考虑添加你所拥有的、但调用者和/或被调用者可能没有的信息。
  • 也请看关于错误记录的指导。

错误结构

如果调用者需要查询错误(例如,区分不同的错误条件),那么给出错误值结构,这样就可以通过编程完成查询,而不是让调用者进行字符串匹配。这个建议适用于生产代码,也适用于关心不同错误条件的测试代码。

最简单的结构化的错误是无参数的全局值。

type Animal string

var (
    // ErrDuplicate occurs if this animal has already been seen.
    ErrDuplicate = errors.New("duplicate")

    // ErrMarsupial occurs because we're allergic to marsupials outside Australia.
    // Sorry.
    ErrMarsupial = errors.New("marsupials are not supported")
)

func pet(animal Animal) error {
    switch {
    case seen[animal]:
        return ErrDuplicate
    case marsupial(animal):
        return ErrMarsupial
    }
    seen[animal] = true
    // ...
    return nil
}

调用者可以简单地将函数返回的错误值与已知的错误值之一进行比较。

// Good:
func handlePet(...) {
    switch err := process(an); err {
    case ErrDuplicate:
        return fmt.Errorf("feed %q: %v", an, err)
    case ErrMarsupial:
        // Try to recover with a friend instead.
        alternate = an.BackupAnimal()
        return handlePet(..., alternate, ...)
    }
}

上面使用了哨兵值,错误必须等于(在==的意义上)预期值。这在许多情况下是完全足够的。如果process函数返回包装后的错误值(在下面讨论),你可以使用errors.Is。

// Good:
func handlePet(...) {
    switch err := process(an); {
    case errors.Is(err, ErrDuplicate):
        return fmt.Errorf("feed %q: %v", an, err)
    case errors.Is(err, ErrMarsupial):
        // ...
    }
}

不要试图根据字符串的形式来区分错误。(参见Go Tip #13:为检查而设计错误)。

// Bad:
func handlePet(...) {
    err := process(an)
    if regexp.MatchString(`duplicate`, err.Error()) {...}
    if regexp.MatchString(`marsupial`, err.Error()) {...}
}

如果在错误中存在调用者需要的额外信息,最好是以结构化方式呈现。例如,os.PathError类型将失败操作的路径名放在调用者可以轻松访问的结构体字段中。

其他的错误结构可以酌情使用,例如一个包含错误代码和细节字符串的项目结构体。status包是一种常见的封装方式;如果你选择这种方式(你没有义务这样做),请使用codes。参见Go Tip #89。何时使用规范的状态代码作为错误,以了解使用状态代码是否是正确的选择。

变量声明

初始化

为了保持一致性,在用非零值初始化一个新的变量时,首选:=而不是var。

// Good:
i := 42
// Bad:
var i = 42

非指针零值

下面的声明使用了零值:

// Good:
var (
    coords Point
    magic  [4]byte
    primes []int
)

当你想表达一个空值,准备以后使用时,你应该使用零值来声明。使用显式初始化的复合字面值可能会很笨重。

// Bad:
var (
    coords = Point{X: 0, Y: 0}
    magic  = [4]byte{0, 0, 0, 0}
    primes = []int(nil)
)

零值声明的一个常见应用是当使用一个变量作为反序列化的输出时:

// Good:
var coords Point
if err := json.Unmarshal(data, &coords); err != nil {

如果你需要一个锁或其他不能复制的结构体字段时,你可以把它变成一个值类类型,以利用零值初始化的优势。这确实意味着,现在必须通过指针而不是值来传递包含的类型。该类型的方法必须采取指针类型接收器。

// Good:
type Counter struct {
    // This field does not have to be "*sync.Mutex". However,
    // users must now pass *Counter objects between themselves, not Counter.
    mu   sync.Mutex
    data map[string]int64
}

// Note this must be a pointer receiver to prevent copying.
func (c *Counter) IncrementBy(name string, n int64)

对复合类型(如结构体和数组)的局部变量使用值类型是可以接受的,即使它们包含这种不可复制的字段。然而,如果复合类型是由函数返回的,或者如果对它的所有访问最终都需要取一个地址,那么最好一开始就把变量声明为指针类型。同样地,protobufs也应该被声明为指针类型。

// Good:
func NewCounter(name string) *Counter {
    c := new(Counter) // "&Counter{}" is also fine.
    registerCounter(name, c)
    return c
}

var myMsg = new(pb.Bar) // or "&pb.Bar{}".

这是因为*pb.Something实现了proto.Message,而pb.Something没有:

// Bad:
func NewCounter(name string) *Counter {
    var c Counter
    registerCounter(name, &c)
    return &c
}

var myMsg = pb.Bar{}

重要:map类型在修改之前必须被显式初始化。但是针对零值map变量进行读操作是可以的。

对于map和slice类型,如果代码对性能特别敏感,并且你事先知道尺寸,请看尺寸提示部分。

尺寸提示

以下是利用尺寸提示的声明,以便预先分配容量。

// Good:
var (
    // Preferred buffer size for target filesystem: st_blksize.
    buf = make([]byte, 131072)
    // Typically process up to 8-10 elements per run (16 is a safe assumption).
    q = make([]Node, 0, 16)
    // Each shard processes shardSize (typically 32000+) elements.
    seen = make(map[string]bool, shardSize)
)

channel方向

尽可能指明channel方向。

// Good:
// sum computes the sum of all of the values. It reads from the channel until
// the channel is closed.
func sum(values <-chan int) int {
    // ...
}

这可以防止在没有规范的情况下可能出现的随意编程错误。

// Bad:
func sum(values chan int) (out int) {
    for v := range values {
        out += v
    }
    // values must already be closed for this code to be reachable, which means
    // a second close triggers a panic.
    close(values)
}

当方向被指定时,编译器会捕捉到像这样的简单错误。它还有助于向类型传达一种所有权的措施。

函数参数列表

不要让一个函数的签名变得太长。当一个函数中的参数越多,单个参数的作用就越不明确,同一类型的相邻参数就越容易混淆。有大量参数的函数不容易被记住,在调用的时候也更难读懂。

在设计API时,可以考虑将一个签名越来越复杂的高可配函数分割成几个更简单的函数。如果有必要,这些函数可以共享一个(未导出的)实现。

当一个函数需要许多输入时,可以考虑为一些参数引入一个功能选项结构,或者采用更高级的variadic选项技术。选择哪种策略的主要考虑因素应该是函数调用在所有预期的使用情况下看起来如何。

下面的建议主要适用于导出的API,它的标准比未导出的API要高。这些技术对于你的用例可能是不必要的。使用你的判断,并平衡清晰性和最小机制的原则。

功能选项结构

功能选项结构是一种结构体类型,它汇集了一个函数或方法的部分或全部参数,然后作为最后一个参数传递给该函数或方法。(只有在导出的函数中使用该结构时,才应该导出该结构)。

使用选项结构有很多好处:

  • 结构体字面值包括每个参数的字段和值,这使得它们可以自我记录,并且更难被交换。
  • 不相关的或”默认”的字段可以被省略。
  • 调用者可以共享选项结构,并编写帮助程序对其进行操作。
  • 与函数参数相比,结构体提供了更清晰的每个字段的文档。
  • 选项结构可以随着时间的推移而增长,而不会影响到存量的函数调用。

下面是一个可以改进的函数的例子。

// Good:
type ReplicationOptions struct {
    Config              *replicator.Config
    PrimaryRegions      []string
    ReadonlyRegions     []string
    ReplicateExisting   bool
    OverwritePolicies   bool
    ReplicationInterval time.Duration
    CopyWorkers         int
    HealthWatcher       health.Watcher
}

func EnableReplication(ctx context.Context, opts ReplicationOptions) {
    // ...
}

然后,该函数可以在不同的包中被调用:

// Good:
func foo(ctx context.Context) {
    // Complex call:
    storage.EnableReplication(ctx, storage.ReplicationOptions{
        Config:              config,
        PrimaryRegions:      []string{"us-east1", "us-central2", "us-west3"},
        ReadonlyRegions:     []string{"us-east5", "us-central6"},
        OverwritePolicies:   true,
        ReplicationInterval: 1 * time.Hour,
        CopyWorkers:         100,
        HealthWatcher:       watcher,
    })

    // Simple call:
    storage.EnableReplication(ctx, storage.ReplicationOptions{
        Config:         config,
        PrimaryRegions: []string{"us-east1", "us-central2", "us-west3"},
    })
}

注意:选项结构中从不包含上下文。

当以下一些情况适用时,这个选项通常是首选。

  • 所有调用者都需要指定一个或多个选项。
  • 大量的调用者需要提供许多选项。
  • 用户将调用的多个函数之间共享这些选项。

不定长选项

使用不定长选项,可以创建导出的函数,其返回的闭包可以传递给函数的不定长选项参数。该函数将选项的值作为其参数(如果有的话),而返回的闭包接受一个可变的引用(通常是一个指向结构体类型的指针),该引用将根据输入进行更新。

  • 使用不定长选项可以提供很多好处:
  • 当不需要配置时,在调用函数时选项将不占用空间
  • 选项仍然是值,所以调用者可以共享它们,编写帮助程序,并积累它们。
  • 选项可以接受多个参数(例如 cartesian.Translate(dx, dy int) TransformOption)。
  • 选项函数可以返回一个命名的类型,以便在godoc中把选项组合起来。
  • 包可以允许(或阻止)第三方包,定义(或不定义)他们自己的选项。

注意:使用不定长选项需要大量的额外代码(见下面的例子),所以只有在优势大于开销的情况下才可以使用。

下面是一个可以改进的函数的例子:

// Good:
type replicationOptions struct {
    readonlyCells       []string
    replicateExisting   bool
    overwritePolicies   bool
    replicationInterval time.Duration
    copyWorkers         int
    healthWatcher       health.Watcher
}

// A ReplicationOption configures EnableReplication.
type ReplicationOption func(*replicationOptions)

// ReadonlyCells adds additional cells that should additionally
// contain read-only replicas of the data.
//
// Passing this option multiple times will add additional
// read-only cells.
//
// Default: none
func ReadonlyCells(cells ...string) ReplicationOption {
    return func(opts *replicationOptions) {
        opts.readonlyCells = append(opts.readonlyCells, cells...)
    }
}

// ReplicateExisting controls whether files that already exist in the
// primary cells will be replicated.  Otherwise, only newly-added
// files will be candidates for replication.
//
// Passing this option again will overwrite earlier values.
//
// Default: false
func ReplicateExisting(enabled bool) ReplicationOption {
    return func(opts *replicationOptions) {
        opts.replicateExisting = enabled
    }
}

// ... other options ...

// DefaultReplicationOptions control the default values before
// applying options passed to EnableReplication.
var DefaultReplicationOptions = []ReplicationOption{
    OverwritePolicies(true),
    ReplicationInterval(12 * time.Hour),
    CopyWorkers(10),
}

func EnableReplication(ctx context.Context, config *placer.Config, primaryCells []string, opts ...ReplicationOption) {
    var options replicationOptions
    for _, opt := range DefaultReplicationOptions {
        opt(&options)
    }
    for _, opt := range opts {
        opt(&options)
    }
}

函数可以在不同的包中调用:

// Good:
func foo(ctx context.Context) {
    // Complex call:
    storage.EnableReplication(ctx, config, []string{"po", "is", "ea"},
        storage.ReadonlyCells("ix", "gg"),
        storage.OverwritePolicies(true),
        storage.ReplicationInterval(1*time.Hour),
        storage.CopyWorkers(100),
        storage.HealthWatcher(watcher),
    )

    // Simple call:
    storage.EnableReplication(ctx, config, []string{"po", "is", "ea"})
}

当以下许多情况适用时,最好选择不定长选项:

  • 大多数调用者不需要指定任何选项。
  • 大多数选项不经常使用。
  • 有大量的选项。
  • 选项需要参数。
  • 选项可能会失败或被错误地设置(在这种情况下,选项函数会返回一个error)。
  • 选项需要大量的文档,在一个结构中很难容纳。
  • 用户或其他软件包可以提供自定义选项。

这种风格的选项应该接受参数,而不是用存在来表示它们的价值;后者会使参数的动态组成变得更加困难。例如,二进制设置应该接受一个布尔值(例如,rpc.FailFast(enable bool)比rpc.EnableFailFast()更合适。) 枚举的选项应该接受一个枚举的常量(例如,log.Format(log.Capacitor)比log.CapacitorFormat()更合适)。另一种方法使那些必须以编程方式选择传递哪些选项的用户更加困难;这种用户被迫改变参数的实际组成,而不是简单地改变选项的参数。不要假设所有的用户都会静态地知道全部的选项。

一般来说,选项应该被按顺序处理。如果有冲突或者一个非累积的选项被多次传递,以最后一个参数为准。

在这种模式下,选项函数的参数通常是非导出的,以限制选项只在包本身内定义。这是一个很好的默认值,尽管有时允许其他包定义选项也是合适的。

posted @   每天提醒自己要学习  阅读(62)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
点击右上角即可分享
微信分享提示