Go包介紹與初始化:搞清Go程式的執行次序

2023-10-21 06:00:57

Go包介紹與初始化:搞清Go程式的執行次序

一、main.main 函數:Go 應用的入口函數

1.1 main.main 函數

在Go語言中,main函數是任何Go應用的入口函數--使用者層入口。當你執行一個Go程式時,作業系統會首先呼叫main函數,然後程式開始執行。main 函數的函數原型是這樣的:

package main

func main() {
    // 使用者層執行邏輯
    ... ...
}

你的程式的執行會從main函數開始,會在這個函數內按照它的呼叫順序展開。

1.2 main.main 函數特點

main.main函數是Go應用程式的入口函數,它具有一些特點和規定,使得Go程式的執行流程有一定的規範性。以下是關於main.main函數的特點:

  1. 唯一入口點: 在一個Go應用程式中,只能有一個main.main函數。這是整個程式的唯一入口點,程式的執行將從這裡開始。如果存在多個main函數,編譯時會報錯。
  2. 不接受引數: main.main函數不接受任何引數,它沒有輸入引數,也沒有返回值。這是Go語言的規定,而程式的命令列引數通常通過os.Args等方式獲取。

二、包介紹

2.1 包介紹與宣告

在Go中,包(Package)是組織和管理程式碼的基本單元。包包括一組相關的函數、型別和變數,它們可以被匯入到其他Go檔案中以便重複使用。Go標準庫以及第三方庫都是以包的形式提供的。

每個Go檔案都屬於一個包,你可以使用package關鍵字來指定宣告一個檔案屬於哪個包。例如:

package main

2.2 非 main包的 main 函數

除了 main 包外,其他包也可以擁有自己的名為 main 的函數或方法。但按照 Go 的可見性規則(小寫字母開頭的識別符號為非匯出識別符號)非 main 包中自定義的 main 函數僅限於包內使用,就像下面程式碼這樣,這是一段在非 main 包中定義 main 函數的程式碼片段:

package pkg1
  
import "fmt"

func Main() {
    main()
}

func main() {
    fmt.Println("main func for pkg1")
}  

你可以看到,這裡 main 函數就主要是用來在包 pkg1 內部使用的,它是沒法在包外使用的。

2.3 包的命名規則

  • 在Go語言中,包的名稱通常使用小寫字母,具有簡潔的、描述性的名稱。這有助於提高程式碼的可讀性和可維護性。標準庫中的包通常具有非常清晰的包名,例如fmtmathstrings等。
  • 在Go語言中,包級別的識別符號(變數、函數、型別等)的可見性是由其首字母的大小寫來決定的。如果一個識別符號以大寫字母開頭,它就是可匯出的(公有的),可以被其他包存取。如果以小寫字母開頭,它就是包內私有的,只能在包內部使用。

三、包的匯入

3.1 包的匯入介紹

要在Go程式中使用其他包的功能,你需要匯入這些包。使用import關鍵字來匯入包,匯入語句通常放在檔案的頂部。一個典型的包匯入語句的格式如下:

import "包的匯入路徑"

其中,包的匯入路徑是指被匯入包的唯一識別符號,通常是包的名稱或路徑,它用於告訴Go編譯器去哪裡找到這個包的程式碼。

例如,匯入標準庫中的fmt包可以這樣做:

import "fmt"

然後,你就可以在你的程式中使用fmt包提供的函數和型別。

3.2 匯入多個包

在Go程式中,你可以一次匯入多個包,只需在import語句中列出這些包的匯入路徑,用括號()括起來並以括號內的方式分隔包的匯入路徑。

範例:

import (
    "fmt"
    "math"
    "net/http"
)

這個範例中匯入了fmtmathnet/http三個包。這種方式使你可以更清晰地組織你的匯入語句,以便程式更易讀。

注意:Go語言的編譯器會自動檢測哪些匯入的包是真正被使用的,未使用的匯入包不會引起編譯錯誤,但通常被視為不良實踐。在Go中,未使用的匯入包可能會引起程式碼不清晰,因此應該避免匯入不需要的包。

3.2 包的別名

在Go語言中,你可以使用包的別名(package alias)來為一個匯入的包賦予一個不同的名稱,以便在程式碼中參照它。包的別名通常用於以下情況:

  1. 避免包名衝突:當你匯入多個包時,有可能出現包名衝突,此時你可以為一個或多個包使用別名來解決衝突。
  2. 簡化包名:有時,包的匯入路徑可能很長,為了減少程式碼中的冗長,你可以為包使用一個短的別名。

使用包的別名是非常簡單的,只需在匯入語句中使用as關鍵字為包指定一個別名。以下是範例:

import fm "fmt"

在上面的範例中,fmfmt包的別名。現在,你可以在程式碼中使用fm來代替fmt,例如:

fm.Println("Hello, World!")

這樣,你就可以使用更短的fm來呼叫fmt包的函數,以減少程式碼中的冗長。

包的別名可以根據需要自定義,但通常建議選擇一個有意義的別名,以使程式碼更易讀。使用別名時要注意避免產生混淆,要確保別名不與其他識別符號(如變數名或函數名)發生衝突。

四、神器的下劃線

4.1 下劃線的作用

下劃線 _ 在Go語言中用於以下幾個不同的場景:

  1. 匿名變數: _ 可以用作匿名變數,用於忽略某個值。當你希望某個值返回但又不需要使用它時,可以將其賦值給 _
  2. 空識別符號: _ 也被稱為空識別符號,它用於宣告但不使用變數或匯入包但不使用包的識別符號。這是為了確保程式碼通過編譯,但不會產生未使用變數或包的警告。

4.2 下劃線在程式碼中

在程式碼中,下劃線 _ 可以用作匿名變數,用於忽略某個值。這通常在函數多返回值中使用,如果你只關心其中的某些值而不需要其他返回值,可以將其賦值給 _

範例:

x, _ := someFunction() // 忽略第二個返回值

在上面的範例中,_ 用於忽略 someFunction 函數的第二個返回值。

4.3 下劃線在import中

  • 當匯入一個包時,該包下的檔案裡所有init()函數都會被執行,然而,有些時候我們並不需要把整個包都匯入進來,僅僅是是希望它執行init()函數而已。
  • 這個時候就可以使用 import _ 參照該包。即使用 import _ 包路徑 只是參照該包,僅僅是為了呼叫init()函數,所以無法通過包名來呼叫包中的其他函數。

以下是一個範例,演示如何使用 import _ 參照一個包以執行其 init() 函數:

專案結構:

src
|
+--- main.go
|
+--- hello
       |
       +--- hello.go

main.go 檔案

package main

import _ "./hello"

func main() {
    // hello.Print() 
    //編譯報錯:./main.go:6:5: undefined: hello
}

hello.go 檔案

package hello

import "fmt"

func init() {
    fmt.Println("imp-init() come here.")
}

func Print() {
    fmt.Println("Hello!")
}

輸出結果:

    imp-init() come here.

五、init 函數:Go 包的初始化函數

5.1 init 函數 介紹

init 函數是在Go包的初始化階段自動呼叫的函數。它的目的是執行一些包級別的初始化工作,例如設定變數、初始化資料、連線資料庫等。init 函數沒有引數,也沒有返回值,它的定義形式如下:

func init() {
    // 包初始化邏輯
    ... ...
}

5.2 init 函數 特點

init 函數有以下特點:

  1. 自動執行: init 函數不需要手動呼叫,它會在程式啟動時自動執行。這確保了包的初始化工作在程式開始執行之前完成。
  2. 包級別: init 函數是包級別的,因此它只能在包的內部定義。不同包中的 init 函數互不影響,它們獨立執行。
  3. 多個 init 函數: 一個包可以包含多個 init 函數,它們按照定義的順序依次執行。被匯入的包的 init 函數會在匯入它的包的 init 函數之前執行。
  4. 沒有引數和返回值: 和前面main.main 函數一樣,init 函數也是一個無引數無返回值的函數,它只用於執行初始化工作,而不與其他函數互動。
  5. 順序執行: 由於 init 函數的執行順序是根據包的匯入順序確定的,因此在編寫程式碼時應該謹慎考慮包的依賴關係,以確保正確的初始化順序。
  6. 可用於註冊和初始化: init 函數通常用於執行包的初始化工作,也可用於在匯入包時註冊一些功能,例如資料庫驅動程式的註冊。

這裡要特別注意的是,在 Go 程式中我們不能手工顯式地呼叫 init,否則就會收到編譯錯誤,就像下面這個範例,它表示的手工顯式呼叫 init 函數的錯誤做法:

package main

import "fmt"

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

func main() {
   init()
}

構建並執行上面這些範例程式碼之後,Go 編譯器會報下面這個錯誤:

$go run call_init.go 
./call_init.go:10:2: undefined: init

接著,我們將程式碼修改如下:

package main

import "fmt"

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

func main() {
	fmt.Println("this is main")
}

Go 編譯器執行結果如下:

init invoked
this is main

我們看到,在初始化 Go 包時,Go 會按照一定的次序,逐一、順序地呼叫這個包的 init 函數。一般來說,先傳遞給 Go 編譯器的原始檔中的 init 函數,會先被執行;而同一個原始檔中的多個 init 函數,會按宣告順序依次執行。所以說,在Go中,main.main 函數可能並不是第一個被執行的函數。

六、Go 包的初始化次序

6.1 包的初始化次序探究

我們從程式邏輯結構角度來看,Go 包是程式邏輯封裝的基本單元,每個包都可以理解為是一個「自治」的、封裝良好的、對外部暴露有限介面的基本單元。一個 Go 程式就是由一組包組成的,程式的初始化就是這些包的初始化。每個 Go 包還會有自己的依賴包、常數、變數、init 函數(其中 main 包有 main 函數)等。

在平時開發中,我們在閱讀和理解程式碼的時候,需要知道這些元素在在程式初始化過程中的初始化順序,這樣便於我們確定在某一行程式碼處這些元素的當前狀態。

下面,我們就通過一張流程圖,來了解 Go 包的初始化次序:

這裡,我們來看看具體的初始化步驟。

首先,main 包依賴 pkg1pkg4 兩個包,所以第一步,Go 會根據包匯入的順序,先去初始化 main 包的第一個依賴包 pkg1。

第二步,Go 在進行包初始化的過程中,會採用「深度優先」的原則,遞迴初始化各個包的依賴包。在上圖裡,pkg1 包依賴 pkg2 包,pkg2 包依賴 pkg3 包,pkg3 沒有依賴包,於是 Go 在 pkg3 包中按照「常數 -> 變數 -> init 函數」的順序先對 pkg3 包進行初始化;

緊接著,在 pkg3 包初始化完畢後,Go 會回到 pkg2 包並對 pkg2 包進行初始化,接下來再回到 pkg1 包並對 pkg1 包進行初始化。在呼叫完 pkg1 包的 init 函數後,Go 就完成了 main 包的第一個依賴包 pkg1 的初始化。

接下來,Go 會初始化 main 包的第二個依賴包 pkg4pkg4 包的初始化過程與 pkg1 包類似,也是先初始化它的依賴包 pkg5,然後再初始化自身;

然後,當 Go 初始化完 pkg4 包後也就完成了對 main 包所有依賴包的初始化,接下來初始化 main 包自身。

最後,在 main 包中,Go 同樣會按照「常數 -> 變數 -> init 函數」的順序進行初始化,執行完這些初始化工作後才正式進入程式的入口函數 main 函數。

現在,我們可以通過一段程式碼範例來驗證一下 Go 程式啟動後,Go 包的初始化次序是否是正確的,範例程式的結構如下:

prog-init-order
├── main.go
├── pkg1
│   └── pkg1.go
├── pkg2
│   └── pkg2.go
└── pkg3
    └── pkg3.go

這裡我只列出了 main 包的程式碼,pkg1pkg2pkg3 可可以到程式碼倉庫中檢視

package main

import (
	"fmt"
	_ "gitee.com/tao-xiaoxin/study-basic-go/syntax/prog-init-order/pkg1"
	_ "gitee.com/tao-xiaoxin/study-basic-go/syntax/prog-init-order/pkg2"
)

var (
	_  = constInitCheck()
	v1 = variableInit("v1")
	v2 = variableInit("v2")
)

const (
	c1 = "c1"
	c2 = "c2"
)

func constInitCheck() string {
	if c1 != "" {
		fmt.Println("main: const c1 has been initialized!")
	}

	if c2 != "" {
		fmt.Println("main: const c2 has been initialized!")
	}

	return ""
}

func variableInit(name string) string {
	fmt.Printf("main: var %s has been initialized\n", name)
	return name
}

func init() {
	fmt.Println("main: first init function invoked")
}

func init() {
	fmt.Println("main: second init function invoked")
}

func main() {
	//
}

我們可以看到,在 main 包中其實並沒有使用 pkg1 和 pkg2 中的函數或方法,而是直接通過空匯入的方式「觸發」pkg1 包和 pkg2 包的初始化(pkg1 包和和 pkg2 包都通過空匯入的方式依賴 pkg3 包的,),下面是這個程式的執行結果:

$go run main.go
pkg3: const c has been initialized
pkg3: var v has been initialized
pkg3: init func invoked
pkg1: const c has been initialized
pkg1: var v has been initialized
pkg1: init func invoked
pkg2: const c has been initialized
pkg2: var v has been initialized
pkg2: init func invoked
main: const c1 has been initialized
main: const c2 has been initialized
main: var v1 has been initialized
main: var v2 has been initialized
main: first init func invoked
main: second init func invoked

正如我們預期的那樣,Go 執行時是按照「pkg3 -> pkg1 -> pkg2 -> main」的順序,來對 Go 程式的各個包進行初始化的,而在包內,則是以「常數 -> 變數 -> init 函數」的順序進行初始化。此外,main 包的兩個 init 函數,會按照在原始檔 main.go 中的出現次序進行呼叫。根據 Go 語言規範,一個被多個包依賴的包僅會初始化一次,因此這裡的 pkg3 包僅會被初始化了一次。

6.2 包的初始化原則

根據以上,包的初始化按照依賴關係的順序執行,遵循以下規則:

  1. 依賴包按照 "深度優先" 的方式進行初始化,即先初始化最底層的依賴包。
  2. 在每個包內部以「常數 -> 變數 -> init 函數」的順序進行初始化。
  3. 包內的多個 init 函數按照它們在程式碼中的出現順序依次自動呼叫。

七、init 函數的常用用途

Go 包初始化時,init 函數的初始化次序在變數之後,這給了開發人員在 init 函數中對包級變數進行進一步檢查與操作的機會。

7.1 用途一:重置包級變數值

init 函數就好比 Go 包真正投入使用之前唯一的「質檢員」,負責對包內部以及暴露到外部的包級資料(主要是包級變數)的初始狀態進行檢查。在 Go 標準庫中,我們能發現很多 init 函數被用於檢查包級變數的初始狀態的例子,標準庫 flag 包對 init 函數的使用就是其中的一個,這裡我們簡單來分析一下。

flag 包定義了一個匯出的包級變數 CommandLine,如果使用者沒有通過 flag.NewFlagSet 建立新的代表命令列標誌集合的範例,那麼 CommandLine 就會作為 flag 包各種匯出函數背後,預設的代表命令列標誌集合的範例。

而在 flag 包初始化的時候,由於 init 函數初始化次序在包級變數之後,因此包級變數 CommandLine 會在 init 函數之前被初始化了,可以看如下程式碼:

var CommandLine = NewFlagSet(os.Args[0], ExitOnError)

func NewFlagSet(name string, errorHandling ErrorHandling) *FlagSet {
    f := &FlagSet{
        name:          name,
        errorHandling: errorHandling,
    }
    f.Usage = f.defaultUsage
    return f
}

func (f *FlagSet) defaultUsage() {
    if f.name == "" {
        fmt.Fprintf(f.Output(), "Usage:\n")
    } else {
        fmt.Fprintf(f.Output(), "Usage of %s:\n", f.name)
    }
    f.PrintDefaults()
}

我們可以看到,在通過 NewFlagSet 建立 CommandLine 變數繫結的 FlagSet 型別範例時,CommandLineUsage 欄位被賦值為 defaultUsage

也就是說,如果保持現狀,那麼使用 flag 包預設 CommandLine 的使用者就無法自定義 usage 的輸出了。於是,flag 包在 init 函數中重置了 CommandLineUsage 欄位:

func init() {
    CommandLine.Usage = commandLineUsage // 重置CommandLine的Usage欄位
}

func commandLineUsage() {
    Usage()
}

var Usage = func() {
    fmt.Fprintf(CommandLine.Output(), "Usage of %s:\n", os.Args[0])
    PrintDefaults()
}

這個時候我們會發現,CommandLineUsage 欄位,設定為了一個 flag 包內的未匯出函數 commandLineUsage,後者則直接使用了 flag 包的另外一個匯出包變數 Usage。這樣,就可以通過 init 函數,將 CommandLine 與包變數 Usage 關聯在一起了。

然後,當用戶將自定義的 usage 賦值給了 flag.Usage 後,就相當於改變了預設代表命令列標誌集合的 CommandLine 變數的 Usage。這樣當 flag 包完成初始化後,CommandLine 變數便處於一個合理可用的狀態了。

7.2 用途二:實現對包級變數的複雜初始化

有些包級變數需要一個比較複雜的初始化過程。有些時候,使用它的型別零值(每個 Go 型別都具有一個零值定義)或通過簡單初始化表示式不能滿足業務邏輯要求,而 init 函數則非常適合完成此項工作。標準庫 http 包中就有這樣一個典型範例:

var (
    http2VerboseLogs    bool // 初始化時預設值為false
    http2logFrameWrites bool // 初始化時預設值為false
    http2logFrameReads  bool // 初始化時預設值為false
    http2inTests        bool // 初始化時預設值為false
)

func init() {
    e := os.Getenv("GODEBUG")
    if strings.Contains(e, "http2debug=1") {
        http2VerboseLogs = true // 在init中對http2VerboseLogs的值進行重置
    }
    if strings.Contains(e, "http2debug=2") {
        http2VerboseLogs = true // 在init中對http2VerboseLogs的值進行重置
        http2logFrameWrites = true // 在init中對http2logFrameWrites的值進行重置
        http2logFrameReads = true // 在init中對http2logFrameReads的值進行重置
    }
}

我們可以看到,標準庫 http 包定義了一系列布林型別的特性開關變數,它們預設處於關閉狀態(即值為 false),但我們可以通過 GODEBUG 環境變數的值,開啟相關特性開關。

可是這樣一來,簡單地將這些變數初始化為型別零值,就不能滿足要求了,所以 http 包在 init 函數中,就根據環境變數 GODEBUG 的值,對這些包級開關變數進行了複雜的初始化,從而保證了這些開關變數在 http 包完成初始化後,可以處於合理狀態。

7.3 用途三:在 init 函數中實現「註冊模式」

首先我們來看一段使用 lib/pq 包存取 PostgreSQL 資料庫的程式碼範例:

import (
    "database/sql"
    _ "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("postgres", "user=pqgotest dbname=pqgotest sslmode=verify-full")
    if err != nil {
        log.Fatal(err)
    }
    
    age := 21
    rows, err := db.Query("SELECT name FROM users WHERE age = $1", age)
    ...
}

其實,這是一段「神奇」的程式碼。你可以看到範例程式碼是以空匯入的方式匯入 lib/pq 包的,main 函數中沒有使用 pq 包的任何變數、函數或方法,這樣就實現了對 PostgreSQL 資料庫的存取。而這一切的奧祕,全在 pq 包的 init 函數中:

func init() {
    sql.Register("postgres", &Driver{})
}

這個奧祕就在,我們其實是利用了用空匯入的方式匯入 lib/pq 包時產生的一個「副作用」,也就是 lib/pq 包作為 main 包的依賴包,它的 init 函數會在 pq 包初始化的時候得以執行。

從上面的程式碼中,我們可以看到在 pq 包的 init 函數中,pq 包將自己實現的 SQL 驅動註冊到了 database/sql 包中。這樣只要應用層程式碼在 Open 資料庫的時候,傳入驅動的名字(這裡是「postgres」),那麼通過 sql.Open 函數,返回的資料庫範例控制程式碼對資料庫進行的操作,實際上呼叫的都是 pq 包中相應的驅動實現。

實際上,這種通過在 init 函數中註冊自己的實現的模式,就有效降低了 Go 包對外的直接暴露,尤其是包級變數的暴露,從而避免了外部通過包級變數對包狀態的改動。

另外,從標準庫 database/sql 包的角度來看,這種「註冊模式」實質是一種工廠設計模式的實現,sql.Open 函數就是這個模式中的工廠方法,它根據外部傳入的驅動名稱「生產」出不同類別的資料庫範例控制程式碼。

這種「註冊模式」在標準庫的其他包中也有廣泛應用,比如說,使用標準庫 image 包獲取各種格式圖片的寬和高:

package main

import (
    "fmt"
    "image"
    _ "image/gif" // 以空匯入方式注入gif圖片格式驅動
    _ "image/jpeg" // 以空匯入方式注入jpeg圖片格式驅動
    _ "image/png" // 以空匯入方式注入png圖片格式驅動
    "os"
)

func main() {
    // 支援png, jpeg, gif
    width, height, err := imageSize(os.Args[1]) // 獲取傳入的圖片檔案的寬與高
    if err != nil {
        fmt.Println("get image size error:", err)
        return
    }
    fmt.Printf("image size: [%d, %d]\n", width, height)
}

func imageSize(imageFile string) (int, int, error) {
    f, _ := os.Open(imageFile) // 開啟圖文件案
    defer f.Close()

    img, _, err := image.Decode(f) // 對檔案進行解碼,得到圖片範例
    if err != nil {
        return 0, 0, err
    }

    b := img.Bounds() // 返回圖片區域
    return b.Max.X, b.Max.Y, nil
}

你可以看到,上面這個範例程式支援 png、jpeg、gif 三種格式的圖片,而達成這一目標的原因,正是 image/pngimage/jpegimage/gif 包都在各自的 init 函數中,將自己「註冊」到 image 的支援格式列表中了,你可以看看下面這個程式碼:

// $GOROOT/src/image/png/reader.go
func init() {
    image.RegisterFormat("png", pngHeader, Decode, DecodeConfig)
}

// $GOROOT/src/image/jpeg/reader.go
func init() {
    image.RegisterFormat("jpeg", "\xff\xd8", Decode, DecodeConfig)
}

// $GOROOT/src/image/gif/reader.go
func init() {
    image.RegisterFormat("gif", "GIF8?a", Decode, DecodeConfig)
}  

那麼,現在我們瞭解了 init 函數的常見用途。init 函數之所以可以勝任這些工作,恰恰是因為它在 Go 應用初始化次序中的特殊「位次」,也就是 main 函數之前,常數和變數初始化之後。