《Go 語言程式設計》 讀書筆記 (八) 包

KevinYan發表於2020-01-13

Go語言有超過100個的標準包(可以用go list std | wc -l命令檢視標準包的具體數目),標準庫為大多數的程式提供了必要的基礎構件。在Go的社群,有很多成熟的包被設計、共享、重用和改進,目前網際網路上已經發布了非常多的Go語言開源包,它們可以透過 http://godoc.org 檢索。在本章,我們將演示如果使用已有的包和建立新的包。

包簡介

任何包系統設計的目的都是為了簡化大型程式的設計和維護工作,透過將一組相關的特性放進一個獨立的單元以便於理解和更新,在每個單元更新的同時保持和程式中其它單元的相對獨立性。這種模組化的特性允許每個包可以被其它的不同專案共享和重用,在專案範圍內、甚至全球範圍統一地分發和複用。

每個包一般都定義了一個不同的名稱空間用於它內部的每個識別符號的訪問。每個名稱空間關聯到一個特定的包,讓我們給型別、函式等選擇簡短明瞭的名字,這樣可以在我們使用它們的時候減少和其它部分名字的衝突。

每個包還透過控制包內名字的可見性和是否匯出來實現封裝特性。透過限制包成員的可見性並隱藏包API的具體實現,將允許包的維護者在不影響外部包使用者的前提下調整包的內部實現。透過限制包內變數的可見性,還可以強制使用者透過某些特定函式來訪問和更新內部變數,這樣可以保證內部變數的一致性和併發時的互斥約束。

當我們修改了一個原始檔,我們必須重新編譯該原始檔對應的包和所有依賴該包的其他包。即使是從頭構建,Go語言編譯器的編譯速度也明顯快於其它編譯語言。Go語言的閃電般的編譯速度主要得益於三個語言特性。第一點,所有匯入的包必須在每個檔案的開頭顯式宣告,這樣的話編譯器就沒有必要讀取和分析整個原始檔來判斷包的依賴關係。第二點,禁止包的環狀依賴,因為沒有迴圈依賴,包的依賴關係形成一個有向無環圖,每個包可以被獨立編譯,而且很可能是被併發編譯。第三點,編譯後包的目標檔案不僅僅記錄包本身的匯出資訊,目標檔案同時還記錄了包的依賴關係。因此,在編譯一個包的時候,編譯器只需要讀取每個直接匯入包的目標檔案,而不需要遍歷所有依賴的的檔案(很多都是重複的間接依賴)。

匯入路徑

每個包是由一個全域性唯一的字串所標識的匯入路徑來定位的。

import (
    "fmt"
    "math/rand"
    "encoding/json"

    "golang.org/x/net/html"

    "github.com/go-sql-driver/mysql"
)

Go語言的規範並沒有指明包的匯入路徑字串的具體含義,匯入路徑的具體含義是由構建工具來解釋的。

如果你計劃分享或釋出包,那麼匯入路徑必須是全球唯一的。為了避免衝突,所有非標準庫包的匯入路徑建議以所在組織的網際網路域名為字首;而且這樣也有利於包的檢索。例如,上面的import語句匯入了Go團隊維護的HTML解析器和一個流行的第三方維護的MySQL驅動。

包宣告

在每個Go原始檔的開頭都必須有包宣告語句。包宣告語句的主要目的是確定當前包被其它包匯入時預設的包名。

例如,math/rand包的每個原始檔的開頭都包含package rand包宣告語句,所以當你匯入這個包,你就可以用rand.Int、rand.Float64類似的方式訪問包的成員。

通常來說,預設的包名就是包匯入路徑名的最後一段,因此即使兩個包的匯入路徑不同,它們依然可能有一個相同的包名。例如,math/rand包和crypto/rand包的包名都是rand。稍後我們將看到如何同時匯入兩個有相同包名的包。

關於預設包名一般採用匯入路徑名的最後一段的約定也有三種例外情況:

第一個例外,包對應一個可執行程式,也就是main包,這時候main包本身的匯入路徑是無關緊要的。名字為main的包是給go build構建命令一個資訊,這個包編譯完之後必須呼叫聯結器生成一個可執行程式。

第二個例外,包所在的目錄中可能有一些檔名是以test.go為字尾的Go原始檔,並且這些原始檔宣告的包名也是以_test為字尾名的。這種目錄可以包含兩種包:一種普通包,加一種則是測試的外部擴充套件包。所有以_test為字尾包名的測試外部擴充套件包都由go test命令獨立編譯,普通包和測試的外部擴充套件包是相互獨立的。測試的外部擴充套件包一般用來避免測試程式碼中的迴圈匯入依賴。

第三個例外,一些依賴版本號的管理工具會在匯入路徑後追加版本號資訊,例如”gopkg.in/yaml.v2”。這種情況下包的名字並不包含版本號字尾,而是yaml。

匯入包

可以在一個Go語言原始檔包宣告語句之後,其它非匯入宣告語句之前,包含零到多個匯入包宣告語句。每個匯入宣告可以單獨指定一個匯入路徑,也可以透過圓括號同時匯入多個匯入路徑。下面兩個匯入形式是等價的,但是第二種形式更為常見。

import "fmt"
import "os"

import (
    "fmt"
    "os"
)

匯入的包之間可以透過新增空行來分組;通常將來自不同組織的包獨自分組。包的匯入順序無關緊要,但是在每個分組中一般會根據字串順序排列。(gofmt和goimports工具都可以將不同分組匯入的包獨立排序。)

import (
    "fmt"
    "html/template"
    "os"

    "golang.org/x/net/html"
    "golang.org/x/net/ipv4"
)

如果我們想同時匯入兩個有著名字相同的包,例如math/rand包和crypto/rand包,那麼匯入宣告必須至少為一個同名包指定一個新的包名以避免衝突。這叫做匯入包的重新命名。

import (
    "crypto/rand"
    mrand "math/rand" // alternative name mrand avoids conflict
)

匯入包的重新命名隻影響當前的原始檔。其它的原始檔如果匯入了相同的包,可以用匯入包原本預設的名字或重新命名為另一個完全不同的名字。

匯入包重新命名是一個有用的特性,它不僅僅只是為了解決名字衝突。如果匯入的一個包名很笨重,特別是在一些自動生成的程式碼中,這時候用一個簡短名稱會更方便。選擇用簡短名稱重新命名匯入包時候最好統一,以避免包名混亂。選擇另一個包名稱還可以幫助避免和本地普通變數名產生衝突。例如,如果檔案中已經有了一個名為path的變數,那麼我們可以將”path”標準包重新命名為pathpkg。

每個匯入宣告語句都明確指定了當前包和被匯入包之間的依賴關係。如果遇到包迴圈匯入的情況,Go語言的構建工具將報告錯誤。

匿名包匯入

如果只是匯入一個包而並不使用匯入的包將會導致一個編譯錯誤。但是有時候我們只是想利用匯入包而產生的副作用:它會計算包級變數的初始化表示式和執行匯入包的init初始化函式。這時候我們需要抑制“unused import”編譯錯誤,我們可以用下劃線_來重新命名匯入的包。像往常一樣,下劃線_為空白識別符號,並不能被訪問。

import _ "image/png" // 註冊 PNG 解碼器

這個被稱為包的匿名匯入。

標準庫提供了GIF、PNG和JPEG等格式影像的解碼器,使用者也可以提供自己的解碼器,但是為了保持程式體積較小,很多解碼器並沒有被全部包含,除非是明確需要支援的格式。image.Decode函式在解碼時會依次查詢支援的格式列表。每個格式解碼器包的入口指定了四件事情:格式的名稱;一個用於描述這種影像格式型別的字串,用於解碼器檢測識別;一個Decode函式用於完成解碼影像工作;一個DecodeConfig函式用於解碼影像的大小和顏色空間的資訊。每個解碼器包的入口透過呼叫image.RegisterFormat函式註冊解碼器,一般是在每個格式包的init初始化函式中呼叫,例如image/png包是這樣註冊的:

package png // image/png

func Decode(r io.Reader) (image.Image, error)
func DecodeConfig(r io.Reader) (image.Config, error)

func init() {
    const pngHeader = "\x89PNG\r\n\x1a\n"
    image.RegisterFormat("png", pngHeader, Decode, DecodeConfig)
}

如果沒有這一行匿名匯入語句,程式依然可以編譯和執行,但是它將不能正確識別和解碼PNG格式的影像。

資料庫包database/sql也是採用了類似的技術,讓使用者可以根據自己需要選擇匯入必要的資料庫驅動。例如:

import (
    "database/sql"
    _ "github.com/lib/pq"              // enable support for Postgres
    _ "github.com/go-sql-driver/mysql" // enable support for MySQL
)

db, err = sql.Open("postgres", dbname) // OK
db, err = sql.Open("mysql", dbname)    // OK
db, err = sql.Open("sqlite3", dbname)  // returns error: unknown driver "sqlite3"

包的命名

下面是一些關於Go語言軟體包和包成員命名的約定。

當建立一個包,一般要簡潔明瞭的包名,但也不能太簡短導致難以理解。標準庫中最常用的包有bufio、bytes、flag、fmt、http、io、json、os、sort、sync和time等包,它們的名字都簡潔明瞭。

要儘量避免包名與經常用於區域性變數的名字發生衝突,否則可能導致使用者重新命名匯入包,例如前面看到的path包。

包名一般採用單數的形式。標準庫的bytes、errors和strings使用了複數形式,這是為了避免和預定義的型別衝突,同樣還有go/types是為了避免和type關鍵字衝突。

要避免包名有其它的含義。例如,2.5節中我們的溫度轉換包最初使用了temp包名,雖然並沒有持續多久。但這是一個糟糕的嘗試,因為temp幾乎是臨時變數的同義詞。然後我們有一段時間使用了temperature作為包名,但是這個名字並沒有表達包的真實用途。最後我們改成了和strconv標準包類似的tempconv包名,這個名字比之前的就好多了。

當設計一個包的時候,需要考慮包名和成員名兩個部分如何很好地配合。成員名不必再包含包名,下面有一些例子:

bytes.Equal    flag.Int    http.Get    json.Marshal

我們可以看到一些常用的命名模式。strings包提供了和字串相關的諸多操作:

package strings

func Index(needle, haystack string) int

type Replacer struct{ /* ... */ }
func NewReplacer(oldnew ...string) *Replacer

type Reader struct{ /* ... */ }
func NewReader(s string) *Reader

字串單詞string本身並沒有出現在每個成員名字中。因為使用者會這樣引用這些成員strings.Indexstrings.Replacer等。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章