Go 语言实战: 编写可维护 Go 语言代码建议
目录
1. 指导原则
1.1 简单性
1.2 可读性
1.3 生产力
2. 标识符
2.1 选择标识是为了清晰, 而不是简洁
2.2 标识符长度
2.3 不要用变量类型命名变量
2.4 使用一致的命名风格
2.5 使用一致的声明样式
2.6 成为团队的合作者
3. 注释
3.1 关于变量和常量的注释应描述其内容而非其目的
3.2 公共符号始终要注释
4. 包的设计
4.1 一个好的包从它的名字开始
4.2 避免使用类似
base
、common
或util
的包名称4.3 尽早
reture
而不是深度嵌套4.4 让零值更有用
4.5 避免包级别状态
5. 项目结构
5.1 考虑更少,更大的包
5.2 保持
main
包内容尽可能的少6. API 设计
6.1 设计难以被误用的 API
6.2 为其默认用例设计 API
6.3 让函数定义它们所需的行为
7. 错误处理
7.1 通过消除错误来消除错误处理
7.2 错误只处理一次
8. 并发
8.1 保持自己忙碌或做自己的工作
8.2 将并发性留给调用者
8.3 永远不要启动一个停止不了的
goroutine
介绍
大家好,
我在接下来的两个会议中的目标是向大家提供有关编写 Go 代码最佳实践的建议。
这是一个研讨会形式的演讲,不会有幻灯片, 而是直接从文档开始。
贴士: 在这里有最新的文章链接
https://dave.cheney.net/practical-go/presentations/qcon-china.html
编者的话
终于翻译完了 Dave 大神的这一篇《Go 语言最佳实践》
耗时两周的空闲时间
翻译的同时也对 Go 语言的开发与实践有了更深层次的了解
有兴趣的同学可以翻阅 Dave 的另一篇博文《SOLID Go 语言设计》(第六章节也会提到)
正文
1. 指导原则
如果我要谈论任何编程语言的最佳实践,我需要一些方法来定义 “什么是最佳”。 如果你昨天来到我的主题演讲,你会看到 Go 团队负责人 Russ Cox 的这句话:
Software engineering is what happens to programming when you add time and other programmers. (软件工程就是你和其他程序员花费时间在编程上所发生的事情。)
— Russ Cox
Russ 作出了软件编程与软件工程的区分。 前者是你自己写的一个程序。 后者是很多人会随着时间的推移而开发的产品。 工程师们来来去去,团队会随着时间增长与缩小,需求会发生变化,功能会被添加,错误也会得到修复。 这是软件工程的本质。
我可能是这个房间里 Go 最早的用户之一,~ 但要争辩说我的资历给我的看法更多是假的~。 相反,今天我要提的建议是基于我认为的 Go 语言本身的指导原则:
简单性
可读性
生产力
注意:
你会注意到我没有说性能或并发。 有些语言比 Go 语言快一点,但它们肯定不像 Go 语言那么简单。 有些语言使并发成为他们的最高目标,但它们并不具有可读性及生产力。
性能和并发是重要的属性,但不如简单性,可读性和生产力那么重要。
1.1. 简单性
我们为什么要追求简单? 为什么 Go 语言程序的简单性很重要?
我们都曾遇到过这样的情况: “我不懂这段代码”,不是吗? 我们都做过这样的项目: 你害怕做出改变,因为你担心它会破坏程序的另一部分; 你不理解的部分,不知道如何修复。
这就是复杂性。 复杂性把可靠的软件中变成不可靠。 复杂性是杀死软件项目的罪魁祸首。
简单性是 Go 语言的最高目标。 无论我们编写什么程序,我们都应该同意这一点: 它们很简单。
1.2. 可读性
Readability is essential for maintainability.
(可读性对于可维护性是至关重要的。)
— Mark Reinhold (2018 JVM 语言高层会议)
为什么 Go 语言的代码可读性是很重要的?我们为什么要争取可读性?
Programs must be written for people to read, and only incidentally for machines to execute. (程序应该被写来让人们阅读,只是顺便为了机器执行。)
— Hal Abelson 与 Gerald Sussman (计算机程序的结构与解释)
可读性很重要,因为所有软件不仅仅是 Go 语言程序,都是由人类编写的,供他人阅读。执行软件的计算机则是次要的。
代码的读取次数比写入次数多。一段代码在其生命周期内会被读取数百次,甚至数千次。
The most important skill for a programmer is the ability to effectively communicate ideas. (程序员最重要的技能是有效沟通想法的能力。)
— Gastón Jorquera [1]
可读性是能够理解程序正在做什么的关键。如果你无法理解程序正在做什么,那你希望如何维护它?如果软件无法维护,那么它将被重写; 最后这可能是你的公司最后一次投资 Go 语言。
~ 如果你正在为自己编写一个程序,也许它只需要运行一次,或者你是唯一一个曾经看过它的人,然后做任何对你有用的事。~ 但是,如果是一个不止一个人会贡献编写的软件,或者在很长一段时间内需求、功能或者环境会改变,那么你的目标必须是你的程序可被维护。
编写可维护代码的第一步是确保代码可读。
1.3. 生产力
Design is the art of arranging code to work today, and be changeable forever. (设计是安排代码到工作的艺术,并且永远可变。)
— Sandi Metz
我要强调的最后一个基本原则是生产力。开发人员的工作效率是一个庞大的主题,但归结为此; 你花多少时间做有用的工作,而不是等待你的工具或迷失在一个外国的代码库里。Go 程序员应该觉得他们可以通过 Go 语言完成很多工作。
有人开玩笑说,Go 语言是在等待 C ++ 语言程序编译时设计的。快速编译是 Go 语言的一个关键特性,也是吸引新开发人员的关键工具。虽然编译速度仍然是一个持久的战场,但可以说,在其他语言中需要几分钟的编译,在 Go 语言中只需几秒钟。这有助于 Go 语言开发人员感受到与使用动态语言的同行一样的高效,而且没有那些语言固有的可靠性问题。
对于开发人员生产力问题更为基础的是,Go 程序员意识到编写代码是为了阅读,因此将读代码的行为置于编写代码的行为之上。 Go 语言甚至通过工具和自定义强制执行所有代码以特定样式格式化。这就消除了项目中学习特定格式的摩擦,并帮助发现错误,因为它们看起来不正确。
Go 程序员不会花费整天的时间来调试不可思议的编译错误。他们也不会将浪费时间在复杂的构建脚本或在生产中部署代码。最重要的是,他们不用花费时间来试图了解他们的同事所写的内容。
当他们说语言必须扩展时,Go 团队会谈论生产力。
2. 标识符
我们要讨论的第一个主题是标识符。 标识符是一个用来表示名称的花哨单词; 变量的名称,函数的名称,方法的名称,类型的名称,包的名称等。
Poor naming is symptomatic of poor design. (命名不佳是设计不佳的症状。)
— Dave Cheney
鉴于 Go 语言的语法有限,我们为程序选择的名称对我们程序的可读性产生了非常大的影响。 可读性是良好代码的定义质量,因此选择好名称对于 Go 代码的可读性至关重要。
2.1. 选择标识符是为了清晰,而不是简洁
Obvious code is important. What you can do in one line you should do in three.
(清晰的代码很重要。在一行可以做的你应当分三行做。(if/else 吗?))
— 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 程序员在谈到习惯用语时所谈论的内容。
让我们深入讨论以下这些属性。
2.2. 标识符长度
有时候人们批评 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
的在第 10
行被声明并且也只在接下来的一行中被引用。 p
在执行函数期间存在时间很短。如果要了解 p
的作用只需阅读两行代码。
相比之下, people
在函数第 7
行参数中被声明。 sum
和 count
也是如此,他们用了更长的名字。读者必须查看更多的行数来定位它们,因此他们名字更为独特。
我可以选择 s
替代 sum
以及 c
(或可能是 n
)替代 count
,但是这样做会将程序中的所有变量份量降低到同样的级别。我可以选择 p
来代替 people
,但是用什么来调用 for ... range
迭代变量。如果用 person
的话看起来很奇怪,因为循环迭代变量的生命时间很短,其名字的长度超出了它的值。
贴士:
与使用段落分解文档的方式一样用空行来分解函数。 在AverageAge
中,按顺序共有三个操作。 第一个是前提条件,检查people
是否为空,第二个是sum
和count
的累积,最后是平均值的计算。
2.2.1. 上下文是关键
重要的是要意识到关于命名的大多数建议都是需要考虑上下文的。 我想说这是一个原则,而不是一个规则。
两个标识符 i
和 index
之间有什么区别。 我们不能断定一个就比另一个好,例如
for index := 0; index < len(s); index++ {
//
}
从根本上说,上面的代码更具有可读性
for i := 0; i < len(s); i++ {
//
}
我认为它不是,因为就此事而论, i
和 index
的范围很大可能上仅限于 for 循环的主体,后者的额外冗长性 (指 index
) 几乎没有增加对于程序的理解。
但是,哪些功能更具可读性?
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
, 模糊了 i
所代表的含义,因为在 SNMP
消息中,每个 OID
的子值称为索引。
贴士: 在同一声明中长和短形式的参数不能混搭。
2.3. 不要用变量类型命名你的变量
你不应该用变量的类型来命名你的变量, 就像您不会将宠物命名为 “狗” 和“猫”。 出于同样的原因,您也不应在变量名字中包含类型的名字。
变量的名称应描述其内容,而不是内容的类型。 例如:
var usersMap map[string]*User
这个声明有什么好处? 我们可以看到它是一个 map
,它与 *User
类型有关。 但是 usersMap
是一个 map
,而 Go 语言是一种静态类型的语言,如果没有定义变量, 不会让我们意外地使用到它,因此 Map
后缀是多余的。
接下来, 如果我们像这样来声明其他变量:
var (
companiesMap map[string]*Company
productsMap map[string]*Products
)
usersMap
, companiesMap
和 productsMap
三个 map
类型变量,所有映射字符串都是不同的类型。 我们知道它们是 map
,我们也知道我们不能使用其中一个来代替另一个 - 如果我们在需要 map[string]*User
的地方尝试使用 companiesMap
, 编译器将抛出错误异常。 在这种情况下,很明显变量中 Map
后缀并没有提高代码的清晰度,它只是增加了要输入的额外样板代码。
我的建议是避免使用任何类似变量类型的后缀。
贴士:
如果users
的描述性都不够用,那么usersMap
也不会。
此建议也适用于函数参数。 例如:
type Config struct {
//
}
func WriteConfig(w io.Writer, config *Config)
命名 *Config
参数 config
是多余的。 我们知道它是 *Config
类型,就是这样。
在这种情况下,如果变量的生命周期足够短,请考虑使用 conf
或 c
。
如果有更多的 *Config
,那么将它们称为 original
和 updated
比 conf1
和 conf2
会更具描述性,因为前者不太可能被互相误解。
贴士:
不要让包名窃取好的变量名。
导入标识符的名称包括其包名称。 例如,context
包中的Context
类型将被称为context.Context
。 这使得无法将context
用作包中的变量或类型。
func WriteLog(context context.Context, message string)
上面的栗子将会编译出错。 这就是为什么
context.Context
类型的通常的本地声明是ctx
。 例如。
func WriteLog(ctx context.Context, message string)
2.4. 使用一致的命名方式
一个好名字的另一个属性是它应该是可预测的。 在第一次遇到该名字时读者就能够理解名字的使用。 当他们遇到常见的名字时,他们应该能够认为自从他们上次看到它以来它没有改变意义。
例如,如果您的代码在处理数据库请确保每次出现参数时,它都具有相同的名称。 与其使用 d * sql.DB
, dbase * sql.DB
, DB * sql.DB
和 database * sql.DB
的组合,倒不如统一使用:
db *sql.DB
这样做使读者更为熟悉; 如果你看到 db
,你知道它就是 *sql.DB
并且它已经在本地声明或者由调用者为你提供。
类似地,对于方法接收器: 在该类型的每个方法上使用相同的接收者名称。 在这种类型的方法内部可以使读者更容易使用。
注意:
Go 语言中的短接收者名称惯例与目前提供的建议不一致。 这只是早期做出的选择之一,已经成为首选的风格,就像使用CamelCase
而不是snake_case
一样。贴士:
Go 语言样式规定接收器具有单个字母名称或从其类型派生的首字母缩略词。 你可能会发现接收器的名称有时会与方法中参数的名称冲突。 在这种情况下,请考虑将参数名称命名稍长,并且不要忘记一致地使用此新参数名称。
最后,某些单字母变量传统上与循环和计数相关联。 例如, i
, j
和 k
通常是简单 for
循环的循环归纳变量。 n
通常与计数器或累加器相关联。 v
是通用编码函数中值的常用简写, k
通常用于 map
的键, s
通常用作字符串类型参数的简写。
与上面的 db
示例一样,程序员认为 i
是一个循环归纳变量。 如果确保 i
始终是循环变量,而且不在 for
循环之外的其他地方中使用。 当读者遇到一个名为 i
或 j
的变量时,他们知道循环就在附近。
贴士:
如果你发现自己有如此多的嵌套循环,i
,j
和k
变量都无法满足时,这个时候可能就是需要将函数分解成更小的函数。
2.5. 使用一致的声明样式
Go 至少有六种不同的方式来声明变量
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
而不是短声明语法在包级别声明变量的要求一致 - 尽管我稍后会说你根本不应该使用包级变量。
在声明和初始化时,使用
:=
。 在同时声明和初始化变量时,也就是说我们不会将变量初始化为零值,我建议使用短变量声明。 这使得读者清楚地知道:=
左侧的变量是初始化过的。
为了解释原因,让我们看看前面的例子,但这次是初始化每个变量:
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)
我们将 players
初始化为 0
,但这是多余的,因为 0
是 players
的零值。 因此,要明确地表示使用零值, 我们将上面例子改写为:
var players int
第二个声明如何? 我们不能省略类型而写作:
var things = nil
因为 nil 没有类型。 [2] 相反,我们有一个选择,如果我们要使用切片的零值则写作:
var things []Thing
或者我们要创建一个有零元素的切片则写作:
var things = make([]Thing, 0)
如果我们想要后者那么这不是切片的零值,所以我们应该向读者说明我们通过使用简短的声明形式做出这个选择:
things := make([]Thing, 0)
这告诉读者我们已选择明确初始化事物。
下面是第三个声明,
var thing = new(Thing)
既是初始化了变量又引入了一些 Go 程序员不喜欢的 new
关键字的罕见用法。 如果我们用推荐地简短声明语法,那么就变成了:
thing := new(Thing)
这清楚地表明 thing
被初始化为 new(Thing)
的结果 - 一个指向 Thing
的指针 - 但依旧我们使用了 new
地罕见用法。 我们可以通过使用紧凑的文字结构初始化形式来解决这个问题,
thing := &Thing{}
与 new(Thing)
相同,这就是为什么一些 Go 程序员对重复感到不满。 然而,这意味着我们使用指向 Thing{}
的指针初始化了 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
声明带有初始化变量的。 这个决定与我的常用的形式不同,这给读者一个线索, 告诉他们一些不寻常的事情将会发生。
2.6. 成为团队合作者
我谈到了软件工程的目标,即编写可读及可维护的代码。 因此,您可能会将大部分职业生涯用于你不是唯一作者的项目。 我在这种情况下的建议是遵循项目自身风格。
在文件中间更改样式是不和谐的。 即使不是你喜欢的方式,对于维护而言一致性比你的个人偏好更有价值。 我的经验法则是: 如果它通过了 gofmt
, 那么通常不值得再做代码审查。
贴士:
如果要在代码库中进行重命名,请不要将其混合到另一个更改中。 如果有人使用git bisect
,他们不想通过数千行重命名来查找您更改的代码。
未完待续,下周三继续给大家带来最新的译文
如有翻译有误或者不理解的地方,请评论指正
待更新的译注之后会做进一步修改翻译
翻译:田浩
邮箱:llitfkitfk@gmail.com
有疑问加站长微信联系(非本文作者)