使用评分准则

本文档列出了在 Fuchsia 源代码树中编写 Go 时应遵循的规范。这些规范结合了最佳实践、项目偏好设置以及为保持一致性而做出的一些选择。

此处所述的惯例可被视为添加或修改常用最佳实践(如有效的 GoGo 代码审核注释中详述)。

常规

文件中声明的顺序

Go 文件中的声明的组织方式千差万别,特别是因为经常使用一个大型文件包含所有内容,或者使用许多较小的文件包含各元素。我们在此提供一些经验法则。

对于顶级声明(常量、接口、类型及其关联方法)的排序,通常按功能组列出,每个组中具有以下顺序:

  • 常量(包括枚举);
  • 导出的接口、类型和函数;
  • 支持导出的类型的未导出的接口、类型和函数。

例如,fidlgen 库中的一组功能可能是处理名称,而另一组功能可能在读取 JSON IR 并对其进行反规范化。随着内容越来越多,此类分组很适合成为其自己的单独文件。

对于包含方法的类型,通常按顺序列出:

  • 类型声明(例如 type myImpl struct { ...);
  • 类型断言(如果有);
  • 方法,首先是导出的方法,然后是未导出的方法;
  • 最好将实现接口的方法(如果适用)分组,即对接口 Foo 的所有方法进行分组,然后对 Bar 接口的所有方法进行分组;
  • 通常情况下,尽管已导出,String() 方法仍是最后的方法。

有关枚举,请参阅如何定义枚举

命名规范

Go 对如何命名非常有想法。一些惯例:

  • var err error
  • var buf bytes.Buffer
  • var buf strings.Builder
  • var mu sync.Mutex
  • var wg sync.WaitGroup
  • var ctx context.Context
  • _, ok := someMap[someKey]
  • _, ok := someValue.(someType)

定义方法时,请确保接收器的名称一致(如果存在)。另请参阅指针与值

避免导出

在 Go 中,可以取消导出或导出标识符(不使用公共/私有术语)。导出的声明以大写字母 MyImportantThing 开头,而未导出的声明以小写字母 myLocalThing 开头。

仅导出您确实需要并且打算重复使用的内容。请注意,在后续更改中导出相对容易,因此除非必要,否则请勿导出。这样可以让文档更加简洁。

如果您的类型将用于反射(例如在模板中或自动差异 à la go-cmp 中),可以有一个包含导出字段的未导出的标识符。同样,除非您必须这样做,否则请优先选择未导出的字段。

局部变量与类型同名不会有什么问题。如果您为导出的类型编写 server := Server{...},则 server := server{...} 可以用于未导出的类型。

切勿使用已命名的返回值

语义混乱,并且容易出错。即使这在某个时间点很适合使用(罕见且可疑),但演变通常会导致情况不再如此,这样一来,差异会大于实际变化。

您将看到如下建议:“可以对此特殊情况使用已命名的返回值”或“该特殊情况非常适合已命名的返回值”。我们的规则比较简单,绝不使用已命名的返回值。

(在定义接口时,可以对内容进行命名。这与实现中的命名返回不同,它没有语义方面的影响。)

定义枚举

用于定义枚举的标准模式为:

type myEnumType int

const (
    _ myEnumType = iota
    myFirstEnumValue
    mySecondEnumValue
)

只有在使用 iota 时才能省略该类型。如果指定显式值,则必须重复该类型:

const (
    _ myEnumType = iota
    myFirstEnumValue
    mySecondEnumValue
    // ...
    myCombinedValue myEnumType = 100
)

此外,如果您希望此枚举具有文本表示形式,请执行以下操作:

var myEnumTypeStrings = map[myEnumType]string{
    myFirstEnumValue:  "myFirstEnumValue",
    mySecondEnumValue: "mySecondEnumValue",
}

func (val myEnumType) String() string {
    if fmt, ok := myEnumTypeStrings[val]; ok {
        return fmt
    }
    return fmt.Sprintf("myEnumType(%d)", val)
}

例如 mdlint: TokenKind。 与使用 switch 相比,首选使用 map 来改进代码以计算地图外的内容,例如 FromString 构造函数(通过搜索或预计算 stringsToMyEnum 反向映射而效率低下)。

仅当枚举成员的值为 0 时,才应使用自然默认值的枚举成员。如需了解详情,请参阅枚举 FIDL API 评分准则条目

另一种方法是使用 cmd/stringer 生成枚举。这样可以生成更高效的代码,但维护工作需要更多工作量。您可以在枚举稳定后使用此方法。

如需表示一个集,请使用映射到空结构体:

allMembers := map[uint32]struct{}{
    5: {},
    8: {},
}

实例化 Slice

如果您要使用字面量定义 Slice,则应该使用 []T{ ... }

否则,请使用 var slice []T 创建空切片,即不要使用 slice:= []T{}

请注意,预先分配 Slice 可以带来有意义的性能提升,可能优先于更简单的语法。如需了解实例,请参阅切片使用情况和内部结构

实例化地图

如果您要使用字面量定义地图,则可以使用 map[K]T{ ... }

否则,请使用 make(map[string]struct{}) 创建空映射,即不要使用 myEmptyMap := map[K]T{}

非正常退货

当函数需要传达非正常返回(即“失败”)时,有以下几种模式:

  1. func doSomething(...) (data, bool),即返回数据或 false。
  2. func doSomething(...) *data,即返回数据或 nil。
  3. func doSomething(...) (data, error),即返回数据或错误。
  4. func doSomething(...) dataWrapper,即返回一个封装容器,其中包含有关操作结果的结构信息。

关于何时不可能使用“哪种变种”的规则,给出了一些硬性规定,但有一些一般原则是适用的。

在多态上下文中使用时,在 Go 中依赖于 nil 与当前存在容易出错:nil 不是一个单元,许多 nil 并且它们彼此不相等!因此,当可能会在多态上下文中使用该方法时,最好返回额外的 ok 布尔值。仅当相应方法将仅用于单态上下文时,才使用 nil 与当前值作为指示。换句话说,使用模式 (2) 比 (1) 简单,并且当所有调用方都将使用函数而不通过接口的视角查看返回值时,使用模式是很好的替代方法。

返回 error 表示发生了问题,调用方会收到所发生情况的说明。调用方应以冒泡的方式显示错误,可能在此过程中封装它,或执行某种错误处理和恢复。与返回 ok 布尔值(或 nil 数据)的方法的一个关键区别在于,在返回 error 时,该方法会断言自身对上下文有充分的了解,以便将发生的情况归类为错误。

例如,如果未找到用户,LookupUser(user_id uint64) 会优先返回 ok 布尔值,而 LookupCountryCode(code IsoCountryCode) 则会在无法查找国家/地区(或请求了无效国家/地区)时返回 error 以标识配置有误。

如果方法的结果很复杂,并且需要结构化数据来描述,则应考虑返回某种封装容器。例如,使用数据封装容器的自然候选是一个验证 API,该 API 会遍历 XML 文档并返回错误路径列表,每个路径都附有警告或错误。

静态类型断言

由于 Go 使用结构子类型,也就是说,某个类型是否实现接口取决于其结构(而不是通过声明)。人们很容易认为某个类型会实现某种接口,但实际上它并未实现。这会导致距离较远、使用点发生损坏,并导致令人困惑的编译器错误。

为了解决此问题,您需要为类型实现的所有接口编写类型断言(没错,标准库中的接口也都是如此):

var _ MyInterface = (*myImplementation)(nil)

这会创建一个类型化的 nil,其赋值会强制编译器检查类型一致性。

通常情况下,有许多实现必须实现相同的接口,例如,表示一个抽象语法树,其中各个节点都实现表达式接口。在这些情况下,您应该一次执行所有类型断言,从而同时记录预期的所有子类型:

var _ = []MyInterface{
    (*myImplementation)(nil),
    (*myOtherImplementation)(nil),
    ...
}

关于应在何处放置这些类型断言的经验法则如下:

  • 如果每个实现各自独立,最好在实现下方提供一个类型断言,例如此处
  • 如果所有实现都旨在协同使用(例如表示 AST 的 Expression 接口的表达式节点),请优先选择接口下方的分组类型断言,例如此处

正在嵌入

嵌入是 Go 中非常强大的一个概念,好好利用它。如需简要了解,请参阅嵌入

嵌入接口或结构体类型时,应首先在其封装接口或结构体类型中列出这些内容,也就是说,嵌入的类型应显示为第一个字段。

指针与值

对于方法接收器,请阅读指针与值以及接收器类型。tl;dr 是为了确保内容的一致性,但如有疑问,请使用指针接收器。对于给定的类型,应始终保持一致的传递方式(即始终按值传递、始终按引用传递),这取决于该类型上是否定义方法(使用值或指针接收器)。

另外值得注意的是,按值传递结构体并认为调用方不会对其进行更改是不正确的。您可以轻松保留对象的映射、切片或引用,并因此对这些元素执行 mutate 操作。因此,在 Go 中,认为“按值传递是常量”这种关联是错误的。

有关方法接收器的具体建议,请参阅实现接口

实现接口

通常使用指针接收器实现接口;使用值接收器实现接口会导致该接口同时由值和指针实现,这会使尝试枚举接口可能实现的类型断言复杂化。请参阅 fxrev.dev/269371 实例。

在一些特定情况下,适合使用值接收器实现接口:

  • 类型从未用作指针时。例如,自定义排序通常通过定义 type mySlice []myElement 并在 mySlice 上实现 sort.Interface 来完成。类型 *mySlice 永远不会使用,因为 []myElement 已经是引用。点击此处查看示例。
  • 不应使用类型断言或类型切换接口类型的值。例如,Stringer 通常在值类型上实现。接受 val Stringer 的函数会开启 val.(type),这种情况不常见。

如有疑问,请始终使用指针接收器实现接口。

注释

请阅读评论,尤其是以下内容:

文档注释最好是完整的句子,可用于进行各种自动演示。第一个句子应该是一句话摘要,以所声明的名称开头。

// Compile parses a regular expression and returns, if successful,
// a Regexp that can be used to match against text.
func Compile(str string) (*Regexp, error) {

对类型的文档注释可能有一个头条文章“A”“An”或“The”。Go 标准库中的所有文档均遵循这种做法,例如 // A Buffer is a variable-sized ...

对于长度超过一句话的评论,风格通常首选一个总结句子,然后添加一个空行,再加上一个能够提供更多详细信息的段落。例如,请参阅 FileHeader 结构体。

封装时出错

使用 fmt.Errorf 传播错误时:

  • 使用 %s 仅添加其字符串值;
  • 使用 %w 可允许调用方解封和观察封装的错误;请注意,%w 会将这些封装的错误纳入您的 API。

请参阅处理 Go 1.13 中的错误部分,详细了解是否换行

在一些特定情况下,错误传播必须以符合 API 协定的方式完成,例如在 RDBMS 驱动程序中通常属于这种情况,其中返回的特定错误代码表示调用方可以从中恢复的情况。在这种情况下,您必须专门封装底层错误,而不是依赖于 fmt.Errorf

fmt 个动词

尽可能避免使用 %v,首选操作数支持的特定 fmt 动词。这样做的好处是,允许 go vet 检查操作数是否确实支持动词。

如果操作数是不实现 fmt.Stringer 的结构体,则 %v 不太可能生成良好的结果;%+v%#v 可能是更好的选择。

此规则的一个常见例外情况是在运行时可能为 nil 的操作数 - nil 值可以很好地由 %v 处理,但不是所有其他动词也会得到妥善处理。

在引用字符串时,请使用 %q,而不是显式调用 strconv.Quote

传播错误时,请参阅错误封装

GN 目标

Go 工具的典型 BUILD.gn 文件如下所示:

go_library("gopkg") {
  sources = [
    "main.go",
    "main_test.go",
  ]
}

go_binary("foo") {
  library = ":gopkg"
}

go_test("foo_test") {
  library = ":gopkg"
}

如果您有嵌套软件包(并且仅在本例中),请在 go_library 中使用 name = "go.fuchsia.dev/fuchsia/<path>/..." 形式启用递归软件包源:

go_library("gopkg") {
  name = "go.fuchsia.dev/fuchsia/tools/foo/..."
  sources = [
    "main.go",
    "subdir/bar.go",
    "extra/baz.go",
  ]
}

测试

以 _test 结尾的文件包

通常,_test.go 文件与其测试的代码位于同一软件包中(例如 package foo),并且可以访问未导出的声明。Go 还允许为软件包名称添加 _test 后缀(例如 package foo_test),在这种情况下,它会被编译为单独的软件包,但与主二进制文件相关联并运行。这种方法称为外部测试,与内部测试不同。请勿使用 _test 后缀命名软件包,除非您要编写外部测试,请改为参阅测试软件包

在进行集成级测试时,请优先使用外部测试,或者与被测软件包中仅导出的部分进行交互。

使用以 _test 结尾的软件包也有助于提供经过编译的示例代码,此代码可随软件包选择器按原样复制。例如,示例代码及其源代码

测试实用程序

测试实用程序是在软件包中使用的辅助程序,用于帮助进行测试。它属于“测试代码”,因为它位于后缀为 _test.go 的文件中。请将测试实用程序放在名为 testutils_test.go 的文件中;编译器会对此惯例进行解释,以使此代码不包含在非测试二进制文件中,从而确保不会在测试之外使用它。

测试软件包

测试软件包是一个库,其重点是简化测试的编写。它是“生产代码”,不以 _test.go 后缀结尾,只可在测试代码中使用。

测试软件包的命名惯例是使用“test”后缀,并加上用于测试的软件包名称。标准库中的示例包括 Fuchsia 树 fidlgentest 中的 httptestiotest,以及 emulatortest

其他资源