zl程序教程

您现在的位置是:首页 >  其他

当前栏目

Go常用错误集锦之误用init初始化函数

2023-02-26 09:48:27 时间

init函数有时候会在Go应用程序中被误用。潜在的后果可能是错误管理不善或代码逻辑难以理解。

首先,我们将重新认识一下什么是init函数。然后,我们看看什么时候该使用init函数,什么时候不推荐使用。

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包。

package main

import (
  "fmt"
  "redis"
)

func init() {
  // ...
}

func main() {
  err := redis.Store("foo", "bar") ①
  // ...
}

① 依赖于redis包

package redis

import ...

func init() {
  // ...
}

func Store(key, value string) error {
  // ...
}

因为 main依赖于redis,所以会首先执行redis包的init函数,然后是main包的init函数,然后是main函数自身,如下图

我们在一个包中也可以定义很多init函数。在这种场景中,在同一个包里的init函数的执行顺序是依赖于源码里按字母顺序执行的。例如,如果一个包里包含一个a.go和一个b.go文件,两个文件里都有init函数,a.go中的init函数将先被执行。我们不应该依赖于同一个包中的init函数的执行顺序。实际上,如果源文件被重命名会影响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包中不使用该包。在下面的这个例子中,我们定义了一个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 何时使用init函数

在下面的例子中,我们会创建一个SQL连接。我们将使用一个init函数并构造一个可用的连接作为全局变量以供后续使用。

var connection *sql.DB
func init() {
   dataSourceName := os.Getenv("MYSQL_DATA_SOURCE_NAME") ①
   c, err := sql.Open("mysql", dataSourceName)
   if err != nil {
       log.Panic(err)
  }
   err = connection.Ping()
   if err != nil {
       log.Panic(err)
  }
   connection = c ②
}

① 环境变量

② 将DB连接赋值给全局connection变量

在这段代码中,有三个主要的缺点。

第一,在init函数中的错误管理是非常受局限的。事实上,因为init函数不会有返回值,所以,如果遇到一些错误时我们才决定使用panic。如果在init函数中发生了panic,是不可能从错误中恢复的,同时该应用程序将会停止。在我们的例子中,如果创建一个连接是绝对必须的,那么遇到panic就停止是可以接受的。但是,是否停止应用程序不一定要由包本身来决定。也许,调用者更希望使用重试机制或使用回调技术。在init函数中进行错误处理阻止了客户端实现错误管理的逻辑处理。

第二,会使单元测试更复杂。如果我们在这个文件中加入了测试,init函数将会在执行测试用例之前执行,这不是我们所期望的。例如,我们可能希望在不需要创建此连接的映射函数上添加单元测试。所以,编写单元测试的方法会很复杂。

第三,是我们创建连接的方法需要一个全局变量。全局变量有一些严重的缺点,例如:

  • 它可以被包中的任何函数更改
  • 它会使单元测试变得更复杂,因为依赖于共享全局状态的函数不是纯函数。

在大多数场景中,我们更喜欢封装一个变量,而不是全局变量。

这是和init函数相关的主要缺点。那么,我们是不是就不使用它了呢?当然不是。也有一些场景是适合使用init函数的。例如,官方博客中所说的使用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会引发panic,但也只有在handler是nil,这里不是这种情况),也没有创建任何全局变量的需要,并且也不会影响单元测试。因此,这个就是一个非常适合用init函数的例子。

总之,我们已经知道init函数可能会导致一些缺点:

  • 错误管理是有局限性的
  • 对实现单元测试会很复杂(例如,外部依赖设置,对于单元测试来说这不是必须的)
  • 如果初始化需要设置一个状态,必须通过全局变量完成

我们必须小心使用init函数。它在一些场景下会很有用,例如定义静态配置;在大多数情况下,我们应该将初始化处理为特殊函数,使代码流更加明确。