每门开发语言都会有其特有的风格规范(亦或指南),开发者遵循规范能带来显著收益,有效促进团队协作、减少 bug 错误、降低维护成本等。

Google 开源的 Google Style Guides (https://google.github.io/styleguide/)为多种编程语言提供了风格规范,包括 C++、Java、Python、JavaScript 等。在 2022 年 11 月,Go 语言风格规范(https://google.github.io/styleguide/go/index)也终于得到开源。

Google 发布的 Go 语言风格规范一共包括四部分内容。

  • 概述篇:https://google.github.io/styleguide/go/index
  • 指南篇:https://google.github.io/styleguide/go/guide
  • 决策篇:https://google.github.io/styleguide/go/decisions
  • 最佳实践篇:https://google.github.io/styleguide/go/best-practices

如果你所在的团体还未形成一套系统的 Go 风格规范,不妨参考这份指南。

风格原则

这里列举了一些总体原则,它们总结了思考如何编写可读性 Go 代码。以下是根据重要性排序的可读性代码特性。

  • 清晰性:对读者来说,代码的目的和基本原理是清楚的。
  • 简单性:代码以尽可能简单的方式实现其目标。
  • 简洁性:代码具有高信噪比。
  • 可维护性:编写的代码易于维护。
  • 一致性:代码与更广泛的 Google 代码库风格一致。
1. 清晰性

可读性的核心目标是编写对读者而言清晰的代码。

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

清晰性应该是从读者的角度来看,而非代码编写者。代码易于阅读比易于编写更重要。代码清晰性有两个不同的方面:

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

Go 的设计使得它应该相对直接地看到代码在做什么。在存在不确定或者读者需要先验知识才能理解代码的情况下,这值得花时间让未来的读者更清晰地了解代码的用途。例如,以下措施可能会有助于提供更清晰的代码:

  • 使用更具描述性的变量名
  • 添加附加注释
  • 用空格和注释分解代码
  • 将代码重构为单独的函数/方法,使其更加模块化

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

为什么代码会做它所做的事情?

代码的基本原理通常通过变量、函数、方法或包的名称充分传达。如果没有,添加注释很重要。当代码包含了读者可能不熟悉的细微差别时,解释”为什么“就显得尤为重要。例如:

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

某个 API 可能需要额外注意才能正确使用。例如,出于性能原因,一段代码可能错综复杂且难以理解,或者一系列复杂的数学运算可能以意想不到的方式使用类型转换。在这些场景以及更多类似的情况下,重要的是要有随附的注释和文档来解释它们,这样以后的维护者才不会犯错误,这些读者能够不用进行逆向工程而理解代码。

同样重要的是要注意到,一些为了提供清晰性的尝试(例如添加额外的注释)可能会模糊代码的实际目的,例如增加混乱、重复描述代码、与代码自相矛盾的注释,或者为了保证注释最新而增加维护负担。让代码自己说话(例如,通过使用可自描述的符号名称)而不是添加多余的注释。注释通常最好是解释为什么做某事,而不是代码在做什么。

Google 代码库在很大程度上是统一与一致的。通常情况下,与众不同的代码(例如,通过使用不熟悉的模式)那样做是有充分理由的,通常是为了性能考虑。保持这一特性很重要,它可以让读者清楚地知道在阅读一段新代码时他们应该把注意力集中在什么地方。。

标准库包含许多实践该原则的示例。例如其中:

  • sort 包中的维护者注释。
  • 同样在 sort 包中有一些很好且可运行的例子,它们同时有利于使用者(例子在[godoc](sort package - sort - Go Packages)显示)和维护者(作为部分测试用途的例子)。
  • strings.Cut 虽然只有四行代码,但它们提高了对调用者而言,使用时的清晰性和正确性。
2. 简单性

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

Go 代码应该以实现其目标的最简单方式编写,无论是在行为还是性能方面。在 Google Go 代码库中,简单的代码意味着:

  • 易于从上到下阅读
  • 不假设你已经提前知道它在做什么
  • 不假设你可以记住前面的所有代码
  • 没有不必要的抽象层次
  • 没有引起人们对普通事物的注意的名字
  • 使读者清楚值和决策的传播情况
  • 有注释解释为什么而不是什么,以及代码正在做什么以避免以后的偏差
  • 有独立的文档
  • 有有用的错误和有用的失败测试用例
  • 可能经常与“故作聪明”的代码相互排斥

在代码简单性和 API 使用简单性之间可能会出现权衡。例如,让代码更复杂可能是值得的,这样 API 的终端用户可以更容易且正确地调用 API。相比之下,为 API 的终端用户留一些额外的工作也可能是值得的,这样代码仍然简单易懂。

当代码需要复杂性时,应该刻意地增加复杂性。如果需要额外的性能,或者一个特定的库或服务有多个不同的用户,这通常是必要的。复杂性应该是合理的,但它应该随附文档,以便用户和未来的维护者能够理解和驾驭复杂度。这应该辅以证明其正确用法的测试和示例,尤其是在同时存在“简单”和“复杂”代码使用方式的情况下。

我们力求避免代码库中不必要的复杂性,以便当复杂性确实出现时,它表明这些相关代码需要仔细理解和维护。理想情况下,应该附有注释,它解释基本原理并确定应注意的事项。在优化代码以提高性能时经常会出现这种情况;这样做通常需要更复杂的方法,比如预分配缓冲区并在 goroutine 的整个生命周期中重用它。当维护者看到它时,这应该是一个线索,表明相关代码是性能的关键代码,未来对该段代码进行修改时应该持有谨慎态度。另一方面,如果使用不当,这种复杂性会给那些将来需要阅读或更改代码的人带来负担。

如果代码的目的很简单但最终实现却很复杂,这通常是应该重新查看实现以确定是否有更简单的方式来完成相同目的的信号。

最少机制

如果有多种方式可以表达相同的想法,请选择使用最标准的一种。复杂的机制常在,但不应无故使用。根据需要而增加代码的复杂性很容易,而在发现不必要的复杂性后,消除现有的复杂性要困难得多。

  1. 在足以满足你的用例时,使用核心语言结构(例如 slice、map、循环或 struct)。
  2. 如果没有,请在标准库中寻找一种工具(如 HTTP 客户端或模板引擎)。
  3. 最后,在引入新的依赖项或自己造轮子之前,请考虑 Google 代码库中是否有对应的核心库。

例如,考虑包含绑定到具有默认值的变量的标志的生产环境代码,该默认值必须在测试中被覆盖。除非打算测试程序的命令行界面本身(例如,使用 os/exec),否则直接覆盖绑定值比使用 flag.Set 更简单,也更可取。类似地,如果一段代码需要对集合成员进行检查,那么一个布尔值类型的 map(map[string]bool)通常就足够了。仅当需要更复杂的操作而 map 无法实现或使用起来过于复杂,才应使用提供类集合类型和功能的库。

3. 简洁性

简洁的 Go 代码具有很高的信噪比。应该很容易地辨别相关细节,它通过命名和代码结构引导读者去详细了解。

在任何时候,都会有很多东西阻碍代码呈现最主要的细节:

  • 重复代码
  • 外来语法
  • 晦涩难懂的名字
  • 不必要的抽象
  • 空格

重复的代码模糊了每个几乎相同部分之间的差异,它需要读者通过视觉比较相似的代码行已找到变化的地方。表格驱动测试室一个很好的机制示例,它可以从每次重复的重要细节中简明地提取出通用代码,但是选择将哪些部分包含在表格中将影响表格的易懂程度。

在考虑多种方式组织代码时,需要考虑哪种方式能让重要的细节最明显。

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

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

如果代码看起来与此非常相似但略有不同,读者可能不会注意到变化。在这种情况下,值得通过添加注释以引起注意,来有意“增强”错误检查的信号。

// Good:
if err := doSomething(); err == nil { // if NO error
    // ...
}
4. 可维护性

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

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

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

可维护的代码也避免了将重要的细节隐藏在容易被忽视的地方。例如,在以下的代码例子中,一个字符的存在都会对理解代码产生重大的影响。

// Bad:
// The use of = instead of := can change this line completely.
if user, err = db.UserByID(userID); err != nil {
    // ...
}
// Bad:
// The ! in the middle of this line is very easy to miss.
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:
// Gregorian leap years aren't just year%4 == 0.
// See 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)

同样的,一个隐藏了关键逻辑或者重要边缘情况的辅助函数会很容易地使得之后的更改中可能没有合适地考虑到它。

可预测的命名是可维护代码的另一个特征。包的用户或一段代码的维护者应该能够预测给定上下文中的变量、方法或函数的名称。相同概念的函数参数和接收者通常应该共享相同的名称,这既可以使文档易于理解,也可以以最小的开销促进代码重构。

可维护代码应最小化其依赖性(隐式和显式)。依赖更少的包意味着可以影响行为的代码行更少。避免对内部或者没有文档记录的行为的依赖,在将来这些行为发生改变时,这些代码就不太可能会成为维护负担。

在考虑如何组织或编写代码时,值得花时间去思考代码可能随时间的推移而演进的方式。如果给定的方法更有利于未来更简单、更安全的更改,那通常是一个很好的权衡,即使这意味着设计稍微复杂一些。

5. 一致性

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

一致性问题不会凌驾于上述任何原则,但如果必须打破平衡,那么打破平衡以保持一致性通常是有益的。

包内的一致性通常是最直接重要的一致性级别。如果同一个问题在包中以多种方式处理,或者如果相同概念在一个文件中有多个名称,则可能会非常不协调。但是,即使这样也不应该凌驾于文档化的风格原则或全局一致性之上。

核心指南

这些指南收集了所有 Go 代码都应遵循的 Go 风格最重要的方面。我们希望在编写可读性代码的时候学习并遵循这些准则。这些准则预计不会被经常更改,并且新添加的内容必须被高标准审核。

下面的指南扩展了 Effective Go 中的建议,这些建议为整个社区的 Go 代码提供了一个共同的基线。

格式化

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

驼峰命名

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

即使它打破了其他语言的约定,这也适用。例如,常量如果是可导出的,则为 MaxLength(而非 MAX_LENGTH),如果为未导出的,则为 maxLength(而非 max_length)。

为了首字母大写为可导出的目的,局部变量被视为未导出的。

代码行长度

Go 源代码没有固定的代码行长度。如果一行感觉太长,应该考虑重构而不是折断。如果它已经尽可能短,那么应该允许该代码行保持变长。

不要分割代码行的情况:

  • 在缩进更改之前(例如,函数声明、条件)
  • 使某个长字符串(例如 URL)适合多个较短的行
命名

命名与其说是科学,不如说是一门艺术。在 Go 中,名称往往比许多其他语言短一些,但适用相同的一般准则。命名时应该注意:

  • 使用时不会感到重复
  • 考虑上下文
  • 不再重复已经清楚的概念

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

局部一致性

在风格指南没有提及特定风格点的地方,作者可以自由选择他们喜欢的风格,除非代码非常接近的地方(通常在同一个文件或包中,但有时在团队或项目目录中) 在该问题上采取了一致的风格。

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

  • 使用 %s 或 %v 格式化打印错误
  • 使用缓存 channel 代替互斥锁

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

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

如果局部风格与风格指南不一致,但可读性影响仅限于一个文件,它通常会在代码审查中浮出水面,一致的修复将超出相关 CL(change list,变更清单) 的范围。那时,提交一个 bug 以追踪该修复是合适的。

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