2.3#3:滥用init函数
有时我们会在Go 应用程序中误用 init 函数,这种做法会带来糟糕的错误管理、难以理解的代码流等潜在后果。让我们重新思考一下 init 函数是什么。然后,我们将看到关于它的推荐用法和不推荐用法。
2.3.1 概念
init 函数是用于初始化应用程序状态的函数。它既不接收参数也不返回结果,仅仅是一个func() 类型的函数。当初始化包时,将对包中所有的常量和变量声明进行计算。然后,执行 init 函数。下面是一个初始化main包的例子:
运行此示例会打印以下输出:
init 函数在初始化包时执行。在下面的例子中,我们定义了两个包——main和redis,其中 main 依赖于 redis。首先,main.go 文件中的main 包的内容如下:
接下来,redis.go 文件中的redis 包的内容如下:
因为 main 依赖于 redis,所以先执行 redis 包的init 函数,然后执行 main 包的init函数,最后执行 main 函数本身。图2.2展示了这个顺序。
图2.2 首先执行redis包中的init()函数,然后执行main包中的init()函数,最后执行main()函数
可以在每个包中定义多个init 函数。当我们这样做时,包内的init函数的执行顺序基于源文件的字母顺序。例如,如果一个包包含一个a.go 文件和一个b.go 文件,并且都有 init函数,则首先执行 a.go 文件中的init 函数。
我们不应该依赖包内初始化函数的顺序。实际上,这可能是十分危险的,因为源文件可能会被重命名,这会影响执行顺序。
我们还可以在同一个源文件中定义多个init 函数。例如,下面这段代码是完全有效的:
执行的第一个init 函数是源顺序中的第一个。输出:
还可以使用init 函数来处理副作用。在下面一个例子中,我们定义了一个对 foo 没有很强依赖的main包(例如,没有直接使用公共函数)。然而,这个例子要求 foo 包被初始化。可以通过使用_操作符这样做:
在这种情况下,foo 包在main 之前被初始化。因此,foo的init 函数被执行。
init 函数不能被直接调用,如下例所示:
这段代码会产生以下编译错误:
现在我们已经重新思考了 init 函数是如何工作的,下面来看看什么时候应该使用它们,什么时候不应该使用它们。
2.3.2 何时使用init函数
首先,让我们看一个使用init 函数可能被认为是不合适的示例:保存数据库连接池。在本例的init()代码体函数中,我们使用sql.Open 打开数据库。我们将这个数据库作为一个全局变量,供其他函数稍后使用:
在本例中,我们打开数据库,检查是否可以 ping 通它,然后将它分配给全局变量。我们应该如何考虑这个实现呢?它有三个主要的缺点。
首先,init 函数中的错误管理是有限的。实际上,由于 init 函数不返回错误,发出错误信号的唯一方法是 panic,它将导致应用程序停止。在我们的例子中,如果打开数据库失败,那可以以任何方式停止应用程序。但是,是否停止应用程序不一定要由包本身决定。调用者可能更喜欢实现重试或使用回退机制。在这种情况下,在init 函数中打开数据库将阻止客户端包实现其错误处理逻辑。
另一个重要的缺点与测试有关。如果我们向该文件添加测试,那么init 函数将在运行测试用例之前执行,这并不一定是我们想要的(例如,如果在不需要创建此连接的实用函数上添加单元测试)。因此,这个例子中的init 函数使编写单元测试变得复杂。
最后一个缺点是,该示例需要将数据库连接池分配给一个全局变量。全局变量有一些严重的缺点,例如:
■ 任何函数都可以改变包中的全局变量。
■ 单元测试可能更加复杂,因为函数依赖的全局变量将不再是独立的。
在大多数情况下,我们应该倾向于封装变量,而不是保持它的全局性。
由于以上这些原因,之前的初始化可能应该像下面这样作为普通函数的一部分被处理:
使用这个函数,我们解决了前面讨论的主要缺点带来的问题。方法如下:
■ 将错误处理的责任留给调用者。
■ 可以创建一个集成测试来检查这个函数是否工作。
■ 将连接池封装在函数中。
是否有必要不惜一切代价避免使用init 函数?答案是否定的。在一些用例中,init 函数还是很有帮助的。例如,官方 Go 博客(参见链接8)使用init 函数设置静态 HTTP 配置:
在本例中,init 函数不能失败(http.HandleFunc 可能会 panic,但只有在处理程序为 nil时才会 panic,这里的情况不是这样的)。同时,不需要创建任何全局变量,函数也不会影响可能的单元测试。因此,这段代码片段提供了一个很好的例子,说明了 init 函数在哪里可以发挥作用。总之,我们看到init 函数会导致一些问题:
■ 它们可以限制错误管理。
■ 它们会使实现测试的方式复杂化(例如,必须设置外部依赖,这对于单元测试的范围可能不是必需的)。
■ 如果初始化需要我们设置一个状态,那就必须通过全局变量来完成。
我们应该谨慎使用init 函数。然而,它们在某些情况下也是有用的,例如定义静态配置,正如我们在本节中看到的。在大多数情况下,我们应该通过特别殊函数处理初始化。