- 100个Go语言典型错误
- (法)Teiva Harsanyi(泰瓦·哈尔萨尼)
- 1815字
- 2024-05-11 18:01:02
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=1739019787-h68jBD9WZeBcFn5JCUdqcsmwb4U8vn2O-0-4ded2b3b427ca6931bba4e5a2037f6d8)
运行此示例会打印以下输出:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_37_2.jpg?sign=1739019787-I951dN1EjiTPbAgEmxGUg69R4wzfh1We-0-be1fd7dc778f6c19010620b9c0b7b861)
init 函数在初始化包时执行。在下面的例子中,我们定义了两个包——main和redis,其中 main 依赖于 redis。首先,main.go 文件中的main 包的内容如下:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_37_3.jpg?sign=1739019787-CK30Aha84NfUNn7oA8mLs1YdoUOwRR8O-0-23e3a84bec25f20ca7ed95665d1b774b)
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_38_1.jpg?sign=1739019787-x1IOqfeeDZpXVDPNGLr95jRknv37Hlyn-0-64894552122f4bf2584040095f9e0506)
接下来,redis.go 文件中的redis 包的内容如下:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_38_2.jpg?sign=1739019787-iIAYGD6cB3YSc0Kl3qPzJsiBfYcJCeEz-0-58a0842008942f3829c6626857c28d2f)
因为 main 依赖于 redis,所以先执行 redis 包的init 函数,然后执行 main 包的init函数,最后执行 main 函数本身。图2.2展示了这个顺序。
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_38_3.jpg?sign=1739019787-fqY4kZSKgeFYzmpAPHpHPzo2BWKDsgAL-0-16dd3c1c5979b042c89530875dd29eb6)
图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=1739019787-KhzhW8BsJm5LrLF2ZMuSqRzDo8H6GoCM-0-ea835505b79adbc2f82e6d981c64f34a)
执行的第一个init 函数是源顺序中的第一个。输出:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_39_2.jpg?sign=1739019787-JsrDvcCcc6Dg4NrYZyAIC78dKemFEPsE-0-9ed517894944c2a29d4fb576003f593e)
还可以使用init 函数来处理副作用。在下面一个例子中,我们定义了一个对 foo 没有很强依赖的main包(例如,没有直接使用公共函数)。然而,这个例子要求 foo 包被初始化。可以通过使用_操作符这样做:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_39_3.jpg?sign=1739019787-S3CCbNeI8V8y6IzEXOZhVLElpv4iywEr-0-92a1649165e223ca5a4100ef8eac8822)
在这种情况下,foo 包在main 之前被初始化。因此,foo的init 函数被执行。
init 函数不能被直接调用,如下例所示:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_40_1.jpg?sign=1739019787-HXKmd2LO7uEztwmCumlR7I6C5QAHS69r-0-513461ff01f2e3d373a0760f6f7fc2f1)
这段代码会产生以下编译错误:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_40_2.jpg?sign=1739019787-wQ4MskDViAegUuBLv8H6zTYUlOXiJaUb-0-971f7c8eb516f915a488163a4ee12781)
现在我们已经重新思考了 init 函数是如何工作的,下面来看看什么时候应该使用它们,什么时候不应该使用它们。
2.3.2 何时使用init函数
首先,让我们看一个使用init 函数可能被认为是不合适的示例:保存数据库连接池。在本例的init()代码体函数中,我们使用sql.Open 打开数据库。我们将这个数据库作为一个全局变量,供其他函数稍后使用:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_40_3.jpg?sign=1739019787-cC46m0lJlqUCgWnEeIcCTCZ1AMm886J0-0-63a93220c10f55e31fd56a6698ad4469)
在本例中,我们打开数据库,检查是否可以 ping 通它,然后将它分配给全局变量。我们应该如何考虑这个实现呢?它有三个主要的缺点。
首先,init 函数中的错误管理是有限的。实际上,由于 init 函数不返回错误,发出错误信号的唯一方法是 panic,它将导致应用程序停止。在我们的例子中,如果打开数据库失败,那可以以任何方式停止应用程序。但是,是否停止应用程序不一定要由包本身决定。调用者可能更喜欢实现重试或使用回退机制。在这种情况下,在init 函数中打开数据库将阻止客户端包实现其错误处理逻辑。
另一个重要的缺点与测试有关。如果我们向该文件添加测试,那么init 函数将在运行测试用例之前执行,这并不一定是我们想要的(例如,如果在不需要创建此连接的实用函数上添加单元测试)。因此,这个例子中的init 函数使编写单元测试变得复杂。
最后一个缺点是,该示例需要将数据库连接池分配给一个全局变量。全局变量有一些严重的缺点,例如:
■ 任何函数都可以改变包中的全局变量。
■ 单元测试可能更加复杂,因为函数依赖的全局变量将不再是独立的。
在大多数情况下,我们应该倾向于封装变量,而不是保持它的全局性。
由于以上这些原因,之前的初始化可能应该像下面这样作为普通函数的一部分被处理:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_41_1.jpg?sign=1739019787-PERX3s5BVEE7JunoxQvZN1ov6ggH1Z7a-0-ad80a5690ec9e5c6cd345e392807a4ea)
使用这个函数,我们解决了前面讨论的主要缺点带来的问题。方法如下:
■ 将错误处理的责任留给调用者。
■ 可以创建一个集成测试来检查这个函数是否工作。
■ 将连接池封装在函数中。
是否有必要不惜一切代价避免使用init 函数?答案是否定的。在一些用例中,init 函数还是很有帮助的。例如,官方 Go 博客(参见链接8)使用init 函数设置静态 HTTP 配置:
![](https://epubservercos.yuewen.com/BD258C/28817488807438306/epubprivate/OEBPS/Images/46913_42_1.jpg?sign=1739019787-uYDTwjKpaGjxzHttpJumpGh0dQxQ5eZg-0-4566383c3f79ea297aa48ec15de192a9)
在本例中,init 函数不能失败(http.HandleFunc 可能会 panic,但只有在处理程序为 nil时才会 panic,这里的情况不是这样的)。同时,不需要创建任何全局变量,函数也不会影响可能的单元测试。因此,这段代码片段提供了一个很好的例子,说明了 init 函数在哪里可以发挥作用。总之,我们看到init 函数会导致一些问题:
■ 它们可以限制错误管理。
■ 它们会使实现测试的方式复杂化(例如,必须设置外部依赖,这对于单元测试的范围可能不是必需的)。
■ 如果初始化需要我们设置一个状态,那就必须通过全局变量来完成。
我们应该谨慎使用init 函数。然而,它们在某些情况下也是有用的,例如定义静态配置,正如我们在本节中看到的。在大多数情况下,我们应该通过特别殊函数处理初始化。