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