从计算编程语言诞生那天起,给标识符命名这件事就一直伴随着程序员。命名这件事看似简单,但在如今大规模软件工程需要程序员个体间紧密协作的背景下,就像上面菲尔·卡尔顿所阐述的那样,做出好的命名并非易事。
命名是编程语言的要求,但是好的命名的目的却是为了提高程序的可读性和可维护性。好的命名是什么样子呢?Go 语言的贡献者和布道师 Dave Cheney 给出了一个隐喻:“好的命名就像一个好笑话。如果你必须解释它,那就不好笑了”。无论哪门编程语言,良好的命名应该遵循一些通用的原则,但就像之前在第一章节中提到的“语言影响思维”的假说那样,不同编程语言在命名上还会有一些个性化的命名惯例。
在 gofmt 的帮助下,Go 语言统一了代码风格标准,Gopher 们再也无需为括号摆放位置、使用制表符还是空格、是否对齐赋值操作等而争论了。在这样的情况下,命名成了广大 gopher 们为数不多可以“自由发挥”的空间了。不过关于命名,Go 语言依然有着自己的期望大家共同遵循的原则。
Go 的设计哲学之一就是追求简单,因此在命名上一样秉承着简单的总体原则。但简单并不意味着一味地为标识符选择短小的名字,而是要选择那种可以在标识符所在上下文中保持其用途清晰明确的名字。Go 自身以及标准库的实现是 Go 命名惯例形成的最初源头,因此如果要寻找良好命名的示范,Go 标准库是一个不错的地方。本节中我们的示例也多来自于 Go 标准库代码,一些结论也是来自于对标准库代码的分析。
要想做好 Go 标识符命名(包括 package 命名),至少要遵循两个原则:
- 简单且一致
- 利用上下文辅助命名
下面我们将详细阐述一下这两个原则以及原则下的一些命名惯例。
对于简单,我们最直观地理解就是“短小”,但这里的简单还包含着清晰明确的前提。短小意味着能用一个单词命名的,就不要使用单词组合;能用单个字母(在特定上下文)表达标识符用途的,就不用完整单词。 甚至在某种情况下,Go 命名惯例选择了简洁命名+注释辅助解释的方式,而不是一个长长的名字。
下面是 Go 语言一些常见类别标识符命名惯例。
Go 中的包(package)一般建议以小写形式的单个单词命名,Go 标准库在这方面给我们做出了很好的示范:
我们在给包命名时不要有是否与其他包重名的顾虑,因为在 Go 中,包名是可以不唯一的,比如 foo 项目有名为 log 的包,bar 项目也可以有自己的名为 log 的包。但是每个包的导入路径是唯一的,对于包名冲突的情况,可以通过包别名(package alias)语法来解决:
import "github.com/bigwhite/foo/log"
import barlog "github.com/bigwhite/bar/log" // package import alias
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.NewByteBuffer [bad]
一个 Go 工程中包的数量还是有限的,变量、类型、函数和方法的命名占据了命名工作的较大比重。
在 Go 中变量分为包级别的变量和局部变量(函数或方法内的变量)。函数或方法的参数、返回值一定程度上都可以视为局部变量。
Go 语言官方要求标识符命名采用驼峰命名法(CamelCase),以变量名为例,如果变量名由一个以上的词组合构成,那么这些词之间紧密相连,不使用任何连接符(比如:下划线)。驼峰命名法有两种形式,一种是第一个词的首字母小写,后面每个词的首字母大写,叫做“小骆峰拼写法”(lowerCamelCase),这也是在 Go 中最常见的标识符命名法;而第一个词的首字母以及后面每个词的首字母都大写,叫做“大驼峰拼写法”(UpperCamelCase),又称“帕斯卡拼写法”(PascalCase)。由于首母大写的标识符在 Go 语言中被视作包导出标识符,因此只有在涉及包导出的情况下,才会用到大驼峰拼写法。不过首字母缩略词要保持全部大写,比如 HTTP(Hypertext Transfer Protocol)、CBC(Cipher Block Chaining) 等。
给变量、类型、函数和方法命名依然要以简单短小为首要考虑的原则,我们对 Go 标准库(Go 1.12 版本)中标识符名称进行统计1的结果如下(去除 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 标准库代码的不完全统计结果来看,不同类别标识符的命名呈现出下面一些特征:
- 循环和条件变量多采用单个字母命名(具体见上面的统计数据);
- 函数/方法的参数和返回值变量一般以单个单词或单个字母为主2;
- 方法名由于在调用时会绑定类型信息,因此命名多以单个单词为主3;
- 函数名则多以多单词的复合词进行命名4;
- 类型名也多以多单词的复合词进行命名5。
除了上述特征,还有一些惯例是在命名时常用的。
- 变量名字中不要带有类型信息
比如:
userSlice []*User [bad]
users []*User [good]
带有类型信息的命名除了让变量看起来更长之外,没有给开发者阅读代码带来任何其他好处。
不过有些开发者依然会质疑:userSlice 中的类型信息可以告诉我们变量所代表的底层存储是一个切片,这样便可以在 userSlice 上应用切片的各种操作了。提出这样质疑的开发者显然忘记了一条编程语言命名的通用惯例:保持变量声明与使用之间的距离越近越好或者说将变量声明在第一次使用该变量之前。 这个惯例与 Go 核心团队的安德鲁·杰拉德曾提到的一种说法:“一个名字的声明和使用之间的距离越大,这个名字的长度就越长”异曲同工。如果在一屏之内能看到 users 的声明,那-Slice 这个类型信息显然是不必放在变量的名称中了。
- 保持简短命名变量含义上的一致性
从上面的统计我们看到,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缓存
在 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, Pi/4 split into three parts
PI4B = 3.77489470793079817668E-8 // 0x3e64442d00000000,
PI4C = 2.69515142907905952645E-15 // 0x3ce8469898cc5170,
)
// $GOROOT/src/syscall/zerrors_linux_amd64.go
// Errors
const (
E2BIG = Errno(0x7)
EACCES = Errno(0xd)
EADDRINUSE = Errno(0x62)
EADDRNOTAVAIL = Errno(0x63)
EADV = Errno(0x44)
EAFNOSUPPORT = Errno(0x61)
EAGAIN = Errno(0xb)
EALREADY = Errno(0x72)
EBADE = Errno(0x34)
EBADF = Errno(0x9)
EBADFD = Errno(0x4d)
EBADMSG = Errno(0x4a)
EBADR = Errno(0x35)
EBADRQC = Errno(0x38)
... ...
)
// Signals
const (
SIGABRT = Signal(0x6)
SIGALRM = Signal(0xe)
SIGBUS = Signal(0x7)
SIGCHLD = Signal(0x11)
SIGCLD = Signal(0x11)
SIGCONT = Signal(0x12)
SIGFPE = Signal(0x8)
SIGHUP = Signal(0x1)
SIGILL = Signal(0x4)
SIGINT = Signal(0x2)
SIGIO = Signal(0x1d)
... ...
)
在 Go 中数值型常量无需显式赋予类型,常量会在使用时根据左值类型和其他运算操作数的类型做自动的转换,因此常量的名字也不要包含类型信息。
Go 语言中的 interface 是 Go 在编程语言层面上的一个创新,它为 Go 代码提供了强大的“解耦合”能力,因此良好的接口类型设计和接口组合是 Go 程序设计的静态骨架和基础。良好的接口设计自然也离不开良好的接口命名。在 Go 语言中 interface 名字仍然以单个词为优先。对于拥有唯一方法(method)或通过多个拥有唯一方法的接口组合而成的接口,Go 语言的惯例是一般用"方法名+er"的方式为 interface 命名。比如:
// $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 语言推荐尽量定义小接口,并通过接口组合的方式构建程序,在后面的接口章节我们会详细描述。
Go 在给标识符命名时还有着考虑上下文环境的惯例,即在不影响可读性前提下,结合一致性的原则,尽可能地用长度短小的名字命名标识符。这与其他一些主流语言在命名上的建议有不同,比如 Java 建议遵循“见名知义”的命名原则。我们可以对比一下 Java 和 Go 在循环变量起名上的差异:
java vs. go
"index" vs. "i"
"value" vs. "v"
我们在 Go 代码来中分别运用这两个命名方案(index、value)和 (i、 v),并做比对:
for index := 0; index < len(s); index++ {
value := s[index]
... ...
}
//vs.
for i := 0; i < len(s); i++ {
v := s[i]
... ...
}
我们看到:至少在 forLoop 这个上下文中,index、value 并没有比 i、v 携带更多额外信息。
这里我引用一下安德鲁·杰拉德在 2014 年的一次关于 Go 命名演讲中的代码,我们再来对比感受一下 Go 命名惯例所带来的效果:
//[bad]
func RuneCount(buffer []byte) int {
runeCount := 0
for index := 0; index < len(buffer); {
if buffer[index] < RuneSelf {
index++
} else {
_, size := DecodeRune(buffer[index:])
index += size
}
runeCount++
}
return runeCount
}
//[good]
func RuneCount(b []byte) int {
count := 0
for i := 0; i < len(b); {
if b[i] < RuneSelf {
i++
} else {
_, n := DecodeRune(b[i:])
i += n
}
count++
}
return count
}
我们看到 Go 语言命名惯例深受 C 语言的影响,这和 Go 语言之父有着深厚的 C 语言背景不无关系。Go 语言追求简单一致且利用上下文辅助名字信息传达的命名惯例可能让一些从其他语言转向 Go 的程序员初期有些不适应。但这就是 Go 语言文化的一部分,也许随着编写 Go 代码量的增多,你就能理解这种命名惯例的好处了。