本文由Austin发表
指导原则
我们要谈论在一个编程语言中的最佳实践,那么我们首先应该明确什么是“最佳”。如果您们听了我昨天那场讲演的话,您一定看到了来自 Go 团队的 Russ Cox 讲的一句话:
软件工程,是您在编程过程中增加了工期或者开发人员之后发生的那些事。 — Russ Cox
Russ 是在阐述软件“编程”和软件“工程”之间的区别,前者是您写的程序,而后者是一个让更多的人长期使用的产品。软件工程师会来来去去地更换,团队也会成长或者萎缩,需求也会发生变化,新的特性也会增加,bug 也会被修复,这就是软件“工程”的本质。
我可能是现场最早的 Go 语言用户,但与其说我的主张来自我的资历,不如说我今天讲的是真实来自于 Go 语言本身的指导原则,那就是:
- 简单性
- 可读性
- 生产率
您可能已经注意到,我并没有提 性能 或者 并发性 。实际上有不少的语言执行效率比 Go 还要高,但它们一定没有 Go 这么简单。有些语言也以 并发性 为最高目标,但它们的可读性和生产率都不好。 性能 和 并发性 都很重要,但它们不如 简单性 、 可读性 和 生产率 那么重要。
简单性
为什么我们要力求简单,为什么简单对 Go 语言编程如此重要?
我们有太多的时候感叹“这段代码我看不懂”,是吧?我们害怕修改一丁点代码,生怕这一点修改就导致其他您不懂的部分出问题,而您又没办法修复它。
这就是复杂性。复杂性把可读的程序变得不可读,复杂性终结了很多软件项目。
简单性是 Go 的最高目标。无论我们写什么程序,我们都应该能一致认为它应当简单。
可读性
Readability is essential for maintainability. — Mark Reinhold, JVM language summit 2018 可读性对于可维护性至关重要。
为什么 Go 代码的可读性如此重要?为什么我们应该力求可读性?
Programs must be written for people to read, and only incidentally for machines to execute. — Hal Abelson and Gerald Sussman, Structure and Interpretation of Computer Programs 程序应该是写来被人阅读的,而只是顺带可以被机器执行。
可阅读性对所有的程序——不仅仅是 Go 程序,都是如此之重要,是因为程序是人写的并且给其他人阅读的,事实上被机器所执行只是其次。
代码被阅读的次数,远远大于被编写的次数。一段小的代码,在它的整个生命周期,可能被阅读成百上千次。
The most important skill for a programmer is the ability to effectively communicate ideas. — Gastón Jorquera ^1 程序员最重要的技能是有效沟通想法的能力。
可读性是弄清楚一个程序是在做什么事的关键。如果您都不知道这个程序在做什么,您如何去维护这个程序?如果一个软件不可用被维护,那就可能被重写,并且这也可能是您公司最后一次在 GO 上面投入了。
如果您仅仅是为自己个人写一个程序,可能这个程序是一次性的,或者使用这个程序的人也只有您一个,那您想怎样写就怎样写。但如果是多人合作贡献的程序,或者因为它解决人们的需求、满足某些特性、运行它的环境会变化,而在一个很长的时间内被很多人使用,那么程序的 可维护性 则必须成为目标。
编写可维护的程序的第一步,那就是确保代码是可读的。
生产率
Design is the art of arranging code to work today, and be changeable forever. — Sandi Metz 设计是一门艺术,要求编写的代码当前可用,并且以后仍能被改动。
我想重点阐述的最后一个基本原则是 生产率 。开发者的生产率是一个复杂的话题,但归结起来就是:为了有效的工作,您因为一些工具、外部代码库而浪费了多少时间。Go 程序员应该感受得到,他们在工作中可以从很多东西中受益了。(Austin Luo:言下之意是,Go 的工具集和基础库完备,很多东西触手可得。)
有一个笑话是说,Go 是在 C++ 程序编译过程中被设计出来的。快速的编译是 Go 语言用以吸引新开发者的关键特性。编译速度仍然是一个不变的战场,很公平地说,其他语言需要几分钟才能编译,而 Go 只需要几秒即可完成。这有助于 Go 开发者拥有动态语言开发者一样的高效,但却不会面临那些动态语言本身可靠性的问题。
Go 开发者意识到代码是写来被阅读的,并且把 阅读 放在 编写 之上。Go 致力于从工具集、习惯等方面强制要求代码必须编写为一种特定样式,这消除了学习项目特定术语的障碍,同时也可以仅仅从“看起来”不正确即可帮助开发者发现潜在的错误。
Go 开发者不会整日去调试那些莫名其妙的编译错误。他们也不会整日浪费时间在复杂的构建脚本或将代码部署到生产中这事上。更重要的是他们不会花时间在尝试搞懂同事们写的代码是什么意思这事上。
当 Go 语言团队在谈论一个语言必须扩展时,他们谈论的就是生产率。
标识符
我们要讨论的第一个议题是标识符。标识符是一个名称的描述词,这个名称可以是一个变量的名称、一个函数的名称、一个方法的名称、一个类型的名称或者一个包的名称等等。
Poor naming is symptomatic of poor design. — Dave Cheney 拙劣的名称是拙劣的设计的表征。
鉴于 Go 的语法限制,我们为程序中的事物选择的名称对我们程序的可读性产生了过大的影响。良好的可读性是评判代码质量的关键,因此选择好名称对于 Go 代码的可读性至关重要。
选择清晰的名称,而不是简洁的名称
Obvious code is important. What you can do in one line you should do in three. — Ukiah Smith 代码要明确这很重要,您在一行中能做的事,应该拆到三行里做。
Go 不是专注于将代码精巧优化为一行的那种语言,Go 也不是致力于将代码精炼到最小行数的语言。我们并不追求源码在磁盘上占用的空间更少,也不关心录入代码需要多长时间。
Good naming is like a good joke. If you have to explain it, it’s not funny. — Dave Cheney 好的名称就如同一个好的笑话,如果您需要去解释它,那它就不搞笑了。
这个清晰度的关键就是我们为 Go 程序选择的标识符。让我们来看看一个好的名称应当具备什么吧:
- **好的名称是简洁的。**一个好的名称未必是尽可能短的,但它肯定不会浪费任何无关的东西在上面,好名字具有高信噪比。
- **好的名称是描述性的。**一个好的名称应该描述一个变量或常量的使用,而非其内容。一个好的命名应该描述函数的结果或一个方法的行为,而不是这个函数或方法本身的操作。一个好的名称应该描述一个包的目的,而不是包的内容。名称描述的东西越准确,名称越好。
- 好的名称是可预测的。 您应该能够从名称中推断出它的使用方式,这是选择描述性名称带来的作用,同时也遵循了传统。Go 开发者在谈论 惯用语 时,即是说的这个。
接下来让我们深入地讨论一下。
标识符长度
有时候人们批评 Go 风格推荐短变量名。正如 Rob Pike 所说,“Go 开发者想要的是合适长度的标识符”。^1
Andrew Gerrand 建议通过使用更长的标识符向读者暗示它们具有更高的重要性。
The greater the distance between a name’s declaration and its uses, the longer the name should be. — Andrew Gerrand ^2 标识符的声明和使用间隔越远,名称的长度就应当越长。
据此,我们可以归纳一些指导意见:
- 短变量名称在声明和上次使用之间的距离很短时效果很好。
- 长变量名需要证明其不同的合理性:越长的变量名,越需要更多的理由来证明其合理。冗长、繁琐的名称与他们在页面上的权重相比,携带的信息很低。
- 不要在变量名中包含其类型的名称。
- 常量需要描述其存储的值的含义,而不是怎么使用它。
- 单字母变量可用于循环或逻辑分支,单词变量可用于参数或返回值,多词短语可用于函数和包这一级的声明。
- 单词可用于方法、接口和包
- 请记住,包的命名将成为用户引用它时采用的名称,确保这个名称更有意义。
让我们来看一个示例:
type Person struct {
Name string
Age int
}
// AverageAge returns the average age of people.
func AverageAge(people []Person) int {
if len(people) == 0 {
return 0
}
var count, sum int
for _, p := range people {
sum += p.Age
count += 1
}
return sum / count
}
复制代码
在这个示例中,范围变量
p
在定义之后只在接下来的一行使用。
p
在整页源码和函数执行过程中都只生存一小段时间。对
p
感兴趣的读者只需要查看两行代码即可。
与之形成对比的是,变量
people
在函数参数中定义,并且存在了 7 行,同理的还有
sum
和
count
,这他们使用了更长的名称,读者必须关注更广泛的代码行。
我也可以使用
s
而不是
sum
,用
c
(或
n
)而不是
count
,但这会将整个程序中的变量都聚集在相同的重要性上。我也可以使用
p
而不是
people
,但是这样又有一个问题,那就是
for ... range
循环中的变量又用什么?单数的
person
看起来也很奇怪,生存时间极短命名却比导出它的那个值更长。
Austin Luo:这里说的是,若数组
people
用变量名p
,那么从数组中获取的每一个元素取名就成了问题,比如用person
,即使使用person
看起来也很奇怪,一方面是单数,一方面person
的生存周期只有两行(很短),命名比生存周期更长的p
(people
)还长了。 小窍门:跟使用空行在文档中分段一样,使用空行将函数执行过程分段。在函数AverageAge
中有按顺序的三个操作。第一个是先决条件,检查当people
为空时我们不会除零,第二个是累加总和和计数,最后一个是计算平均数。
上下文是关键
绝大多数的命名建议都是根据上下文的,意识到这一点很重要。我喜欢称之为原则,而不是规则。
i
和
index
这两个标识符有什么不同?我们很难确切地说其中一个比另一个好,比如:
for index := 0; index < len(s); index++ {
//
}
复制代码
上述代码的可读性,基本上都会认为比下面这段要强:
for i := 0; i < len(s); i++ {
//
}
复制代码
但我表示不赞同。因为无论是
i
还是
index
,都是限定于
for
循环体的,更冗长的命名,并没有让我们
更容易
地理解这段代码。
话说回来,下面两段代码那一段可读性更强呢?
func (s *SNMP) Fetch(oid []int, index int) (int, error)
复制代码
或者
func (s *SNMP) Fetch(o []int, i int) (int, error)
复制代码
在这个示例中,
oid
是
SNMP
对象 ID 的缩写,因此将其略写为
o
意味着开发者必须将他们在文档中看到的常规符号转换理解为代码中更短的符号。同样地,将
index
简略为
i
,减少了其作为
SNMP
消息的索引的含义。
小窍门:在参数声明中不要混用长、短不同的命名风格。
命名中不要包含所属类型的名称
正如您给宠物取名一样,您会给狗取名“汪汪”,给猫取名为“咪咪”,但不会取名为“汪汪狗”、“咪咪猫”。出于同样的原因,您也不应在变量名称中包含其类型的名称。
变量命名应该体现它的内容,而不是类型。我们来看下面这个例子:
var usersMap map[string]*User
复制代码
这样的命名有什么好处呢?我们能知道它是个 map,并且它与
*User
类型有关,这可能还不错。但是 Go 作为一种静态类型语言,它并不会允许我们在需要标量变量的地方意外地使用到这个变量,因此
Map
后缀实际上是多余的。
现在我们来看像下面这样定义变量又是什么情况:
var (
companiesMap map[string]*Company
productsMap map[string]*Products
)
复制代码
现在这个范围内我们有了三个 map 类型的变量了:
usersMap
,
companiesMap
,以及
productsMap
,所有这些都从字符串映射到了不同的类型。我们知道它们都是 map,我们也知道它们的 map 声明会阻止我们使用一个代替另一个——如果我们尝试在需要
map[string]*User
的地方使用
companiesMap
,编译器将抛出错误。在这种情况下,很明显
Map
后缀不会提高代码的清晰度,它只是编程时需要键入的冗余内容。(Austin Luo:陈旧的思维方式)
我的建议是,避免给变量加上与类型相关的任何后缀。
小窍门:如果
users
不能描述得足够清楚,那usersMap
也一定不能。
这个建议也适用于函数参数,比如:
type Config struct {
//
}
func WriteConfig(w io.Writer, config *Config)
复制代码
将
*Config
参数命名为
config
是多余的,我们知道它是个
*Config
,函数签名上写得很清楚。
在这种情况建议考虑
conf
或者
c
——如果生命周期足够短的话。
如果在一个范围内有超过一个
*Config
,那命名为
conf1
、
conf2
的描述性就比
original
、
updated
更差,而且后者比前者更不容易出错。
NOTE: 不要让包名占用了更适合变量的名称。 导入的标识符是会包含它所属包的名称的。 例如我们很清楚
context.Context
是包context
中的类型Context
。这就导致我们在我们自己的包里,再也无法使用context
作为变量或类型名了。func WriteLog(context context.Context, message string)
这无法编译。这也是为什么我们通常将context.Context
类型的变量命名为ctx
的原因,如:func WriteLog(ctx context.Context, message string)
使用一致的命名风格
一个好名字的另一个特点是它应该是可预测的。阅读者应该可以在第一次看到的时候就能够理解它如何使用。如果遇到一个约定俗称的名字,他们应该能够认为和上次看到这个名字一样,一直以来它都没有改变意义。
例如,如果您要传递一个数据库句柄,请确保每次的参数命名都是一样的。与其使用
d *sql.DB
,
dbase *sql.DB
,
DB *sql.DB
和
database *sql.DB
,还不如都统一为:
db *sql.DB
复制代码
这样做可以增进熟悉度:如果您看到
db
,那么您就知道那是个
*sql.DB
,并且已经在本地定义或者由调用者提供了。
对于方法接收者也类似,在类型的每个方法中使用相同的接收者名称,这样可以让阅读者在跨方法阅读和理解时更容易主观推断。
Austin Luo:“接收者”是一种特殊类型的参数。^2 比如
func (b *Buffer) Read(p []byte) (n int, err error)
,它通常只用一到两个字母来表示,但在不同的方法中仍然应当保持一致。 注意:Go 中对接收者的短命名规则惯例与目前提供的建议不一致。这只是早期做出的选择之一,并且已经成为首选的风格,就像使用CamelCase
而不是snake_case
一样。 小窍门:Go 的命名风格规定接收器具有单个字母名称或其派生类型的首字母缩略词。有时您可能会发现接收器的名称有时会与方法中参数的名称冲突,在这种情况下,请考虑使参数名称稍长,并且仍然不要忘记一致地使用这个新名称。
最后,某些单字母变量传统上与循环和计数有关。例如,
i
,
j
,和
k
通常是简单的
for
循环变量。
n
通常与计数器或累加器有关。
v
通常是某个值的简写,
k
通常用于映射的键,
s
通常用作
string
类型参数的简写。
与上面
db
的例子一样,程序员期望
i
是循环变量。如果您保证
i
始终是一个循环变量——而不是在
for
循环之外的情况下使用,那么当读者遇到一个名为
i
或者
j
的变量时,他们就知道当前还在循环中。
小窍门:如果您发现在嵌套循环中您都使用完
i
,j
,k
了,那么很显然这已经到了将函数拆得更小的时候了。
使用一致的声明风格
Go 中至少有 6 种声明变量的方法(Austin Luo:作者说了 6 种,但只列了 5 种)
-
var x int = 1
-
var x = 1
-
var x int; x = 1
-
var x = int(1)
-
x := 1
我敢肯定还有更多我没想到的。这是 Go 的设计师认识到可能是一个错误的地方,但现在改变它为时已晚。有这么多不同的方式来声明变量,那么我们如何避免每个 Go 程序员选择自己个性独特的声明风格呢?
我想展示一些在我自己的程序里声明变量的建议。这是我尽可能使用的风格。
-
只声明,不初始化时,使用*
var
***。**在声明之后,将会显式地初始化时,使用var
关键字。
复制代码
var players int // 0
var things []Thing // an empty slice of Things
var thing Thing // empty Thing struct
json.Unmarshall(reader, &thing)
复制代码
var
关键字表明这个变量被
有意地
声明为该类型的零值。这也与在包级别声明变量时使用
var
而不是短声明语法(Austin Luo:
:=
)的要求一致——尽管我稍后会说您根本不应该使用包级变量。
-
既声明,也初始化时,使用*
:=
***。**当同时要声明和初始化变量时,换言之我们不让变量隐式地被初始化为零值时,我建议使用短声明语法的形式。这使得读者清楚地知道:=
左侧的变量是有意被初始化的。
为解释原因,我们回头再看看上面的例子,但这一次每个变量都被有意初始化了:
var players int = 0
var things []Thing = nil
var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)
复制代码
第一个和第三个示例中,因为 Go 没有从一种类型到另一种类型的自动转换,赋值运算符左侧和右侧的类型必定是一致的。编译器可以从右侧的类型推断出左侧所声明变量的类型。对于这个示例可以更简洁地写成这样:
var players = 0
var things []Thing = nil
var thing = new(Thing)
json.Unmarshall(reader, thing)
复制代码
由于
0
是
players
的零值,因此为
players
显式地初始化为
0
就显得多余了。所以为了更清晰地表明我们使用了零值,应该写成这样:
var players int
复制代码
那第二条语句呢?我们不能忽视类型写成:
var things = nil
复制代码
因为
nil
根本就没有类型^2。相反,我们有一个选择,我们是否希望切片的零值?
var things []Thing
复制代码
或者我们是否希望创建一个没有元素的切片?
var things = make([]Thing, 0)
复制代码
如果我们想要的是后者,这不是个切片类型的零值,那么我们应该使用短声明语法让阅读者很清楚地明白我们的选择:
things := make([]Thing, 0)
复制代码
这告诉了读者我们显式地初始化了
things
。
再来看看第三个声明:
var thing = new(Thing)
复制代码
这既显式地初始化了变量,也引入了 Go 程序员不喜欢而且很不常用的
new
关键字。如果我们遵循短命名语法的建议,那么这句将变成:
thing := new(Thing)
复制代码
这很清楚地表明,
thing
被显式地初始化为
new(Thing)
的结果——一个指向
Thing
的指针——但仍然保留了我们不常用的
new
。我们可以通过使用
紧凑结构初始化的形式
来解决这个问题,
thing := &Thing{}
复制代码
这和
new(Thing)
做了同样的事——也因此很多 Go 程序员对这种重复感觉不安。不过,这一句仍然意味着我们为
thing
明确地初始化了一个
Thing{}
的指针——一个
Thing
的零值。
在这里,我们应该意识到,
thing
被初始化为了零值,并且将它的指针地址传递给了
json.Unmarshall
:
var thing Thing
json.Unmarshall(reader, &thing)
复制代码
注意:当然,对于任何经验法则都有例外。比如,有些变量之间很相关,那么与其写成这样: var min int max := 1000 不如写成这样更具可读性:
min, max := 0, 1000
综上所述:
-
只声明,不初始化时,使用
var
。 -
既声明,也显式地初始化时,使用
:=
。
小窍门: 使得机巧的声明更加显而易见。 当某件事本身很复杂时, 应当 使它看起来就复杂。
var length uint32 = 0x80
这里的length
可能和一个需要有特定数字类型的库一起使用,并且length
被很明确地指定为uint32
类型而不只是短声明形式:length := uint32(0x80)
在第一个例子中,我故意违反了使用var
声明形式和显式初始化程序的规则。这个和我惯常形式不同的决定,可以让读者意识到这里需要注意。
成为团队合作者
我谈到了软件工程的目标,即生成可读,可维护的代码。而您的大部分职业生涯参与的项目可能您都不是唯一的作者。在这种情况下我的建议是遵守团队的风格。
在文件中间改变编码风格是不适合的。同样,即使您不喜欢,可维护性也比您的个人喜好有价值得多。我的原则是:如果满足
gofmt
,那么通常就不值得再进行代码风格审查了。
小窍门:如果您要横跨整个代码库进行重命名,那么不要在其中混入其他的修改。如果其他人正在使用 git bisect,他们一定不愿意从几千行代码的重命名中“跋山涉水”地去寻找您别的修改。