4 变量声明形式尽量保持一致
和 Python、Ruby 等动态脚本语言不同,Go 语言沿袭了静态语言的传统:使用变量之前需要先进行变量的声明。
这里大致列一下 Go 语言常见的变量声明形式:
var a int32
var s string = "hello"
var i = 13
n := 17
var (
crlf = []byte("\r\n")
colonSpace = []byte(": ")
)
如果让 Go 语言的设计者重新来设计一次变量声明语法,我相信他们很大可能不会给予 Gopher 们这么大的变量声明灵活性,但目前这一切都无法改变。对于以面向工程著称且以解决“规模化(scale)”问题为目标的 Go 语言而言,Gopher 们在进行变量声明形式的选择上应该尽量保持项目范围内是一致的。 Go 语言有两类变量:
- 包级变量(package varible):即在 package 级别可见的变量。如果是导出变量,则该包级变量也可以被视为全局变量;
- 局部变量(local varible):函数或方法体内声明的变量,仅在函数或方法体内可见。
下面我们就来分别说明一下这两类变量在声明形式选择上保持一致性的一些最佳实践。
包级变量只能使用带有 var 关键字的变量声明形式,但在形式细节上仍有一定灵活度。我们从变量声明的时候是否延迟初始化这个角度对包级变量进行一次分类。
下面是摘自 Go 标准库中的代码(Go 版本 1.12):
// $GOROOT/src/io/pipe.go
var ErrClosedPipe = errors.New("io: read/write on closed pipe")
// $GOROOT/src/io/io.go
var ErrShortWrite = errors.New("short write")
var ErrShortBuffer = errors.New("short buffer")
var EOF = errors.New("EOF")
var ErrUnexpectedEOF = errors.New("unexpected EOF")
var ErrNoProgress = errors.New("multiple Read calls return no data or error")
我们看到,对于变量声明的同时进行显式初始化的这类包级变量,实践中多使用下面格式:
var variableName = InitExpression
Go 编译器会自动根据等号右侧 InitExpression 结果值的类型确定左侧声明的变量的类型。对于下面这些声明语句,Go 会为包级变量设置默认类型:
var a = 17
var f = 3.14
对于未显式赋予类型的整型变量 a,Go 编译器会将之设置为默认类型 int;而浮点型变量 f 的默认类型则为 float64。如果我们不接受默认类型,而是要显式为 a 和 f 指定类型,那么我们有两种方式:
var a int32 = 17
var f float32 = 3.14
//vs.
var a = int32(17)
var f = float32(3.14)
从声明一致性的角度出发,Go 更推荐我们使用后者,这样就统一了接受默认类型和显式指定类型两种声明形式,尤其是在将这些变量放在一个 var 块中声明时,我们更青睐下面这样的形式:
var (
a = 17
f = float32(3.14)
)
而不是下面这种看起来不一致的声明形式:
var (
a = 17
f float32 = 3.14
)
对于声明时并不立即显式初始化的包级变量,我们使用最基本的声明形式:
var a int32
var f float64
我们知道,虽然没有显式初始化,Go 语言也会让这些变量拥有初始的“零值”。如果是自定义的类型,保证其零值可用是非常必要的,这个我们在后续章节会详细说明。
Go 语言提供 var 块用于将多于一个的变量声明放在一起,并且在语法上不会限制放置在 var 块中的声明类型。但是我们一般将同一类的变量声明放在一个 var 块中,不同类的声明放在不同的 var 块中;或者将延迟初始化的变量声明放在一个 var 块,而将声明且显式初始化的变量放在另一个 var 块中。这里我称之为“声明聚类”。比如下面 Go 标准库中的代码:
// $GOROOT/src/net/http/server.go
var (
bufioReaderPool sync.Pool
bufioWriter2kPool sync.Pool
bufioWriter4kPool sync.Pool
)
var copyBufPool = sync.Pool{
New: func() interface{} {
b := make([]byte, 32*1024)
return &b
},
}
... ...
// $GOROOT/src/net/net.go
var (
// aLongTimeAgo is a non-zero time, far in the past, used for
// immediate cancelation of dials.
aLongTimeAgo = time.Unix(1, 0)
// nonDeadline and noCancel are just zero values for
// readability with functions taking too many parameters.
noDeadline = time.Time{}
noCancel = (chan struct{})(nil)
)
var threadLimit chan struct{}
... ...
我们看到在 server.go 中,copyBufPool 变量没有放入 var 块就是因为它的声明是带有显式初始化的,而 var 块中的变量声明都是延迟初始化的;net.go 中的 threadLimit 单独放在 var 块外面,一方面是考虑它是延迟初始化的变量声明,另一方面 threadLimit 在含义上与 var 块中标识时间限制的变量也有所不同。
那么大家可能还有一个问题,那就是是否将包级变量的声明全部集中放在源文件头部呢?使用静态编程语言的开发人员都知道,变量声明最佳实践中还有一条:就近原则。即尽可能在靠近第一次使用变量的位置声明该变量。就近原则实际上也是变量的作用域最小化的一种实现手段。在 Go 标准库中我们很容易找到符合就近原则的变量声明的例子:
// $GOROOT/src/net/http/request.go
// ErrNoCookie is returned by Request's Cookie method when a cookie is not found.
var ErrNoCookie = errors.New("http: named cookie not present")
// Cookie returns the named cookie provided in the request or
// ErrNoCookie if not found.
// If multiple cookies match the given name, only one cookie will
// be returned.
func (r *Request) Cookie(name string) (*Cookie, error) {
for _, c := range readCookies(r.Header, name) {
return c, nil
}
return nil, ErrNoCookie
}
当然如果一个包级变量在包内部被多处使用,那么这个变量还是放在源文件头部声明比较适合。
有了包级变量做铺垫,我们再来讲解局部变量就容易很多了。和包级变量相比,局部变量又多了一种短变量声明形式,这也是局部变量采用最多的一种声明形式,下面我们来详细看看。
- 对于延迟初始化的局部变量声明,采用带有 var 关键字的声明形式。
如下面代码中的变量 buf:
// $GOROOT/src/strings/replace.go
func (r *byteReplacer) Replace(s string) string {
var buf []byte // lazily allocated
for i := 0; i < len(s); i++ {
b := s[i]
if r[b] != b {
if buf == nil {
buf = []byte(s)
}
buf[i] = r[b]
}
}
if buf == nil {
return s
}
return string(buf)
}
另外最常见的采用 var 关键字的声明的变量之一就是 err:
var err error
- 对于声明且显式初始化的局部变量,建议使用短变量声明形式。
短变量声明形式是局部变量最常用的声明形式,它遍布在 Go 标准库代码中。 对于接受默认类型的变量,我们使用下面形式:
a := 17
f := 3.14
s := "hello, gopher!"
对于不接受默认类型的变量,我们依然可以使用短变量声明形式,只是在":="右侧要做一个显式转型:
a := int32(17)
f := float32(3.14)
s := []byte("hello, gopher!")
- 尽量在分支控制时应用短变量声明形式。
这应该是 Go 中短变量声明形式应用最广泛的场景了。在编写 Go 代码时,我们很少单独声明用于分支控制语句中的变量,而是将其与 if、for 等通过短变量声明形式融合在一起,就像下面这样:
// $GOROOT/src/strings/strings.go
func LastIndexAny(s, chars string) int {
if chars == "" {
// Avoid scanning all of s.
return -1
}
if len(s) > 8 {
// 作者注:在if条件控制语句中使用短变量声明形式
if as, isASCII := makeASCIISet(chars); isASCII {
for i := len(s) - 1; i >= 0; i-- {
if as.contains(s[i]) {
return i
}
}
return -1
}
}
for i := len(s); i > 0; {
// 作者注:在for循环控制语句中使用短变量声明形式
r, size := utf8.DecodeLastRuneInString(s[:i])
i -= size
for _, c := range chars {
if r == c {
return i
}
}
}
return -1
}
// $GOROOT/src/net/net.go
func (v *Buffers) WriteTo(w io.Writer) (n int64, err error) {
// 作者注:在for循环控制语句中使用短变量声明形式
if wv, ok := w.(buffersWriter); ok {
return wv.writeBuffers(v)
}
// 作者注:在if条件控制语句中使用短变量声明形式
for _, b := range *v {
nb, err := w.Write(b)
n += int64(nb)
if err != nil {
v.consume(n)
return n, err
}
}
v.consume(n)
return n, nil
}
这样的应用方式同时也体现出“就近”原则,也让变量的作用域最小化了。
由于良好的函数/方法设计讲究的是“单一职责”,因此每个函数/方法规模都不大,很少需要应用 var 块来聚类声明局部变量,当然如果你在声明局部变量时遇到适合聚类的应用场景,你也应该毫不犹豫地使用 var 块来声明多于一个的局部变量。比如:
// $GOROOT/src/net/dial.go
func (r *Resolver) resolveAddrList(ctx context.Context, op, network,
addr string, hint Addr) (addrList, error) {
... ...
var (
tcp *TCPAddr
udp *UDPAddr
ip *IPAddr
wildcard bool
)
... ...
}
或是:
// $GOROOT/src/reflect/type.go
// 这是一个非常长的函数,因此将所有var声明都聚合在函数的开始处了
func StructOf(fields []StructField) Type {
var (
hash = fnv1(0, []byte("struct {")...)
size uintptr
typalign uint8
comparable = true
hashable = true
methods []method
fs = make([]structField, len(fields))
repr = make([]byte, 0, 64)
fset = map[string]struct{}{} // fields' names
hasPtr = false // records whether at least one struct-field is a pointer
hasGCProg = false // records whether a struct-field type has a GCProg
)
... ...
}
使用一致的变量声明是 Go 语言的一个最佳实践,我们用一幅图将变量声明形式做个形象化的小结:
从图中我们看到要想做好代码中变量声明的一致性,我们需要明确要声明的变量是包级变量还是局部变量、是否要延迟初始化、是否接受默认类型、是否是分支控制变量并结合聚类和就近原则。