[Go]程式結構——包和檔案
Go語言中的包和其它語言中的庫或模組的概念類似,目的都是為了支援模組化、封裝、單獨編譯和程式碼複用。
一個包的原始碼儲存在一個或多個以.go為檔案字尾名的原始檔中,通常一個包所在目錄路徑的字尾是包的匯入路徑;例如,包gopl.io/ch1/helloworld對應的目錄路徑是$GOPATH/src/gopl.io/ch1/helloworld 。
每個包都對應一個獨立的名字空間。流入,在image包中的Decode函式和在unicode/utf16包中的Decode函式是不同的。要在外部引用該函式,必須顯示地使用image.Decode或utf16.Decode形式訪問。
包還可以讓我們通過控制哪些識別符號名字是外部可見的來隱藏內部實現資訊。在GO語言中,一個簡單的規則是:如果一個識別符號的名字是大寫字母開頭,那麼該名字是匯出的。
為了掩飾包的基本用法,先假設我們的溫度轉換軟體已經很流行,我們希望到GO語言社群也能使用這個包,我們該如何做呢?
讓我們建立一個名為gopl.io/ch2/tempconv的包,這是前面例子的一個改進版本。包程式碼儲存在兩個原始檔中,用來演示如何在一個原始檔中宣告然後在其它原始檔中訪問的標識。
我們把變數的宣告、對應的常量,還有方法都放到tempconv.go原始檔中。
gopl.io/ch2/tempconv
// Package tempconv performs Celsius and Fahrenheit conversions.
package tempconv
import "fmt"
type Celsius float64
type Fahrenheit float64
const (
AbsoluteZeroC Celsius = -273.15
FreezingC Celsius = 0
BoilingC Celsius = 100
)
func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
func (f Fahrenheit) String() string { return fmt.Sprintf("%g°F", f) }
轉換函式則放在另一個conv.go原始檔中:
package tempconv
// CToF converts a Celsius temperature to Fahrenheit.
func CToF(c Celsius) Fahrenheit { return Fahrenheit(c*9/5 + 32) }
// FToC converts a Fahrenheit temperature to Celsius.
func FToC(f Fahrenheit) Celsius { return Celsius((f - 32) * 5 / 9) }
每個原始檔都是以包的宣告語句開始,用來指明包的名字。當包被匯入的時候,包內的成員將通過類似 “包名.成員” 的形式訪問。而包級別的名字,例如在一個檔案中宣告的型別和常量,在同一個包的其它原始檔也是可見的,就好像所有程式碼都在一個檔案一樣。要注意的是tempconv.go原始檔匯入了fmt包,但是conv.go原始檔並沒有,因為這個原始檔中的程式碼並沒有用到fmt包。
因為包級別的常量名都是以大寫字母開頭,它們可以像tempconv.AbsoluteZeroC這樣被外部程式碼訪問:
fmt.Printf("Brrrr! %v\n", tempconv.AbsoluteZeroC) // "Brrrr! -273.15°C"
要講攝氏度轉換為華氏度,需要先用import語句匯入gopl.io/ch2/tempconv包,然後就可以使用了:
fmt.Println(tempconv.CToF(tempconv.BoilingC)) // "212°F"
在每個原始檔的包宣告前是註釋,通常用於包功能概要說明。一個包通常只有一個原始檔有包註釋(譯註:如果有多個包註釋,目前的文件工具會根據原始檔名的先後順序將它們連結為一個包註釋)。如果包註釋很大,通常會放到一個獨立的doc.go檔案中。
匯入包
在GO語言程式中,每個包都有一個全域性唯一的匯入路徑。匯入語句類似“gopl.io/ch2/tempconv”的字串對應包的匯入路徑。GO語言的規範並沒有定義這些字串的具體含義或包來自哪裡,它們是由構建工具來解釋的。當使用GO工具箱時,一個匯入路徑代表一個目錄中的一個或多個GO原始檔。
除了包的匯入路徑,每個包還有一個包名,包名一般是短小名字(並不要求包名是唯一的),包名在包的宣告處指定。按照慣例,一個包的名字和包的匯入路徑的最後一個欄位相同,例如,gopl.io/ch2/tempconv包的名字一般是tempconv。
要是用gopl.io/ch2/tempconv包,需要先匯入:
gopl.io/ch2/cf
// Cf converts its numeric argument to Celsius and Fahrenheit.
package main
import (
"fmt"
"os"
"strconv"
"gopl.io/ch2/tempconv"
)
func main() {
for _, arg := range os.Args[1:] {
t, err := strconv.ParseFloat(arg, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "cf: %v\n", err)
os.Exit(1)
}
f := tempconv.Fahrenheit(t)
c := tempconv.Celsius(t)
fmt.Printf("%s = %s, %s = %s\n",
f, tempconv.FToC(f), c, tempconv.CToF(c))
}
}
匯入語句將匯入的包繫結到一個短小的名字,然後通過該短小的名字就可以引用包中匯出的全部內容。上面匯入宣告將允許我們以tempconv.CToF的形式來訪問gopl.io/ch2/tempconv包中的內容。在預設情況下,匯入的包繫結到tempconv這個名字,但是我們也可以繫結到另一個名字,以避免衝突。
如果匯入一個包,但是沒有使用該包中的任何匯出成員,那麼將被當作一個編譯錯誤處理。這種強制規則可以有效減少不必要的依賴,雖然在除錯期間可能會讓人討厭,因為刪除一個類似 log.Print("got here!")的列印語句可能導致需要同時刪除log包匯入宣告,否則,編譯器將會報錯。在這種情況下,我們需要將不必要的包刪除會註釋掉。
不過有更好的解決方案,我們可以使用golang.org/x/tools/cmd/goimports匯入工具,它可以根據需要自動刪除或新增匯入包。許多編輯器可以整合goimports工具,然後在儲存檔案的時候自動執行。類似的還有gofmt工具,可用來格式化GO原始檔。
包的初始化
包的初始化首先是解決包級別變數的依賴順序,然後按照包級別變數宣告出現的順序依次初始化:
ar a = b + c // a 第三個初始化, 為 3
var b = f() // b 第二個初始化, 為 2, 通過呼叫 f (依賴c)
var c = 1 // c 第一個初始化, 為 1
func f() int { return c + 1 }
如果包中有多個.go原始檔,它們將按照發給編譯器的順序進行初始化,GO語言的構建工具首先會將.go檔案根據檔名排序,然後依次編譯。
對於包級別的變數宣告,如果有初始化表示式則用表示式初始化,還有一些沒有初始元表示式的,例如,某些表格資料的初始化並不是一個簡單的賦值過程。在這種情況下,我們可以用一個特殊的init初始化函式來簡化初始化工作。每個檔案都可以包含多個init初始化函式
func init() { /* ... */ }
這樣的init初始化函式除了不能被呼叫或引用外,其他行為和普通函式類似。在每個檔案中的init初始化函式,在程式開始執行時,按照它們宣告的順序被自動呼叫。
每個包在解決依賴的前提下,以匯入宣告的順序初始化。每個包只會被初始化依次。因此,如果一個p包匯入q包,那麼在p包初始化的時候可以認為q包必然已經被初始化了。初始化工作是自上而下進行的,main包最後被初始化。以這種方式,可以確保在main函式執行之前,所有依賴的包都已經完成初始化工作了。
下面的程式碼定義了一個PopCount函式,用於返回一個數字中含有二進位制1的bit個數。它使用init初始化函式來生成輔助表格pc,pc表格用於處理每個8bit寬度的數字韓二進位制1的bit個數。這樣的話,在處理64bit寬度的數字時就沒有必要迴圈64次,只需要8次查表就可以了。
gopl.io/ch2/popcount
package popcount
// pc[i] is the population count of i.
var pc [256]byte
func init() {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
}
// PopCount returns the population count (number of set bits) of x.
func PopCount(x uint64) int {
return int(pc[byte(x>>(0*8))] +
pc[byte(x>>(1*8))] +
pc[byte(x>>(2*8))] +
pc[byte(x>>(3*8))] +
pc[byte(x>>(4*8))] +
pc[byte(x>>(5*8))] +
pc[byte(x>>(6*8))] +
pc[byte(x>>(7*8))])
}
要注意的是在init函式中,range迴圈只是用了索引,省略了沒用到的值。迴圈也可以這樣寫:
for i, _ := range pc {
譯註:對於pc這類需要複雜處理的初始化,可以通過將初始化邏輯包裝為一個匿名函式,像下面這樣:
// pc[i] is the population count of i.
var pc [256]byte = func() (pc [256]byte) {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
return
}()
包是結構化程式碼的一種方式:每個程式都由包(通常簡稱為 pkg)的概念組成,可以使用自身的包或者從其它包中匯入內容。
如同其它一些程式語言中的類庫或名稱空間的概念,每個 Go 檔案都屬於且僅屬於一個包。一個包可以由許多以.go
為副檔名的原始檔組成,因此檔名和包名一般來說都是不相同的。
你必須在原始檔中非註釋的第一行指明這個檔案屬於哪個包,如:package main
。package
main
表示一個可獨立執行的程式,每個 Go 應用程式都包含一個名為 main
的包。
一個應用程式可以包含不同的包,而且即使你只使用 main 包也不必把所有的程式碼都寫在一個巨大的檔案裡:你可以用一些較小的檔案,並且在每個檔案非註釋的第一行都使用 package main
來指明這些檔案都屬於
main 包。如果你打算編譯包名不是為 main 的原始檔,如 pack1
,編譯後產生的物件檔案將會是 pack1.a
而不是可執行程式。另外要注意的是,所有的包名都應該使用小寫字母。
標準庫
在 Go 的安裝檔案裡包含了一些可以直接使用的包,即標準庫。在 Windows 下,標準庫的位置在 Go 根目錄下的子目錄 pkg\windows_386
中;在 Linux
下,標準庫在 Go 根目錄下的子目錄 pkg\linux_amd64
中(如果是安裝的是 32 位,則在 linux_386
目錄中)。一般情況下,標準包會存放在 $GOROOT/pkg/$GOOS_$GOARCH/
目錄下。
Go 的標準庫包含了大量的包(如:fmt 和 os),但是你也可以建立自己的包。
如果想要構建一個程式,則包和包內的檔案都必須以正確的順序進行編譯。包的依賴關係決定了其構建順序。
屬於同一個包的原始檔必須全部被一起編譯,一個包即是編譯時的一個單元,因此根據慣例,每個目錄都只包含一個包。
如果對一個包進行更改或重新編譯,所有引用了這個包的客戶端程式都必須全部重新編譯。
Go 中的包模型採用了顯式依賴關係的機制來達到快速編譯的目的,編譯器會從字尾名為 .o
的物件檔案(需要且只需要這個檔案)中提取傳遞依賴型別的資訊。
如果 A.go
依賴 B.go
,而 B.go
又依賴 C.go
:
- 編譯
C.go
,B.go
, 然後是A.go
. - 為了編譯
A.go
, 編譯器讀取的是B.o
而不是C.o
.
這種機制對於編譯大型的專案時可以顯著地提升編譯速度。
每一段程式碼只會被編譯一次
一個 Go 程式是通過 import
關鍵字將一組包連結在一起。
import "fmt"
告訴 Go 編譯器這個程式需要使用 fmt
包(的函式,或其他元素),fmt
包實現了格式化
IO(輸入/輸出)的函式。包名被封閉在半形雙引號 ""
中。如果你打算從已編譯的包中匯入並載入公開宣告的方法,不需要插入已編譯包的原始碼。
如果需要多個包,它們可以被分別匯入:
import "fmt"
import "os"
或:
import "fmt"; import "os"
但是還有更短且更優雅的方法(被稱為因式分解關鍵字,該方法同樣適用於 const、var 和 type 的宣告或定義):
import (
"fmt"
"os"
)
它甚至還可以更短的形式,但使用 gofmt 後將會被強制換行:
import ("fmt"; "os")
當你匯入多個包時,匯入的順序會按照字母排序。
如果包名不是以 .
或 /
開頭,如 "fmt"
或者 "container/list"
,則
Go 會在全域性檔案進行查詢;如果包名以 ./
開頭,則 Go 會在相對目錄中查詢;如果包名以 /
開頭(在
Windows 下也可以這樣使用),則會在系統的絕對路徑中查詢。
匯入包即等同於包含了這個包的所有的程式碼物件。
除了符號 _
,包中所有程式碼物件的識別符號必須是唯一的,以避免名稱衝突。但是相同的識別符號可以在不同的包中使用,因為可以使用包名來區分它們。
包通過下面這個被編譯器強制執行的規則來決定是否將自身的程式碼物件暴露給外部檔案:
可見性規則
當識別符號(包括常量、變數、型別、函式名、結構欄位等等)以一個大寫字母開頭,如:Group1,那麼使用這種形式的識別符號的物件就可以被外部包的 程式碼所使用(客戶端程式需要先匯入這個包),這被稱為匯出(像面嚮物件語言中的 public);識別符號如果以小寫字母開頭,則對包外是不可見的,但是他們在整個包的內部是可見並且可用的(像面嚮物件語言中的 private )。
(大寫字母可以使用任何 Unicode 編碼的字元,比如希臘文,不僅僅是 ASCII 碼中的大寫字母)。
因此,在匯入一個外部包後,能夠且只能夠訪問該包中匯出的物件。
假設在包 pack1 中我們有一個變數或函式叫做 Thing(以 T 開頭,所以它能夠被匯出),那麼在當前包中匯入 pack1 包,Thing 就可以像面嚮物件語言那樣使用點標記來呼叫:pack1.Thing
(pack1
在這裡是不可以省略的)。
因此包也可以作為名稱空間使用,幫助避免命名衝突(名稱衝突):兩個包中的同名變數的區別在於他們的包名,例如 pack1.Thing
和 pack2.Thing
。
你可以通過使用包的別名來解決包名之間的名稱衝突,或者說根據你的個人喜好對包名進行重新設定,如:import fm "fmt"
。下面的程式碼展示瞭如何使用包的別名:
package main
import fm "fmt" // alias3
func main() {
fm.Println("hello, world")
}
注意事項
如果你匯入了一個包卻沒有使用它,則會在構建程式時引發錯誤,如 imported and not used: os
,這正是遵循了 Go 的格言:“沒有不必要的程式碼!“。
包的分級宣告和初始化
你可以在使用 import
匯入包之後定義或宣告 0 個或多個常量(const)、變數(var)和型別(type),這些物件的作用域都是全域性的(在本包範圍內),所以可以被本包中所有的函式呼叫(如 gotemplate.go 原始檔中的
c 和 v),然後宣告一個或多個函式(func)。
Go 程式的一般結構
下面的程式可以被順利編譯但什麼都做不了,不過這很好地展示了一個 Go 程式的首選結構。這種結構並沒有被強制要求,編譯器也不關心 main 函式在前還是變數的宣告在前,但使用統一的結構能夠在從上至下閱讀 Go 程式碼時有更好的體驗。
所有的結構將在這一章或接下來的章節中進一步地解釋說明,但總體思路如下:
- 在完成包的 import 之後,開始對常量、變數和型別的定義或宣告。
- 如果存在 init 函式的話,則對該函式進行定義(這是一個特殊的函式,每個含有該函式的包都會首先執行這個函式)。
- 如果當前包是 main 包,則定義 main 函式。
- 然後定義其餘的函式,首先是型別的方法,接著是按照 main 函式中先後呼叫的順序來定義相關函式,如果有很多函式,則可以按照字母順序來進行排序。
package main
import (
"fmt"
)
const c = "C"
var v int = 5
type T struct{}
func init() { // initialization of package
}
func main() {
var a int
Func1()
// ...
fmt.Println(a)
}
func (t T) Method1() {
//...
}
func Func1() { // exported function Func1
//...
}
Go 程式的執行(程式啟動)順序如下:
- 按順序匯入所有被 main 包引用的其它包,然後在每個包中執行如下流程:
- 如果該包又匯入了其它的包,則從第一步開始遞迴執行,但是每個包只會被匯入一次。
- 然後以相反的順序在每個包中初始化常量和變數,如果該包含有 init 函式的話,則呼叫該函式。
- 在完成這一切之後,main 也執行同樣的過程,最後呼叫 main 函式開始執行程式。