Go语言精进之路:从新手到高手的编程思想、方法和技巧(1)
上QQ阅读APP看书,第一时间看更新

7.1 简单且一致

对于简单,我们最直观的理解就是“短小”,但这里的简单还包含着清晰明确这一前提。短小意味着能用一个单词命名的,就不要使用单词组合;能用单个字母(在特定上下文中)表达标识符用途的,就不用完整单词。甚至在某种情况下,Go命名惯例选择了简洁命名+注释辅助解释的方式,而不是一个长长的名字。

下面是Go语言中一些常见类别标识符的命名惯例。

1. 包

对于Go中的包(package),一般建议以小写形式的单个单词命名。Go标准库在这方面给我们做出了很好的示范,如图7-1所示。

064-1

图7-1 Go标准库包列表(部分)

我们在给包命名时不要有是否与其他包重名的顾虑,因为在Go中,包名可以不唯一。比如:foo项目有名为log的包,bar项目也可以有自己的名为log的包。每个包的导入路径是唯一的,对于包名冲突的情况,可以在导入包时使用一个显式包名来指代导入的包,并且在这个源文件中使用这个显式包名来引用包中的元素,示例如下。

import "github.com/bigwhite/foo/log"             // log.XX中的log指代github.com/ bigwhite/foo/log下的包
import barlog "github.com/bigwhite/bar/log"      // barlog这个显式包名指代github.com/ bigwhite/bar/log下的包

Go语言建议,包名应尽量与包导入路径(import path)的最后一个路径分段保持一致。比如:包导入路径golang.org/x/text/encoding的最后路径分段是encoding,该路径下包名就应该为encoding。但在实际情况中,包名与导入路径最后分段不同的也有很多。比如:实时分布式消息队列NSQ的官方客户端包的导入路径为github.com/nsqio/go-nsq,但是该路径下面的包名却是nsq。笔者分析这主要是为了用仓库名称强调该实现是针对Go语言的,比如go-nsq的意义是这是一份Go语言实现的NSQ客户端API库,为的是与nsq-java、pynsq、rust-nsq等其他语言的客户端API进行显式区分。这种情况在笔者的gocmpp项目中也存在。gocmpp项目的导入路径是github.com/bigwhite/gocmpp,gocmpp这个仓库名强调的是这是一份CMPP协议(中国移动通信互联短信网关接口协议)的Go实现,但该路径下包的名字却是cmpp。

那如果将NSQ的Go客户端API放入github.com/nsqio/go-nsq/nsq下是否更理想呢?显然在导入路径中出现两次“nsq”字样的这种“口吃”现象也是不被Go官方推荐的。在今天看来,如果能将所有Go实现放入GitHub账号顶层路径下的golang或go路径下应该是更好的方案,比如:github.com/nsqio/go/nsq或github.com/nsqio/golang/nsq,github.com/bigwhite/go/cmpp或github.com/bigwhite/golang/cmpp。

此外,我们在给包命名的时候,不仅要考虑包自身的名字,还要兼顾该包导出的标识符(如变量、常量、类型、函数等)的命名。由于对这些包导出标识符的引用必须以包名为前缀,因此对包导出标识符命名时,在名字中不要再包含包名,比如:

strings.Reader              [good]
strings.StringReader        [bad]
strings.NewReader           [good]
strings.NewStringReader     [bad]

bytes.Buffer                [good]
bytes.ByteBuffer            [bad]
bytes.NewBuffer             [good]
bytes.NewByteBuffer         [bad]

2. 变量、类型、函数和方法

一个Go工程中包的数量是有限的,变量、类型、函数和方法的命名占据了命名工作的较大比重。

在Go中变量分为包级别的变量和局部变量(函数或方法内的变量)。函数或方法的参数、返回值都可以被视为局部变量。

Go语言官方要求标识符命名采用驼峰命名法(CamelCase),以变量名为例,如果变量名由一个以上的词组合构成,那么这些词之间紧密相连,不使用任何连接符(如下划线)。驼峰命名法有两种形式:一种是第一个词的首字母小写,后面每个词的首字母大写,叫作“小骆峰拼写法”(lowerCamelCase),这也是在Go中最常见的标识符命名法;而第一个词的首字母以及后面每个词的首字母都大写,叫作“大驼峰拼写法”(UpperCamelCase),又称“帕斯卡拼写法”(PascalCase)。由于首字母大写的标识符在Go语言中被视作包导出标识符,因此只有在涉及包导出的情况下才会用到大驼峰拼写法。不过如果缩略词的首字母是大写的,那么其他字母也要保持全部大写,比如HTTP(Hypertext Transfer Protocol)、CBC(Cipher Block Chaining)等。

为变量、类型、函数和方法命名时依然要以简单、短小为首要原则。我们对Go标准库(Go 1.12版本)中标识符名称进行统计的结果如下(去除Go关键字和builtin函数):

// 在$GOROOT/src下

$cat $(find . -name '*.go') | indents | sort | uniq -c | sort -nr | sed 30q
105896 v
71894 err
54512 Args
49472 t
44090 _
43881 x
43322 b
36019 i
34432 p
32011 s
28435 AddArg
26185 c
25518 n
25242 e1
23881 r
21681 AuxInt
20700 y
...

我们看到了大量单字母的标识符命名,这是Go在命名上的一个惯例。一般来说,Go标识符仍以单个单词作为命名首选。从Go标准库代码的不完全统计结果来看,不同类别标识符的命名呈现出以下特征:

  • 循环和条件变量多采用单个字母命名(具体见上面的统计数据);
  • 函数/方法的参数和返回值变量以单个单词或单个字母为主;
  • 由于方法在调用时会绑定类型信息,因此方法的命名以单个单词为主;
  • 函数多以多单词的复合词进行命名;
  • 类型多以多单词的复合词进行命名。

除了上述特征,还有一些在命名时常用的惯例。

(1)变量名字中不要带有类型信息

比如以下命名:

userSlice []*User         [bad]
users     []*User         [good]

带有类型信息的命名只是让变量看起来更长,并没有给开发者阅读代码带来任何好处。

不过有些开发者会认为:userSlice中的类型信息可以告诉我们变量所代表的底层存储是一个切片,这样便可以在userSlice上应用切片的各种操作了。提出这样质疑的开发者显然忘记了一条编程语言命名的惯例:保持变量声明与使用之间的距离越近越好,或者在第一次使用变量之前声明该变量。这个惯例与Go核心团队的Andrew Gerrard曾说的“一个名字的声明和使用之间的距离越大,这个名字的长度就越长”异曲同工。如果在一屏之内能看到users的声明,那么-Slice这个类型信息显然不必放在变量的名称中了。

(2)保持简短命名变量含义上的一致性

从上面的统计可以看到,Go语言中有大量单字母、单个词或缩写命名的简短命名变量。有人可能会认为简短命名变量会降低代码的可读性。Go语言建议通过保持一致性来维持可读性。一致意味着代码中相同或相似的命名所传达的含义是相同或相似的,这样便于代码阅读者或维护者猜测出变量的用途。

这里大致分析一下Go标准库中常见短变量名字所代表的含义,这些含义在整个标准库范畴内的一致性保持得很好。

变量v、k、i的常用含义:

// 循环语句中的变量
for i, v := range s { ... }           // i为下标变量; v为元素值
for k, v := range m { ... }           // k为key变量; v为元素值
for v := range r { // channel ... }   // v为元素值

// if、switch/case分支语句中的变量
if v := mimeTypes[ext]; v != "" { }   // v: 元素值
switch v := ptr.Elem(); v.Kind() {
    ...
}

case v := <-c:                        // v: 元素值

// 反射的结果值
v := reflect.ValueOf(x)

变量t的常用含义:

t := time.Now()                            // 时间
t := &Timer{}                              // 定时器
if t := md.typemap[off]; t != nil { }      // 类型

变量b的常用含义:

b := make([]byte, n)                       // byte切片
b := new(bytes.Buffer)                     // byte缓存

3. 常量

在C语言家族中,常量通常用全大写的单词命名,比如下面的C语言和Java定义的常量:

// C语言
#define MAX_VALUE 1000
#define DEFAULT_START_DATA  "2019-07-08"

// Java语言
public static final int MAX_VALUE = 1000;
public static final String DEFAULT_START_DATA = "2019-07-08";

但在Go语言中,常量在命名方式上与变量并无较大差别,并不要求全部大写。只是考虑其含义的准确传递,常量多使用多单词组合的方式命名。下面是标准库中的例子:

// $GOROOT/src/net/http/request.go

const (
    defaultMaxMemory = 32 << 20 // 32 MB
)

const (
    deleteHostHeader = true
    keepHostHeader   = false
)

当然,可以对名称本身就是全大写的特定常量使用全大写的名字,比如数学计算中的PI,或是为了与系统错误码、系统信号名称保持一致而用全大写方式命名:

// $GOROOT/src/math/sin.go
const (
    PI4A = 7.85398125648498535156E-1  // 0x3fe921fb40000000,
    PI4B = 3.77489470793079817668E-8  // 0x3e64442d00000000,
    PI4C = 2.69515142907905952645E-15 // 0x3ce8469898cc5170,
)

// $GOROOT/src/syscall/zerrors_linux_amd64.go

// 错误码
const (
    E2BIG           = Errno(0x7)
    EACCES          = Errno(0xd)
    EADDRINUSE      = Errno(0x62)
    EADDRNOTAVAIL   = Errno(0x63)
    EADV            = Errno(0x44)
    ...
)

// 信号
const (
    SIGABRT   = Signal(0x6)
    SIGALRM   = Signal(0xe)
    SIGBUS    = Signal(0x7)
    SIGCHLD   = Signal(0x11)
    ...
)

在Go中数值型常量无须显式赋予类型,常量会在使用时根据左值类型和其他运算操作数的类型进行自动转换,因此常量的名字也不要包含类型信息。

4. 接口

Go语言中的接口是Go在编程语言层面的一个创新,它为Go代码提供了强大的解耦合能力,因此良好的接口类型设计和接口组合是Go程序设计的静态骨架和基础。良好的接口设计自然离不开良好的接口命名。在Go语言中,对于接口类型优先以单个单词命名。对于拥有唯一方法(method)或通过多个拥有唯一方法的接口组合而成的接口,Go语言的惯例是用“方法名+er”命名。比如:

// $GOROOT/src/io/io.go

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Closer interface {
    Close() error
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

Go语言推荐尽量定义小接口,并通过接口组合的方式构建程序,后文会详细讲述。