2.6. 包和文件

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.CToF的形式訪問。而包級別的名字,例如在一個文件聲明的類型和常量,在同一個包的其他源文件也是可以直接訪問的,就好像所有代碼都在一個文件一樣。要注意的是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"

在每個源文件的包聲明前緊跟著的註釋是包註釋(§10.7.4)。通常,包註釋的第一句應該先是包的功能概要說明。一個包通常只有一個源文件有包註釋(譯註:如果有多個包註釋,目前的文檔工具會根據源文件名的先後順序將它們鏈接為一個包註釋)。如果包註釋很大,通常會放到一個獨立的doc.go文件中。

練習 2.1: 向tempconv包添加類型、常量和函數用來處理Kelvin絕對溫度的轉換,Kelvin 絕對零度是−273.15°C,Kelvin絕對溫度1K和攝氏度1°C的單位間隔是一樣的。

2.6.1. 導入包

在Go語言程序中,每個包都有一個全局唯一的導入路徑。導入語句中類似"gopl.io/ch2/tempconv"的字符串對應包的導入路徑。Go語言的規範並沒有定義這些字符串的具體含義或包來自哪裡,它們是由構建工具來解釋的。當使用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名字(譯註:指包聲明語句指定的名字),但是我們也可以綁定到另一個名稱,以避免名字衝突(§10.4)。

cf程序將命令行輸入的一個溫度在Celsius和Fahrenheit溫度單位之間轉換:

$ go build gopl.io/ch2/cf
$ ./cf 32
32°F = 0°C, 32°C = 89.6°F
$ ./cf 212
212°F = 100°C, 212°C = 413.6°F
$ ./cf -40
-40°F = -40°C, -40°C = -40°F

如果導入了一個包,但是又沒有使用該包將被當作一個編譯錯誤處理。這種強制規則可以有效減少不必要的依賴,雖然在調試期間可能會讓人討厭,因為刪除一個類似log.Print("got here!")的打印語句可能導致需要同時刪除log包導入聲明,否則,編譯器將會發出一個錯誤。在這種情況下,我們需要將不必要的導入刪除或註釋掉。

不過有更好的解決方案,我們可以使用golang.org/x/tools/cmd/goimports導入工具,它可以根據需要自動添加或刪除導入的包;許多編輯器都可以集成goimports工具,然後在保存文件的時候自動運行。類似的還有gofmt工具,可以用來格式化Go源文件。

練習 2.2: 寫一個通用的單位轉換程序,用類似cf程序的方式從命令行讀取參數,如果缺省的話則是從標準輸入讀取參數,然後做類似Celsius和Fahrenheit的單位轉換,長度單位可以對應英尺和米,重量單位可以對應磅和公斤等。

2.6.2. 包的初始化

包的初始化首先是解決包級變量的依賴順序,然後按照包級變量聲明出現的順序依次初始化:

var 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函數,用於返回一個數字中含二進制1bit的個數。它使用init初始化函數來生成輔助表格pc,pc表格用於處理每個8bit寬度的數字含二進制的1bit的bit個數,這樣的話在處理64bit寬度的數字時就沒有必要循環64次,只需要8次查表就可以了。(這並不是最快的統計1bit數目的算法,但是它可以方便演示init函數的用法,並且演示瞭如何預生成輔助表格,這是編程中常用的技術)。

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

譯註:對於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
}()

要注意的是在init函數中,range循環只使用了索引,省略了沒有用到的值部分。循環也可以這樣寫:

for i, _ := range pc {

我們在下一節和10.5節還將看到其它使用init函數的地方。

練習 2.3: 重寫PopCount函數,用一個循環代替單一的表達式。比較兩個版本的性能。(11.4節將展示如何系統地比較兩個不同實現的性能。)

練習 2.4: 用移位算法重寫PopCount函數,每次測試最右邊的1bit,然後統計總數。比較和查表算法的性能差異。

練習 2.5: 表達式x&(x-1)用於將x的最低的一個非零的bit位清零。使用這個算法重寫PopCount函數,然後比較性能。