在Go語言中,main
函數是任何Go應用的入口函數--使用者層入口。當你執行一個Go程式時,作業系統會首先呼叫main
函數,然後程式開始執行。main
函數的函數原型是這樣的:
package main
func main() {
// 使用者層執行邏輯
... ...
}
你的程式的執行會從main
函數開始,會在這個函數內按照它的呼叫順序展開。
main.main
函數是Go應用程式的入口函數,它具有一些特點和規定,使得Go程式的執行流程有一定的規範性。以下是關於main.main
函數的特點:
main.main
函數。這是整個程式的唯一入口點,程式的執行將從這裡開始。如果存在多個main
函數,編譯時會報錯。main.main
函數不接受任何引數,它沒有輸入引數,也沒有返回值。這是Go語言的規定,而程式的命令列引數通常通過os.Args
等方式獲取。在Go中,包(Package)是組織和管理程式碼的基本單元。包包括一組相關的函數、型別和變數,它們可以被匯入到其他Go檔案中以便重複使用。Go標準庫以及第三方庫都是以包的形式提供的。
每個Go檔案都屬於一個包,你可以使用package
關鍵字來指定宣告一個檔案屬於哪個包。例如:
package 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
內部使用的,它是沒法在包外使用的。
fmt
、math
、strings
等。要在Go程式中使用其他包的功能,你需要匯入這些包。使用import
關鍵字來匯入包,匯入語句通常放在檔案的頂部。一個典型的包匯入語句的格式如下:
import "包的匯入路徑"
其中,包的匯入路徑
是指被匯入包的唯一識別符號,通常是包的名稱或路徑,它用於告訴Go編譯器去哪裡找到這個包的程式碼。
例如,匯入標準庫中的fmt
包可以這樣做:
import "fmt"
然後,你就可以在你的程式中使用fmt
包提供的函數和型別。
在Go程式中,你可以一次匯入多個包,只需在import
語句中列出這些包的匯入路徑,用括號()括起來並以括號內的方式分隔包的匯入路徑。
範例:
import (
"fmt"
"math"
"net/http"
)
這個範例中匯入了fmt
、math
和net/http
三個包。這種方式使你可以更清晰地組織你的匯入語句,以便程式更易讀。
注意:Go語言的編譯器會自動檢測哪些匯入的包是真正被使用的,未使用的匯入包不會引起編譯錯誤,但通常被視為不良實踐。在Go中,未使用的匯入包可能會引起程式碼不清晰,因此應該避免匯入不需要的包。
在Go語言中,你可以使用包的別名(package alias)來為一個匯入的包賦予一個不同的名稱,以便在程式碼中參照它。包的別名通常用於以下情況:
使用包的別名是非常簡單的,只需在匯入語句中使用as
關鍵字為包指定一個別名。以下是範例:
import fm "fmt"
在上面的範例中,fm
是fmt
包的別名。現在,你可以在程式碼中使用fm
來代替fmt
,例如:
fm.Println("Hello, World!")
這樣,你就可以使用更短的fm
來呼叫fmt
包的函數,以減少程式碼中的冗長。
包的別名可以根據需要自定義,但通常建議選擇一個有意義的別名,以使程式碼更易讀。使用別名時要注意避免產生混淆,要確保別名不與其他識別符號(如變數名或函數名)發生衝突。
下劃線 _
在Go語言中用於以下幾個不同的場景:
_
可以用作匿名變數,用於忽略某個值。當你希望某個值返回但又不需要使用它時,可以將其賦值給 _
。_
也被稱為空識別符號,它用於宣告但不使用變數或匯入包但不使用包的識別符號。這是為了確保程式碼通過編譯,但不會產生未使用變數或包的警告。在程式碼中,下劃線 _
可以用作匿名變數,用於忽略某個值。這通常在函數多返回值中使用,如果你只關心其中的某些值而不需要其他返回值,可以將其賦值給 _
。
範例:
x, _ := someFunction() // 忽略第二個返回值
在上面的範例中,_
用於忽略 someFunction
函數的第二個返回值。
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包的初始化階段自動呼叫的函數。它的目的是執行一些包級別的初始化工作,例如設定變數、初始化資料、連線資料庫等。init
函數沒有引數,也沒有返回值,它的定義形式如下:
func init() {
// 包初始化邏輯
... ...
}
init
函數有以下特點:
init
函數不需要手動呼叫,它會在程式啟動時自動執行。這確保了包的初始化工作在程式開始執行之前完成。init
函數是包級別的,因此它只能在包的內部定義。不同包中的 init
函數互不影響,它們獨立執行。init
函數: 一個包可以包含多個 init
函數,它們按照定義的順序依次執行。被匯入的包的 init
函數會在匯入它的包的 init
函數之前執行。main.main
函數一樣,init
函數也是一個無引數無返回值的函數,它只用於執行初始化工作,而不與其他函數互動。init
函數的執行順序是根據包的匯入順序確定的,因此在編寫程式碼時應該謹慎考慮包的依賴關係,以確保正確的初始化順序。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 包是程式邏輯封裝的基本單元,每個包都可以理解為是一個「自治」的、封裝良好的、對外部暴露有限介面的基本單元。一個 Go 程式就是由一組包組成的,程式的初始化就是這些包的初始化。每個 Go 包還會有自己的依賴包、常數、變數、init
函數(其中 main
包有 main
函數)等。
在平時開發中,我們在閱讀和理解程式碼的時候,需要知道這些元素在在程式初始化過程中的初始化順序,這樣便於我們確定在某一行程式碼處這些元素的當前狀態。
下面,我們就通過一張流程圖,來了解 Go 包的初始化次序:
這裡,我們來看看具體的初始化步驟。
首先,main
包依賴 pkg1
和 pkg4
兩個包,所以第一步,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
包的第二個依賴包 pkg4
,pkg4
包的初始化過程與 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
包的程式碼,pkg1
、pkg2
和 pkg3
可可以到程式碼倉庫中檢視。
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 包僅會被初始化了一次。
根據以上,包的初始化按照依賴關係的順序執行,遵循以下規則:
init
函數按照它們在程式碼中的出現順序依次自動呼叫。Go 包初始化時,init
函數的初始化次序在變數之後,這給了開發人員在 init
函數中對包級變數進行進一步檢查與操作的機會。
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
型別範例時,CommandLine
的 Usage
欄位被賦值為 defaultUsage
。
也就是說,如果保持現狀,那麼使用 flag
包預設 CommandLine
的使用者就無法自定義 usage
的輸出了。於是,flag
包在 init
函數中重置了 CommandLine
的 Usage
欄位:
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()
}
這個時候我們會發現,CommandLine
的 Usage
欄位,設定為了一個 flag
包內的未匯出函數 commandLineUsage
,後者則直接使用了 flag
包的另外一個匯出包變數 Usage
。這樣,就可以通過 init
函數,將 CommandLine
與包變數 Usage
關聯在一起了。
然後,當用戶將自定義的 usage
賦值給了 flag.Usage
後,就相當於改變了預設代表命令列標誌集合的 CommandLine
變數的 Usage
。這樣當 flag
包完成初始化後,CommandLine
變數便處於一個合理可用的狀態了。
有些包級變數需要一個比較複雜的初始化過程。有些時候,使用它的型別零值(每個 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
包完成初始化後,可以處於合理狀態。
首先我們來看一段使用 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/png
、image/jpeg
和 image/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
函數之前,常數和變數初始化之後。