指导原则

我们要谈论在一个编程语言中的最佳实践,那么我们首先应该明确什么是“最佳”。如果您们听了我昨天那场讲演的话,您一定看到了来自 Go 团队的 Russ Cox 讲的一句话:

Russ 是在阐述软件“编程”和软件“工程”之间的区别,前者是您写的程序,而后者是一个让更多的人长期使用的产品。软件工程师会来来去去地更换,团队也会成长或者萎缩,需求也会发生变化,新的特性也会增加,bug 也会被修复,这就是软件“工程”的本质。

我可能是现场最早的 Go 语言用户,但与其说我的主张来自我的资历,不如说我今天讲的是真实来自于 Go 语言本身的指导原则,那就是:

  1. 简单性
  2. 可读性
  3. 生产率

简单性

为什么我们要力求简单,为什么简单对 Go 语言编程如此重要?

我们有太多的时候感叹“这段代码我看不懂”,是吧?我们害怕修改一丁点代码,生怕这一点修改就导致其他您不懂的部分出问题,而您又没办法修复它。

这就是复杂性。复杂性把可读的程序变得不可读,复杂性终结了很多软件项目。

简单性是 Go 的最高目标。无论我们写什么程序,我们都应该能一致认为它应当简单。

可读性

为什么 Go 代码的可读性如此重要?为什么我们应该力求可读性?

可阅读性对所有的程序——不仅仅是 Go 程序,都是如此之重要,是因为程序是人写的并且给其他人阅读的,事实上被机器所执行只是其次。

代码被阅读的次数,远远大于被编写的次数。一段小的代码,在它的整个生命周期,可能被阅读成百上千次。

可读性是弄清楚一个程序是在做什么事的关键。如果您都不知道这个程序在做什么,您如何去维护这个程序?如果一个软件不可用被维护,那就可能被重写,并且这也可能是您公司最后一次在 GO 上面投入了。

如果您仅仅是为自己个人写一个程序,可能这个程序是一次性的,或者使用这个程序的人也只有您一个,那您想怎样写就怎样写。但如果是多人合作贡献的程序,或者因为它解决人们的需求、满足某些特性、运行它的环境会变化,而在一个很长的时间内被很多人使用,那么程序的可维护性则必须成为目标。

编写可维护的程序的第一步,那就是确保代码是可读的。

生产率

我想重点阐述的最后一个基本原则是生产率。开发者的生产率是一个复杂的话题,但归结起来就是:为了有效的工作,您因为一些工具、外部代码库而浪费了多少时间。Go 程序员应该感受得到,他们在工作中可以从很多东西中受益了。(Austin Luo:言下之意是,Go 的工具集和基础库完备,很多东西触手可得。)

有一个笑话是说,Go 是在 C++ 程序编译过程中被设计出来的。快速的编译是 Go 语言用以吸引新开发者的关键特性。编译速度仍然是一个不变的战场,很公平地说,其他语言需要几分钟才能编译,而 Go 只需要几秒即可完成。这有助于 Go 开发者拥有动态语言开发者一样的高效,但却不会面临那些动态语言本身可靠性的问题。

Go 开发者意识到代码是写来被阅读的,并且把阅读放在编写之上。Go 致力于从工具集、习惯等方面强制要求代码必须编写为一种特定样式,这消除了学习项目特定术语的障碍,同时也可以仅仅从“看起来”不正确即可帮助开发者发现潜在的错误。

Go 开发者不会整日去调试那些莫名其妙的编译错误。他们也不会整日浪费时间在复杂的构建脚本或将代码部署到生产中这事上。更重要的是他们不会花时间在尝试搞懂同事们写的代码是什么意思这事上。

当 Go 语言团队在谈论一个语言必须扩展时,他们谈论的就是生产率。

标识符

我们要讨论的第一个议题是标识符。标识符是一个名称的描述词,这个名称可以是一个变量的名称、一个函数的名称、一个方法的名称、一个类型的名称或者一个包的名称等等。

鉴于 Go 的语法限制,我们为程序中的事物选择的名称对我们程序的可读性产生了过大的影响。良好的可读性是评判代码质量的关键,因此选择好名称对于 Go 代码的可读性至关重要。

选择清晰的名称,而不是简洁的名称

Go 不是专注于将代码精巧优化为一行的那种语言,Go 也不是致力于将代码精炼到最小行数的语言。我们并不追求源码在磁盘上占用的空间更少,也不关心录入代码需要多长时间。

这个清晰度的关键就是我们为 Go 程序选择的标识符。让我们来看看一个好的名称应当具备什么吧:

  • 好的名称是简洁的。一个好的名称未必是尽可能短的,但它肯定不会浪费任何无关的东西在上面,好名字具有高信噪比。
  • 好的名称是描述性的。一个好的名称应该描述一个变量或常量的使用,而非其内容。一个好的命名应该描述函数的结果或一个方法的行为,而不是这个函数或方法本身的操作。一个好的名称应该描述一个包的目的,而不是包的内容。名称描述的东西越准确,名称越好。
  • 好的名称是可预测的。您应该能够从名称中推断出它的使用方式,这是选择描述性名称带来的作用,同时也遵循了传统。Go 开发者在谈论惯用语时,即是说的这个。

接下来让我们深入地讨论一下。

标识符长度

有时候人们批评 Go 风格推荐短变量名。正如 Rob Pike 所说,“Go 开发者想要的是合适长度的标识符”。^1

Andrew Gerrand 建议通过使用更长的标识符向读者暗示它们具有更高的重要性。

据此,我们可以归纳一些指导意见:

  • 短变量名称在声明和上次使用之间的距离很短时效果很好。
  • 长变量名需要证明其不同的合理性:越长的变量名,越需要更多的理由来证明其合理。冗长、繁琐的名称与他们在页面上的权重相比,携带的信息很低。
  • 不要在变量名中包含其类型的名称。
  • 常量需要描述其存储的值的含义,而不是怎么使用它。
  • 单字母变量可用于循环或逻辑分支,单词变量可用于参数或返回值,多词短语可用于函数和包这一级的声明。
  • 单词可用于方法、接口和包
  • 请记住,包的命名将成为用户引用它时采用的名称,确保这个名称更有意义。

让我们来看一个示例:

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 行,同理的还有sumcount,这他们使用了更长的名称,读者必须关注更广泛的代码行。

我也可以使用s而不是sum,用c(或n)而不是count,但这会将整个程序中的变量都聚集在相同的重要性上。我也可以使用p而不是people,但是这样又有一个问题,那就是for ... range循环中的变量又用什么?单数的 person 看起来也很奇怪,生存时间极短命名却比导出它的那个值更长。

上下文是关键

绝大多数的命名建议都是根据上下文的,意识到这一点很重要。我喜欢称之为原则,而不是规则。

iindex 这两个标识符有什么不同?我们很难确切地说其中一个比另一个好,比如:

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)

在这个示例中,oidSNMP对象 ID 的缩写,因此将其略写为 o 意味着开发者必须将他们在文档中看到的常规符号转换理解为代码中更短的符号。同样地,将index简略为i,减少了其作为SNMP消息的索引的含义。

命名中不要包含所属类型的名称

正如您给宠物取名一样,您会给狗取名“汪汪”,给猫取名为“咪咪”,但不会取名为“汪汪狗”、“咪咪猫”。出于同样的原因,您也不应在变量名称中包含其类型的名称。

变量命名应该体现它的内容,而不是类型。我们来看下面这个例子:

var usersMap map[string]*User

这样的命名有什么好处呢?我们能知道它是个 map,并且它与*User类型有关,这可能还不错。但是 Go 作为一种静态类型语言,它并不会允许我们在需要标量变量的地方意外地使用到这个变量,因此Map后缀实际上是多余的。

现在我们来看像下面这样定义变量又是什么情况:

var (
  companiesMap map[string]*Company
  productsMap map[string]*Products
)

现在这个范围内我们有了三个 map 类型的变量了:usersMapcompaniesMap,以及 productsMap,所有这些都从字符串映射到了不同的类型。我们知道它们都是 map,我们也知道它们的 map 声明会阻止我们使用一个代替另一个——如果我们尝试在需要map[string]*User的地方使用companiesMap,编译器将抛出错误。在这种情况下,很明显Map后缀不会提高代码的清晰度,它只是编程时需要键入的冗余内容。(Austin Luo:陈旧的思维方式)

我的建议是,避免给变量加上与类型相关的任何后缀。

这个建议也适用于函数参数,比如:

type Config struct {
  //
}

func WriteConfig(w io.Writer, config *Config)

*Config参数命名为config是多余的,我们知道它是个*Config,函数签名上写得很清楚。

在这种情况建议考虑conf或者c——如果生命周期足够短的话。

如果在一个范围内有超过一个*Config,那命名为conf1conf2的描述性就比originalupdated更差,而且后者比前者更不容易出错。

使用一致的命名风格

一个好名字的另一个特点是它应该是可预测的。阅读者应该可以在第一次看到的时候就能够理解它如何使用。如果遇到一个约定俗称的名字,他们应该能够认为和上次看到这个名字一样,一直以来它都没有改变意义。

例如,如果您要传递一个数据库句柄,请确保每次的参数命名都是一样的。与其使用d *sql.DBdbase *sql.DBDB *sql.DBdatabase *sql.DB,还不如都统一为:

db *sql.DB

这样做可以增进熟悉度:如果您看到db,那么您就知道那是个*sql.DB,并且已经在本地定义或者由调用者提供了。

对于方法接收者也类似,在类型的每个方法中使用相同的接收者名称,这样可以让阅读者在跨方法阅读和理解时更容易主观推断。

最后,某些单字母变量传统上与循环和计数有关。例如,ij,和k通常是简单的for循环变量。n通常与计数器或累加器有关。 v通常是某个值的简写,k通常用于映射的键,s通常用作string类型参数的简写。

与上面db的例子一样,程序员期望i是循环变量。如果您保证i始终是一个循环变量——而不是在for循环之外的情况下使用,那么当读者遇到一个名为i或者j的变量时,他们就知道当前还在循环中。

使用一致的声明风格

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)

由于0players的零值,因此为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
  • 既声明,也显式地初始化时,使用:=

成为团队合作者

我谈到了软件工程的目标,即生成可读,可维护的代码。而您的大部分职业生涯参与的项目可能您都不是唯一的作者。在这种情况下我的建议是遵守团队的风格。

在文件中间改变编码风格是不适合的。同样,即使您不喜欢,可维护性也比您的个人喜好有价值得多。我的原则是:如果满足gofmt,那么通常就不值得再进行代码风格审查了。

代码注释

在我们进行下一个更大的主题之前,我想先花几分钟说说注释的事。

代码注释对 Go 程序的可读性极为重要。一个注释应该做到如下三个方面的至少一个:

  1. 注释应该解释“做什么”。
  2. 注释应该解释“怎么做的”。
  3. 注释应该解释“为什么这么做”。

第一种形式适合公开的符号:

// Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.

第二种形式适合方法内的注释:

// queue all dependant actions
var results []chan error
for _, dep := range a.Deps {
        results = append(results, execute(seen, dep))
}

第三种形式,“为什么这么做”,这是独一无二的,无法被前两种取代,也无法取代前两种。第三种形式的注释用于解释更多的状况,而这些状况往往难以脱离上下文,否则将没有意义,这些注释就是用来阐述上下文的。

return &v2.Cluster_CommonLbConfig{
  // Disable HealthyPanicThreshold
  HealthyPanicThreshold: &envoy_type.Percent{
    Value: 0,
  },
}

在这个示例中,很难立即弄清楚把HealthyPanicThreshold的百分比设置为零会产生什么影响。注释就用来明确将值设置为0实际上是禁用了panic阈值的这种行为。

变量和常量上的注释应当描述它的内容,而非目的

我之前谈过,变量或常量的名称应描述其目的。向变量或常量添加注释时,应该描述变量的内容,而不是定义它的目的

const randomNumber = 6 // determined from an unbiased die

这个示例的注释描述了“为什么”randomNumber被赋值为 6,也说明了 6 这个值是从何而来的。但它没有描述randomNumber会被用到什么地方。下面是更多的例子:

const (
    StatusContinue           = 100 // RFC 7231, 6.2.1
    StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2
    StatusProcessing         = 102 // RFC 2518, 10.1

    StatusOK                 = 200 // RFC 7231, 6.3.1

如在 RFC 7231 的第 6.2.1 节中定义的那样,在 HTTP 语境中 100 被当做StatusContinue

总是为公开符号写文档说明

因为 godoc 将作为您的包的文档,您应该总是为每个公开的符号写好注释说明——包括变量、常量、函数和方法——所有定义在您包内的公开符号。

这里是 Go 风格指南的两条规则:

  • 任何既不明显也不简短的公共功能必须加以注释。
  • 无论长度或复杂程度如何,都必须对库中的任何函数进行注释。
package ioutil

// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r io.Reader) ([]byte, error)

对这个规则有一个例外:您不需要为实现接口的方法进行文档说明,特别是不要这样:

// Read implements the io.Reader interface
func (r *FileReader) Read(buf []byte) (int, error)

这个注释等于说明都没说,它没有告诉您这个方法做了什么,实际上更糟的是,它让您去找别的地方的文档。在这种情况我建议将注释整个去掉。

这里有一个来自io这个包的示例:

// LimitReader returns a Reader that reads from r
// but stops with EOF after n bytes.
// The underlying implementation is a *LimitedReader.
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }

// A LimitedReader reads from R but limits the amount of
// data returned to just N bytes. Each call to Read
// updates N to reflect the new amount remaining.
// Read returns EOF when N <= 0 or when the underlying R returns EOF.
type LimitedReader struct {
  R Reader // underlying reader
  N int64  // max bytes remaining
}

func (l *LimitedReader) Read(p []byte) (n int, err error) {
  if l.N <= 0 {
    return 0, EOF
  }
  if int64(len(p)) > l.N {
    p = p[0:l.N]
  }
  n, err = l.R.Read(p)
  l.N -= int64(n)
  return
}

请注意,LimitedReader的声明紧接在使用它的函数之后,并且LimitedReader.Read又紧接着定义在LimitedReader之后,即便LimitedReader.Read本身没有文档注释,那和很清楚它是io.Reader的一种实现。

不要为坏的代码写注释,重写它

为粗制滥造的代码片段着重写注释是不够的,如果您遭遇到一段这样的注释,您应该发起一个问题(issue)从而记得后续重构它。技术债务只要不是过多就没有关系。

在标准库的惯例是,批注一个 TODO 风格的注释,说明是谁发现了坏代码。

// TODO(dfc) this is O(N^2), find a faster way to do this.

注释中的姓名并不意味着承诺去修复问题,但在解决问题时,他可能是最合适的人选。其他批注内容一般还有日期或者问题编号。

与其为一大段代码写注释,不如重构它

函数应该只做一件事。如果您发现一段代码因为与函数的其他部分不相关因而需要注释时,考虑将这段代码拆分为独立的函数。

除了更容易理解之外,较小的函数更容易单独测试,现在您将不相关的代码隔离拆分到不同的函数中,估计只有函数名才是唯一需要的文档注释了。

此文已由作者授权腾讯云+社区发布,更多原文请点击

搜索关注公众号「云加社区」,第一时间获取技术干货,关注后回复1024 送你一份技术课程大礼包!

11-30 10:43