跳转到主要内容

介绍


在 Go 中,预定义的 init() 函数会启动一段代码,以便在包的任何其他部分之前运行。此代码将在导入包后立即执行,并且可以在您需要应用程序在特定状态下初始化时使用,例如当您具有特定配置或应用程序需要启动的一组资源时。导入副作用时也使用它,这是一种通过导入特定包来设置程序状态的技术。这通常用于将一个包注册到另一个包,以确保程序正在考虑任务的正确代码。

尽管 init() 是一个有用的工具,但它有时会使代码难以阅读,因为难以找到的 init() 实例会极大地影响代码的运行顺序。因此,对于刚接触 Go 的开发人员来说,了解这个函数的各个方面非常重要,这样他们才能确保在编写代码时以清晰的方式使用 init()。

在本教程中,您将了解如何使用 init() 来设置和初始化特定包变量、一次计算以及注册一个包以与另一个包一起使用。

先决条件


对于本文中的一些示例,您将需要:

  • 按照如何安装 Go 和设置本地编程环境设置的 Go 工作区。本教程将使用以下文件结构:

.
├── bin 
│ 
└── src
    └── github.com
        └── gopherguides


声明 init()


任何时候你声明一个 init() 函数,Go 都会在该包中的任何其他内容之前加载并运行它。为了演示这一点,本节将介绍如何定义 init() 函数并展示对包运行方式的影响。

我们首先以下面的代码为例,没有使用 init() 函数:

package main

import "fmt"

var weekday string

func main() {
    fmt.Printf("Today is %s", weekday)
}

在这个程序中,我们声明了一个名为 weekday 的全局变量。 默认情况下,weekday 的值为空字符串。

让我们运行这段代码:

go run main.go


因为 weekday 的值是空白的,所以当我们运行程序时,会得到如下输出:

Output
Today is

我们可以通过引入一个将 weekday 的值初始化为当前日期的 init() 函数来填充空白变量。 将以下突出显示的行添加到 main.go:

package main

import (
    "fmt"
    "time"
)

var weekday string

func init() {
    weekday = time.Now().Weekday().String()
}

func main() {
    fmt.Printf("Today is %s", weekday)
}

在这段代码中,我们导入并使用 time 包来获取当前星期几 (Now().Weekday().String()),然后使用 init() 使用该值初始化 weekday。

现在当我们运行程序时,它会打印出当前的工作日:

Output
Today is Monday


虽然这说明了 init() 的工作原理,但 init() 更典型的用例是在导入包时使用它。当您需要先在包中执行特定的设置任务才能使用该包时,这会很有用。为了证明这一点,让我们创建一个程序,该程序需要特定的初始化才能使包按预期工作。

导入时初始化包


首先,我们将编写一些代码,从切片中选择一个随机生物并将其打印出来。但是,我们不会在初始程序中使用 init()。这将更好地展示我们遇到的问题,以及 init() 将如何解决我们的问题。

在您的 src/github.com/gopherguides/ 目录中,使用以下命令创建一个名为 creative 的文件夹:

mkdir creature


在creature文件夹中,创建一个名为 bio.go 的文件:

nano creature/creature.go


在此文件中,添加以下内容:

package creature

import (
    "math/rand"
)

var creatures = []string{"shark", "jellyfish", "squid", "octopus", "dolphin"}

func Random() string {
    i := rand.Intn(len(creatures))
    return creatures[i]
}

该文件定义了一个名为的变量,该变量具有一组初始化为值的海洋生物。 它还有一个导出的 Random 函数,该函数将返回来自变量变量的随机值。

保存并退出此文件。

接下来,让我们创建一个 cmd 包,我们将使用它来编写我们的 main() 函数并调用生物包。

在我们创建生物文件夹的同一文件级别,使用以下命令创建一个 cmd 文件夹:

mkdir cmd


在 cmd 文件夹中,创建一个名为 main.go 的文件:

nano cmd/main.go


将以下内容添加到文件中:

package main

import (
    "fmt"

    "github.com/gopherguides/creature"
)

func main() {
    fmt.Println(creature.Random())
    fmt.Println(creature.Random())
    fmt.Println(creature.Random())
    fmt.Println(creature.Random())
}

这里我们导入了这个生物包,然后在main()函数中,使用了生物.Random()函数来获取一个随机的生物并打印出来四次。

保存并退出 main.go。

我们现在已经编写了整个程序。但是,在我们可以运行这个程序之前,我们还需要创建几个配置文件以使我们的代码正常工作。 Go 使用 Go Modules 来配置用于导入资源的包依赖项。这些模块是放置在包目录中的配置文件,告诉编译器从哪里导入包。虽然学习模块超出了本文的范围,但我们只需编写几行配置即可使此示例在本地工作。

在 cmd 目录下,创建一个名为 go.mod 的文件:

nano cmd/go.mod


打开文件后,放入以下内容:

module github.com/gopherguides/cmd
 replace github.com/gopherguides/creature => ../creature


这个文件的第一行告诉编译器我们创建的cmd包其实就是github.com/gopherguides/cmd。第二行告诉编译器 github.com/gopherguides/creature 可以在本地磁盘的 ../creature 目录中找到。

保存并关闭文件。接下来,在生物目录中创建一个 go.mod 文件:

nano creature/go.mod


将以下代码行添加到文件中:

creature/go.mod
 

module github.com/gopherguides/creature


这告诉编译器我们创建的生物包实际上是 github.com/gopherguides/creature 包。没有这个, cmd 包将不知道从哪里导入这个包。

保存并退出文件。

您现在应该具有以下目录结构和文件布局:

├── cmd
│   ├── go.mod
│   └── main.go
└── creature
    ├── go.mod
    └── creature.go


现在我们已经完成了所有配置,我们可以使用以下命令运行主程序:

go run cmd/main.go


这将给出:

Output
jellyfish
squid
squid
dolphin


当我们运行这个程序时,我们收到了四个值并将它们打印出来。如果我们多次运行程序,我们会注意到我们总是得到相同的输出,而不是预期的随机结果。这是因为 rand 包创建了伪随机数,这些伪随机数将始终为单个初始状态生成相同的输出。为了获得更多的随机数,我们可以为包播种,或者设置一个不断变化的源,以便每次运行程序时初始状态都不同。在 Go 中,通常使用当前时间来播种 rand 包。

因为我们希望生物包处理随机功能,所以打开这个文件:

nano creature/creature.go


将以下突出显示的行添加到 creative.go 文件中:

package creature

import (
    "math/rand"
    "time"
)

var creatures = []string{"shark", "jellyfish", "squid", "octopus", "dolphin"}

func Random() string {
    rand.Seed(time.Now().UnixNano())
    i := rand.Intn(len(creatures))
    return creatures[i]
}

在这段代码中,我们导入了时间包并使用 Seed() 来播种当前时间。保存并退出文件。

现在,当我们运行程序时,我们会得到一个随机结果:

go run cmd/main.go

Output
jellyfish
octopus
shark
jellyfish


如果您继续一遍又一遍地运行程序,您将继续获得随机结果。然而,这还不是我们代码的理想实现,因为每次调用 rand.Random() 时,它也会通过再次调用 rand.Seed(time.Now().UnixNano()) 重新播种 rand 包。如果内部时钟未更改,重新播种将增加使用相同初始值播种的机会,这将导致随机模式的可能重复,或者通过让您的程序等待时钟更改来增加 CPU 处理时间。

为了解决这个问题,我们可以使用 init() 函数。让我们更新 bio.go 文件:

nano creature/creature.go


添加以下代码行:

package creature

import (
    "math/rand"
    "time"
)

var creatures = []string{"shark", "jellyfish", "squid", "octopus", "dolphin"}

func init() {
    rand.Seed(time.Now().UnixNano())
}

func Random() string {
    i := rand.Intn(len(creatures))
    return creatures[i]
}

添加 init() 函数告诉编译器,当生物包被导入时,它应该运行一次 init() 函数,为随机数生成提供单个种子。这确保了我们不会运行过多的代码。现在如果我们运行程序,我们将继续得到随机结果:

go run cmd/main.go

Output
dolphin
squid
dolphin
octopus


在本节中,我们看到了使用 init() 如何确保在使用包之前执行适当的计算或初始化。接下来,我们将看到如何在一个包中使用多个 init() 语句。

init() 的多个实例


与只能声明一次的 main() 函数不同,init() 函数可以在整个包中声明多次。但是,多个 init() 可能很难知道哪个优先于其他。在本节中,我们将展示如何保持对多个 init() 语句的控制。

在大多数情况下,init() 函数将按照您遇到它们的顺序执行。我们以下面的代码为例:

main.go

package main

import "fmt"

func init() {
    fmt.Println("First init")
}

func init() {
    fmt.Println("Second init")
}

func init() {
    fmt.Println("Third init")
}

func init() {
    fmt.Println("Fourth init")
}

func main() {}

如果我们使用以下命令运行程序:

go run main.go


我们将收到以下输出:

Output
First init
Second init
Third init
Fourth init


请注意,每个 init() 都按照编译器遇到它的顺序运行。但是,确定调用 init() 函数的顺序可能并不总是那么容易。

让我们看一个更复杂的包结构,其中我们有多个文件,每个文件都声明了自己的 init() 函数。为了说明这一点,我们将创建一个共享名为 message 的变量并将其打印出来的程序。

删除上一节中的 creative 和 cmd 目录及其内容,并将它们替换为以下目录和文件结构:

├── cmd
│   ├── a.go
│   ├── b.go
│   └── main.go
└── message
    └── message.go


现在让我们添加每个文件的内容。在 a.go 中,添加以下行:

cmd/a.go

package main

import (
    "fmt"

    "github.com/gopherguides/message"
)

func init() {
    fmt.Println("a ->", message.Message)
}

该文件包含一个 init() 函数,该函数从消息包中打印出 message.Message 的值。

接下来,将以下内容添加到 b.go 中:

cmd/b.go

package main

import (
    "fmt"

    "github.com/gopherguides/message"
)

func init() {
    message.Message = "Hello"
    fmt.Println("b ->", message.Message)
}

在 b.go 中,我们有一个 init() 函数,它将 message.Message 的值设置为 Hello 并将其打印出来。

接下来,创建 main.go,如下所示:

cmd/main.go

package main

func main() {}

该文件什么都不做,但为程序运行提供了一个入口点。

最后,创建你的 message.go 文件,如下所示:

message/message.go

package message

var Message string

我们的消息包声明了导出的消息变量。

要运行程序,请从 cmd 目录执行以下命令:

go run *.go


因为我们在组成主包的 cmd 文件夹中有多个 Go 文件,所以我们需要告诉编译器 cmd 文件夹中的所有 .go 文件都应该被编译。使用 *.go 告诉编译器加载 cmd 文件夹中以 .go 结尾的所有文件。如果我们发出 go run main.go 命令,程序将无法编译,因为它看不到 a.go 和 b.go 文件中的代码。

这将给出以下输出:

Output
a ->
b -> Hello


根据包初始化的 Go 语言规范,当一个包中遇到多个文件时,它们会按字母顺序进行处理。正因为如此,我们第一次从a.go打印出message.Message时,值是空的。直到 b.go 的 init() 函数运行后,该值才被初始化。

如果我们将 a.go 的文件名更改为 c.go,我们会得到不同的结果:

Output
b -> Hello
a -> Hello


现在编译器首先遇到了b.go,因此当遇到c.go中的init()函数时,message.Message的值已经用Hello初始化了。

这种行为可能会在您的代码中产生问题。更改文件名在软件开发中很常见,并且由于处理 init() 的方式,更改文件名可能会更改处理 init() 的顺序。这可能会产生改变程序输出的不良影响。为了确保可重现的初始化行为,鼓励构建系统将属于同一包的多个文件按照词法文件名顺序呈现给编译器。确保按顺序加载所有 init() 函数的一种方法是将它们全部声明在一个文件中。即使更改了文件名,这也将防止顺序更改。

除了确保 init() 函数的顺序不会改变之外,您还应该尽量避免使用全局变量来管理包中的状态,即可以从包中的任何位置访问的变量。在前面的程序中,message.Message 变量可用于整个包并维护程序的状态。由于这种访问,init() 语句能够更改变量并破坏程序的可预测性。为避免这种情况,请尝试在受控空间中使用变量,这些空间尽可能少地访问,同时仍允许程序运行。

我们已经看到您可以在一个包中包含多个 init() 声明。但是,这样做可能会产生不良影响并使您的程序难以阅读或预测。避免多个 init() 语句或将它们全部保存在一个文件中将确保程序的行为在文件移动或名称​​更改时不会改变。

接下来,我们将研究如何使用 init() 来导入副作用。

使用 init() 处理副作用


在 Go 中,有时需要导入包不是因为它的内容,而是因为导入包时出现的副作用。这通常意味着在导入的代码中有一个 init() 语句在任何其他代码之前执行,从而允许开发人员操纵他们的程序启动的状态。这种技术被称为导入的副作用。

导入副作用的一个常见用例是在您的代码中注册功能,这让包知道您的程序需要使用代码的哪一部分。例如,在图像包中,image.Decode 函数需要知道它正在尝试解码的图像格式(jpg、png、gif 等),然后才能执行。您可以通过首先导入具有 init() 语句副作用的特定程序来完成此操作。

假设您正在尝试使用带有以下代码片段的 .png 文件上的 image.Decode:

. . .
func decode(reader io.Reader) image.Rectangle {
    m, _, err := image.Decode(reader)
    if err != nil {
        log.Fatal(err)
    }
    return m.Bounds()
}
. . .

使用此代码的程序仍然可以编译,但是每当我们尝试解码 png 图像时,都会出现错误。

为了解决这个问题,我们需要首先为 image.Decode 注册一个图像格式。幸运的是,image/png 包包含以下 init() 语句:

image/png/reader.go


func init() {
    image.RegisterFormat("png", pngHeader, Decode, DecodeConfig)
}


因此,如果我们将 image/png 导入到我们的解码片段中,那么 image/png 中的 image.RegisterFormat() 函数将在我们的任何代码之前运行:

Sample Decoding Snippet

. . .
import _ "image/png"
. . .

func decode(reader io.Reader) image.Rectangle {
    m, _, err := image.Decode(reader)
    if err != nil {
        log.Fatal(err)
    }
    return m.Bounds()
}

这将设置状态并注册我们需要 image.Decode() 的 png 版本。此注册将作为导入 image/png 的副作用发生。

您可能已经注意到“image/png”之前的空白标识符 (_)。这是必需的,因为 Go 不允许您导入整个程序中未使用的包。通过包含空白标识符,导入本身的值被丢弃,因此只有导入的副作用通过。这意味着,即使我们从未在代码中调用 image/png 包,我们仍然可以导入它以获得副作用。

了解何时需要导入包以获取其副作用非常重要。如果没有正确注册,您的程序可能会编译,但在运行时可能无法正常工作。标准库中的包将在其文档中声明需要这种类型的导入。如果您编写的包需要导入以产生副作用,您还应该确保您使用的 init() 语句已记录在案,以便导入您的包的用户能够正确使用它。

结论


在本教程中,我们了解到 init() 函数在加载包中的其余代码之前加载,并且它可以为包执行特定任务,例如初始化所需状态。我们还了解到,编译器执行多个 init() 语句的顺序取决于编译器加载源文件的顺序。如果您想了解有关 init() 的更多信息,请查看 Golang 官方文档,或阅读 Go 社区中有关该函数的讨论。

文章链接