本文永久链接 – https://tonybai.com/google-go-style/google-go-style-guide

本页面是2022年11月中旬Google发布的Go语言编码风格规范系列文档的指南文档的中译版。

概述 | 指南 | 决定 | 最佳实践

注意:这是介绍Google Go编码风格的系列文档的一部分。本文档具备权威性和规范性。更多信息请参见概述


编码风格原则


这里列举了几条有关思考如何编写可读Go代码的总体原则。以下是可读代码的属性,按重要性排序:

  • 清晰:代码的目的和原理对读者来说是清晰的。
  • 简单:代码以最简单的方式完成其目标。
  • 简明:代码具有较高的信噪比。
  • 可维护:编写的代码可以很容易维护。
  • 一致:代码与更广泛的谷歌代码库风格一致。

清晰(Clarity)

可读性的核心目标是生产对读者清晰的代码。

清晰主要是通过有效的命名、有用的注释和有效的代码组织来实现的。

清晰与否要从代码的读者角度来看,而不是从代码的作者的角度来看。代码的易读性比易写性更重要。代码的清晰性有两个不同的方面:

  • 代码实际上在做什么?
  • 为什么代码在做它所做的事?

代码实际上在做什么?

Go的设计是这样的,它应该是可以相对直接地看到代码在做什么。在不确定的情况下,或者在读者可能需要先验知识才能理解代码的情况下,值得投入时间让代码的目的对未来的读者更加清晰。比如,下面这些措施可能会对清晰描述代码目的有帮助:

  • 使用更具描述性的变量名
  • 添加额外的注释
  • 用空白和注释来分隔代码
  • 将代码重构为独立的函数/方法,使其更加模块化。

这里没有一个放之四海而皆准的方法,但在开发Go代码时,优先考虑清晰性是很重要的。

为什么代码要做它所做的事?

代码的原理往往是通过变量、函数、方法或包的名称来充分传达的。如果这些元素名称无法做到这点,那么添加注释就会变得很重要。当代码包含读者可能不熟悉的细微差别时,解释“为什么”将变得尤其重要,例如:

  • 语言上的细微差别,例如,一个闭包将捕获一个循环变量,但闭包写在很多行之外;
  • 业务逻辑的细微差别,例如,访问控制检查需要区分实际用户和冒充用户的人。

一个API可能需要额外注意才能正确使用。例如,由于性能原因,一段代码可能是错综复杂的,导致很难理解,或者一连串复杂的数学运算可能以一种意想不到的方式使用类型了转换。在这些情况以及更多类似的情况下,使用附带注释和文档来解释这些方面就变得十分重要了,这样未来的维护者才不会犯错,读者也无需进行反向工程就可以理解代码。

同样重要的是要意识到,一些试图提升代码清晰度的尝试(如添加额外的注释)实际上会让代码的目的变得更加模糊,比如增加杂乱无章的内容、用注释重述代码逻辑、注释与代码逻辑自相矛盾或为保持注释同步而增加维护负担。让代码自己说话(例如,通过使用可自描述的符号名称),而不是添加多余的注释。通常情况下,注释最好是解释为什么要做某事,而不是解释代码在做什么。

谷歌的代码库在很大程度上是统一和一致的。通常情况下,那些“另类”的代码(例如,通过使用不熟悉的模式)也是有充分理由的,通常是为了性能。保持这一特性对于让读者在阅读一段新的代码时清楚地知道他们应该把注意力放在哪里是很重要的。

Go标准库中包含了许多践行这一原则的实例,其中包括:

  • sort包中的维护者注释;
  • 在同一个包中有可读性好的可运行的例子,这对用户(例子可以在godoc中显示)和维护者(例子作为测试的一部分运行)都有好处。
  • strings.Cut函数只有四行代码,但从调用者角度来看,该函数的存在提高了代码的清晰度和正确性

简单(Simplicity)

对于使用、阅读和维护它的人来说,你的Go代码应该是简单的。

Go代码应该以能实现其目标的最简单的方式编写,无论在行为还是性能方面。在GoogleGo代码库中,简单的代码具有如下属性:

  • 从上到下都易于阅读
  • 不假设你已经知道它的工作原理
  • 不假设你能记住前面所有的代码
  • 没有不必要的抽象层次
  • 在平凡代码中没有引起人们注意的名字
  • 让读者清楚地了解价值和决策的传播情况
  • 有注释,解释为什么,而不是代码在做什么,以避免将来出现偏差
  • 有独立的文档
  • 拥有有用的错误和有用的失败测试用例
  • 通常与“故作聪明的”代码相互排斥

在代码的简单性和API使用的简单性之间可能会产生权衡。例如,让代码更复杂可能是值得的,这样API的终端用户可能更容易正确地调用API。相反,把一些额外的工作留给API的终端用户也是值得的,这样代码就会保持简单和容易理解。

当代码需要复杂性时,应该有意地增加复杂性。如果需要额外的性能,或者一个特定的库或服务有多个不同的客户,这通常是必要的。复杂性可能是合理的,但它应该有相应的文档,以便客户和未来的维护者能够理解和驾驭这种复杂性。这应该用测试和例子来补充说明其正确的用法,特别是要在例子中既包含使用代码的“简单”方法,也包含“复杂”的使用方法。

这一原则并不意味着复杂的代码不能或不应该用Go编写,也不意味着Go代码不允许复杂。我们努力使代码库避免不必要的复杂性,这样当复杂性出现时,就表明有关的代码需要认真理解和维护。理想情况下,应该有相应的注释来解释其中的道理,并指出应该注意的地方。在优化代码以提高性能时,经常会出现这种情况;这样做往往需要更复杂的方法,比如预先分配一个缓冲区并在整个goroutine生命周期内重复使用它。当维护者看到这种情况时,应该视其为一个线索,说明相关的代码是性能敏感型的,未来修改这段代码时应该给予足够的谨慎。反过来,如果使用不当,这种复杂性会给那些需要在未来阅读或修改代码的人带来负担。

如果代码被证明是非常复杂的,而其目的应该是简单的,这往往是一个信号,可以重新审视实现,看看是否有更简单的方法来完成同样的事情。

最少的机制

如果有几种方法来表达同一个想法,最好选择使用最标准的一种。复杂的机制经常存在,但不应该无缘无故地使用。根据需要增加代码的复杂性是很容易的,而在发现没有必要之后再去掉现有的复杂性则要难得多。

  • 当足以满足你的使用情况时,要争取使用一个核心语言结构(例如channel、slice、map、循环或struct);
  • 如果没有,就在标准库中寻找一个工具(如HTTP客户端或模板引擎);
  • 最后,在引入新的依赖关系或自己造轮子之前,考虑谷歌代码库中是否有一个核心库是可以满足你要求的。

举个例子,考虑生产环境代码中包含一个与变量绑定的标志(flag),其默认值必须在测试中被重写。除非打算测试程序的命令行界面本身(例如,用os/exec),否则更可取的是直接重写绑定的值,这比使用flag.Set更简单。

同样地,如果一段代码需要检查集合的成员资格,一个值元素类型为布尔类型的map(例如map[string]bool)往往就足够了。只有在需要更复杂的操作,而使用map不可能完成或完成起来过于复杂时,才应使用提供类似集合类型和功能特性的库。

简明(Concision)

简明的Go代码具有很高的信噪比。它很容易分辨出相关的细节,而命名和结构则可以引导读者了解这些细节。

在任何时候,都有很多东西会阻碍主要的细节浮现出来:

  • 重复的代码
  • 不相干的语法
  • 难懂的名字
  • 不必要的抽象
  • 空白

重复的代码尤其掩盖了每个几乎相同的部分之间的差异,这需要读者可视化地比较相似的代码行后才能发现其中的差异。表格驱动的测试是一个很好的例子,这种机制可以简明地从每个重复的重要细节中找出共同的代码,但是选择在表格中包括哪些部分会对表格的易懂程度有影响。

当考虑用多种方式来组织代码结构时,值得考虑哪种方式更能突显重要的细节。

理解和使用常见的代码结构和地道用法对于保持高信噪比也很重要。例如,下面这个代码块在错误处理中非常常见,读者可以很快理解这个代码块的意图:

// Good:
if err := doSomething(); err != nil {
    // ...
}

如果代码看起来与此非常相似,但却有细微的不同,读者可能不会注意到这种变化。在这样的情况下,值得故意“提高”错误检查的信号,我们可以通过添加一个注释来引起注意。

// Good:
if err := doSomething(); err == nil { // if NO error
    // ...
}

可维护(Maintainability)

代码被编辑的次数比它被写的次数多得多。可读的代码不仅对试图了解其工作原理的读者有意义,而且对需要改变它的程序员也有意义。清晰度是关键。

可维护的代码具有如下性质:

  • 易于被未来的程序员正确修改
  • 具有结构化的API,使其能够优雅地扩展
  • 清楚它所做的假设,并选择与问题结构相对应的抽象,而不是与代码的结构相对应。
  • 避免不必要的耦合,不包含未用到的功能特性。
  • 拥有一个全面的测试套件,以确保承诺的行为得到维护以及重要的逻辑是正确的,并且在测试失败的情况下为开发人员提供清晰、可操作的诊断。

当使用像接口和类型这样的抽象时,顾名思义就是把信息从使用它们的环境中移除,因此必须确保它们提供足够的好处。当使用具体类型时,编辑器和IDE可以直接连接到方法定义并显示相应的文档,但在其他情况下只能参考接口定义。接口是一个强大的工具,但也是有代价的,因为维护者可能需要了解底层实现的具体细节才能正确使用接口,这必须在接口文档中或在调用现场进行解释。

可维护的代码还可以避免将重要的细节隐藏在容易被忽视的地方。例如,在下面示例的代码行中,一个字符的存在与否都会对代码的理解产生至关重要的影响:

// Bad:
// 使用=而不是:=可以完全改变这一行。
if user, err = db.UserByID(userID); err != nil {
    // ...
}
// Bad:
// 这行代码中间的!很容易错过。
leap := (year%4 == 0) && (!(year%100 == 0) || (year%400 == 0))

以上两段代码都不是不正确的,但都可以写得更明确,或者可以有一个附带的注释,提醒要注意的重要行为:

// Good:
u, err := db.UserByID(userID)
if err != nil {
    return fmt.Errorf("invalid origin user: %s", err)
}
user = u
// Good:
// 格里高利闰年不能仅通过year%4==0来判定。
// 具体请参见https://en.wikipedia.org/wiki/Leap_year#Algorithm.
var (
    leap4   = year%4 == 0
    leap100 = year%100 == 0
    leap400 = year%400 == 0
)
leap := leap4 && (!leap100 || leap400)

同样地,一个隐藏了关键逻辑或重要边缘情况的辅助函数(helper function),可能会使未来的变化很容易无法正确地解释它。

可预测的名字是可维护代码的另一个特点。一个包的用户或一段代码的维护者应该能够预测一个变量、方法或函数在特定情况下的名称。相同概念的函数(形式)参数和接收器名称(receiver name)通常应该共享相同的名称,这既是为了保持文档的可理解性,也是为了方便以最小的开销重构代码。

可维护的代码尽量减少其依赖性(包括隐性和显性的依赖)。更少的包的依赖意味着更少的可以影响行为的代码行。避免对内部或未记录的行为的依赖,使得代码在将来这些行为发生变化时,不太可能造成维护负担。

当考虑如何构造或编写代码时,值得花时间去思考代码可能随着时间的推移而演变的方式。如果一个给定的方法更有利于未来更容易和更安全的变化,这往往是一个很好的权衡,即使它意味着一个稍微复杂的设计。

一致(Consistency)

一致性的代码是指在更广泛的代码库中,在一个团队的代码中或一个包的范围内,甚至在一个文件中,看起来、感觉和行为都类似的代码。

一致性的问题并不凌驾于上述的任何原则之上,但如果必须打破平衡,那么打破平局的往往是有利于一致性的实现。

一个包内的一致性通常是最直接重要的一致性水平的体现。如果同一个问题在一个包里有多种处理方式,或者同一个概念在一个文件里有很多名字,那就会非常不协调。然而,即使这样,也不应该凌驾于文件的风格原则或全局一致性之上。

## 核心指导准则(Core guidelines)

这些准则收集了所有Go代码都应遵循的Go风格的最重要方面。我们希望开发者在编写可读性代码时学习和遵循这些准则。这些准则预计不会经常改变,新增加的内容必须通过一个高标准的审核门槛。

下面的准则是对Effective Go中的建议的扩展,它为整个社区的Go代码提供了一个共同的基线。

格式化

所有Go源文件必须符合gofmt工具输出的格式。这种格式由Google代码库中的预提交(presubmit)检查强制执行。生成的代码通常也应该被格式化(例如,通过使用format.Source),因为它也可以在代码搜索中被浏览。

驼峰命名(MixedCaps)

Go源代码在编写多字名称时使用MixedCaps或mixedCaps(驼峰命名)而不是下划线(蛇形命名)。

这甚至适用于打破其他语言的惯例的情况。例如,一个常量如果被导出,就用MaxLength(而不是MAX_LENGTH);如果未导出,就用maxLength(而不是max_length)。

为了选择头母大写的导出变量方案,本地变量被认为是未导出的

行的长度

Go源代码没有固定的行长。如果某一行感觉太长了,应该重构而不是断掉。如果它已经很短了,那么应该允许它继续保持长行。

在下面情况下,不要分割行:

  • 缩进改变之前(例如,函数声明,条件)。
  • 为了使一个长的字符串(例如,一个URL)适合于多个短行

命名

命名是艺术而不是科学。在Go中,名字往往比许多其他语言要短一些,但同样的一般准则也适用。名称应该具有如下属性:

  • 在使用时不感到重复
  • 将上下文因素考虑进去
  • 不要重复已经很清楚的概念

你可以在决定中找到更多关于命名的具体指导。

局部一致性

当风格指南对某一特定的风格点没有说明时,作者可以自由地选择他们喜欢的风格,除非附近的代码(通常在同一个文件或包内,但有时在一个团队或项目目录内)对这个问题采取了一致的风格。

有效的局部风格考量的例子:

  • 使用%s或%v的格式化打印错误
  • 使用带缓冲channel来代替mutex

无效的局部风格考量的例子:

  • 代码的行长限制
  • 使用基于断言的测试库

如果局部风格与风格指南不一致,但对可读性的影响仅限于一个文件,它通常会在代码审查中浮出水面,而一致的修正会超出有关CL的范围。在这一点上,提交一个bug来跟踪此类修复更为合适。

如果一个改变会使现有的风格偏差恶化,在更多的API表面暴露出来,扩大存在偏差的文件数量,或者引入一个实际的错误,那么局部一致性就不再是违反新代码风格指南的有效理由。在这些情况下,作者应该在同一CL中清理现有的代码库,在当前CL之前进行重构,或者找到一个至少不会使局部问题恶化的替代方案。