瞭解 Go 中的 init


簡介

在 Go 中,預定義的 init() 函數設置了一段代碼,在你的包的任何其他部分之前運行。這段代碼將在包被導入_(點擊跳轉查看)_後立即執行,當你需要你的應用程序在一個特定的狀態下初始化時,例如你有一個特定的配置或一組資源,你的應用程序需要用它來啓動。

它也可以在_導入副作用_時使用,這是一種通過導入特定包來設置程序狀態的技術,經常被用於 register 一個包和另一個包,以確保程序考慮任務的正確代碼。

儘管 init() 是一個有用的工具,但它有時會使代碼難以閱讀,因爲難以找到的 init() 實例會大大影響代碼的運行順序。

正因爲如此,對於剛接觸 Go 的開發者來說,瞭解這個函數的方方面面是非常重要的,這樣他們在寫代碼時就能確保以可讀的方式使用 init()

在本教程中,你將學習 init() 如何用於設置和初始化特定包的變量、一次性計算,以及註冊一個包以便與另一個包一起使用。

先決條件

對於本文中的一些例子,你將需要:

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

定義 init()


只要你定義一個 init() 函數,Go 就會在該包的其他東西之前加載並運行它。爲了證明這一點,本節將介紹如何定義一個 init() 函數,並展示對包的運行的影響。

首先,讓我們以下面這個沒有 init() 函數的代碼爲例:

main.go

package main

import "fmt"

var weekday string

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

在這個程序中,我們聲明瞭一個全局變量(點擊跳轉查看),叫做 weekday。默認情況下,weekday 的值是一個空字符串。

讓我們運行這段代碼:

go run main.go

因爲 weekday 的值是空的,當我們運行程序時,我們將得到以下輸出:

Today is

我們可以通過引入一個 init() 函數,將 weekday 的值初始化爲當前日期,來填補這個空白變量。在 main.go 中加入以下高亮行:

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。現在當我們運行該程序時,它將打印出當前的工作日:

Today is Monday

雖然這說明了 init() 是如何工作的,但 init() 更典型的使用情況是在導入軟件包時使用它。當你在使用軟件包之前需要在軟件包中進行特定的設置任務時,這就很有用。爲了證明這一點,讓我們創建一個程序,該程序需要一個特定的初始化,以便包能夠如期工作。

導入時初始化軟件包

首先,我們將寫一些代碼,從切片_(點擊跳轉查看)_中選擇一個隨機的生物並打印出來。然而,我們不會在初始程序中使用 init()。這將更好地展示我們的問題,以及 init() 將如何解決我們的問題。

在你的 src/github.com/gopherguides/ 目錄中,用以下命令創建一個名爲 creature 的文件夾。

mkdir creature

creature 文件夾下,創建一個名爲 creature.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]
}

這個文件定義了一個叫做 creatures 的變量,它有一組初始化爲數值的海洋生物。它還有一個 exported [1]Random 函數,將從 creatures 變量中返回一個隨機值。

保存並退出這個文件。

接下來,讓我們創建一個 cmd 包,我們將用它來編寫 main() 函數並調用 creature 包。

在我們創建 creature 文件夾的同一文件層,用以下命令創建一個 cmd 文件夾:

mkdir cmd

cmd 文件夾中,創建一個名爲 main.go 的文件:

nano cmd/main.go

在文件中添加以下內容:

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())
}

這裏我們導入了 creature 包,然後在 main() 函數中,使用 creature.Random() 函數來檢索一個隨機生物並打印出來四次。

保存並退出 main.go

我們現在已經寫好了我們的整個程序。然而,在運行這個程序之前,我們還需要創建幾個配置文件,以便我們的代碼能夠正常工作。

Go 使用 Go Modules[2] 來配置導入資源的軟件包依賴性。這些模塊是放置在你的包目錄中的配置文件,告訴編譯器從哪裏導入包。雖然對模塊的學習超出了本文的範圍,但我們只需寫幾行配置就可以讓這個例子在本地運行。

cmd 目錄下,創建一個名爲 go.mod 的文件:

nano cmd/go.mod

文件打開後,放入以下內容:

cmd/go.mod

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

這個文件的第一行告訴編譯器,我們創建的 cmd 包實際上是 github.com/gopherguides/cmd。第二行告訴編譯器,github.com/gopherguides/creature可以在磁盤上的 .../creature 目錄下找到。保存並關閉該文件。接下來,在 creature 目錄下創建一個 go.mod 文件。

nano creature/go.mod

在文件中添加以下一行代碼:

creature/go.mod

 module github.com/gopherguides/creature

這告訴編譯器,我們創建的 creature 包實際上是 github.com/gopherguides/creature 包。沒有這個,cmd 包就不知道從哪裏導入這個包。保存並退出該文件。

現在你應該有以下的目錄結構和文件佈局:

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

現在我們已經完成了所有的配置,我們可以用下面的命令運行 main 程序:

go run cmd/main.go

這將輸出:

jellyfish
squid
squid
dolphin

當我們運行這個程序時,我們收到了四個數值並打印出來。如果我們多次運行這個程序,會注意到,我們_**總是**_得到相同的輸出,而不是預期的隨機結果。

這是因爲 rand 包創建了僞隨機數,對於單一的初始狀態會持續產生相同的輸出。爲了實現更多的隨機數,我們可以用 seed 包,或者設置一個不斷變化的源,這樣每次運行程序時的初始狀態都會不同。

在 Go 中,通常使用當前時間作爲 rand 包的種子。由於我們想讓 creature 包來處理隨機功能,所以打開這個文件。

nano creature/creature.go

creature.go 文件中添加以下高亮行:

creature/creature.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]
}

在這段代碼中,我們導入了 time 包,並使用當前時間作爲 Seed() 的種子。保存並退出該文件。

現在,當我們運行該程序時,我們將得到一個隨機的結果:

go run cmd/main.go
jellyfish
octopus
shark
jellyfish

如果你繼續反覆運行該程序,你將繼續得到隨機結果。然而,這還不是我們代碼的理想實現,因爲每次調用 creature.Random() 時,也會通過再次調用 rand.Seed(time.Now().UnixNano() 來重新播種 rand 包。如果內部時鐘沒有改變,重新播種會增加用相同初始值播種的機會,這將導致隨機模式可能的重複,或者會因爲讓你的程序等待時鐘改變而增加 CPU 處理時間。

爲了解決這個問題,我們可以使用一個 init() 函數。讓我們更新 creature.go 文件:

nano creature/creature.go

添加以下幾行代碼:

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() 函數告訴編譯器,當 creature 包被導入時,它應該運行一次 init() 函數,爲隨機數生成提供一個種子。這確保了我們不會超過必須的時間來運行代碼。現在,如果我們運行該程序,我們將繼續得到隨機結果:

go run cmd/main.go
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

我們將收到以下輸出:

First init
Second init
Third init
Fourth init

注意,每個 init() 都是按照編譯器遇到它的順序來運行的。然而,要確定 init() 函數的調用順序可能並不總是那麼容易。讓我們看一個更復雜的包結構,其中有多個文件,每個文件都有自己的 init() 函數聲明。爲了說明這一點,我們將創建一個程序,共享一個名爲 message 的變量並將其打印出來。

刪除前面的 creaturecmd 目錄及其內容,用下面的目錄和文件結構取代它們:

├── 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.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

我們的 message 包聲明瞭導出的 Message 變量。

要運行該程序,在 cmd 目錄下執行以下命令:

go run *.go

因爲我們在 cmd 文件夾中有多個 Go 文件組成 main 包,我們需要告訴編譯器,cmd 文件夾中所有的 .go 文件都應該被編譯。使用 *.go 告訴編譯器加載 cmd 文件夾中所有以 .go 結尾的文件。如果我們發出 go run main.go 的命令,程序將無法編譯,因爲它看不到 a.gob.go 文件中的代碼。

這將得到以下輸出:

a ->
b -> Hello

根據 Go 語言對包初始化 [3] 的規範,當一個包中遇到多個文件時,會按字母順序處理。正因爲如此,我們第一次從 a.go 中打印出 message.Message 時,其值是空白的。在運行 b.goinit() 函數之前,該值沒有被初始化。如果我們把 a.go 的文件名改爲 c.go,我們會得到一個不同的結果:

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 包 [4] 中,image.Decode 函數在執行前需要知道它要解碼的圖像格式(jpgpnggif,等等)。

你可以通過首先導入一個有 init() 語句副作用的特定程序來完成這個任務。

假設你試圖在一個.png 文件上使用 image.Decode,代碼片段如下:

Sample Decoding Snippet

. . .
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之前的空白標識符 (_)[5] 。這是有必要的,因爲 Go 不允許你導入那些在整個程序中不使用的包。通過包括空白標識符,導入本身的值被丟棄了,所以只有導入的副作用纔會出現。

這意味着,即使我們在代碼中從未調用 image/png 包,我們仍然可以導入它的副作用。

當你需要導入一個包的時候,知道它的副作用是很重要的。如果沒有適當的註冊,你的程序很可能會被編譯,但在運行時卻不能正常工作。

標準庫中的包會在其文檔中聲明需要這種類型的導入。如果你寫了一個需要導入副作用的包,你也應該確保你所使用的 init() 語句是有文檔的,這樣導入你的包的用戶就能正確使用它。

總結

在本教程中,我們瞭解到 init() 函數是在你的包中的其他代碼被加載之前加載的,它可以爲一個包執行特定的任務,如初始化一個期望的狀態。

我們還了解到,編譯器執行多個 init() 語句的順序取決於編譯器加載源文件的順序。

如果你想了解更多關於 init() 的信息,請查看官方的 Golang 文檔 [6],或者閱讀 Go 社區中關於該函數的討論 [7]。

你可以通過我們的《如何在 Go 中定義和調用函數》_(點__擊跳轉查看)_文章閱讀更多關於函數的信息,或者探索整個 Go 中‍如何編程系列 [8]。

相關鏈接:

[1] https://gocn.github.io/How-To-Code-in-Go/docs/22-Understanding_Package_Visibility_in_Go/#%E5%8F%AF%E5%AF%BC%E5%87%BA%E4%B8%8E%E4%B8%8D%E5%8F%AF%E5%AF%BC%E5%87%BA

[2] https://blog.golang.org/using-go-modules

[3] https://golang.org/ref/spec#Package_initialization

[4] https://golang.org/pkg/image/

[5] https://golang.org/ref/spec#Blank_identifier

[6] https://golang.org/doc/effective_go.html#init

[7] https://github.com/golang/go/issues/25885

[8] https://gocn.github.io/How-To-Code-in-Go/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/g4_HWOzL3NNFvyOHseLE6Q