翻译自<A theory of modern Go> by Peter Bourgon 2017/06/09
全文结论:
全局状态会产生巨大的副作用 ——> 需要避免包级别的变量和init函数
Part1
Go is easy to read
Go语言唯一最佳的属性是基本上没有什么魔法代码。除了极少数的例外外,直接阅读Go的源码不会产生诸如“定义”,“依赖关系”,“运行时行为”的歧义,而这让Go的可读性较好,从而使得Go代码较容易维护,这是工业化编程的最高境界。
Part2
Magic is bad
但是魔法代码仍然有一些方式混入其中。不幸的是,非常普遍的一种方式是通过使用全局状态。包级别的全局对象可以对外部调用者隐藏状态和行为。调用这些全局变量的代码可能会产生意外的副作用,从而破坏了读者理解和脑海中构建程序的能力。
函数(包括方法,在go中二者略有不同)基本上是Go用来构建抽象的唯一机制。
思考以下函数定义:
func NewObject(n int) (*Object, error)
Part3 *
按照惯例来讲,我们希望形式为NewXxx的函数是类型构造函数。而这个函数也确实是构造函数,因为我们看到函数返回指向对象的指针和错误。由此我们可以推断出构造函数可能构造成功也可能构造失败,如果构造失败,将收到error告诉我们原因。
该构造函数参数为单int,我们假定该int参数控制了函数返回对象Object的生成。我们假定对参数int n有一些约束,如果不满足约束将导致错误。但是由于该函数不接受其他参数,因此我们希望它除了分配内存外应该没有其他副作用。
仅通过阅读函数签名,我们就可以得到这些推论,脑海中大概就有此函数了。从main函数的第一行开始重复递归的应用这个过程,是我们阅读和理解程序的方式。
假定这是NewObject函数的实现:
func NewObject(n int) (*Object, error) {
row := dbconn.QueryRow("SELECT ... FROM ... WHERE ...")
var id string
if err := row.Scan(&id); err != nil {
logger.Log("during row scan: %v", err)
id = "default"
}
resource, err := pool.Request(n)
if err != nil {
return nil, err
}
return &Object{
id: id,
res: resource,
}, nil
}
该函数调用了:
1.包级别的全局变量 database / sql.Conn,以对某些未指定的数据库进行查询;
2.包级别的全局记录器,用于将任意格式的字符串输出到某个位置;
3.以及包级别的某种类型的链接池对象,以请求某种类型的资源。
所有这些操作都有副作用,而这些副作用从函数签名则完全不可见。调用者没有办法预测这些事情发生,除非通过阅读函数体并跳到所有全局变量的定义处查看。
考虑另一种形式的签名函数:
func NewObject(db *sql.DB, pool *resource.Pool, n int, logger log.Logger) (*Object, error)
通过将每个全局依赖作为参数,我们使读者可以准确地知道函数的作用范围和在函数体内可能发生的行为。调用者确切地知道该函数需要什么参数,并可以提供这些参数。
如果我们正在为此程序设计公共API,我们甚至可以采取更有效的措施。
// RowQueryer models part of a database/sql.DB.
type RowQueryer interface {
QueryRow(string, ...interface{}) *sql.Row
}
// Requestor models the requesting side of a resource.Pool.
type Requestor interface {
Request(n int) (*resource.Value, error)
}
func NewObject(q RowQueryer, r Requestor, n int, logger log.Logger) (*Object, error) {
// ...
}
通过将每个具体对象抽象为接口,及仅捕获函数中使用到的方法,我们允许调用者自己去实现。这减少了包之间的源码级耦合,并使我们能够模拟测试中的具体依赖关系。如果对使用具体的包级别全局变量代码进行测试,我们会发现这种做法是多么乏味且容易出错。
如果我们所有的构造函数和其他函数都显式地接受了它们的依赖关系,那么全局变量就没有任何用处。相反,我们可以在主函数中构造所有数据库连接,日志记录,链接池,以便 将来的读者可以非常清楚地绘制出组件图并使用。
而且,我们可以非常明确地将这些依赖关系传递给使用它们的组件/函数,从而不会对全局变量感到困惑。另外值得注意的是,如果没有全局变量,那么也就不再需要使用init函数了,init函数的唯一目的是实例化或改变包级别的全局状态。
Part4
Try to write go without global state
编写几乎没有全局状态的Go程序不仅可能,而且非常容易。以我的经验来看,以这种方式编程不会比使用全局变量缩小函数定义慢或乏味。
相反,当函数签名可靠且完整地描述了函数主体的作用范围时,我们可以更高效地进行代码推理,重构和维护。 Go kit从一开始就以这种风格编写,并因此受益。
Part5
Avoid two things
综上所述,我们可以发展出现代Go理论。根据 Dave Cheney所述,提出以下准则:
- 避免包级别的变量
- 避免初始化函数
当然也存在例外。
有疑问加站长微信联系(非本文作者)