二、代码和项目组织
本章涵盖
- 习惯性地组织我们的代码
- 有效处理抽象:接口和泛型
- 关于如何构建项目的最佳实践
以一种干净、惯用和可维护的方式组织 Go 代码库并不是一件容易的事情。理解所有与代码和项目组织相关的最佳实践需要经验,甚至是错误。要避免哪些陷阱(例如,变量隐藏和嵌套代码滥用)?我们如何构造包?我们何时何地使用接口或泛型、init
函数和实用工具包?在这一章中,我们检查常见的组织错误。
2.1 #1:意外的变量隐藏
变量的作用域指的是变量可以被引用的地方:换句话说,就是应用中名字绑定有效的部分。在 Go 中,块中声明的变量名可以在内部块中重新声明。这个原理叫做变量隐藏,容易出现常见错误。
以下示例显示了由于隐藏变量而产生的意外副作用。它以两种不同的方式创建 HTTP 客户端,这取决于一个tracing
布尔值:
var client *http.Client // ❶
if tracing {
client, err := createClientWithTracing() // ❷
if err != nil {
return err
}
log.Println(client)
} else {
client, err := createDefaultClient() // ❸
if err != nil {
return err
}
log.Println(client)
}
// Use client
❶ 声明了一个client
变量
❷ 创建一个启用了跟踪的 HTTP 客户端。(client
变量在此块中被隐藏。)
❸ 创建一个默认的 HTTP 客户端。(client
变量在这个块中也被隐藏。)
在这个例子中,我们首先声明一个client
变量。然后,我们在两个内部块中使用短变量声明操作符(:=
)将函数调用的结果分配给内部client
变量——而不是外部变量。因此,外部变量总是nil
。
注意这段代码会编译,因为内部的client
变量会在日志调用中使用。如果没有,我们就会出现client declared
and
not
used
等编译错误。
我们如何确保给原始的client
变量赋值呢?有两种不同的选择。
第一个选项以这种方式在内部块中使用临时变量:
var client *http.Client
if tracing {
c, err := createClientWithTracing() // ❶
if err != nil {
return err
}
client = c // ❷
} else {
// Same logic
}
❶ 创建了一个临时变量c
❷ 将这个临时变量分配给client
这里,我们将结果赋给一个临时变量c
,它的范围只在if
块内。然后,我们将它赋回给client
变量。同时,我们对else
部分做同样的工作。
第二个选项使用内部程序块中的赋值运算符(=
)将函数结果直接赋给client
变量。然而,这需要创建一个error
变量,因为赋值操作符只有在已经声明了变量名的情况下才起作用。例如:
var client *http.Client
var err error // ❶
if tracing {
client, err = createClientWithTracing() // ❷
if err != nil {
return err
}
} else {
// Same logic
}
❶ 声明了一个err
变量
❷ 使用赋值操作符给*http
赋值。客户端直接返回到client
变量
不用先赋给一个临时变量,我们可以直接把结果赋给client
。
两种选择都完全有效。这两个选项之间的主要区别是,我们在第二个选项中只执行一个赋值,这可能被认为更容易阅读。同样,使用第二个选项,我们可以在if
/ else
语句之外共同化和实现错误处理,如下例所示:
if tracing {
client, err = createClientWithTracing()
} else {
client, err = createDefaultClient()
}
if err != nil {
// Common error handling
}
当在内部块中重新声明变量名时,会出现变量隐藏,但是我们看到这种做法容易出错。强加一个禁止隐藏变量的规则取决于个人喜好。例如,有时重用现有的变量名(如err
)来处理错误会很方便。然而,总的来说,我们应该保持谨慎,因为我们现在知道我们可能会面临这样的场景:代码可以编译,但是接收值的变量不是预期的变量。在本章的后面,我们还将看到如何检测隐藏变量,这可能有助于我们发现可能的错误。
下一节展示了避免滥用嵌套代码的重要性。
2.2 #2:不必要的嵌套代码
应用于软件的心智模型是系统行为的内部表示。在编程时,我们需要维护心智模型(例如,关于整体代码交互和功能实现)。基于多种标准,如命名、一致性、格式等,代码被限定为可读的。可读代码需要较少的认知努力来维护心智模型;因此,它更容易阅读和维护。
可读性的一个重要方面是嵌套层次的数量。让我们做一个练习。假设我们正在进行一个新项目,需要理解下面的join
函数是做什么的:
func join(s1, s2 string, max int) (string, error) {
if s1 == "" {
return "", errors.New("s1 is empty")
} else {
if s2 == "" {
return "", errors.New("s2 is empty")
} else {
concat, err := concatenate(s1, s2) // ❶
if err != nil {
return "", err
} else {
if len(concat) > max {
return concat[:max], nil
} else {
return concat, nil
}
}
}
}
}
func concatenate(s1 string, s2 string) (string, error) {
// ...
}
❶ 调用concatenate
函数来执行某些特定的连接,但可能会返回错误
这个join
函数连接两个字符串,如果长度大于max
,则返回一个子字符串。同时,它处理对s1
和s2
的检查,以及对concatenate
的调用是否返回错误。
从实现的角度来看,这个函数是正确的。然而,建立一个包含所有不同情况的心智模型可能不是一件简单的任务。为什么?因为嵌套层次的数量。
现在,让我们使用相同的函数,但以不同的方式再次尝试这个练习:
func join(s1, s2 string, max int) (string, error) {
if s1 == "" {
return "", errors.New("s1 is empty")
}
if s2 == "" {
return "", errors.New("s2 is empty")
}
concat, err := concatenate(s1, s2)
if err != nil {
return "", err
}
if len(concat) > max {
return concat[:max], nil
}
return concat, nil
}
func concatenate(s1 string, s2 string) (string, error) {
// ...
}
你可能已经注意到,尽管做着和以前一样的工作,但构建这个新版本的心智模型需要的认知负荷更少。这里我们只维护两个嵌套层次。正如 Mat Ryer 在 Go Time 播客(@matryer/line-of-sight-in-code-186dd7cdea88"target="_blank"">medium.com/@matryer/line-of-sight-in-code-186dd7cdea88
)中提到的:
向左对齐幸福路径;您应该很快能够向下扫描一列,以查看预期的执行流。
由于嵌套的if
/ else
语句,在第一个版本中很难区分预期的执行流。相反,第二个版本需要向下扫描一列来查看预期的执行流,向下扫描第二列来查看边缘情况是如何处理的,如图 2.1 所示。
图 2.1 为了理解预期的执行流程,我们只需浏览一下快乐路径列。
一般来说,函数需要的嵌套层次越多,阅读和理解起来就越复杂。让我们看看这条规则的一些不同应用,以优化我们的代码可读性:
当一个
if
块返回时,我们应该在所有情况下省略else
块。例如,我们不应该写if foo() { // ... return true } else { // ... }
相反,我们像这样省略了
else
块:if foo() { // ... return true } // ...
在这个新版本中,先前在
else
块中的代码被移到顶层,使其更容易阅读。我们也可以沿着这个逻辑走一条不快乐的路:
if s != "" { // ... } else { return errors.New("empty string") }
这里,空的
s
代表非快乐路径。因此,我们应该像这样翻转条件:if s == "" { // ❶ return errors.New("empty string") } // ...
❶翻转了
if
条件这个新版本更容易阅读,因为它将快乐路径保留在左边,并减少了块数。
编写可读的代码对每个开发人员来说都是一个重要的挑战。努力减少嵌套块的数量,将快乐路径放在左边,尽可能早地返回,这些都是提高代码可读性的具体方法。
在下一节中,我们将讨论 Go 项目中一个常见的误用:init
函数。
2.3 #3:误用init
函数
有时我们会在 Go 应用中误用init
函数。潜在的后果是糟糕的错误管理或更难理解的代码流。让我们重温一下什么是init
函数。然后,我们将会看到它的用法是否被推荐。
2.3.1 概念
init
函数是用于初始化应用状态的函数。它不接受任何参数,也不返回任何结果(一个func()
函数)。当一个包被初始化时,包中所有的常量和变量声明都会被求值。然后,执行init
函数。下面是一个初始化main
包的例子:
package main
import "fmt"
var a = func() int {
fmt.Println("var") // ❶
return 0
}()
func init() {
fmt.Println("init") // ❷
}
func main() {
fmt.Println("main") // ❸
}
❶ 首先被执行
❷ 其次被执行
❸ 最后被执行
运行此示例将打印以下输出:
var
init
main
初始化软件包时会执行init
函数。在下面的例子中,我们定义了两个包,main
和redis
,其中main
依赖于redis
。首先,主要的。从main
包开始:
package main
import (
"fmt"
"redis"
)
func init() {
// ...
}
func main() {
err := redis.Store("foo", "bar") // ❶
// ...
}
❶ 依赖于redis
实现
然后从redis
包中redis.go
:
package redis
// imports
func init() {
// ...
}
func Store(key, value string) error {
// ...
}
因为main
依赖于redis
,所以首先执行redis
包的init
函数,然后是main
包的init
,然后是的main
函数本身。图 2.2 显示了这个顺序。
我们可以为每个包定义多个init
函数。当我们这样做时,包内init
函数的执行顺序是基于源文件的字母顺序。例如,如果一个包包含一个a.go
文件和一个b.go
文件,并且这两个文件都有一个init
函数,则首先执行a.go
init
函数。
图 2.2 首先执行redis
包的init
函数,然后是main
的init
函数,最后是的main
函数。
我们不应该依赖包中init
函数的排序。事实上,这可能很危险,因为源文件可能会被重命名,从而潜在地影响执行顺序。
我们也可以在同一个源文件中定义多个init
函数。例如,这段代码完全有效:
package main
import "fmt"
func init() { // ❶
fmt.Println("init 1")
}
func init() { // ❷
fmt.Println("init 2")
}
func main() {
}
❶ 第一个init
函数
❷ 第二个init
函数
执行的第一个init
函数是源代码顺序中的第一个。以下是输出结果:
init 1
init 2
我们也可以使用init
函数来产生副作用。在下一个例子中,我们定义了一个main
包,它对foo
没有很强的依赖性(例如,没有直接使用公共函数)。然而,这个例子需要初始化foo
包。我们可以这样使用_
操作符:
package main
import (
"fmt"
_ "foo" // ❶
)
func main() {
// ...
}
❶ 导入foo
有副作用
在这种情况下,foo
包在main
之前初始化。因此,执行foo
的init
函数。
init
函数的另一个特点是它不能被直接调用,如下例所示:
package main
func init() {}
func main() {
init() // ❶
}
❶ 无效引用
这段代码会产生以下编译错误:
$ go build .
./main.go:6:2: undefined: init
既然我们已经了解了init
函数是如何工作的,那么让我们看看什么时候应该使用或者不使用它们。下一节将对此进行阐述。
2.3.2 何时使用init
函数
首先,让我们看一个使用init
函数被认为不合适的例子:持有数据库连接池。在示例中的init
函数中,我们使用sql.Open
打开一个数据库。我们使这个数据库成为一个全局变量,其他函数以后可以使用:
var db *sql.DB
func init() {
dataSourceName :=
os.Getenv("MYSQL_DATA_SOURCE_NAME") // ❶
d, err := sql.Open("mysql", dataSourceName)
if err != nil {
log.Panic(err)
}
err = d.Ping()
if err != nil {
log.Panic(err)
}
db = d // ❷
}
❶ 环境变量
❷ 将数据库连接分配给全局db
变量
在本例中,我们打开数据库,检查是否可以 ping 它,然后将它赋给全局变量。我们应该如何看待这个实现?让我们描述三个主要的缺点。
首先,init
函数中的错误管理是有限的。事实上,由于init
函数不返回错误,发出错误信号的唯一方式就是恐慌,导致应用停止。在我们的例子中,如果打开数据库失败,无论如何停止应用也是可以的。然而,不应该由包本身来决定是否停止应用。也许调用者可能更喜欢实现重试或使用回退机制。在这种情况下,在init
函数中打开数据库会阻止客户端包实现它们的错误处理逻辑。
另一个重要的缺点与测试有关。如果我们向这个文件添加测试,init
函数将在运行测试用例之前执行,这不一定是我们想要的(例如,如果我们在一个不需要创建这个连接的实用函数上添加单元测试)。因此,本例中的init
函数使编写单元测试变得复杂。
最后一个缺点是,该示例要求将数据库连接池分配给一个全局变量。全局变量有一些严重的缺点;例如:
任何函数都可以改变包内的全局变量。
单元测试可能会更复杂,因为依赖于全局变量的函数不再是孤立的。
在大多数情况下,我们应该倾向于封装一个变量,而不是保持它的全局。
出于这些原因,之前的初始化可能应该作为普通旧函数的一部分来处理,如下所示:
func createClient(dsn string) (*sql.DB, error) { // ❶
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err // ❷
}
if err = db.Ping(); err != nil {
return nil, err
}
return db, nil
}
❶ 接受数据源名称并返回一个*sql.DB
和一个错误
❷ 返回一个错误
使用这个函数,我们解决了前面讨论的主要缺点。方法如下:
错误处理的责任留给了调用者。
可以创建一个集成测试来检查该函数是否有效。
连接池封装在函数中。
有必要不惜一切代价避免init
函数吗?不完全是。在一些用例中,init
函数仍然是有用的。例如,官方的 Go 博客(mng.bz/PW6w
)使用init
函数来设置静态 HTTP 配置:
func init() {
redirect := func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
http.HandleFunc("/blog", redirect)
http.HandleFunc("/blog/", redirect)
static := http.FileServer(http.Dir("static"))
http.Handle("/favicon.ico", static)
http.Handle("/fonts.css", static)
http.Handle("/fonts/", static)
http.Handle("/lib/godoc/", http.StripPrefix("/lib/godoc/",
http.HandlerFunc(staticHandler)))
}
在这个例子中,init
函数不会失败(http.HandleFunc
可能会恐慌,但只有在处理器是nil
的情况下才会恐慌,但这里的情况不是这样)。同时,不需要创建任何全局变量,该函数不会影响可能的单元测试。因此,这个代码片段提供了一个很好的例子,说明了init
函数的用处。总之,我们看到init
函数会导致一些问题:
他们可以限制错误管理。
它们会使如何实现测试变得复杂(例如,必须建立一个外部依赖,这对于单元测试的范围来说可能是不必要的)。
如果初始化需要我们设置一个状态,那必须通过全局变量来完成。
我们应该谨慎使用init
函数。然而,在某些情况下,它们会很有帮助,比如定义静态配置,正如我们在本节中看到的。否则,在大多数情况下,我们应该通过特殊函数来处理初始化。
2.4 #4:过度使用获取器和设置器
在编程中,数据封装是指隐藏一个对象的值或状态。获取器和设置器是通过在未导出的对象字段上提供导出的方法来启用封装的方法。
在 Go 中,没有像我们在一些语言中看到的那样自动支持获取器和设置器。使用获取器和设置器来访问结构字段也被认为既不强制也不习惯。例如,标准库实现了这样的结构,其中一些字段可以直接访问,例如作为time.Timer
结构:
timer := time.NewTimer(time.Second)
<-timer.C // ❶
❶ C 是一个<–chan Time
字段
尽管不推荐,我们甚至可以直接修改C
(但是我们不会再接收事件了)。然而,这个例子说明了标准的 Go 库并不强制使用获取器和/或设置器,即使我们不应该修改一个字段。
另一方面,使用获取器和设置器有一些优点,包括:
它们封装了与获取或设置字段相关的行为,允许以后添加新功能(例如,验证字段、返回计算值或用互斥体包装对字段的访问)。
它们隐藏了内部表现,让我们在展示时更加灵活。
它们为运行时属性的改变提供了一个调试拦截点,使得调试更加容易。
如果我们陷入这些情况或者预见到一个可能的用例,同时保证向前兼容,使用获取器和设置器可以带来一些价值。例如,如果我们将它们用于一个名为balance
的字段,我们应该遵循这些命名约定:
获取器方法应该命名为
Balance
(不是GetBalance
)。设置器方法应该命名为
SetBalance
。
这里有一个例子:
currentBalance := customer.Balance() // ❶
if currentBalance < 0 {
customer.SetBalance(0) // ❷
}
❶ 获取器
❷ 设置器
总之,如果结构上的获取器和设置器没有带来任何价值,我们就不应该用它们来淹没我们的代码。我们应该务实,努力在效率和遵循习惯用法之间找到正确的平衡,这些习惯用法在其他编程范例中有时被认为是无可争议的。
请记住,Go 是一种独特的语言,它具有许多特性,包括简单性。然而,如果我们发现需要获取器和设置器,或者,如前所述,在保证向前兼容性的同时预见到未来的需要,使用它们没有任何问题。
接下来,我们将讨论过度使用接口的问题。
2.5 #5:接口污染
在设计和构建我们的代码时,接口是 Go 语言的基石之一。然而,像许多工具或概念一样,滥用它们通常不是一个好主意。接口污染就是用不必要的抽象来淹没我们的代码,使代码更难理解。这是来自不同习惯的另一种语言的开发人员经常犯的错误。在深入探讨这个话题之前,我们先来回顾一下 Go 的接口。然后,我们将看到什么时候使用接口是合适的,什么时候它可能被认为是污染。
2.5.1 概念
接口提供了一种指定对象行为的方式。我们使用接口来创建多个对象可以实现的公共抽象。使 Go 接口如此不同的是它们被隐式地满足了。没有像implements
这样明确的关键字来标记一个对象X
实现了接口Y
。
为了理解是什么让接口如此强大,我们将从标准库中挖掘两个流行的接口:io.Reader
和io.Writer
。io
包为 I/O 原语提供了抽象。在这些抽象中,io.Reader
与从数据源读取数据有关,io.Writer
与向目标写入数据有关,如图 2.3 所示。
图 2.3 io.Reader
从数据源读取并填充一个字节切片,而io.Writer
从一个字节切片写入目标。
io.Reader
包含一个单个Read
方法:
type Reader interface {
Read(p []byte) (n int, err error)
}
接口的定制实现应该接受一个字节切片,用它的数据填充它,并返回读取的字节数或一个错误。
另一方面,io.Writer
定义了单个方法,Write
:
type Writer interface {
Write(p []byte) (n int, err error)
}
io.Writer
的定制实现应该将来自一个片的数据写入一个目标,并返回写入的字节数或一个错误。因此,这两个接口都提供了基本的抽象:
io.Reader
从数据源读取数据。io.Writer
将数据写入目标。
语言中有这两个接口的基本原理是什么?创建这些抽象的目的是什么?
假设我们需要实现一个将一个文件的内容复制到另一个文件的函数。我们可以创建一个特定的函数,将两个*os.File
作为输入。或者,我们可以选择使用io.Reader
和io.Writer
抽象来创建一个更加通用的函数:
func copySourceToDest(source io.Reader, dest io.Writer) error {
// ...
}
这个函数将与*os.File
参数一起工作(因为*os.File
实现了io.Reader
和io.Writer
)以及实现这些接口的任何其他类型。例如,我们可以创建自己的写入数据库的io.Writer
,而代码保持不变。它增加了函数的通用性;因此,它的可重用性。
此外,为这个函数编写单元测试更加容易,因为我们可以使用strings
和bytes
包,而提供了有用的实现,而不是处理文件:
func TestCopySourceToDest(t *testing.T) {
const input = "foo"
source := strings.NewReader(input) // ❶
dest := bytes.NewBuffer(make([]byte, 0)) // ❷
err := copySourceToDest(source, dest) // ❸
if err != nil {
t.FailNow()
}
got := dest.String()
if got != input {
t.Errorf("expected: %s, got: %s", input, got)
}
}
❶ 创建了一个io.Reader
❷ 创建了一个io.Writer
❸ 从*strings
、io.Reader
和io.Writer
调用copySourceToDest
。
在本例中,source
是一个*strings.Reader
,而dest
是一个*bytes.Buffer
。这里,我们在不创建任何文件的情况下测试copySourceToDest
的行为。
在设计接口时,粒度(接口包含多少方法)也是需要记住的。Go (www.youtube.com/watch?v=PAAkCSZUG1c&t=318s
)中一个众所周知的谚语与一个接口应该有多大有关:
接口越大,抽象越弱。
——罗布·派克
事实上,向接口添加方法会降低接口的可重用性。io.Reader
和io.Writer
是强大的抽象,因为它们不能再简单了。此外,我们还可以结合细粒度的接口来创建更高级别的抽象。io.ReadWriter
就是这种情况,它结合了读者和作者的行为:
type ReadWriter interface {
Reader
Writer
}
注正如爱因斯坦所说,“一切都应该尽可能简单,但不能再简单了。”应用于接口,这意味着找到接口的完美粒度不一定是一个简单的过程。
现在让我们讨论推荐接口的常见情况。
2.5.2 何时使用接口
我们应该什么时候在 Go 中创建接口?让我们看三个具体的用例,在这些用例中,接口通常被认为是带来价值的。请注意,我们的目标并不是详尽无遗的,因为我们添加的案例越多,它们就越依赖于上下文。然而,这三个案例应该给我们一个大致的概念:
普通行为
解耦
限制行为
普通行为
我们将讨论的第一个选项是当多个类型实现一个公共行为时使用接口。在这种情况下,我们可以分析出接口内部的行为。如果我们看看标准库,我们可以找到许多这样的用例的例子。例如,可以通过三种方法对集合进行排序:
检索集合中元素的数量
报告一个元素是否必须在另一个元素之前排序
交换两个元素
因此,以下接口被添加到sort
包中:
type Interface interface {
Len() int // ❶
Less(i, j int) bool // ❷
Swap(i, j int) // ❸
}
元素的❶数
❷ 检查了两个要素
❸ 互换了两个元素
这个接口具有很强的可重用性,因为它包含了对任何基于索引的集合进行排序的通用行为。
纵观sort
包,我们可以找到几十个实现。例如,如果在某个时候我们计算了一个整数集合,并且我们想对它进行排序,我们有必要对实现类型感兴趣吗?排序算法是归并排序还是快速排序重要吗?很多时候,我们并不在意。因此,排序行为可以被抽象出来,我们可以依赖于sort.Interface
。
找到正确的抽象来分解行为也可以带来很多好处。例如,sort
包提供了同样依赖于sort.Interface
的实用函数,比如检查集合是否已经排序。举个例子,
func IsSorted(data Interface) bool {
n := data.Len()
for i := n - 1; i > 0; i-- {
if data.Less(i, i-1) {
return false
}
}
return true
}
因为sort.Interface
是正确的抽象层次,所以它非常有价值。
现在让我们看看使用接口的另一个主要用例。
退耦
另一个重要的用例是关于从实现中分离我们的代码。如果我们依赖一个抽象而不是一个具体的实现,实现本身可以被另一个代替,甚至不需要改变我们的代码。这就是利斯科夫替代原理(Robert C. Martin 的 SOLID 设计原理中的 L)。
解耦的一个好处与单元测试有关。让我们假设我们想要实现一个CreateNewCustomer
方法来创建一个新客户并存储它。我们决定直接依赖于具体的实现(比如说一个mysql.Store
结构):
type CustomerService struct {
store mysql.Store // ❶
}
func (cs CustomerService) CreateNewCustomer(id string) error {
customer := Customer{id: id}
return cs.store.StoreCustomer(customer)
}
❶ 取决于具体的实现
现在,如果我们想测试这个方法呢?因为customerService
依赖于实际的实现来存储一个Customer
,我们不得不通过集成测试来测试它,这需要构建一个 MySQL 实例(除非我们使用另一种技术,比如go-sqlmock
,但这不是本节的范围)。尽管集成测试很有帮助,但这并不总是我们想要做的。为了给我们更多的灵活性,我们应该将CustomerService
从实际的实现中分离出来,这可以通过这样的接口来实现:
type customerStorer interface { // ❶
StoreCustomer(Customer) error
}
type CustomerService struct {
storer customerStorer // ❷
}
func (cs CustomerService) CreateNewCustomer(id string) error {
customer := Customer{id: id}
return cs.storer.StoreCustomer(customer)
}
❶ 创建了存储抽象
❷ 将客户服务从实际实现中分离出来
因为存储一个客户现在是通过一个接口完成的,这给了我们更多的灵活性来测试这个方法。例如,我们可以
通过集成测试使用具体实现
通过单元测试使用模拟(或任何类型的双测试)
或者两者都有
现在让我们讨论另一个用例:限制一个行为。
限制行为
我们将讨论的最后一个用例乍一看可能非常违反直觉。它是关于将一个类型限制到一个特定的行为。假设我们实现了一个定制的配置包来处理动态配置。我们通过一个IntConfig
结构为int
配置创建一个特定的容器,该结构还公开了两个方法:Get
和Set
。下面是代码的样子:
type IntConfig struct {
// ...
}
func (c *IntConfig) Get() int {
// Retrieve configuration
}
func (c *IntConfig) Set(value int) {
// Update configuration
}
现在,假设我们收到一个IntConfig
,它保存了一些特定的配置,比如一个阈值。然而,在我们的代码中,我们只对检索配置值感兴趣,并且我们希望防止更新它。如果我们不想改变我们的配置包,我们怎么能强制这个配置在语义上是只读的呢?通过创建一个抽象,将行为限制为仅检索配置值:
type intConfigGetter interface {
Get() int
}
然后,在我们的代码中,我们可以依靠intConfigGetter
而不是具体的实现:
type Foo struct {
threshold intConfigGetter
}
func NewFoo(threshold intConfigGetter) Foo { // ❶
return Foo{threshold: threshold}
}
func (f Foo) Bar() {
threshold := f.threshold.Get() // ❷
// ...
}
❶ intConfigGetter
❷ 读取配置
在这个例子中,配置获取器被注入到NewFoo
工厂方法中。它不会影响这个函数的客户端,因为它仍然可以在实现intConfigGetter
时传递一个IntConfig
结构。然后,我们只能读取Bar
方法中的配置,不能修改。因此,出于各种原因,我们也可以使用接口将类型限制为特定的行为,例如语义强制。
在本节中,我们看到了三个潜在的用例,其中接口通常被认为是有价值的:分解出一个公共行为,创建一些解耦,以及将一个类型限制到某个特定的行为。同样,这个列表并不详尽,但是它应该让我们对接口在 Go 中的作用有一个大致的了解。
现在,让我们结束这一节,讨论接口污染的问题。
2.5.3 接口污染
在 Go 项目中过度使用接口是很常见的。也许开发人员的背景是 C#或 Java,他们发现在具体类型之前创建接口是很自然的。然而,这并不是GO的工作方式。
正如我们所讨论的,接口是用来创建抽象的。当编程遇到抽象时,主要的警告是记住抽象应该被发现,而不是被创建。这是什么意思?这意味着如果没有直接的理由,我们就不应该开始在代码中创建抽象。我们不应该设计接口,而应该等待具体的需求。换句话说,我们应该在需要的时候创建接口,而不是在预见到可能需要的时候。
如果我们过度使用接口,主要问题是什么?答案是它们使代码流更加复杂。增加一个无用的间接层不会带来任何价值;它创建了一个毫无价值的抽象,使得代码更难阅读、理解和推理。如果我们没有添加接口的充分理由,并且不清楚接口如何使代码更好,我们应该质疑这个接口的用途。为什么不直接调用实现?
注意当我们通过一个接口调用一个方法时,我们也可能经历性能开销。它需要在哈希表的数据结构中查找,以找到接口指向的具体类型。但是在很多情况下这不是问题,因为开销很小。
总之,在我们的代码中创建抽象时,我们应该谨慎——抽象应该被发现,而不是被创建。对于我们这些软件开发人员来说,基于我们认为以后可能需要的东西,通过试图猜测什么是完美的抽象层次来过度工程化我们的代码是很常见的。应该避免这个过程,因为在大多数情况下,它用不必要的抽象污染了我们的代码,使其阅读起来更加复杂。
不要设计接口,去发现它们。
——抢派克
让我们不要试图抽象地解决问题,而是解决现在必须解决的问题。最后,但同样重要的是,如果不清楚一个接口如何使代码变得更好,我们可能应该考虑删除它以使我们的代码更简单。
下一节继续这个主题,并讨论一个常见的接口错误:在生成器端创建接口。
2.6 #6:生产者方面的接口
我们在上一节中看到了接口被认为是有价值的。但是 Go 开发者经常会误解一个问题:一个接口应该活在哪里?
在深入探讨这个主题之前,让我们确保我们在本节中使用的术语是清楚的:
生产者端——与具体实现定义在同一个包中的接口(见图 2.4)。
图 2.4 接口是在具体实现的旁边定义的。
消费者端——在使用它的外部包中定义的接口(参见图 2.5)。
图 2.5 接口是在使用的地方定义的。
常见的是,开发人员在具体实现的同时,在生产者端创建接口。这种设计可能是具有 C#或 Java 背景的开发人员的习惯。但在GO中,大多数情况下这并不是我们应该做的。
让我们讨论下面的例子。这里,我们创建一个特定的包来存储和检索客户数据。同时,仍然在同一个包中,我们决定所有的调用都必须通过以下接口:
package store
type CustomerStorage interface {
StoreCustomer(customer Customer) error
GetCustomer(id string) (Customer, error)
UpdateCustomer(customer Customer) error
GetAllCustomers() ([]Customer, error)
GetCustomersWithoutContract() ([]Customer, error)
GetCustomersWithNegativeBalance() ([]Customer, error)
}
我们可能认为我们有一些很好的理由在生产者端创建和公开这个接口。也许这是将客户端代码从实际实现中分离出来的好方法。或者,也许我们可以预见它将帮助客户创建测试替身。不管是什么原因,这都不是GO的最佳实践。
如前所述,接口在 Go 中是隐式满足的,与具有显式实现的语言相比,Go 往往是游戏规则的改变者。在大多数情况下,要遵循的方法类似于我们在上一节中描述的:抽象应该被发现,而不是被创建。这意味着不能由生产者来为所有客户强制一个给定的抽象。相反,由客户决定是否需要某种形式的抽象,然后确定满足其需求的最佳抽象级别。
在前面的例子中,也许一个客户端对解耦它的代码不感兴趣。也许另一个客户想要解耦它的代码,但是只对GetAllCustomers
方法感兴趣。在这种情况下,这个客户机可以用一个方法创建一个接口,从外部包中引用Customer
结构:
package client
type customersGetter interface {
GetAllCustomers() ([]store.Customer, error)
}
从一个包组织,图 2.6 显示了结果。有几点需要注意:
因为
customersGetter
接口只在client
包中使用,所以可以不导出。视觉上,在图中,看起来像是循环依赖。然而,从
store
到client
没有依赖性,因为接口是隐式满足的。这就是为什么这种方法在具有显式实现的语言中并不总是可行的。
图 2.6client
包通过创建自己的接口定义了它需要的抽象。
主要的一点是client
包现在可以为它的需求定义最精确的抽象(这里,只有一个方法)。它涉及到接口分离原则的概念(SOLID 中的 I),该原则声明不应该强迫任何客户端依赖它不使用的方法。因此,在这种情况下,最好的方法是在生产者端公开具体的实现,让客户决定如何使用它以及是否需要抽象。
为了完整起见,让我们提一下这种方法——生产者端的接口——有时在标准库中使用。例如,encoding
包定义了由其他子包如encoding/json
或encoding/binary
实现的接口。encoding
包装在这点上有错吗?肯定不是。在这种情况下,encoding
包中定义的抽象在标准库中使用,语言设计者知道预先创建这些抽象是有价值的。我们回到上一节的讨论:如果你认为抽象在想象的未来可能是有帮助的,或者至少,如果你不能证明这个抽象是有效的,就不要创建它。
在大多数情况下,接口应该位于消费者端。然而,在特定的环境中(例如,当我们知道——而不是预见——一个抽象将对消费者有帮助时),我们可能希望它在生产者一方。如果我们这样做了,我们应该努力使它尽可能的小,增加它的可重用性,使它更容易组合。
让我们在函数签名的上下文中继续讨论接口。
2.7 #7:返回接口
在设计函数签名时,我们可能需要返回一个接口或者一个具体的实现。让我们来理解为什么返回一个接口在很多情况下被认为是 Go 中的一个坏习惯。
我们刚刚介绍了为什么接口通常存在于消费者端。图 2.7 显示了如果一个函数返回一个接口而不是一个结构,依赖关系会发生什么。我们会看到它会导致一些问题。
我们将考虑两种方案:
client
,其中包含一个Store
接口store
,包含Store
的一个实现
图 2.7 从store
包到client
包有一个依赖关系。
在store
包中,我们定义了一个实现Store
接口的InMemoryStore
结构。同时,我们创建一个NewInMemoryStore
函数来返回一个Store
接口。在这个设计中,从实现包到客户机包有一个依赖关系,这听起来可能有点奇怪。
比如client
包已经不能调用NewInMemoryStore
函数了;否则,就会出现循环依赖。一个可能的解决方案是从另一个包中调用这个函数,并将一个Store
实现注入到client
。然而,被迫这样做意味着设计应该受到质疑。
此外,如果另一个客户机使用了InMemoryStore
结构会怎么样?在这种情况下,也许我们想将Store
接口移动到另一个包中,或者回到实现包中——但是我们讨论了为什么在大多数情况下,这不是最佳实践。这看起来像代码的味道。
因此,一般来说,返回一个接口会限制灵活性,因为我们强迫所有的客户端使用一种特定类型的抽象。大多数情况下,我们可以从 Postel 定律(datatracker.ietf.org/doc/html/rfc761
)中得到启发:
做自己的事要保守,接受别人的东西要开明。
——传输控制协议
如果我们把这个习语用到GO上,那就意味着
返回结构而不是接口
如果可能的话接受接口
当然,也有一些例外。作为软件工程师,我们熟悉这样一个事实:规则从来不是 100%正确的。最相关的是类型,一个由许多函数返回的接口。我们还可以用包io
检查标准库中的另一个异常:
func LimitReader(r Reader, n int64) Reader {
return &LimitedReader{r, n}
}
这里,函数返回一个导出的结构,io.LimitedReader
。然而,函数签名是一个接口io.Reader
。打破我们到目前为止讨论的规则的基本原理是什么?io.Reader
是一个预先的抽象概念。它不是由客户定义的,但它是强制的,因为语言设计者事先知道这种抽象级别会有帮助(例如,在可重用性和可组合性方面)。
总而言之,大多数情况下,我们不应该返回接口,而应该返回具体的实现。否则,由于包的依赖性,它会使我们的设计更加复杂,并且会限制灵活性,因为所有的客户端都必须依赖相同的抽象。同样,结论类似于前面的章节:如果我们知道(不是预见)一个抽象对客户有帮助,我们可以考虑返回一个接口。否则,我们不应该强迫抽象;他们应该被客户发现。如果客户端出于某种原因需要抽象一个实现,它仍然可以在客户端这样做。
在下一节中,我们将讨论一个与使用any
相关的常见错误。
2.8 #8:any
什么都不代表
在 Go 中,指定零方法的接口类型被称为空接口,interface{}
。到了 Go 1.18,预声明的类型any
变成了空接口的别名;因此,所有的interface{}
事件都可以用any
代替。在很多情况下,any
可以认为是一种过度概括;而且就像罗布派克说的,不传达任何东西(www.youtube.com/watch?v=PAAkCSZUG1c&t=7m36s
)。让我们先提醒自己核心概念,然后我们可以讨论潜在的问题。
一个any
类型可以保存任何值类型:
func main() {
var i any
i = 42 // ❶
i = "foo" // ❷
i = struct { // ❸
s string
}{
s: "bar",
}
i = f // ❹
_ = i // ❺
}
func f() {}
国际// ❶
❷ 字符串
❸ 结构
❹ 函数
❺ 赋值给空白标识符,以便该示例编译
在给和any
类型赋值时,我们丢失了所有的类型信息,这需要一个类型断言来从i
变量中获取任何有用的信息,就像前面的例子一样。让我们看另一个例子,这里使用any
是不准确的。在下面,我们实现了一个Store
结构和两个方法Get
和Set
的框架。我们使用这些方法来存储不同的结构类型,Customer
和Contract
:
package store
type Customer struct{
// Some fields
}
type Contract struct{
// Some fields
}
type Store struct{}
func (s *Store) Get(id string) (any, error) { // ❶
// ...
}
func (s *Store) Set(id string, v any) error { // ❷
// ...
}
❶ 返回any
❷ 接受any
虽然Store
在编译方面没有任何问题,但是我们应该花一分钟来考虑一下方法签名。因为我们接受并返回any
参数,所以这些方法缺乏表现力。如果未来的开发人员需要使用Store
结构,他们可能需要钻研文档或阅读代码来理解如何使用这些方法。因此,接受或返回一个any
类型并不能传达有意义的信息。此外,因为在编译时没有安全措施,所以没有什么可以阻止调用者用任何数据类型调用这些方法,比如一个int
:
s := store.Store{}
s.Set("foo", 42)
通过使用any
,我们失去了 Go 作为静态类型语言的一些好处。相反,我们应该避免any
类型,尽可能使我们的签名显式化。对于我们的例子,这可能意味着为每个类型复制Get
和Set
方法:
func (s *Store) GetContract(id string) (Contract, error) {
// ...
}
func (s *Store) SetContract(id string, contract Contract) error {
// ...
}
func (s *Store) GetCustomer(id string) (Customer, error) {
// ...
}
func (s *Store) SetCustomer(id string, customer Customer) error {
// ...
}
在这个版本中,这些方法很有表现力,减少了不理解的风险。拥有更多的方法不一定是问题,因为客户也可以使用一个接口创建他们自己的抽象。例如,如果一个客户只对Contract
方法感兴趣,它可以写这样的东西:
type ContractStorer interface {
GetContract(id string) (store.Contract, error)
SetContract(id string, contract store.Contract) error
}
有哪些any
有帮助的情况?让我们看看标准库,看看函数或方法接受any
参数的两个例子。第一个例子是在即encoding/json
包中。因为我们可以封送任何类型,Marshal
函数接受any
参数:
func Marshal(v any) ([]byte, error) {
// ...
}
另一个例子是在的database/sql
包中。如果查询是参数化的(例如,SELECT
*
FROM
FOO
WHERE
id
=
?
),参数可以是任何种类。因此,它也使用any
参数:
func (c *Conn) QueryContext(ctx context.Context, query string,
args ...any) (*Rows, error) {
// ...
}
总之,如果确实需要接受或返回任何可能的类型(例如,当涉及到封送或格式化时),any
会很有帮助。一般来说,我们应该不惜一切代价避免过度概括我们编写的代码。也许少量的重复代码偶尔会更好,如果它改善了其他方面,比如代码的表达能力。
接下来,我们将讨论另一种类型的抽象:泛型。
2.9 #9:对何时使用泛型感到困惑
Go 1.18 在语言中加入了泛型。简而言之,这允许用可以在以后指定并在需要时实例化的类型来编写代码。然而,什么时候使用泛型,什么时候不使用泛型可能会令人困惑。在这一节中,我们将描述 Go 中泛型的概念,然后看看常见的用法和误用。
2.9.1 概念
考虑以下从map[string]int
类型中提取所有键的函数:
func getKeys(m map[string]int) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}
如果我们想对另一种映射类型(如map[int]string
)使用类似的函数,该怎么办?在泛型出现之前,Go 开发者有几个选择:使用代码生成、反射或复制代码。例如,我们可以编写两个函数,每个函数对应一种映射类型,或者甚至尝试扩展getKeys
来接受不同的映射类型:
func getKeys(m any) ([]any, error) { // ❶
switch t := m.(type) {
default:
return nil, fmt.Errorf("unknown type: %T", t) // ❷
case map[string]int:
var keys []any
for k := range t {
keys = append(keys, k)
}
return keys, nil
case map[int]string:
// Copy the extraction logic
}
}
❶ 接受并返回任何参数
❷ 如果类型还没有实现,处理运行时错误
通过这个例子,我们开始注意到一些问题。首先,它增加了样板代码。事实上,当我们想要添加一个案例时,它需要复制的range
循环。同时,函数现在接受了和any
类型,这意味着我们失去了 Go 作为类型化语言的一些好处。事实上,检查一个类型是否被支持是在运行时而不是编译时完成的。因此,如果提供的类型未知,我们也需要返回一个错误。最后,因为键类型可以是int
或string
,我们必须返回一部分any
类型来提取键类型。这种方法增加了调用方的工作量,因为客户端可能还需要执行键的类型检查或额外的转换。多亏了泛型,我们现在可以使用类型参数重构这段代码。
类型参数是我们可以在函数和类型中使用的泛型类型。例如,以下函数接受类型参数:
func foo[T any](t T) { // ❶
// ...
}
❶ T
是一个类型参数。
调用foo
时,我们传递一个any
类型的类型实参。提供类型参数是调用实例化,工作在编译时完成。这使得类型安全成为核心语言特性的一部分,并避免了运行时开销。
让我们回到getKeys
函数,使用类型参数编写一个通用版本,它可以接受任何类型的映射:
func getKeys[K comparable, V any](m map[K]V) []K { // ❶
var keys []K // ❷
for k := range m {
keys = append(keys, k)
}
return keys
}
❶ 键是可比较的,而值是任意类型的。
❷ 创建了键的切片
为了处理映射,我们定义了两种类型参数。首先,值可以是any
类型:V any
。然而,在 Go 中,映射键不能是和any
类型。例如,我们不能使用切片:
var m map[[]byte]int
这段代码导致编译错误:invalid
map
key
type
[]byte
。因此,我们不接受任何键类型,而是必须限制类型参数,以便键类型满足特定的要求。这里的要求是键的类型必须具有可比性(我们可以用==
或者!=
)。因此,我们将K
定义为comparable
而不是any
。
限制类型参数以匹配特定的需求被称为约束。约束是一种接口类型,可以包含
一套行为(方法)
任意类型
让我们来看看后者的一个具体例子。假设我们不想为map
键类型接受任何comparable
类型。例如,我们希望将限制为的int
或string
类型。我们可以这样定义自定义约束:
type customConstraint interface {
~int | ~string // ❶
}
func getKeys[K customConstraint, // ❷
V any](m map[K]V) []K {
// Same implementation
}
❶ 定义了一个自定义类型,将类型限制为int
和string
❷ 将类型参数k
更改为customConstraint
类型
首先,我们定义一个customConstraint
接口,使用联合操作符|
将和类型限制为int
或string
(稍后我们将讨论~
的用法)。K
现在是customConstraint
而不是之前的comparable
。
getKeys
的签名要求我们可以用任何值类型的映射来调用它,但是键类型必须是int
或string
——例如,在调用者端:
m = map[string]int{
"one": 1,
"two": 2,
"three": 3,
}
keys := getKeys(m)
注意 Go 可以推断出getKeys
是用string
类型参数调用的。前面的调用相当于:
keys := getKeys[string](m)
~int vs. int
使用~int
的约束和使用int
的约束有什么区别?使用int
将其限制为该类型,而~int
则限制所有底层类型为int
的类型。为了说明,让我们设想一个约束,我们希望将一个类型限制为实现String()`
string方法的任何
int`类型:
type customConstraint interface {
~int
String() string
}
使用此约束将类型参数限制为自定义类型。举个例子,
type customInt int
func (i customInt) String() string {
return strconv.Itoa(int(i))
}
因为customInt
是一个int
并实现了String() string
方法,所以customInt
类型满足定义的约束。然而,如果我们改变约束来包含一个int
而不是~int
,使用customInt
会导致编译错误,因为类型int
没有实现String() string
。
到目前为止,我们已经讨论了对函数使用泛型的例子。然而,我们也可以使用数据结构的泛型。例如,我们可以创建一个包含任何类型值的链表。为此,我们将编写一个Add
方法来追加一个节点:
type Node[T any] struct { // ❶
Val T
next *Node[T]
}
func (n *Node[T]) Add(next *Node[T]) { // ❷
n.next = next
}
❶ 使用类型参数
❷ 实例化一个类型接收器
在示例中,我们使用类型参数来定义T
,并在Node
中使用这两个字段。关于该方法,接收器被实例化。事实上,因为Node
是泛型的,所以它也必须遵循定义的类型参数。
关于类型参数需要注意的最后一点是,它们不能与方法参数一起使用,只能与函数参数或方法接收器一起使用。例如,下面的方法不会编译:
type Foo struct {}
func (Foo) bar[T any](t T) {}
./main.go:29:15: methods cannot have type parameters
如果我们想在方法中使用泛型,那么接收者需要成为类型参数。
现在,让我们检查一下我们应该和不应该使用泛型的具体情况。
2.9.2 常见用途和误用
泛型什么时候有用?让我们讨论一些建议使用泛型的常见用法:
数据结构——例如,如果我们实现了二叉树、链表或堆,我们可以使用泛型来提取元素类型。
处理任何类型的切片、贴图和通道的函数——例如,合并两个通道的函数可以处理任何类型的通道。因此,我们可以使用类型参数来提取通道类型:
func merge[T any](ch1, ch2 <-chan T) <-chan T { // ... }
分解出行为而不是类型——
sort
包,例如,包含一个接口和三个方法:type Interface interface { Len() int Less(i, j int) bool Swap(i, j int) }
该接口由
sort.Ints
或sort .Float64s
等不同的函数使用。使用类型参数,我们可以分解出排序行为(例如,通过定义一个包含切片和比较函数的结构):type SliceFn[T any] struct { // ❶ S []T Compare func(T, T) bool // ❷ } func (s SliceFn[T]) Len() int { return len(s.S) } func (s SliceFn[T]) Less(i, j int) bool { return s.Compare(s.S[i], s.S[j]) } func (s SliceFn[T]) Swap(i, j int) { s.S[i], s.S[j] = s.S[j], s.S[i] }
❶使用类型参数
❷比较了两个元素
然后,因为
SliceFn
结构实现了sort.Interface
,我们可以使用的sort.Sort(sort.Interface)
函数对提供的切片进行排序:s := SliceFn[int]{ S: []int{3, 2, 1}, Compare: func(a, b int) bool { return a < b }, } sort.Sort(s) fmt.Println(s.S) [1 2 3]
在这个例子中,分解出一个行为允许我们避免为每个类型创建一个函数。
反过来说,什么时候建议我们不要使用泛型?
当调用类型参数的方法时——考虑一个接收
io.Writer
并调用的Write
方法的函数,例如:func foo[T io.Writer](w T) { b := getBytes() _, _ = w.Write(b) }
在这种情况下,使用泛型不会给我们的代码带来任何价值。我们应该把
w
直接变成io.Writer
。当它让我们的代码变得更复杂的时候——泛型从来都不是强制性的,作为 Go 开发者,我们已经没有它们十多年了。如果我们正在编写通用的函数或结构,并且我们发现它并没有使我们的代码更清晰,我们可能应该重新考虑我们对于这个特殊用例的决定。
虽然泛型在特定的情况下会有帮助,但是我们应该小心什么时候使用它们,什么时候不使用它们。一般来说,如果我们想回答什么时候不使用泛型,我们可以找到与什么时候不使用接口的相似之处。事实上,泛型引入了一种抽象形式,我们必须记住,不必要的抽象引入了复杂性。
同样,让我们不要用不必要的抽象污染我们的代码,现在让我们专注于解决具体的问题。这意味着我们不应该过早地使用类型参数。让我们等到要写样板代码的时候再考虑使用泛型。
在下一节中,我们将讨论使用类型嵌入时可能出现的问题。
2.10 #10:不知道类型嵌入可能存在的问题
当创建一个结构时,Go 提供了嵌入类型的选项。但是如果我们不理解类型嵌入的所有含义,这有时会导致意想不到的行为。在这一节中,我们将探讨如何嵌入类型,它们会带来什么,以及可能出现的问题。
在 Go 中,如果一个结构字段没有名字就被声明,那么它就被称为嵌入的。举个例子,
type Foo struct {
Bar // ❶
}
type Bar struct {
Baz int
}
❶ 嵌入字段
在Foo
结构中,Bar
类型是在没有关联名称的情况下声明的;因此,它是一个嵌入式字段。
我们使用嵌入来提升嵌入类型的字段和方法。因为Bar
包含一个Baz
字段,这个字段被提升为Foo
(见图 2.8)。因此,Baz
从Foo
开始变为可用:
foo := Foo{}
foo.Baz = 42
请注意,Baz
可从两个不同的路径获得:要么从使用Foo.Baz
的提升路径获得,要么通过Bar
、Foo.Bar.Baz
从名义路径获得。两者都涉及同一个字段。
图 2.8 baz
被提升,因此可直接从S
进入。
接口和嵌入
嵌入也用在接口中,与其他接口组成一个接口。在下面的例子中,io.ReadWriter
由一个io.Reader
和一个io.Writer
组成:
type ReadWriter interface {
Reader
Writer
}
但是本节的范围只与结构中的嵌入字段相关。
现在我们已经提醒自己什么是嵌入类型,让我们看一个错误用法的例子。在下面的代码中,我们实现了一个保存一些内存数据的结构,我们希望使用互斥锁来保护它免受并发访问:
type InMem struct {
sync.Mutex // ❶
m map[string]int
}
func New() *InMem {
return &InMem{m: make(map[string]int)}
}
❶ 嵌入字段
我们决定不导出映射,这样客户端就不能直接与它交互,只能通过导出的方法。同时,互斥字段被嵌入。因此,我们可以这样实现一个Get
方法:
func (i *InMem) Get(key string) (int, bool) {
i.Lock() // ❶
v, contains := i.m[key]
i.Unlock() // ❷
return v, contains
}
❶ 直接访问Lock
方法
❷ Unlock
方法也是如此。
因为互斥体是嵌入的,所以我们可以从i
接收器直接访问Lock
和Unlock
方法。
我们提到过这样的例子是类型嵌入的错误用法。这是什么原因呢?由于sync.Mutex
是一个嵌入式类型,所以Lock
和Unlock
方法将被提升。因此,这两种方法对于使用InMem
的外部客户端都是可见的:
m := inmem.New()
m.Lock() // ??
这种提升可能是不可取的。在大多数情况下,互斥体是我们希望封装在一个结构中并对外部客户端不可见的东西。因此,在这种情况下,我们不应该将其作为嵌入字段:
type InMem struct {
mu sync.Mutex // ❶
m map[string]int
}
❶ 指定sync.Mutex
不是嵌入的
因为互斥体没有嵌入也没有导出,所以它不能从外部客户端访问。现在让我们看另一个例子,但是这次嵌入被认为是一种正确的方法。
我们想要编写一个定制的日志记录器,它包含一个io.WriteCloser
并公开两个方法Write
和Close
。如果io.WriteCloser
没有嵌入,我们需要这样写:
type Logger struct {
writeCloser io.WriteCloser
}
func (l Logger) Write(p []byte) (int, error) {
return l.writeCloser.Write(p) // ❶
}
func (l Logger) Close() error {
return l.writeCloser.Close() // ❶
}
func main() {
l := Logger{writeCloser: os.Stdout}
_, _ = l.Write([]byte("foo"))
_ = l.Close()
}
❶ 将调用转发给writeCloser
Logger
必须为提供一个Write
和一个Close
方法,该方法只能将调用转发给io.WriteCloser
。但是,如果该字段现在变成嵌入的,我们可以删除这些转发方法:
type Logger struct {
io.WriteCloser // ❶
}
func main() {
l := Logger{WriteCloser: os.Stdout}
_, _ = l.Write([]byte("foo"))
_ = l.Close()
}
❶ 指定io.WriteCloser
是嵌入的
对于具有两个导出的Write
和Close
方法的客户端来说是一样的。但是该示例阻止实现这些附加方法来简单地转移调用。同样,随着Write
和Close
被提升,意味着Logger
满足的io.WriteCloser
接口。
嵌入与 OOP 子类化
区分嵌入和 OOP 子类有时会令人困惑。主要的区别与方法接收者的身份有关。我们来看下图。左手边代表嵌入在Y
中的类型X
,而右手边的Y
延伸出X
。
对于嵌入,嵌入类型仍然是方法的接收者。相反,有了子类化,子类就变成了方法的接收者。
通过嵌入,Foo
的接收者仍然是X
。然而,通过子类化,Foo
的接收者变成了子类,Y
。嵌入是构图,不是继承。
关于类型嵌入我们应该得出什么结论?首先,让我们注意到这很少是必要的,这意味着无论什么用例,我们都可以不用类型嵌入来解决它。类型嵌入主要是为了方便:在大多数情况下,是为了促进行为。
如果我们决定使用类型嵌入,我们需要记住两个主要约束:
它不应该仅仅作为某种语法糖来简化对字段的访问(比如用
Foo.Baz()
代替Foo.Bar.Baz()
)。如果这是唯一的理由,让我们不要嵌入内部类型,而是使用字段。它不应该促进我们想要对外部隐藏的数据(字段)或行为(方法):例如,如果它允许客户端访问一个锁定行为,该行为应该对该结构保持私有。
注意,有些人可能会认为,在导出结构的上下文中,使用类型嵌入会导致额外的维护工作。事实上,在导出的结构中嵌入一个类型意味着当这个类型发展时要保持谨慎。例如,如果我们向内部类型添加一个新方法,我们应该确保它不会破坏后面的约束。因此,为了避免这种额外的工作,团队还可以防止在公共结构中嵌入类型。
通过记住这些约束,有意识地使用类型嵌入有助于避免带有额外转发方法的样板代码。然而,让我们确保我们不仅仅是为了化妆品而这样做,也不宣传那些应该隐藏的元素。
在下一节中,我们将讨论处理可选配置的常见模式。
2.11 #11:不使用函数式选项模式
设计 API 时,可能会出现一个问题:我们如何处理可选配置?有效地解决这个问题可以提高我们的 API 的便利性。这一节将通过一个具体的例子来介绍处理可选配置的不同方法。
对于这个例子,假设我们必须设计一个库,它公开一个函数来创建一个 HTTP 服务器。这个函数接受不同的输入:一个地址和一个端口。下面显示了该函数的框架:
func NewServer(addr string, port int) (*http.Server, error) {
// ...
}
我们库的客户端已经开始使用这个函数了,大家都很高兴。但是在某个时候,我们的客户开始抱怨这个函数有些受限,并且缺少其他参数(例如,写超时和连接上下文)。然而,我们注意到添加新的函数参数破坏了兼容性,迫使客户端修改它们调用NewServer
的方式。同时,我们希望以这种方式丰富与端口管理相关的逻辑(图 2.9):
如果未设置端口,则使用默认端口。
如果端口为负,则返回错误。
如果端口等于 0,则使用随机端口。
否则,使用客户端提供的端口。
图 2.9 与端口选项相关的逻辑
我们如何以一种 API 友好的方式实现这个功能?让我们看看不同的选项。
2.11.1 配置结构
因为 Go 不支持函数签名中的可选参数,第一种可能的方法是使用配置结构来传达什么是强制的,什么是可选的。例如,强制参数可以作为函数参数存在,而可选参数可以在Config
结构中处理:
type Config struct {
Port int
}
func NewServer(addr string, cfg Config) {
}
此解决方案解决了兼容性问题。事实上,如果我们添加新的选项,它不会在客户端中断。然而,这种方法不能解决我们与端口管理相关的需求。事实上,我们应该记住,如果没有提供结构字段,它将被初始化为零值:
整数为 0
浮点型为 0.0
字符串为
""
对于切片、映射、通道、指针、接口和函数,为
nil
因此,在下面的示例中,两个结构是相等的:
c1 := httplib.Config{
Port: 0, // ❶
}
c2 := httplib.Config{
// ❷
}
❶ 将端口初始化为 0
❷ 端口丢失,所以它被初始化为 0。
在我们的例子中,我们需要找到一种方法来区分故意设置为 0 的端口和丢失的端口。也许一种选择是以这种方式将配置结构的所有参数作为指针来处理:
type Config struct {
Port *int
}
使用整数指针,在语义上,我们可以突出显示值0
和缺失值(零指针)之间的差异。
这种选择是可行的,但也有一些缺点。首先,客户端提供一个整数指针并不方便。客户端必须创建一个变量,然后以这种方式传递指针:
port := 0
config := httplib.Config{
Port: &port, // ❶
}
❶ 提供一个整数指针
它本身并不引人注目,但是整体的 API 使用起来有点不方便。同样,我们添加的选项越多,代码就变得越复杂。
第二个缺点是,使用默认配置的库的客户端需要以这种方式传递一个空结构:
httplib.NewServer("localhost", httplib.Config{})
这段代码看起来不怎么样。读者必须理解这个神奇的结构是什么意思。
另一种选择是使用经典的构建器模式,这将在下一节中介绍。
2.11.2 构建器模式
builder 模式最初是四人组设计模式的一部分,它为各种对象创建问题提供了灵活的解决方案。Config
的构造与结构本身是分离的。它需要一个额外的结构ConfigBuilder
,该结构接收配置和构建Config
的方法。
让我们看一个具体的例子,看看它如何帮助我们设计一个友好的 API 来满足我们的所有需求,包括端口管理:
type Config struct { // ❶
Port int
}
type ConfigBuilder struct { // ❷
port *int
}
func (b *ConfigBuilder) Port(
port int) *ConfigBuilder { // ❸
b.port = &port
return b
}
func (b *ConfigBuilder) Build() (Config, error) { // ❹
cfg := Config{}
if b.port == nil { // ❺
cfg.Port = defaultHTTPPort
} else {
if *b.port == 0 {
cfg.Port = randomPort()
} else if *b.port < 0 {
return Config{}, errors.New("port should be positive")
} else {
cfg.Port = *b.port
}
}
return cfg, nil
}
func NewServer(addr string, config Config) (*http.Server, error) {
// ...
}
❶ 配置结构
❷ 配置生成器结构,包含可选端口
❸ 公共端口的设置方法
创建配置结构的❹构建方法
❺ 与港口管理相关的主要逻辑
ConfigBuilder
结构保存客户端配置。它公开了一个设置端口的Port
方法。通常,这样的配置方法会返回构建器本身,以便我们可以使用方法链接(例如,builder.Foo("foo").Bar("bar")
)。它还公开了一个Build
方法,该方法保存初始化端口值的逻辑(指针是否为nil
等等)。)并在创建后返回一个Config
结构。
请注意,构建器模式没有单一的可能实现。例如,有些人可能喜欢定义最终端口值的逻辑在Port
方法中而不是在Build
中的方法。本节的范围是呈现构建器模式的概述,而不是查看所有不同的可能变体。
然后,一个客户会以下面的方式使用我们的基于构建器的 API(我们假设我们已经把代码放在了一个httplib
包中):
builder := httplib.ConfigBuilder{} // ❶
builder.Port(8080) // ❷
cfg, err := builder.Build() // ❸
if err != nil {
return err
}
server, err := httplib.NewServer("localhost", cfg) // ❹
if err != nil {
return err
}
❶ 创建一个生成器配置
❷ 设置端口
❸ 构建配置结构
❹ 传递配置结构
首先,客户端创建一个ConfigBuilder
并使用它来设置一个可选字段,比如端口。然后,它调用Build
方法并检查错误。如果正常,配置被传递到NewServer
。
这种方法使得端口管理更加方便。不需要传递整数指针,因为Port
方法接受整数。但是,如果客户端想要使用默认配置,我们仍然需要传递一个可以为空的配置结构:
server, err := httplib.NewServer("localhost", nil)
在某些情况下,另一个缺点与错误管理有关。在抛出异常的编程语言中,如果输入无效,像Port
这样的构建器方法可以引发异常。如果我们想保持链接调用的能力,函数就不能返回错误。因此,我们不得不延迟在Build
方法中的验证。如果一个客户端可以传递多个选项,但是我们想要精确地处理端口无效的情况,这使得错误处理变得更加复杂。
现在让我们看看另一种方法,叫做函数选项模式,它依赖于变量参数。
2.11.3 函数式选项模式
我们将讨论的最后一种方法是函数式选项模式(图 2.10)。虽然有不同的实现,但有细微的变化,主要思想如下:
未导出的结构保存配置:
options
。每个选项都是返回相同类型的函数:
type Option func(options *options) error
。例如,WithPort
接受一个代表端口的int
参数,并返回一个代表如何更新options
结构的Option
类型。
图 2.10WithPort
选项更新最终的options
结构。
下面是options
结构、Option
类型和WithPort
选项的 Go 实现:
type options struct { // ❶
port *int
}
type Option func(options *options) error // ❷
func WithPort(port int) Option { // ❸
return func(options *options) error {
if port < 0 {
return errors.New("port should be positive")
}
options.port = &port
return nil
}
}
❶ 配置结构
❷ 表示更新配置结构的函数类型
❸ 更新端口的配置函数
这里,WithPort
返回一个闭包。一个闭包是一个匿名函数,从它的正文外部引用变量;在这种情况下,port
变量。闭包遵循Option
类型并实现端口验证逻辑。每个配置字段都需要创建一个公共函数(按照惯例,以前缀With
开始),包含类似的逻辑:如果需要,验证输入并更新配置结构。
让我们看看提供者端的最后一部分:NewServer
实现。我们将把选项作为变量参数传递。因此,我们必须迭代这些选项来改变options
配置结构:
func NewServer(addr string, opts ...Option) ( // ❶
*http.Server, error) {
var options options // ❷
for _, opt := range opts { // ❸
err := opt(&options) // ❹
if err != nil {
return nil, err
}
}
// At this stage, the options struct is built and contains the config
// Therefore, we can implement our logic related to port configuration
var port int
if options.port == nil {
port = defaultHTTPPort
} else {
if *options.port == 0 {
port = randomPort()
} else {
port = *options.port
}
}
// ...
}
❶ 接受可变选项参数
❷ 创建了一个空的选项结构
❸ 迭代所有的输入选项
❹ 调用每个选项,这导致修改公共选项结构
我们首先创建一个空的options
结构。然后,我们迭代每个Option
参数并执行它们来改变options
结构(记住Option
类型是一个函数)。一旦构建了options
结构,我们就可以实现关于端口管理的最终逻辑。
因为NewServer
接受可变的Option
参数,客户端现在可以通过在强制地址参数后传递多个选项来调用这个 API。举个例子,
server, err := httplib.NewServer("localhost",
httplib.WithPort(8080),
httplib.WithTimeout(time.Second))
但是,如果客户机需要默认配置,它不必提供参数(例如,一个空结构,正如我们在前面的方法中看到的)。客户端的调用现在可能看起来像这样:
server, err := httplib.NewServer("localhost")
这种模式就是函数式选项模式。它提供了一种方便且 API 友好的方式来处理选项。尽管构建者模式可能是一个有效的选项,但是它有一些小的缺点,这使得函数可选项模式成为 Go 中处理这个问题的惯用方法。我们还要注意,这种模式在 gRPC 等不同的 Go 库中使用。
下一节将讨论另一个常见的错误:组织不当。
2.12 #12:项目组织不当
组织一个GO项目并不是一件容易的事情。因为 Go 语言在设计包和模块方面提供了很大的自由度,所以最佳实践并没有像它们应该的那样普遍存在。本节首先讨论构建项目的一种常见方法,然后讨论一些最佳实践,展示改进我们如何组织项目的方法。
2.12.1 项目结构
Go 语言维护者对于在 Go 中构建项目没有很强的约定。然而,这些年来出现了一种布局:项目布局(github.com/golang-standards/project-layout
)。
如果我们的项目足够小(只有几个文件),或者如果我们的组织已经创建了它的标准,它可能不值得使用或者迁移到project-layout
。否则,可能值得考虑。让我们看一下这个布局,看看主要目录是什么:
/cmd
——主源文件。foo
应用的main.go
应该位于/cmd/foo/main.go
中。/internal
——我们不希望其他人为他们的应用或库导入的私有代码。/pkg
——我们要公开给别人的公共代码。/test
——附加外部测试和测试数据。中的单元测试与源文件放在同一个包中。但是,公共 API 测试或集成测试应该位于/test
中。/configs
——配置文件。/docs
——设计和用户文档。/examples
——我们的应用和/或公共库的示例。/api
——API 合同文件(Swagger、协议缓冲区等)。/web
——特定于 Web 应用的资产(静态文件等)。/build
——打包和持续集成(CI)文件。/script
——用于分析、安装等的脚本。/vendor
——应用依赖关系(例如,Go 模块依赖关系)。
不像其他语言那样有/src
目录。理由是/src
太通用了;因此,这种布局倾向于使用/cmd
、/internal
或/pkg
这样的目录。
注 2021 年,GO核心维护者之一 Russ Cox 批评了这种布局。尽管不是官方标准,但一个项目主要隶属于 GitHub golang 标准组织。无论如何,我们必须记住,关于项目结构,没有强制性的约定。这种布局可能对你有帮助,也可能没有,但这里重要的是,优柔寡断是唯一错误的决定。因此,在布局上达成一致,以保持组织中的一致性,这样开发人员就不会浪费时间从一个存储库切换到另一个存储库。
现在,让我们讨论如何组织 Go 存储库的主要逻辑。
2,12,2 包组织
在 Go 中,没有子包的概念。然而,我们可以决定在子目录中组织包。如果我们看一下标准库,net
目录是这样组织的:
/net
/http
client.go
...
/smtp
auth.go
...
addrselect.go
...
net
既作为一个包,又作为包含其他包的目录。但是net/http
并不从net
继承,也没有对net
包的特定访问权限。net/http
内的元素只能看到导出的net
元素。子目录的主要好处是保持包在一个地方,在那里它们有很高的内聚性。
关于整体组织,有不同的学派。例如,我们应该按上下文还是按层来组织我们的应用?这取决于我们的喜好。我们可能倾向于按上下文(如客户上下文、合同上下文等)对代码进行分组。),或者我们可能倾向于遵循六边形架构原则并按技术层分组。如果我们做出的决策符合我们的用例,只要我们保持一致,它就不会是一个错误的决策。
关于包,有许多我们应该遵循的最佳实践。首先,我们应该避免过早打包,因为这可能会导致项目过于复杂。有时,最好使用简单的组织,当我们理解了项目包含的内容时,让我们的项目发展,而不是强迫我们自己预先构建完美的结构。
粒度是另一个需要考虑的基本问题。我们应该避免几十个只包含一两个文件的 nano 包。如果我们这样做了,那是因为我们可能错过了这些包之间的一些逻辑联系,使得读者更难理解我们的项目。反过来,我们也应该避免淡化包装名称意义的巨大包装。
包命名也应该仔细考虑。众所周知(作为开发者),命名很难。为了帮助客户理解一个 Go 项目,我们应该根据它们提供的东西来命名我们的包,而不是它们包含的内容。还有,命名要有意义。因此,包名应该简短,有表现力,按照惯例,应该是一个小写的单词。
关于导出什么,规则非常简单。我们应该尽可能地减少应该导出的内容,以减少包之间的耦合,并隐藏不必要的导出元素。如果我们不确定是否要导出一个元素,我们应该默认不导出它。稍后,如果我们发现我们需要导出它,我们可以调整我们的代码。让我们记住一些例外,比如导出字段,以便可以用encoding/json
解组一个结构。
组织一个项目并不简单,但是遵循这些规则应该有助于使它更容易维护。然而,记住一致性对于简化可维护性也是至关重要的。因此,让我们确保代码库中的东西尽可能保持一致。
在下一节中,我们将讨论实用工具包。
2.13 #13:创建实用工具包
本节讨论一个常见的不好的实践:创建共享的包,比如utils
、common
和base
。我们将用这种方法来检查问题,并学习如何改进我们的组织。
让我们看一个受 Go 官方博客启发的例子。它是关于实现一个集合数据结构(一个值被忽略的映射)。在 Go 中惯用的方法是通过一个带有K
的map[K]struct{}
类型来处理它,它可以是映射中允许的任何类型作为键,而值是一个struct{}
类型。事实上,值类型为struct{}
的映射表明我们对值本身不感兴趣。让我们在一个util
包中公开两个方法:
package util
func NewStringSet(...string) map[string]struct{} { // ❶
// ...
}
func SortStringSet(map[string]struct{}) []string { // ❷
// ...
}
❶ 创建了一个字符串集合
❷ 返回一个排序的键列表
客户端将像这样使用这个包:
set := util.NewStringSet("c", "a", "b")
fmt.Println(util.SortStringSet(set))
这里的问题是util
没有意义。我们可以称它为common
、shared
或base
,但是它仍然是一个没有意义的名字,不能提供任何关于这个包提供了什么的信息。
我们应该创建一个表达性的包名,比如stringset
,而不是一个实用工具包。举个例子,
package stringset
func New(...string) map[string]struct{} { ... }
func Sort(map[string]struct{}) []string { ... }
在本例中,我们删除了NewStringSet
和SortStringSet
的后缀,它们分别变成了New
和Sort
。在客户端,现在看起来是这样的:
set := stringset.New("c", "a", "b")
fmt.Println(stringset.Sort(set))
注:在上一节中,我们讨论了纳米封装的概念。我们提到了在一个应用中创建几十个 nano 包会使代码路径变得更加复杂。然而,纳米包装的想法本身并不一定是坏的。如果一个小的代码组具有很高的内聚性,并且不属于其他地方,那么将它组织到一个特定的包中是完全可以接受的。没有严格的规则可以适用,通常,挑战在于找到正确的平衡。
我们甚至可以更进一步。我们可以创建一个特定的类型并将Sort
作为方法公开,而不是公开实用函数,如下所示:
package stringset
type Set map[string]struct{}
func New(...string) Set { ... }
func (s Set) Sort() []string { ... }
这一变化使得客户端更加简单。只有一个对stringset
包的引用:
set := stringset.New("c", "a", "b")
fmt.Println(set.Sort())
通过这个小小的重构,我们去掉了一个无意义的包名,公开了一个有表现力的 API。正如 Dave Cheney(Go 的项目成员)提到的,我们经常合理地找到处理公共设施的实用工具包。例如,如果我们决定有一个客户机和一个服务器包,那么我们应该把公共类型放在哪里呢?在这种情况下,也许一个解决方案是将客户机、服务器和公共代码组合成一个包。
命名包是应用设计的一个关键部分,我们也应该对此保持谨慎。根据经验,创建没有有意义的名字的共享包不是一个好主意;这包括实用工具包,如utils
、common
或base
。此外,请记住,以包提供的内容而不是包包含的内容来命名包是增加其表达性的有效方法。
在下一节中,我们将讨论包和包冲突。
2.14 #14:忽略包名冲突
当一个变量名与一个已存在的包名冲突时,包冲突就会发生,阻止包被重用。让我们看一个具体的例子,一个库公开了一个 Redis 客户机:
package redis
type Client struct { ... }
func NewClient() *Client { ... }
func (c *Client) Get(key string) (string, error) { ... }
现在,让我们跳到客户端。尽管包名为redis
,但在 Go 中创建一个名为redis
的变量是完全有效的:
redis := redis.NewClient() // ❶
v, err := redis.Get("foo") // ❷
❶ 从redis
包中调用NewClient
❷ 使用redis
变量
这里,redis
变量名与redis
包名冲突。即使这是允许的,也应该避免。事实上,在redis
变量的整个范围内,redis
包将不会被访问。
假设一个限定符在整个函数中同时引用了变量和包名。在这种情况下,对于代码读者来说,知道限定符指的是什么可能是不明确的。有什么选择可以避免这样的碰撞?第一种选择是使用不同的变量名。举个例子,
redisClient := redis.NewClient()
v, err := redisClient.Get("foo")
这可能是最直接的方法。然而,如果出于某种原因,我们希望保留名为redis
的变量,我们可以使用包导入。使用包导入,我们可以使用别名来改变限定符来引用redis
包。举个例子,
import redisapi "mylib/redis" // ❶
// ...
redis := redisapi.NewClient() // ❷
v, err := redis.Get("foo")
❶ 为redis
包创建了一个别名
❷ 通过redisapi
别名访问redis
包
这里,我们使用了redisapi
导入别名来引用redis
包,这样就可以保留我们的变量名redis
。
注一个选择也可以是使用点导入来访问一个包的所有公共元素,而不用包限定符。但是,这种方法会增加混乱,在大多数情况下应该避免。
还要注意,我们应该避免变量和内置函数之间的命名冲突。例如,我们可以这样做:
copy := copyFile(src, dst) // ❶
❶ 复制变量与复制内置函数冲突。
在这种情况下,只要copy
变量存在,内置函数copy
就不会被访问。总之,我们应该防止变量名冲突,以避免歧义。如果我们面临冲突,我们应该找到另一个有意义的名称或使用导入别名。
在下一节中,我们将看到一个与代码文档相关的常见错误。
2.15 #15:缺少代码文档
文档是编码的一个重要方面。它简化了客户使用 API 的方式,但也有助于维护项目。在 Go 中,我们应该遵循一些规则来使我们的代码符合习惯。让我们检查一下这些规则。
首先,必须记录每个导出的元素。不管是结构、接口、函数,还是别的什么,如果导出来了,就必须有文档记录。惯例是添加注释,从导出元素的名称开始。举个例子,
// Customer is a customer representation.
type Customer struct{}
// ID returns the customer identifier.
func (c Customer) ID() string { return "" }
按照惯例,每个注释都应该是一个完整的句子,以标点符号结尾。还要记住,当我们记录一个函数(或者一个方法)时,我们应该强调函数打算做什么,而不是它是如何做的;这属于函数和注释的核心,而不是文档。此外,理想情况下,文档应该提供足够的信息,使用户不必查看我们的代码就能理解如何使用导出的元素。
不推荐使用的元素
可以这样使用// Deprecated:
注释来废弃导出的元素:
// ComputePath returns the fastest path between two points.
// Deprecated: This function uses a deprecated way to compute
// the fastest path. Use ComputeFastestPath instead.
func ComputePath() {}
然后,如果开发人员使用了ComputePath
函数,他们应该会得到一个警告。(大多数 ide 处理不赞成使用的注释。)
当涉及到记录变量或常数时,我们可能对传达两个方面感兴趣:它的目的和它的内容。前者应该作为代码文档存在,以便对外部客户有用。不过,后者不一定是公开的。举个例子,
// DefaultPermission is the default permission used by the store engine.
const DefaultPermission = 0o644 // Need read and write accesses.
此常数表示默认权限。代码文档传达了它的目的,而常量旁边的注释描述了它的实际内容(读写访问)。
为了帮助客户和维护者理解一个包的范围,我们也应该记录每个包。惯例是以// Package
开始注释,后跟包名:
// Package math provides basic constants and mathematical functions.
//
// This package does not guarantee bit-identical results
// across architectures.
package math
包注释的第一行应该简洁。那是因为它会出现在包里(图 2.11 提供了一个例子)。然后,我们可以在下面几行中提供我们需要的所有信息。
图 2.11 生成的 Go 标准库示例
可以在任何 Go 文件中记录一个包;没有规则。一般来说,我们应该将包文档放在与包同名的相关文件中,或者放在特定的文件中,比如doc.go
。
关于包文档最后要提到的一点是,与声明不相邻的注释被省略了。例如,以下版权注释在生成的文档中不可见:
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package math provides basic constants and mathematical functions.
// // ❶
// This package does not guarantee bit-identical results
// across architectures.
package math
❶ 空行。之前的注释将不包括在文档中。
总之,我们应该记住,每个导出的元素都需要被记录。记录我们的代码不应该成为一种约束。我们应该抓住机会,确保它有助于客户和维护人员理解我们代码的目的。
最后,在本章的最后一节,我们将看到一个关于工具的常见错误:不使用linter。
2.16 #16:不使用linter
一个 linter 是一个自动分析代码和捕捉错误的工具。本节的范围不是给出现有linter的详尽列表;否则,它很快就会被弃用。但是我们应该理解并记住为什么linter对于大多数GO项目是必不可少的。
为了理解为什么linter很重要,让我们举一个具体的例子。在错误#1,“意外的变量阴影”,我们讨论了与变量阴影相关的潜在错误。使用vet
(Go 工具集中的一个标准工具)和shadow
,我们可以检测隐藏的变量:
package main
import "fmt"
func main() {
i := 0
if true {
i := 1 // ❶
fmt.Println(i)
}
fmt.Println(i)
}
❶ 阴影变量
因为vet
包含在 Go 二进制文件中,所以让我们首先安装shadow
,将其与 Go vet
链接,然后在前面的例子中运行它:
$ go install \
golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow // ❶
$ go vet -vettool=$(which shadow) // ❷
./main.go:8:3:
declaration of "i" shadows declaration at line 6 // ❸
❶ 安装阴影
❷ 使用vettol
参数链接到 Go vet
❸ GO兽医检测影子变量。
正如我们所看到的,vet
通知我们在这个例子中变量i
被隐藏了。使用适当的 linters 可以帮助我们的代码更加健壮,并检测潜在的错误。
注意短评没有涵盖本书中的所有错误。所以,建议你只是继续读下去;).
同样,本节的目标不是列出所有可用的linter。然而,如果你不是 linters 的经常用户,这里有一个你可能想每天使用的列表:
golang.org/cmd/vet
——标准GO分析仪github.com/kisielk/errcheck
——错误检查器github.com/fzipp/gocyclo
——一个圈复杂度分析器github.com/jgautheron/goconst
——重复字符串常量分析器
除了 linters,我们还应该使用代码格式化程序来修复代码风格。这里有一些代码格式化程序供您尝试:
golang.org/cmd/gofmt
——一个标准的 Go 代码格式化程序godoc.org/golang.org/x/tools/cmd/goimports
——一个标准的 Go 导入格式化程序
同时,我们还应该看看golangci-lint
(github.com/golangci/golangci-lint
)。这是一个林挺工具,在许多有用的 linters 和排版工具之上提供了一个门面。此外,它允许并行运行 linters 以提高分析速度,这非常方便。
Linters 和排版工具是提高我们代码库的质量和一致性的一个强大的方法。让我们花点时间来理解我们应该使用哪一个,并确保我们自动执行它们(例如 CI 或 Git 预提交钩子)。
总结
避免隐藏变量有助于防止出现错误,比如引用错误的变量或迷惑读者。
避免嵌套层次并保持快乐路径在左侧对齐使得构建心理代码模型更容易。
初始化变量时,记住
init
函数的错误处理有限,会使状态处理和测试更加复杂。在大多数情况下,初始化应该作为特定的函数来处理。在 Go 中强制使用获取器和设置器并不符合习惯。务实一点,在效率和盲从某些习惯用法之间找到合适的平衡点,应该是应该走的路。
抽象应该被发现,而不是被创建。为了避免不必要的复杂性,在你需要的时候创建一个接口,而不是在你预见到需要的时候,或者如果你至少能证明抽象是有效的,就创建一个接口。
在客户端保留接口可以避免不必要的抽象。
为了防止在灵活性方面受到限制,在大多数情况下,函数不应该返回接口,而应该返回具体的实现。相反,函数应该尽可能接受接口。
只在需要接受或返回任何可能的类型时才使用
any
,比如json. Marshal
。否则,any
不会提供有意义的信息,并且会导致编译时问题,因为它允许调用者调用任何数据类型的方法。依赖泛型和类型参数可以防止编写样板代码来提取元素或行为。但是,不要过早地使用类型参数,只有当您看到对它们的具体需求时才使用。否则,它们会引入不必要的抽象和复杂性。
使用类型嵌入还有助于避免样板代码;但是,要确保这样做不会导致一些本应隐藏的字段出现可见性问题。
为了以 API 友好的方式方便地处理选项,请使用函数选项模式。
遵循诸如
project-layout
这样的布局是开始构建 Go 项目的好方法,尤其是如果你正在寻找现有的约定来标准化一个新项目。命名是应用设计的关键部分。创建出
common
、util
、shared
这样的包装,并不能给读者带来多少价值。将这样的包重构为有意义的、特定的包名。为了避免变量和包之间的命名冲突,导致混乱甚至错误,为每个变量使用唯一的名字。如果这不可行,可以使用导入别名来更改限定符,以区分包名和变量名,或者想一个更好的名称。
为了帮助客户和维护者理解你的代码的目的,记录导出的元素。
为了提高代码质量和一致性,使用 linters 和排版工具。