高效 Go 程式設計 (Effective Go)
前言
Go 是一門新的語言。儘管它從現有的語言中借鑒了許多概念,但它擁有獨特的屬性,使得編寫有效的 Go 程式在風格上與使用其相關語言編寫的程式大不相同。直接將 C++ 或 Java 程式翻譯成 Go 通常無法產生令人滿意的結果——Java 程式是用 Java 寫的,而不是 Go。另一方面,從 Go 的角度思考問題,可能會產生一個成功但完全不同的程式。換句話說,要寫好 Go,理解其屬性和慣用法(idioms)非常重要。同樣重要的是要瞭解 Go 中既定的程式設計慣例,例如命名、格式化、程式結構等,這樣你編寫的程式才容易讓其他 Go 程式設計師理解。
本文檔提供了編寫清晰、符合 Go 慣用法的代碼的技巧。它是對語言規範、《Go 指南 (A Tour of Go)》以及《如何編寫 Go 代碼 (How to Write Go Code)》的補充,這些內容你都應該先閱讀。
2022 年 1 月補充:本文檔是為 2009 年 Go 發佈時編寫的,自那以後並未進行重大更新。儘管它是理解如何使用這門語言本身的良好指南,但由於語言的穩定性,它很少提及庫,也沒有涉及自編寫以來 Go 生態系統中的重大變化,例如建構系統、測試、模組和多型。目前沒有更新它的計劃,因為發生了太多的變化,且現有大量不斷增長的文檔、部落格和書籍已經很好地描述了現代 Go 的用法。Effective Go 仍然很有用,但讀者應該明白它遠非一份完整的指南。有關上下文,請參見 issue 28782。
範例
Go 軟體包源代碼不僅旨在作為核心庫,還旨在作為如何使用該語言的範例。此外,許多軟體包包含可運行的、獨立的可執行範例,你可以直接從 go.dev 網站運行,例如 這個範例(如有必要,點擊 "Example" 字樣將其展開)。如果你對如何處理問題或某個功能如何實現有疑問,庫中的文檔、代碼和範例可以提供答案、思路和背景知識。
格式化 (Formatting)
格式化問題是最具爭議但最不重要的。人們可以適應不同的格式風格,但如果不需要這樣做會更好,而且如果每個人都遵守相同的風格,在該話題上投入的時間也會更少。問題在於,如何在沒有長篇累牘的指導方針下達到這個烏托邦。
在 Go 中,我們採取了一種不同尋常的方法,讓機器處理大部分格式化問題。gofmt 程式(也可作為 go fmt 使用,它在軟體包級別而不是源檔案級別運作)會讀取 Go 程式並以標準的縮排和垂直對齊方式輸出源代碼,保留並在必要時重新格式化註釋。如果你想知道如何處理某種新的佈局情況,請運行 gofmt;如果結果看起來不對,請重構你的程式(或者提交關於 gofmt 的錯誤報告),不要試圖繞過它。
例如,沒有必要花時間對齊結構體欄位上的註釋。Gofmt 會為你完成這項工作。給定宣告:
type T struct {
name string // name of the object
value int // its value
}
gofmt 會對齊列:
type T struct {
name string // name of the object
value int // its value
}
標準庫中的所有 Go 代碼都已經過 gofmt 格式化。
一些格式化細節仍然存在。簡而言之:
- 縮排
- 我們使用 Tab 進行縮排,
gofmt預設會輸出它們。只有在必要時才使用空格。 - 行長度
- Go 沒有行長度限制。不用擔心超出打孔卡片。如果一行覺得太長,請換行並額外縮排一個 Tab。
- 括號
- Go 比 C 和 Java 需要更少的括號:控制結構(
if、for、switch)在語法中不需要括號。此外,運算子優先級層級更短且更清晰,因此:x<<8 + y<<16
其含義與空格所暗示的一致,這與其他語言不同。
註釋
Go 提供了 C 風格的 /* */ 區塊註釋和 C++ 風格的 // 行註釋。行註釋是常態;區塊註釋主要作為軟體包註釋出現,但在表達式內部或禁用大段代碼時很有用。
出現在頂層宣告之前且沒有中間空行的註釋被視為該宣告本身的文檔。這些「文檔註釋 (doc comments)」是特定 Go 軟體包或命令的主要文檔。有關文檔註釋的更多資訊,請參見「Go 文檔註釋」。
命名
命名在 Go 中與在任何其他語言中一樣重要。它們甚至具有語義影響:名稱在軟體包外部的可見性由其第一個字元是否為大寫字母決定。因此,花點時間談談 Go 程式中的命名慣例是值得的。
軟體包名稱
當導入軟體包時,軟體包名稱成為訪問內容的存取器。在:
import "bytes"
之後,導入的軟體包可以使用 bytes.Buffer。如果每個使用該軟體包的人都能使用相同的名稱來引用其內容,這會很有幫助,這意味著軟體包名稱應該是好的:簡短、簡潔、具啟發性。按照慣例,軟體包名稱為小寫、單詞;不需要下劃線或混合大小寫 (mixedCaps)。傾向於簡潔,因為每個使用你軟體包的人都會輸入該名稱。而且不用擔心 a priori(先驗)衝突。軟體包名稱只是導入的預設名稱;它不需要在所有源代碼中唯一,在罕見的衝突情況下,導入的軟體包可以在本地選擇不同的名稱。無論如何,混淆很少見,因為導入中的檔案名稱決定了具體使用了哪個軟體包。
另一個慣例是軟體包名稱是其源目錄的基本名稱;src/encoding/base64 中的軟體包導入為 "encoding/base64",但名稱為 base64,而不是 encoding_base64,也不是 encodingBase64。
軟體包的導入者將使用該名稱來引用其內容,因此軟體包中的導出名稱可以利用這一點來避免重複。(不要使用 import . 符號,這可以簡化必須在測試軟體包之外運行的測試,但除此之外應避免使用。)例如,bufio 軟體包中的緩衝讀取器類型被稱為 Reader,而不是 BufReader,因為使用者將其視為 bufio.Reader,這是一個清晰、簡潔的名稱。此外,由於導入的實體總是通過其軟體包名稱進行定址,bufio.Reader 不會與 io.Reader 衝突。同樣,創建 ring.Ring 新實例的函數——這就是 Go 中構造函數的定義——通常被稱為 NewRing,但由於 Ring 是該軟體包導出的唯一類型,且該軟體包名為 ring,它僅被稱為 New,軟體包的使用者將其視為 ring.New。利用軟體包結構來幫助你選擇好的名稱。
另一個簡短的例子是 once.Do;once.Do(setup) 讀起來很流暢,寫成 once.DoOrWaitUntilDone(setup) 並不會有所改善。長名稱並不會自動使內容更具可讀性。一個有幫助的文檔註釋通常比一個超長的名稱更有價值。
Getter
Go 不提供對 Getter 和 Setter 的自動支持。你自己提供 Getter 和 Setter 並沒有錯,通常這也是合適的,但在 Getter 的名稱中加入 Get 既不符合慣例也不必要。如果你有一個名為 owner(小寫,未導出)的欄位,Getter 方法應該命名為 Owner(大寫,已導出),而不是 GetOwner。使用大寫名稱進行導出提供了區分欄位和方法的鉤子。Setter 函數如果需要的話,可能會被稱為 SetOwner。這兩個名稱在實踐中讀起來都很好。
owner := obj.Owner()
if owner != user {
obj.SetOwner(user)
}
介面名稱
按照慣例,單方法介面由方法名稱加上 -er 後綴或類似的修飾來構造代理名:Reader、Writer、Formatter、CloseNotifier 等。
這類名稱有很多,尊重它們以及它們所捕獲的函數名稱是高效的。Read、Write、Close、Flush、String 等都有規範的簽名和含義。為了避免混淆,除非你的方法具有相同的簽名和含義,否則不要給它賦予這些名稱。相反,如果你的類型實現了一個與知名類型上的方法含義相同的方法,請給它相同的名稱和簽名;將你的字串轉換方法稱為 String 而不是 ToString。
MixedCaps
最後,Go 中的慣例是使用 MixedCaps 或 mixedCaps 而不是下劃線來編寫多單詞名稱。
分號
像 C 一樣,Go 的形式語法使用分號來終止語句,但與 C 不同的是,這些分號不會出現在源代碼中。相反,詞法分析器使用一個簡單的規則在掃描時自動插入分號,因此輸入文本中大部分都沒有分號。
規則如下。如果換行符之前的最後一個標記是識別碼(包括 int 和 float64 等單詞)、基本字面量(如數字或字串常量)或以下標記之一:
break continue fallthrough return ++ -- ) }
詞法分析器總會在標記後插入一個分號。這可以總結為:「如果換行符出現在一個可以結束語句的標記之後,則插入一個分號」。
分號也可以在右大括號之前立即省略,因此像這樣的語句:
go func() { for { dst <- <-src } }()
不需要分號。符合慣例的 Go 程式僅在如 for 迴圈子句中,為了分隔初始化器、條件和延續元素時才使用分號。如果你以那種方式編寫代碼,它們對於分隔一行中的多個語句也是必要的。
分號插入規則的一個後果是,你不能將控制結構(if、for、switch 或 select)的左大括號放在下一行。如果你這樣做,分號將被插入到大括號之前,這可能會導致不必要的影響。請這樣寫:
if i < f() {
g()
}
而不是這樣:
if i < f() // wrong!
{ // wrong!
g()
}
控制結構
Go 的控制結構與 C 的相關,但在重要方面有所不同。沒有 do 或 while 迴圈,只有稍微通用化的 for;switch 更靈活;if 和 switch 接受可選的初始化語句,就像 for 一樣;break 和 continue 語句採用可選標籤來標識要中斷或繼續的內容;還有新的控制結構,包括類型 switch 和多路通信多工器 select。語法也略有不同:沒有括號,且主體必須始終以大括號括起來。
If
在 Go 中,簡單的 if 看起來像這樣:
if x > 0 {
return y
}
強制性的大括號鼓勵將簡單的 if 語句寫在多行上。無論如何,這是一種良好的風格,特別是當主體包含控制語句(如 return 或 break)時。
由於 if 和 switch 接受初始化語句,因此經常看到使用它們來設置局部變量。
if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}
在 Go 庫中,你會發現當 if 語句沒有進入下一個語句時——也就是說,主體以 break、continue、goto 或 return 結束——不必要的 else 會被省略。
f, err := os.Open(name)
if err != nil {
return err
}
codeUsing(f)
這是一個常見情況的例子,其中代碼必須防止一系列錯誤條件。如果成功的控制流沿頁面向下運行,消除錯誤情況,代碼讀起來會很流暢。由於錯誤情況往往以 return 語句結束,因此生成的代碼不需要 else 語句。
f, err := os.Open(name)
if err != nil {
return err
}
d, err := f.Stat()
if err != nil {
f.Close()
return err
}
codeUsing(f, d)
重新宣告與重新賦值
題外話:上一節中的最後一個例子展示了 := 短宣告形式工作方式的一個細節。調用 os.Open 的宣告內容如下:
f, err := os.Open(name)
該語句宣告了兩個變量,f 和 err。幾行後,對 f.Stat 的調用如下:
d, err := f.Stat()
這看起來像是宣告了 d 和 err。但請注意,err 出現在兩個語句中。這種重複是合法的:err 由第一個語句宣告,但在第二個語句中僅被重新賦值。這意味著對 f.Stat 的調用使用了上面宣告的現有 err 變量,並賦予它一個新值。
在 := 宣告中,變量 v 即使已經宣告過也可以出現,前提是:
- 此宣告與
v的現有宣告處於相同的範圍內(如果v已在外層範圍中宣告,該宣告將創建一個新變量 §), - 初始化中的對應值可賦值給
v,並且 - 宣告創建了至少另一個變量。
這種不尋常的屬性純粹是務實的,例如,使得在長 if-else 鏈中輕鬆使用單個 err 值成為可能。你會經常看到它被使用。
§ 值得注意的是,在 Go 中,函數參數和返回值的範圍與函數主體相同,儘管它們在語法上出現在包圍主體的大括號之外。
For
Go 的 for 迴圈與 C 的相似,但並不完全相同。它統一了 for 和 while,且沒有 do-while。它有三種形式,其中只有一種有分號。
// Like a C for
for init; condition; post { }
// Like a C while
for condition { }
// Like a C for(;;)
for { }
短宣告使得在迴圈中輕鬆宣告索引變量變得容易。
sum := 0
for i := 0; i < 10; i++ {
sum += i
}
如果你要遍歷陣列、切片、字串或 Map,或者從通道讀取數據,range 子句可以管理迴圈。
for key, value := range oldMap {
newMap[key] = value
}
如果你只需要範圍中的第一個項目(鍵或索引),請刪除第二個:
for key := range m {
if key.expired() {
delete(m, key)
}
}
如果你只需要範圍中的第二個項目(值),請使用空白識別碼 (blank identifier)(下劃線)來丟棄第一個:
sum := 0
for _, value := range array {
sum += value
}
空白識別碼有很多用途,正如後面的章節所述。
對於字串,range 通過解析 UTF-8 來為你做更多工作,拆分出單個 Unicode 碼點。錯誤的編碼會消耗一個位元組並產生替換符號 U+FFFD。(名稱(連同相關的內建類型)rune 是 Go 術語,指代單個 Unicode 碼點。詳細資訊請參見語言規範。)迴圈:
for pos, char := range "日本\x80語" { // \x80 is an illegal UTF-8 encoding
fmt.Printf("character %#U starts at byte position %d\n", char, pos)
}
列印:
character U+65E5 '日' starts at byte position 0 character U+672C '本' starts at byte position 3 character U+FFFD '�' starts at byte position 6 character U+8A9E '語' starts at byte position 7
最後,Go 沒有逗號運算子,且 ++ 和 -- 是語句而不是表達式。因此,如果你想在 for 中運行多個變量,應該使用並行賦值(儘管這排除了 ++ 和 --)。
// Reverse a
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}
Switch
Go 的 switch 比 C 的更通用。表達式不需要是常量甚至整數,case 從上到下評估直到找到匹配項,如果 switch 沒有表達式,它會對 true 進行 switch。因此,將 if-else-if-else 鏈寫成 switch 是可能的——也是慣用的。
func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
case 'A' <= c && c <= 'F':
return c - 'A' + 10
}
return 0
}
沒有自動的 fall through,但 case 可以以逗號分隔的列表形式呈現。
func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}
儘管它們在 Go 中不像在某些其他類似 C 的語言中那麼常見,但 break 語句可以用來提前終止 switch。然而,有時需要跳出周圍的迴圈,而不是 switch,在 Go 中可以通過在迴圈上放置標籤並「break」到該標籤來實現。這個例子同時展示了這兩種用法:
Loop:
for n := 0; n < len(src); n += size {
switch {
case src[n] < sizeOne:
if validateOnly {
break
}
size = 1
update(src[n])
case src[n] < sizeTwo:
if n+1 >= len(src) {
err = errShortInput
break Loop
}
if validateOnly {
break
}
size = 2
update(src[n] + src[n+1]<<shift)
}
}
當然,continue 語句也接受可選標籤,但它僅適用於迴圈。
作為本節的結尾,這裡是一個使用兩個 switch 語句的位元組切片比較常式:
// Compare returns an integer comparing the two byte slices,
// lexicographically.
// The result will be 0 if a == b, -1 if a < b, and +1 if a > b
func Compare(a, b []byte) int {
for i := 0; i < len(a) && i < len(b); i++ {
switch {
case a[i] > b[i]:
return 1
case a[i] < b[i]:
return -1
}
}
switch {
case len(a) > len(b):
return 1
case len(a) < len(b):
return -1
}
return 0
}
類型 Switch (Type switch)
Switch 也可用於發現介面變量的動態類型。這種 類型 switch 在括號內使用帶有 type 關鍵字的類型斷言語法。如果 switch 在表達式中宣告了一個變量,該變量在每個 case 子句中將具有對應的類型。在這種情況下重用名稱也是慣用的,實際上是在每個 case 中宣告一個名稱相同但類型不同的新變量。
var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T\n", t) // %T prints whatever type t has
case bool:
fmt.Printf("boolean %t\n", t) // t has type bool
case int:
fmt.Printf("integer %d\n", t) // t has type int
case *bool:
fmt.Printf("pointer to boolean %t\n", *t) // t has type *bool
case *int:
fmt.Printf("pointer to integer %d\n", *t) // t has type *int
}
函數
多個返回值
Go 不尋常的特性之一是函數和方法可以返回多個值。這種形式可以用來改進 C 程式中一些笨拙的慣用法:如 -1 表示 EOF 的頻內錯誤返回,以及修改通過地址傳遞的參數。
在 C 中,寫入錯誤由負計數表示,錯誤碼隱藏在 volatile 位置。在 Go 中,Write 可以返回一個計數和一個錯誤:「是的,你寫入了一些位元組,但不是全部,因為你填滿了設備」。os 軟體包中檔案的 Write 方法簽名是:
func (file *File) Write(b []byte) (n int, err error)
正如文檔所說,當 n != len(b) 時,它會返回寫入的位元組數和一個非 nil 的 error。這是一種常見的風格;有關更多範例,請參見錯誤處理一節。
類似的方法消除了傳遞返回值指標以模擬參考參數的需要。這裡有一個簡單的函數,從位元組切片的位置抓取一個數字,返回該數字和下一個位置。
func nextInt(b []byte, i int) (int, int) {
for ; i < len(b) && !isDigit(b[i]); i++ {
}
x := 0
for ; i < len(b) && isDigit(b[i]); i++ {
x = x*10 + int(b[i]) - '0'
}
return x, i
}
你可以這樣使用它來掃描輸入切片 b 中的數字:
for i := 0; i < len(b); {
x, i = nextInt(b, i)
fmt.Println(x)
}
命名結果參數
Go 函數的返回或結果「參數」可以被賦予名稱並像普通變量一樣使用,就像傳入參數一樣。當被命名時,它們在函數開始時被初始化為其類型的零值;如果函數執行沒有參數的 return 語句,結果參數的當前值將被用作返回的值。
名稱不是強制性的,但它們可以使代碼更短、更清晰:它們是文檔。如果我們命名 nextInt 的結果,哪個返回的 int 是哪個就顯而易見了。
func nextInt(b []byte, pos int) (value, nextPos int) {
由於命名結果已被初始化並綁定到無參數的 return,它們可以簡化並澄清代碼。這裡有一個很好地使用了它們的 io.ReadFull 版本:
func ReadFull(r Reader, buf []byte) (n int, err error) {
for len(buf) > 0 && err == nil {
var nr int
nr, err = r.Read(buf)
n += nr
buf = buf[nr:]
}
return
}
Defer
Go 的 defer 語句調度一個函數調用(延遲 (deferred) 函數),使其在執行 defer 的函數返回前立即運行。這是一種處理資源釋放等情況的非常規但有效的方法,無論函數通過哪條路徑返回,資源都必須被釋放。典型的例子是解鎖互斥鎖或關閉檔案。
// Contents returns the file's contents as a string.
func Contents(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", err
}
defer f.Close() // f.Close will run when we're finished.
var result []byte
buf := make([]byte, 100)
for {
n, err := f.Read(buf[0:])
result = append(result, buf[0:n]...) // append is discussed later.
if err != nil {
if err == io.EOF {
break
}
return "", err // f will be closed if we return here.
}
}
return string(result), nil // f will be closed if we return here.
}
延遲調用如 Close 的函數有兩個優點。首先,它保證你永遠不會忘記關閉檔案,這是一個如果你稍後編輯函數添加新返回路徑時很容易犯的錯誤。其次,這意味著關閉操作位於開啟操作附近,這比將其放在函數末尾要清晰得多。
延遲函數的參數(包括如果函數是方法時的接收者)在 defer 執行時評估,而不是在 調用 執行時評估。除了避免擔心函數執行過程中變量值變化的問題外,這還意味著單個延遲調用點可以延遲多個函數執行。這裡有一個愚蠢的例子:
for i := 0; i < 5; i++ {
defer fmt.Printf("%d ", i)
}
延遲函數以 LIFO(後進先出)順序執行,因此當函數返回時,此代碼將導致列印 4 3 2 1 0。一個更合理的例子是一種簡單的追蹤程式中函數執行的方法。我們可以編寫幾個簡單的追蹤常式,如下所示:
func trace(s string) { fmt.Println("entering:", s) }
func untrace(s string) { fmt.Println("leaving:", s) }
// Use them like this:
func a() {
trace("a")
defer untrace("a")
// do something....
}
我們可以利用延遲函數的參數在 defer 執行時評估這一事實做得更好。追蹤常式可以設置延遲函數的參數。這個例子:
func trace(s string) string {
fmt.Println("entering:", s)
return s
}
func un(s string) {
fmt.Println("leaving:", s)
}
func a() {
defer un(trace("a"))
fmt.Println("in a")
}
func b() {
defer un(trace("b"))
fmt.Println("in b")
a()
}
func main() {
b()
}
列印:
entering: b in b entering: a in a leaving: a leaving: b
對於習慣於其他語言中區塊級資源管理的程式設計師來說,defer 可能看起來很奇怪,但它最有趣且強大的應用恰恰源於它是基於函數而不是基於區塊的。在關於 panic 和 recover 的部分,我們將看到它可能性的另一個例子。
數據
使用 new 分配
Go 有兩個分配原語,內建函數 new 和 make。它們做不同的事情並應用於不同的類型,這可能會令人困惑,但規則很簡單。我們先談談 new。它是一個分配內存的內建函數,但與其他語言中的同名函數不同,它不會初始化內存,而只是將其歸零 (zero)。也就是說,new(T) 為 T 類型的新項目分配歸零的存儲空間並返回其地址,即 *T 類型的值。在 Go 術語中,它返回一個指向新分配的 T 類型零值的指標。
由於 new 返回的內存已歸零,因此在設計數據結構時,安排使得每個類型的零值無需進一步初始化即可使用是有幫助的。這意味著數據結構的使用者可以使用 new 創建一個並直接開始工作。例如,bytes.Buffer 的文檔指出「Buffer 的零值是一個空的、準備好使用的緩衝區」。同樣,sync.Mutex 沒有顯式的構造函數或 Init 方法。相反,sync.Mutex 的零值被定義為未鎖定的互斥鎖。
零值是有用的屬性具有傳遞性。考慮這個類型宣告:
type SyncedBuffer struct {
lock sync.Mutex
buffer bytes.Buffer
}
SyncedBuffer 類型的值在分配或僅宣告後也可以立即使用。在下一個代碼片段中,p 和 v 都將正確工作,無需進一步安排:
p := new(SyncedBuffer) // type *SyncedBuffer var v SyncedBuffer // type SyncedBuffer
構造函數與複合字面量
有時零值是不夠的,需要一個初始化構造函數,正如這個源自 os 軟體包的例子:
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := new(File)
f.fd = fd
f.name = name
f.dirinfo = nil
f.nepipe = 0
return f
}
那裡有很多樣板代碼。我們可以使用複合字面量 (composite literal) 來簡化它,這是一個每次評估時都會創建一個新實例的表達式:
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := File{fd, name, nil, 0}
return &f
}
注意,與 C 不同,返回局部變量的地址是完全沒問題的;與變量關聯的存儲在函數返回後依然存在。事實上,獲取複合字面量的地址每次評估時都會分配一個全新的實例,因此我們可以組合最後兩行:
return &File{fd, name, nil, 0}
複合字面量的欄位按順序排列,並且必須全部存在。然而,通過將元素顯式標記為 field:value 對,初始化器可以以任何順序出現,丟失的欄位將保留為各自的零值。因此我們可以說:
return &File{fd: fd, name: name}
作為極端情況,如果複合字面量根本不包含欄位,它會為該類型創建一個零值。表達式 new(File) 和 &File{} 是等價的。
複合字面量也可以為陣列、切片和 Map 創建,欄位標籤為索引或 Map 鍵(視情況而定)。在這些例子中,初始化無論 Enone、Eio 和 Einval 的值是多少都可以工作,只要它們是不同的。
a := [...]string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
s := []string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
m := map[int]string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
使用 make 分配
回到分配。內建函數 make(T, args) 的目的與 new(T) 不同。它僅創建切片、Map 和通道,並且它返回 T 類型(不是 *T)的初始化 (initialized)(不是歸零)值。區分的原因是,這三種類型在底層代表了在使用前必須初始化的數據結構的引用。例如,切片是一個包含指向數據的指標(在陣列內)、長度和容量的三項目描述符,在這些項目初始化之前,切片是 nil。對於切片、Map 和通道,make 初始化內部數據結構並準備值以供使用。例如:
make([]int, 10, 100)
分配了一個包含 100 個 int 的陣列,然後創建了一個長度為 10、容量為 100 並指向陣列前 10 個元素的切片結構。(當創建切片時,容量可以省略;有關更多資訊,請參見切片一節。)相比之下,new([]int) 返回一個指向新分配的歸零切片結構的指標,即一個指向 nil 切片值的指標。
這些例子說明了 new 和 make 之間的區別:
var p *[]int = new([]int) // allocates slice structure; *p == nil; rarely useful var v []int = make([]int, 100) // the slice v now refers to a new array of 100 ints // Unnecessarily complex: var p *[]int = new([]int) *p = make([]int, 100, 100) // Idiomatic: v := make([]int, 100)
記住 make 僅適用於 Map、切片和通道,且不返回指標。要獲得顯式指標,請使用 new 分配或顯式獲取變量的地址。
陣列
陣列在規劃內存的詳細佈局時很有用,有時可以幫助避免分配,但它們主要作為切片的構件,這是下一節的主題。為了為該主題奠定基礎,這裡簡要介紹一下陣列。
陣列在 Go 和 C 中的工作方式有很大不同。在 Go 中:
- 陣列是值。將一個陣列賦值給另一個陣列會複製所有元素。
- 特別是,如果你將陣列傳遞給函數,它將接收該陣列的副本,而不是指向它的指標。
- 陣列的大小是其類型的一部分。類型
[10]int和[20]int是不同的。
值屬性很有用,但代價也很昂貴;如果你想要 C 風格的行為和效率,你可以傳遞指向陣列的指標。
func Sum(a *[3]float64) (sum float64) {
for _, v := range *a {
sum += v
}
return
}
array := [...]float64{7.0, 8.5, 9.1}
x := Sum(&array) // Note the explicit address-of operator
但即使這種風格也不是慣用的 Go。請改用切片。
切片 (Slices)
切片包裝陣列,為數據序列提供更通用、更強大、更方便的介面。除了具有顯式維度(如變換矩陣)的項目外,Go 中的大多數陣列編程都是使用切片而不是簡單的陣列完成的。
切片持有到底層陣列的引用,如果你將一個切片賦值給另一個切片,兩者都引用同一個陣列。如果函數接受切片參數,它對切片元素的更改將對調用者可見,這類似於傳遞指向底層陣列的指標。因此,Read 函數可以接受切片參數而不是指標和計數;切片內的長度設置了讀取數據量的上限。這是 os 軟體包中 File 類型 Read 方法的簽名:
func (f *File) Read(buf []byte) (n int, err error)
該方法返回讀取的位元組數和錯誤值(如果有)。要讀取到更大的緩衝區 buf 的前 32 個位元組,切片 (slice)(這裡用作動詞)該緩衝區:
n, err := f.Read(buf[0:32])
這種切片操作很常見且高效。事實上,暫時撇開效率不談,以下代碼片段也可以讀取緩衝區的前 32 個位元組:
var n int
var err error
for i := 0; i < 32; i++ {
nbytes, e := f.Read(buf[i:i+1]) // Read one byte.
n += nbytes
if nbytes == 0 || e != nil {
err = e
break
}
}
只要切片仍然適合底層陣列的限制,切片的長度就可以更改;只需將其賦值給它自己的一個切片即可。切片的容量(可由內建函數 cap 訪問)報告切片可能假定的最大長度。這裡有一個將數據附加到切片的函數。如果數據超過容量,切片將被重新分配。返回生成的切片。該函數利用了 len 和 cap 在應用於 nil 切片時是合法的,並且返回 0 的事實。
func Append(slice, data []byte) []byte {
l := len(slice)
if l + len(data) > cap(slice) { // reallocate
// Allocate double what's needed, for future growth.
newSlice := make([]byte, (l+len(data))*2)
// The copy function is predeclared and works for any slice type.
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0:l+len(data)]
copy(slice[l:], data)
return slice
}
我們必須在之後返回切片,因為儘管 Append 可以修改 slice 的元素,但切片本身(持有指標、長度和容量的運行時數據結構)是按值傳遞的。
附加到切片的想法非常有用,以至於它被內建函數 append 所捕獲。不過,要理解該函數的設計,我們需要更多資訊,因此我們稍後會回到它。
二維切片
Go 的陣列和切片是一維的。要創建二維陣列或切片的等效物,有必要定義陣列的陣列或切片的切片,如下所示:
type Transform [3][3]float64 // A 3x3 array, really an array of arrays. type LinesOfText [][]byte // A slice of byte slices.
由於切片是可變長度的,因此每個內部切片長度不同是可能的。這是一種常見的情況,就像我們的 LinesOfText 例子:每一行都有獨立的長度。
text := LinesOfText{
[]byte("Now is the time"),
[]byte("for all good gophers"),
[]byte("to bring some fun to the party."),
}
有時需要分配二維切片,這種情況在處理像素掃描線時可能會出現。實現這一點有兩種方法。一種是獨立分配每個切片;另一種是分配一個單一的陣列,並讓各個切片指向它。使用哪種方法取決於你的應用程式。如果切片可能會增長或縮小,它們應該被獨立分配以避免覆蓋下一行;如果不是,則使用單一分配構建對象可能更高效。為了參考,這裡是這兩種方法的草圖。首先,逐行:
// Allocate the top-level slice.
picture := make([][]uint8, YSize) // One row per unit of y.
// Loop over the rows, allocating the slice for each row.
for i := range picture {
picture[i] = make([]uint8, XSize)
}
現在作為一次分配,切分為行:
// Allocate the top-level slice, the same as before.
picture := make([][]uint8, YSize) // One row per unit of y.
// Allocate one large slice to hold all the pixels.
pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8.
// Loop over the rows, slicing each row from the front of the remaining pixels slice.
for i := range picture {
picture[i], pixels = pixels[:XSize], pixels[XSize:]
}
Map
Map 是一種方便且強大的內建數據結構,它將一種類型(鍵 (key))的值與另一種類型(元素 (element) 或 值)的值關聯起來。鍵可以是任何定義了相等運算子的類型,例如整數、浮點數和複數、字串、指標、介面(只要動態類型支持相等)、結構體和陣列。切片不能用作 Map 鍵,因為沒有為它們定義相等。像切片一樣,Map 持有到底層數據結構的引用。如果你將 Map 傳遞給更改 Map 內容的函數,這些更改將對調用者可見。
Map 可以使用通常的複合字面量語法和冒號分隔的鍵值對來構建,因此在初始化期間構建它們很容易。
var timeZone = map[string]int{
"UTC": 0*60*60,
"EST": -5*60*60,
"CST": -6*60*60,
"MST": -7*60*60,
"PST": -8*60*60,
}
賦值和獲取 Map 值在語法上看起來與對陣列和切片的操作一樣,只是索引不需要是整數。
offset := timeZone["EST"]
嘗試使用 Map 中不存在的鍵獲取 Map 值將返回 Map 中條目類型的零值。例如,如果 Map 包含整數,查找不存在的鍵將返回 0。集合 (Set) 可以實現為值類型為 bool 的 Map。將 Map 條目設置為 true 以將值放入集合中,然後通過簡單索引測試它。
attended := map[string]bool{
"Ann": true,
"Joe": true,
...
}
if attended[person] { // will be false if person is not in the map
fmt.Println(person, "was at the meeting")
}
有時你需要區分缺失的條目和零值。是否有 "UTC" 的條目,還是因為它根本不在 Map 中所以為 0?你可以使用多重賦值的一種形式來區分。
var seconds int var ok bool seconds, ok = timeZone[tz]
由於明顯的原因,這被稱為「逗號 ok」慣用法。在這個例子中,如果 tz 存在,seconds 將被適當設置,ok 將為 true;如果不存在,seconds 將被設置為零,ok 將為 false。這裡有一個函數,將它與一個好的錯誤報告結合起來:
func offset(tz string) int {
if seconds, ok := timeZone[tz]; ok {
return seconds
}
log.Println("unknown time zone:", tz)
return 0
}
要測試 Map 中是否存在而不擔心實際值,你可以使用 空白識別碼 (_) 代替通常的值變量。
_, present := timeZone[tz]
要刪除 Map 條目,請使用內建函數 delete,其參數是 Map 和要刪除的鍵。即使鍵已經不在 Map 中,這樣做也是安全的。
delete(timeZone, "PDT") // Now on Standard Time
列印
Go 中的格式化列印使用類似於 C 的 printf 系列的風格,但更豐富且更通用。這些函數位於 fmt 軟體包中,且名稱首字母大寫:fmt.Printf、fmt.Fprintf、fmt.Sprintf 等。字串函數(Sprintf 等)返回一個字串而不是填寫提供的緩衝區。
你不需要提供格式化字串。對於 Printf、Fprintf 和 Sprintf 中的每一個,還有另一對函數,例如 Print 和 Println。這些函數不採用格式化字串,而是為每個參數生成預設格式。Println 版本還會在參數之間插入空格並在輸出末尾追加換行符,而 Print 版本僅在兩側的運算元都不是字串時才添加空格。在這個例子中,每行產生相同的輸出:
fmt.Printf("Hello %d\n", 23)
fmt.Fprint(os.Stdout, "Hello ", 23, "\n")
fmt.Println("Hello", 23)
fmt.Println(fmt.Sprint("Hello ", 23))
格式化列印函數 fmt.Fprint 及其同類函數將任何實現了 io.Writer 介面的對象作為第一個參數;變量 os.Stdout 和 os.Stderr 是熟悉的實例。
這裡的事情開始偏離 C 了。首先,諸如 %d 之類的數字格式不採用符號或大小標誌;相反,列印常式使用參數的類型來決定這些屬性。
var x uint64 = 1<<64 - 1
fmt.Printf("%d %x; %d %x\n", x, x, int64(x), int64(x))
列印:
18446744073709551615 ffffffffffffffff; -1 -1
如果你只想要預設轉換(例如整數的十進制),你可以使用通用格式 %v(代表「值」);結果與 Print 和 Println 產生的完全相同。此外,該格式可以列印任何值,甚至是陣列、切片、結構體和 Map。這裡是上一節中定義的時區 Map 的列印語句:
fmt.Printf("%v\n", timeZone) // or just fmt.Println(timeZone)
其輸出為:
map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]
對於 Map,Printf 及其同類函數按鍵的字典順序排序輸出。
當列印結構體時,修改後的格式 %+v 用它們的名稱註釋結構體的欄位,對於任何值,替代格式 %#v 以完整的 Go 語法列印該值。
type T struct {
a int
b float64
c string
}
t := &T{ 7, -2.35, "abc\tdef" }
fmt.Printf("%v\n", t)
fmt.Printf("%+v\n", t)
fmt.Printf("%#v\n", t)
fmt.Printf("%#v\n", timeZone)
列印:
&{7 -2.35 abc def}
&{a:7 b:-2.35 c:abc def}
&main.T{a:7, b:-2.35, c:"abc\tdef"}
map[string]int{"CST":-21600, "EST":-18000, "MST":-25200, "PST":-28800, "UTC":0}
(注意與號。)當應用於 string 或 []byte 類型的值時,該帶引號的字串格式也通過 %q 提供。替代格式 %#q 如果可能的話會使用反引號。(%q 格式也適用於整數和 rune,產生單引號的 rune 常量。)此外,%x 也適用於字串、位元組陣列和位元組切片以及整數,生成長十六進制字串;如果在格式中使用空格(% x),它會在位元組之間放置空格。
另一個方便的格式是 %T,它列印值的類型。
fmt.Printf("%T\n", timeZone)
列印:
map[string]int
如果你想控制自定義類型的預設格式,只需要在該類型上定義一個簽名為 String() string 的方法。對於我們的簡單類型 T,可能看起來像這樣:
func (t *T) String() string {
return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)
以以下格式列印:
7/-2.35/"abc\tdef"
(如果你需要列印 T 類型的值以及指向 T 的指標,String 的接收者必須是值類型;此例子使用了指標,因為對於結構體類型來說,這更高效且更慣用。有關更多資訊,請參見下面關於 指標 vs 值接收者 的部分。)
我們的 String 方法能夠調用 Sprintf,因為列印常式是完全可重入的,並且可以這樣包裝。然而,關於這種方法,有一個重要的細節需要理解:不要通過調用 Sprintf 的方式來構造 String 方法,以免無限遞歸進入你的 String 方法。如果 Sprintf 調用嘗試直接將接收者作為字串列印,這就會發生,進而再次調用該方法。這是一個常見且容易犯的錯誤,正如這個例子所示:
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", m) // Error: will recur forever.
}
修復它也很容易:將參數轉換為不具備該方法的基本字串類型:
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", string(m)) // OK: note conversion.
}
在初始化一節中,我們將看到另一種避免這種遞歸的技術。
另一種列印技術是將列印常式的參數直接傳遞給另一個此類常式。Printf 的簽名在其最後一個參數中使用類型 ...interface{},以指定格式化之後可以出現任意數量的參數(任意類型)。
func Printf(format string, v ...interface{}) (n int, err error) {
在函數 Printf 內部,v 的行為就像 []interface{} 類型的變量,但如果將其傳遞給另一個變長函數,它就表現得像一個普通的參數列表。這裡是我們上面使用的 log.Println 函數的實現。它將其參數直接傳遞給 fmt.Sprintln 進行實際格式化:
// Println prints to the standard logger in the manner of fmt.Println.
func Println(v ...interface{}) {
std.Output(2, fmt.Sprintln(v...)) // Output takes parameters (int, string)
}
我們在嵌套調用 Sprintln 中的 v 後面寫 ...,以告訴編譯器將 v 視為參數列表;否則它只會將 v 作為單個切片參數傳遞。
列印功能遠不止我們在這裡介紹的內容。有關詳細資訊,請參見 fmt 軟體包的 godoc 文檔。
順便說一句,... 參數可以是特定類型的,例如用於選擇整數列表最小值函數的 ...int:
func Min(a ...int) int {
min := int(^uint(0) >> 1) // largest int
for _, i := range a {
if i < min {
min = i
}
}
return min
}
Append
現在我們有了解釋內建函數 append 設計所需的最後一塊拼圖。append 的簽名與我們上面自定義的 Append 函數不同。圖示如下:
func append(slice []T, elements ...T) []T
其中 T 是任何給定類型的預留位置。在 Go 中,你實際上無法編寫類型 T 由調用者確定的函數。這就是為什麼 append 是內建的:它需要編譯器的支持。
append 所做的是將元素附加到切片的末尾並返回結果。結果需要返回,因為與我們手寫的 Append 一樣,底層陣列可能會改變。這個簡單的例子:
x := []int{1,2,3}
x = append(x, 4, 5, 6)
fmt.Println(x)
列印 [1 2 3 4 5 6]。所以 append 的工作方式有點像 Printf,收集任意數量的參數。
但是,如果我們想做我們 Append 所做的,將切片附加到切片怎麼辦?簡單:在調用點使用 ...,就像我們在上面調用 Output 時所做的那樣。這個代碼片段產生的輸出與上面的相同:
x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)
沒有那個 ...,它將無法編譯,因為類型將會錯誤;y 不是 int 類型。
初始化
儘管從表面上看與 C 或 C++ 中的初始化沒有太大區別,但 Go 中的初始化更強大。複雜的結構可以在初始化期間構建,且初始化對象之間的順序問題(即使在不同的軟體包之間)也能得到正確處理。
常量
Go 中的常量就是常量。它們在編譯時創建,即使在函數中定義為局部變量時也是如此,且只能是數字、字元 (runes)、字串或布林值。由於編譯時的限制,定義它們的表達式必須是常量表達式,可以由編譯器評估。例如,1<<3 是常量表達式,而 math.Sin(math.Pi/4) 則不是,因為對 math.Sin 的函數調用需要在運行時進行。
在 Go 中,枚舉常量是使用 iota 枚舉器創建的。由於 iota 可以是表達式的一部分且表達式可以隱式重複,因此很容易構建複雜的值集。
type ByteSize float64
const (
_ = iota // ignore first value by assigning to blank identifier
KB ByteSize = 1 << (10 * iota)
MB
GB
TB
PB
EB
ZB
YB
)
將諸如 String 之類的方法附加到任何使用者定義類型的能力,使得任意值能夠自動格式化以進行列印成為可能。儘管你會看到它最常應用於結構體,但這種技術對於純量類型(如浮點類型,如 ByteSize)也很有用。
func (b ByteSize) String() string {
switch {
case b >= YB:
return fmt.Sprintf("%.2fYB", b/YB)
case b >= ZB:
return fmt.Sprintf("%.2fZB", b/ZB)
case b >= EB:
return fmt.Sprintf("%.2fEB", b/EB)
case b >= PB:
return fmt.Sprintf("%.2fPB", b/PB)
case b >= TB:
return fmt.Sprintf("%.2fTB", b/TB)
case b >= GB:
return fmt.Sprintf("%.2fGB", b/GB)
case b >= MB:
return fmt.Sprintf("%.2fMB", b/MB)
case b >= KB:
return fmt.Sprintf("%.2fKB", b/KB)
}
return fmt.Sprintf("%.2fB", b)
}
表達式 YB 列印為 1.00YB,而 ByteSize(1e13) 列印為 9.09TB。
這裡使用 Sprintf 來實現 ByteSize 的 String 方法是安全的(避免無限遞歸),這不是因為轉換,而是因為它使用 %f 調用 Sprintf,這不是字串格式:Sprintf 僅在需要字串時才會調用 String 方法,而 %f 需要浮點值。
變量
變量可以像常量一樣初始化,但初始化器可以是運行時計算的通用表達式。
var (
home = os.Getenv("HOME")
user = os.Getenv("USER")
gopath = os.Getenv("GOPATH")
)
init 函數
最後,每個源檔案可以定義自己的無參數 init 函數來設置所需的任何狀態。(實際上每個檔案可以有多個 init 函數。)而且「最後」意味著最終:init 在軟體包中的所有變量宣告評估完初始化器後調用,並且這些初始化器僅在所有導入的軟體包完成初始化後才評估。
除了無法表達為宣告的初始化之外,init 函數的一個常見用途是在真實執行開始前驗證或修復程式狀態的正確性。
func init() {
if user == "" {
log.Fatal("$USER not set")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath may be overridden by --gopath flag on command line.
flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}
方法
指標 vs 值
正如我們在 ByteSize 中看到的,方法可以為任何命名類型(指標或介面除外)定義;接收者不一定是結構體。
在上面關於切片的討論中,我們編寫了一個 Append 函數。我們可以將其定義為切片上的方法。為此,我們首先宣告一個可以綁定方法的命名類型,然後使該方法的接收者成為該類型的值。
type ByteSlice []byte
func (slice ByteSlice) Append(data []byte) []byte {
// Body exactly the same as the Append function defined above.
}
這仍然要求方法返回更新後的切片。我們可以通過將方法重新定義為採用指向 ByteSlice 的指標作為其接收者來消除這種笨拙,這樣方法就可以覆蓋調用者的切片。
func (p *ByteSlice) Append(data []byte) {
slice := *p
// Body as above, without the return.
*p = slice
}
事實上,我們可以做得更好。如果我們修改函數使其看起來像標準的 Write 方法,如下所示:
func (p *ByteSlice) Write(data []byte) (n int, err error) {
slice := *p
// Again as above.
*p = slice
return len(data), nil
}
那麼 *ByteSlice 類型滿足標準介面 io.Writer,這很方便。例如,我們可以列印到其中:
var b ByteSlice
fmt.Fprintf(&b, "This hour has %d days\n", 7)
我們傳遞 ByteSlice 的地址,因為只有 *ByteSlice 滿足 io.Writer。關於接收者指標 vs 值的規則是:值方法可以在指標和值上調用,但指標方法只能在指標上調用。
此規則出現是因為指標方法可以修改接收者;在值上調用它們會導致方法接收到該值的副本,因此任何修改都將被丟棄。因此,語言禁止這種錯誤。不過,有一個方便的例外。當該值是可定址的時,語言會自動插入地址運算子,從而處理在值上調用指標方法的常見情況。在我們的例子中,變量 b 是可定址的,因此我們可以僅用 b.Write 調用其 Write 方法。編譯器會為我們將其重寫為 (&b).Write。
順便說一句,在位元組切片上使用 Write 的想法是 bytes.Buffer 實現的核心。
介面和其他類型
介面
Go 中的介面提供了一種指定對象行為的方法:如果某個東西可以做這件事,那麼它就可以用在這裡。我們已經看過幾個簡單的例子;自定義列印機可以通過 String 方法實現,而 Fprintf 可以向任何具有 Write 方法的東西生成輸出。只有一個或兩個方法的介面在 Go 代碼中很常見,通常會根據方法賦予名稱,例如對於實現 Write 的東西稱為 io.Writer。
類型可以實現多個介面。例如,如果集合實現了 sort.Interface(包含 Len()、Less(i, j int) bool 和 Swap(i, j int)),它就可以通過 sort 軟體包中的常式進行排序,並且它還可以具有自定義格式化程式。在這個人為的例子中,Sequence 同時滿足這兩者:
type Sequence []int // Methods required by sort.Interface. func (s Sequence) Len() int { return len(s) } func (s Sequence) Less(i, j int) bool { return s[i] < s[j] } func (s Sequence) Swap(i, j int) { s[i], s[j] = s[j], s[i] } // Copy returns a copy of the Sequence. func (s Sequence) Copy() Sequence { copy := make(Sequence, 0, len(s)) return append(copy, s...) } // Method for printing - sorts the elements before printing. func (s Sequence) String() string { s = s.Copy() // Make a copy; don't overwrite argument. sort.Sort(s) str := "[" for i, elem := range s { // Loop is O(N²); will fix that in next example. if i > 0 { str += " " } str += fmt.Sprint(elem) } return str + "]" }
轉換
Sequence 的 String 方法正在重新創建 Sprint 已經為切片所做的工作。(它還有 O(N²) 的複雜度,這很差。)如果我們在調用 Sprint 之前將 Sequence 轉換為普通的 []int,我們可以共享這份工作(並加速它)。
func (s Sequence) String() string {
s = s.Copy()
sort.Sort(s)
return fmt.Sprint([]int(s))
}
這個方法是從 String 方法安全調用 Sprintf 的轉換技術的另一個例子。因為如果我們忽略類型名稱,這兩個類型(Sequence 和 []int)是相同的,因此在它們之間進行轉換是合法的。轉換不會創建新值,它只是暫時表現得好像現有的值具有新類型。(還有其他合法的轉換,例如從整數到浮點數,確實會創建新值。)
在 Go 程式中,轉換表達式的類型以訪問不同的方法集是一種慣用法。作為例子,我們可以使用現有的類型 sort.IntSlice 將整個例子簡化為:
type Sequence []int
// Method for printing - sorts the elements before printing
func (s Sequence) String() string {
s = s.Copy()
sort.IntSlice(s).Sort()
return fmt.Sprint([]int(s))
}
現在,我們不是讓 Sequence 實現多個介面(排序和列印),而是利用數據項可以轉換為多種類型(Sequence、sort.IntSlice 和 []int)的能力,每一種類型都完成工作的一部分。這在實踐中比較少見,但可能很有效。
介面轉換和類型斷言
類型 switch 是一種轉換形式:它們採用一個介面,對於 switch 中的每個 case,在某種意義上將其轉換為該 case 的類型。這裡是 fmt.Printf 下的代碼如何使用類型 switch 將值轉變為字串的簡化版本。如果它已經是字串,我們想要介面持有的實際字串值,而如果它有 String 方法,我們想要調用該方法的結果。
type Stringer interface {
String() string
}
var value interface{} // Value provided by caller.
switch str := value.(type) {
case string:
return str
case Stringer:
return str.String()
}
第一個 case 找到一個具體值;第二個將介面轉換為另一個介面。這樣混合類型是完全沒問題的。
如果我們只關心一種類型怎麼辦?如果我們知道該值持有 string 並且我們只想提取它?單 case 類型 switch 可以做到,但類型斷言 (type assertion) 也可以。類型斷言採用介面值並從中提取指定顯式類型的值。語法借鑒了打開類型 switch 的子句,但使用顯式類型而不是 type 關鍵字:
value.(typeName)
結果是一個具有靜態類型 typeName 的新值。該類型必須是介面持有的具體類型,或者是該值可以轉換到的第二個介面類型。要提取我們知道在該值中的字串,我們可以這樣寫:
str := value.(string)
但如果事實證明該值不包含字串,程式將因運行時錯誤而崩潰。為了防止這種情況,請使用「逗號 ok」慣用法來安全地測試該值是否為字串:
str, ok := value.(string)
if ok {
fmt.Printf("string value is: %q\n", str)
} else {
fmt.Printf("value is not a string\n")
}
如果類型斷言失敗,str 將依然存在且為字串類型,但它將具有零值,即空字串。
作為能力的說明,這裡是一個 if-else 語句,它等同於本節開頭的類型 switch:
if str, ok := value.(string); ok {
return str
} else if str, ok := value.(Stringer); ok {
return str.String()
}
通用性
如果類型的存在僅僅是為了實現介面,並且永遠不會有超出該介面的導出方法,則沒有必要導出類型本身。僅導出介面可以清楚地表明該值除了介面中描述的內容外沒有有趣的行為。它還避免了在常用方法的每個實例上重複文檔的需要。
在這種情況下,構造函數應該返回介面值而不是實現類型。例如,在雜湊庫中,crc32.NewIEEE 和 adler32.New 都返回介面類型 hash.Hash32。在 Go 程式中用 Adler-32 替換 CRC-32 演算法只需要更改構造函數調用;程式的其餘部分不受演算法更改的影響。
類似的方法允許將各種 crypto 軟體包中的流密碼演算法與它們鏈接在一起的塊密碼分開。crypto/cipher 軟體包中的 Block 介面指定了塊密碼的行為,它提供單個數據塊的加密。然後,通過與 bufio 軟體包類比,實現此介面的密碼軟體包可用於構建流密碼,由 Stream 介面表示,而無需知道塊加密的細節。
crypto/cipher 介面看起來像這樣:
type Block interface {
BlockSize() int
Encrypt(dst, src []byte)
Decrypt(dst, src []byte)
}
type Stream interface {
XORKeyStream(dst, src []byte)
}
這是計數器模式 (CTR) 流的定義,它將塊密碼轉變為流密碼;注意塊密碼的細節被抽象掉了:
// NewCTR returns a Stream that encrypts/decrypts using the given Block in // counter mode. The length of iv must be the same as the Block's block size. func NewCTR(block Block, iv []byte) Stream
NewCTR 不僅適用於一種特定的加密演算法和數據源,還適用於 Block 介面的任何實現和任何 Stream。因為它們返回介面值,用其他加密模式替換 CTR 加密是一個局部更改。構造函數調用必須被編輯,但因為周圍的代碼必須僅將結果視為 Stream,所以它不會注意到差異。
介面和方法
由於幾乎任何東西都可以附加方法,幾乎任何東西都可以滿足介面。一個說明性的例子是在 http 軟體包中,它定義了 Handler 介面。任何實現了 Handler 的對象都可以服務 HTTP 請求。
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
ResponseWriter 本身是一個介面,提供對將響應返回給客戶端所需方法的訪問。這些方法包括標準的 Write 方法,因此 http.ResponseWriter 可以用在任何可以使用 io.Writer 的地方。Request 是一個結構體,包含來自客戶端的請求的解析表示。
為了簡潔,讓我們忽略 POST 並假設 HTTP 請求總是 GET;這種簡化不會影響處理程式的設置方式。這裡是計算頁面訪問次數的處理程式的簡單實現:
// Simple counter server.
type Counter struct {
n int
}
func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctr.n++
fmt.Fprintf(w, "counter = %d\n", ctr.n)
}
(保持我們的主題,注意 Fprintf 如何列印到 http.ResponseWriter。)在真正的伺服器中,對 ctr.n 的訪問需要防止併發訪問。有關建議,請參見 sync 和 atomic 軟體包。
作為參考,這裡是如何將這樣的伺服器連接到 URL 樹上的節點:
import "net/http"
...
ctr := new(Counter)
http.Handle("/counter", ctr)
但為什麼要把 Counter 做成結構體?只需要一個整數。(接收者必須是指標,以便增量對調用者可見。)
// Simpler counter server.
type Counter int
func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
*ctr++
fmt.Fprintf(w, "counter = %d\n", *ctr)
}
如果你的程式有一些內部狀態需要被通知頁面已被訪問怎麼辦?將通道綁定到網頁:
// A channel that sends a notification on each visit.
// (Probably want the channel to be buffered.)
type Chan chan *http.Request
func (ch Chan) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ch <- req
fmt.Fprint(w, "notification sent")
}
最後,假設我們想在 /args 上呈現調用伺服器二進位檔案時使用的參數。寫一個函數來列印參數很容易:
func ArgServer() {
fmt.Println(os.Args)
}
我們如何將其轉變為 HTTP 伺服器?我們可以將 ArgServer 做成我們忽略其值類型的某些類型的方法,但有一種更乾淨的方法。由於我們可以為指標和介面以外的任何類型定義方法,因此我們可以為函數編寫方法。http 軟體包包含此代碼:
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler object that calls f.
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(w, req).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
f(w, req)
}
HandlerFunc 是一個帶有方法 ServeHTTP 的類型,因此該類型的值可以服務 HTTP 請求。看看該方法的實現:接收者是一個函數 f,該方法調用 f。這看起來可能很奇怪,但它與接收者是一個通道且方法在通道上發送數據並沒有什麼不同。
要將 ArgServer 轉變為 HTTP 伺服器,我們首先修改它以具有正確的簽名:
// Argument server.
func ArgServer(w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, os.Args)
}
ArgServer 現在具有與 HandlerFunc 相同的簽名,因此它可以轉換為該類型以訪問其方法,就像我們將 Sequence 轉換為 IntSlice 以訪問 IntSlice.Sort 一樣。設置它的代碼很簡潔:
http.Handle("/args", http.HandlerFunc(ArgServer))
當有人訪問頁面 /args 時,安裝在該頁面上的處理程式的值為 ArgServer,類型為 HandlerFunc。HTTP 伺服器將調用該類型的 ServeHTTP 方法,以 ArgServer 作為接收者,這將反過來調用 ArgServer(通過 HandlerFunc.ServeHTTP 內的調用 f(w, req))。然後參數將被顯示出來。
在本節中,我們從結構體、整數、通道和函數構建了一個 HTTP 伺服器,所有這些都是因為介面只是一組方法,可以為(幾乎)任何類型定義。
空白識別碼
我們已經幾次提到空白識別碼了,在 for range 迴圈 和 Map 的上下文中。空白識別碼可以被賦值或宣告為任何類型的任何值,值會被無害地丟棄。它有點像寫入 Unix /dev/null 檔案:它代表一個只寫的值,用作需要變量但實際值無關緊要時的預留位置。它除了我們已經看到的用途外,還有其他用途。
多重賦值中的空白識別碼
在 for range 迴圈中使用空白識別碼是通用情況的一個特例:多重賦值。
如果賦值在左側需要多個值,但其中一個值不會被程式使用,則賦值左側的空白識別碼避免了創建虛擬變量的需要,並明確表示該值將被丟棄。例如,當調用返回一個值和一個錯誤但只有錯誤重要的函數時,使用空白識別碼來丟棄不相關的值。
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("%s does not exist\n", path)
}
有時你會看到丟棄錯誤值以忽略錯誤的代碼;這是一個糟糕的做法。務必檢查錯誤返回;它們被提供是有原因的。
// Bad! This code will crash if path does not exist.
fi, _ := os.Stat(path)
if fi.IsDir() {
fmt.Printf("%s is a directory\n", path)
}
未使用的導入和變量
導入軟體包或宣告變量而不使用它們是一個錯誤。未使用的導入會使程式膨脹並減慢編譯速度,而初始化但未使用的變量至少是浪費的計算,並且可能指示更大的錯誤。然而,當程式處於活躍開發中時,未使用的導入和變量經常出現,僅僅為了讓編譯繼續而刪除它們,結果稍後又需要它們,這可能很煩人。空白識別碼提供了一種變通方法。
這個寫了一半的程式有兩個未使用的導入(fmt 和 io)和一個未使用的變量(fd),因此它無法編譯,但看看目前的代碼是否正確會很好。
package main
import (
"fmt"
"io"
"log"
"os"
)
func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
}
為了消除對未使用導入的抱怨,請使用空白識別碼來引用導入軟體包中的符號。同樣,將未使用的變量 fd 賦值給空白識別碼將消除未使用變量的錯誤。該版本的程式確實可以編譯。
package main
import (
"fmt"
"io"
"log"
"os"
)
var _ = fmt.Printf // For debugging; delete when done.
var _ io.Reader // For debugging; delete when done.
func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
_ = fd
}
按照慣例,消除導入錯誤的全局宣告應該緊跟在導入之後,並添加註釋,既為了便於查找,也為了提醒稍後進行清理。
導入以產生副作用
前一個例子中像 fmt 或 io 這樣的未使用導入最終應該被使用或刪除:空白賦值識別程式碼為進行中的工作。但有時僅為了副作用導入軟體包而不做任何顯式使用是很有用的。例如,在其 init 函數期間,net/http/pprof 軟體包註冊提供調試資訊的 HTTP 處理程式。它有一個導出的 API,但大多數客戶端只需要處理程式註冊並通過網頁訪問數據。要僅為了副作用導入軟體包,請將軟體包重命名為空白識別碼:
import _ "net/http/pprof"
這種形式的導入明確表示該軟體包是為了副作用而導入的,因為沒有其他可能的軟體包使用方式:在該檔案中,它沒有名稱。(如果它有名稱,而我們沒有使用該名稱,編譯器將拒絕該程式。)
介面檢查
正如我們在上面關於 介面 的討論中看到的,類型不需要顯式宣告它實現了介面。相反,類型僅通過實現介面的方法來實現介面。在實踐中,大多數介面轉換是靜態的,因此在編譯時進行檢查。例如,將 *os.File 傳遞給期望 io.Reader 的函數,除非 *os.File 實現了 io.Reader 介面,否則無法編譯。
不過,有些介面檢查確實是在執行時期(run-time)進行的。其中一個例子是 encoding/json 套件,它定義了一個 Marshaler 介面。當 JSON 編碼器收到一個實作了該介面的值時,編碼器會呼叫該值的 marshaling 方法將其轉換為 JSON,而不是執行標準的轉換。編碼器會在執行時期使用型別斷言(type assertion)來檢查此屬性,如下所示:
m, ok := val.(json.Marshaler)
如果僅僅需要確認某個型別是否實作了介面,而不需要實際使用該介面(例如作為錯誤檢查的一部分),請使用空白識別字(blank identifier)來忽略斷言後的型別值:
if _, ok := val.(json.Marshaler); ok {
fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val)
}
這種情況通常發生在實作該型別的套件內部,需要確保該型別確實滿足某個介面時。如果某個型別(例如 json.RawMessage)需要自訂 JSON 表示法,它應該實作 json.Marshaler,但編譯器並不會自動執行靜態轉換來驗證這一點。如果該型別無意中未滿足介面,JSON 編碼器仍會運作,但不會使用自訂實作。為了確保實作正確,可以在套件中使用包含空白識別字的的全域宣告:
var _ json.Marshaler = (*RawMessage)(nil)
在此宣告中,將 *RawMessage 轉換為 Marshaler 的賦值操作要求 *RawMessage 必須實作 Marshaler,此屬性將在編譯時期進行檢查。如果 json.Marshaler 介面發生變更,該套件將無法編譯,我們便會得知需要進行更新。
在此結構中出現空白識別字,表示該宣告僅用於型別檢查,而非建立變數。不過,不要為每個滿足介面的型別都這樣做。依照慣例,這類宣告僅在程式碼中尚無靜態轉換時使用,這在實際情況中很少見。
嵌入(Embedding)
Go 不提供典型的、由型別驅動的子類別概念,但它確實具有透過在結構(struct)或介面中嵌入型別來「借用」實作片段的能力。
介面嵌入非常簡單。我們之前已經提過 io.Reader 和 io.Writer 介面;以下是它們的定義。
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
io 套件還導出了其他幾個介面,用以指定可以實作多個這類方法的物件。例如,io.ReadWriter 介面同時包含了 Read 和 Write。我們可以透過明確列出這兩個方法來指定 io.ReadWriter,但更簡單且更具表現力的方式是將兩個介面嵌入以組成新的介面,如下所示:
// ReadWriter is the interface that combines the Reader and Writer interfaces.
type ReadWriter interface {
Reader
Writer
}
這句話字面意思即是:ReadWriter 可以做到 Reader 能做的事,同時也能做到 Writer 能做的事;它是被嵌入介面的聯集。只有介面可以被嵌入到介面中。
同樣的基本概念也適用於結構,但具有更深遠的影響。bufio 套件有兩個結構型別:bufio.Reader 和 bufio.Writer,當然每個都實作了 io 套件中對應的介面。此外,bufio 也實作了緩衝讀取器/寫入器,做法是利用嵌入將讀取器和寫入器結合成一個結構:它在結構中列出了型別,但沒有給它們欄位名稱。
// ReadWriter stores pointers to a Reader and a Writer.
// It implements io.ReadWriter.
type ReadWriter struct {
*Reader // *bufio.Reader
*Writer // *bufio.Writer
}
嵌入的元素是指向結構的指標,當然在被使用前必須先初始化為指向有效的結構。ReadWriter 結構可以寫成:
type ReadWriter struct {
reader *Reader
writer *Writer
}
但若要提升欄位的方法並滿足 io 介面,我們還需要提供轉發方法,如下所示:
func (rw *ReadWriter) Read(p []byte) (n int, err error) {
return rw.reader.Read(p)
}
透過直接嵌入結構,我們避免了這些繁瑣工作。嵌入型別的方法會自動獲得,這意味著 bufio.ReadWriter 不僅擁有 bufio.Reader 和 bufio.Writer 的方法,它還同時滿足了三個介面:io.Reader、io.Writer 和 io.ReadWriter。
嵌入與子類別化有一個重要的區別。當我們嵌入一個型別時,該型別的方法會成為外部型別的方法,但當這些方法被呼叫時,方法的接收者是內部型別,而非外部型別。在我們的例子中,當 bufio.ReadWriter 的 Read 方法被呼叫時,其效果與上述所寫的轉發方法完全相同;接收者是 ReadWriter 的 reader 欄位,而不是 ReadWriter 本身。
嵌入也可以是一種簡單的便利手段。此範例顯示了一個嵌入欄位與一個普通的命名欄位並存。
type Job struct {
Command string
*log.Logger
}
Job 型別現在擁有 *log.Logger 的 Print、Printf、Println 等方法。當然,我們可以給 Logger 一個欄位名稱,但沒有必要這麼做。現在,一旦初始化,我們就可以對 Job 進行日誌記錄:
job.Println("starting now...")
Logger 是 Job 結構的一個普通欄位,因此我們可以在 Job 的建構函式中以常規方式初始化它,如下所示:
func NewJob(command string, logger *log.Logger) *Job {
return &Job{command, logger}
}
或者使用複合字面值(composite literal):
job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}
如果我們需要直接參考嵌入欄位,該欄位的型別名稱(忽略套件路徑)即作為欄位名稱,正如我們在 ReadWriter 結構的 Read 方法中所做的那樣。在這裡,如果我們需要存取 Job 變數 job 的 *log.Logger,我們會寫 job.Logger,如果我們想進一步修改 Logger 的方法,這會很有用。
func (job *Job) Printf(format string, args ...interface{}) {
job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}
嵌入型別會引入名稱衝突的問題,但解決規則很簡單。首先,欄位或方法 X 會隱藏型別中更深層巢狀結構中的任何其他項目 X。如果 log.Logger 包含名為 Command 的欄位或方法,Job 的 Command 欄位將會覆蓋它。
其次,如果相同的名稱出現在相同的巢狀層級,通常會導致錯誤;如果 Job 結構已經包含了另一個名為 Logger 的欄位或方法,再去嵌入 log.Logger 將會出錯。然而,如果重複的名稱在型別定義之外的程式中從未被提及,則是允許的。這種限制提供了一種針對外部嵌入型別變更的保護;如果新增的欄位與另一個子型別中的欄位衝突,但這兩個欄位都沒有被使用,就不會有問題。
並行處理 (Concurrency)
透過通訊來共享
並行程式設計是一個龐大的主題,此處僅有空間介紹一些 Go 特有的亮點。
在許多環境中,並行程式設計因實現正確存取共享變數所需的複雜細節而變得困難。Go 鼓勵一種不同的方法:共享值透過通道(channels)傳遞,實際上永遠不會由不同的執行執行緒主動共享。任何時候只有一個 goroutine 能夠存取該值。根據設計,資料競爭(data races)是不會發生的。為了鼓勵這種思考方式,我們將其簡化為一句口號:
不要透過共享記憶體來通訊;相反地,要透過通訊來分享記憶體。
這種方法有時可能會被過度使用。例如,參考計數可能最好是透過在整數變數周圍放置互斥鎖(mutex)來完成。但作為一種高階方法,使用通道來控制存取,確實可以更容易地寫出清晰、正確的程式。
思考此模型的一種方式是考慮在單個 CPU 上執行的典型單執行緒程式。它不需要同步原語。現在執行另一個這樣的實例;它也不需要同步。現在讓這兩者通訊;如果通訊就是同步機制,那麼就不需要其他同步。例如,Unix 的管道(pipelines)就完全符合這個模型。雖然 Go 的並行方法源於 Hoare 的通訊順序處理(CSP),但它也可以被視為 Unix 管道的一種型別安全推廣。
Goroutines
它們被稱為 goroutines,是因為現有的術語——執行緒(threads)、協程(coroutines)、程序(processes)等——傳達了不準確的意涵。一個 goroutine 有一個簡單的模型:它是一個與同一位址空間中的其他 goroutines 並發執行的函式。它是輕量級的,成本僅略高於分配堆疊空間。堆疊起初很小,因此成本低廉,並根據需要透過分配(和釋放)堆積儲存空間來成長。
Goroutines 被多工處理到多個 OS 執行緒上,因此如果一個 goroutine 阻塞(例如在等待 I/O 時),其他 goroutine 仍會繼續執行。這種設計隱藏了執行緒建立與管理的許多複雜性。
在函式或方法呼叫前加上 go 關鍵字,即可在新的 goroutine 中執行該呼叫。當呼叫完成時,該 goroutine 會默默地退出。(效果類似於 Unix shell 用於在背景執行指令的 & 符號。)
go list.Sort() // run list.Sort concurrently; don't wait for it.
函式字面值(function literal)在 goroutine 呼叫中非常實用。
func Announce(message string, delay time.Duration) {
go func() {
time.Sleep(delay)
fmt.Println(message)
}() // Note the parentheses - must call the function.
}
在 Go 中,函式字面值是閉包(closures):實作機制確保了函式所參考的變數,只要函式處於活躍狀態,它們就會一直存活。
這些範例不太實用,因為函式沒有辦法發送完成訊號。為了做到這一點,我們需要通道。
通道(Channels)
就像映射(maps)一樣,通道是用 make 分配的,產生的值作為底層資料結構的參考。如果提供了可選的整數參數,它會設定通道的緩衝區大小。預設值為零,表示無緩衝或同步通道。
ci := make(chan int) // unbuffered channel of integers cj := make(chan int, 0) // unbuffered channel of integers cs := make(chan *os.File, 100) // buffered channel of pointers to Files
無緩衝通道結合了通訊(值的交換)與同步(確保兩個計算—goroutines—處於已知狀態)。
使用通道有很多很棒的慣用語。這裡有一個入門範例。在上一節中,我們在背景啟動了一個排序作業。通道可以讓啟動排序的 goroutine 等待排序完成。
c := make(chan int) // Allocate a channel.
// Start the sort in a goroutine; when it completes, signal on the channel.
go func() {
list.Sort()
c <- 1 // Send a signal; value does not matter.
}()
doSomethingForAWhile()
<-c // Wait for sort to finish; discard sent value.
接收者會一直阻塞,直到有資料可以接收。如果通道是無緩衝的,發送者會阻塞直到接收者接收了該值。如果通道有緩衝區,發送者僅在值被複製到緩衝區時才會阻塞;如果緩衝區已滿,這意味著要等待直到某個接收者取出一個值。
緩衝通道可以用作信號量(semaphore),例如用於限制吞吐量。在此範例中,傳入的請求被傳遞給 handle,它將值發送到通道中,處理請求,然後從通道接收一個值,為下一個消費者準備好「信號量」。通道緩衝區的容量限制了對 process 的同時呼叫次數。
var sem = make(chan int, MaxOutstanding)
func handle(r *Request) {
sem <- 1 // Wait for active queue to drain.
process(r) // May take a long time.
<-sem // Done; enable next request to run.
}
func Serve(queue chan *Request) {
for {
req := <-queue
go handle(req) // Don't wait for handle to finish.
}
}
一旦有 MaxOutstanding 個處理器正在執行 process,任何更多的請求將在嘗試發送到已滿的通道緩衝區時阻塞,直到其中一個現有的處理器完成並從緩衝區接收值。
然而,這種設計有一個問題:Serve 為每個傳入的請求建立一個新的 goroutine,儘管任何時刻只有 MaxOutstanding 個可以執行。結果,如果請求進來太快,程式可能會消耗無限的資源。我們可以透過修改 Serve 來控制 goroutine 的建立,從而解決該缺陷:
func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func() {
process(req)
<-sem
}()
}
}
(請注意,在 1.22 之前的 Go 版本中,此程式碼有一個錯誤:迴圈變數在所有 goroutines 之間共享。詳情請參閱 Go wiki。)
另一種能良好管理資源的方法是啟動固定數量的 handle goroutines,它們都從請求通道讀取資料。goroutines 的數量限制了對 process 的同時呼叫數。此 Serve 函式也接受一個通道,用以接收退出指令;在啟動 goroutines 後,它會阻塞並從該通道接收訊息。
func handle(queue chan *Request) {
for r := range queue {
process(r)
}
}
func Serve(clientRequests chan *Request, quit chan bool) {
// Start handlers
for i := 0; i < MaxOutstanding; i++ {
go handle(clientRequests)
}
<-quit // Wait to be told to exit.
}
通道的通道
Go 最重要的屬性之一是通道本身就是一等公民值(first-class value),可以像其他任何值一樣被分配和傳遞。此屬性的一個常見用途是實作安全、平行的解多工(demultiplexing)。
在上節的範例中,handle 是一個理想化的請求處理器,但我們沒有定義它正在處理的型別。如果該型別包含一個用於回覆的通道,每個客戶端都可以提供自己接收答案的路徑。以下是 Request 型別的架構定義。
type Request struct {
args []int
f func([]int) int
resultChan chan int
}
客戶端提供一個函式及其參數,以及請求物件內的一個通道,用於接收答案。
func sum(a []int) (s int) {
for _, v := range a {
s += v
}
return
}
request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
// Send request
clientRequests <- request
// Wait for response.
fmt.Printf("answer: %d\n", <-request.resultChan)
在伺服器端,只有處理器函式發生了變化。
func handle(queue chan *Request) {
for req := range queue {
req.resultChan <- req.f(req.args)
}
}
顯然,要使其變得實用還有很多工作要做,但這段程式碼是一個具備速率限制、平行且非阻塞 RPC 系統的框架,且完全不需要互斥鎖。
平行化(Parallelization)
這些想法的另一個應用是在多個 CPU 核心上平行化計算。如果計算可以拆分為可以獨立執行的片段,那麼它就可以被平行化,並使用一個通道來通知每個片段何時完成。
假設我們需要對一組項目(向量)執行一項昂貴的操作,並且對每個項目的操作值是相互獨立的,如這個理想化的範例所示:
type Vector []float64
// Apply the operation to v[i], v[i+1] ... up to v[n-1].
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
for ; i < n; i++ {
v[i] += u.Op(v[i])
}
c <- 1 // signal that this piece is done
}
我們在迴圈中獨立啟動這些片段,每個 CPU 一個。它們可以以任何順序完成,但這並不重要;我們只需要在啟動所有 goroutines 後,透過排空通道來計算完成訊號的數量。
const numCPU = 4 // number of CPU cores
func (v Vector) DoAll(u Vector) {
c := make(chan int, numCPU) // Buffering optional but sensible.
for i := 0; i < numCPU; i++ {
go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
}
// Drain the channel.
for i := 0; i < numCPU; i++ {
<-c // wait for one task to complete
}
// All done.
}
與其為 numCPU 建立一個常數值,不如詢問執行時期環境什麼值是合適的。runtime.NumCPU 函式會回傳機器上的硬體 CPU 核心數,因此我們可以寫成:
var numCPU = runtime.NumCPU()
此外還有一個 runtime.GOMAXPROCS 函式,它報告(或設定)Go 程式可以同時執行的使用者指定核心數。其預設值為 runtime.NumCPU,但可以透過設定同名的 shell 環境變數或以正整數呼叫該函式來覆蓋。呼叫時傳入 0 僅會查詢該值。因此,如果我們想尊重使用者的資源請求,我們應該這樣寫:
var numCPU = runtime.GOMAXPROCS(0)
請務必不要混淆「並行」(concurrency)——將程式建構為獨立執行的元件——與「平行」(parallelism)——為了在多個 CPU 上提高效率而平行執行計算——這兩個概念。雖然 Go 的並行特性可以使某些問題更容易建構為平行計算,但 Go 是一種並行語言,而非平行語言,並非所有平行化問題都適合 Go 的模型。關於區別的討論,請參閱這篇部落格文章中所引用的演講。
洩漏緩衝區(A leaky buffer)
並行程式設計的工具甚至可以讓非並行的想法更容易表達。這是一個從 RPC 套件中抽象出來的範例。客戶端 goroutine 迴圈從某個來源(例如網路)接收資料。為了避免分配和釋放緩衝區,它維護一個空閒列表,並使用緩衝通道來表示它。如果通道是空的,則會分配一個新的緩衝區。一旦訊息緩衝區準備好,它就會在 serverChan 上發送給伺服器。
var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)
func client() {
for {
var b *Buffer
// Grab a buffer if available; allocate if not.
select {
case b = <-freeList:
// Got one; nothing more to do.
default:
// None free, so allocate a new one.
b = new(Buffer)
}
load(b) // Read next message from the net.
serverChan <- b // Send to server.
}
}
伺服器迴圈從客戶端接收每個訊息,處理它,然後將緩衝區歸還給空閒列表。
func server() {
for {
b := <-serverChan // Wait for work.
process(b)
// Reuse buffer if there's room.
select {
case freeList <- b:
// Buffer on free list; nothing more to do.
default:
// Free list full, just carry on.
}
}
}
客戶端嘗試從 freeList 獲取緩衝區;如果沒有可用的,它會分配一個新的。伺服器發送到 freeList 會將 b 放回空閒列表中,除非列表已滿;若列表已滿,緩衝區就會被丟棄,由垃圾回收器回收。(select 語句中的 default 子句在沒有其他 case 就緒時執行,這意味著 selects 永遠不會阻塞。)這個實作僅用幾行程式碼就建立了一個洩漏桶(leaky bucket)空閒列表,依賴於緩衝通道和垃圾回收器來處理簿記工作。
Errors (錯誤)
函式庫常規通常需要向呼叫者回傳某種錯誤指示。如前所述,Go 的多值回傳功能使得在普通回傳值之外回傳詳細的錯誤描述變得容易。使用此特性來提供詳細的錯誤資訊是一種良好的風格。例如,稍後我們將看到,os.Open 在失敗時不僅回傳 nil 指標,還會回傳一個描述錯誤原因的錯誤值。
按照慣例,錯誤的型別為 error,這是一個簡單的內建介面。
type error interface {
Error() string
}
函式庫開發者可以自由地在底層以更豐富的模型來實作此介面,使得不僅能看到錯誤,還能提供一些上下文。如前所述,除了通常的 *os.File 回傳值外,os.Open 也會回傳一個錯誤值。如果檔案成功開啟,錯誤將為 nil,但當出現問題時,它將包含一個 os.PathError:
// PathError records an error and the operation and
// file path that caused it.
type PathError struct {
Op string // "open", "unlink", etc.
Path string // The associated file.
Err error // Returned by the system call.
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
PathError 的 Error 會產生如下字串:
open /etc/passwx: no such file or directory
這種包含問題檔案名稱、操作內容以及觸發的作業系統錯誤的錯誤資訊,即使在距離引發呼叫很遠的地方列印出來也很有用;它比單純的 "no such file or directory" 資訊量大得多。
在可行時,錯誤字串應識別其來源,例如透過在字串前加上生成錯誤的操作或套件名稱。例如,在 image 套件中,由於格式未知導致解碼錯誤時,其字串表示為 "image: unknown format"。
關心精確錯誤細節的呼叫者可以使用型別開關(type switch)或型別斷言來尋找特定錯誤並提取細節。對於 PathErrors,這可能包括檢查內部的 Err 欄位以處理可恢復的失敗。
for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return
}
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // Recover some space.
continue
}
return
}
這裡的第二個 if 語句是另一個型別斷言。如果斷言失敗,ok 將為 false,且 e 將為 nil。如果成功,ok 將為 true,這意味著錯誤的型別為 *os.PathError,因此 e 也是該型別,我們可以檢查它以獲取有關錯誤的更多資訊。
Panic
向呼叫者報告錯誤的通常方式是回傳一個 error 作為額外的回傳值。正規的 Read 方法就是一個眾所周知的例子;它回傳位元組計數和一個 error。但如果錯誤是無法恢復的呢?有時程式簡直無法繼續執行。
為此,有一個內建的 panic 函式,它實際上會建立一個執行時期錯誤,從而停止程式(但請參閱下一節)。該函式接受一個任意型別的單一參數(通常是字串),並在程式崩潰時列印出來。這也是一種指示「不可能發生的事情」發生了的方式,例如跳出無窮迴圈。
// A toy implementation of cube root using Newton's method.
func CubeRoot(x float64) float64 {
z := x/3 // Arbitrary initial value
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z-x) / (3*z*z)
if veryClose(z, prevz) {
return z
}
}
// A million iterations has not converged; something is wrong.
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}
這只是一個例子,但真實的函式庫函式應避免使用 panic。如果問題可以被遮蔽或繞過,讓程式繼續執行總是比讓整個程式崩潰更好。一個可能的反例是在初始化期間:如果函式庫確實無法設定自己,那麼呼叫 panic 可能是合理的。
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}
Recover
當呼叫 panic 時(包括執行時期錯誤(如切片索引越界或型別斷言失敗)隱含的呼叫),它會立即停止目前函式的執行,並開始展開 goroutine 的堆疊,同時執行任何延遲(deferred)函式。如果這種展開到達 goroutine 堆疊的頂端,程式就會死亡。然而,可以使用內建的 recover 函式來重新獲得 goroutine 的控制權並恢復正常執行。
對 recover 的呼叫會停止展開並回傳傳遞給 panic 的參數。因為展開過程中唯一運行的程式碼是在延遲函式內部,所以 recover 僅在延遲函式內有用。
recover 的一個應用是關閉伺服器內出現故障的 goroutine,而不殺死其他正在執行的 goroutines。
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work)
}
}
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
在此範例中,如果 do(work) 觸發了 panic,結果將會被記錄下來,且該 goroutine 將乾淨地退出,而不會干擾其他 goroutines。在延遲閉包(deferred closure)中不需要做任何其他事情;呼叫 recover 可以完全處理該狀況。
因為除非直接從延遲函式中呼叫,否則 recover 總是回傳 nil,所以延遲程式碼可以呼叫那些本身使用 panic 和 recover 的函式庫常規,而不會失敗。例如,safelyDo 中的延遲函式可能會在呼叫 recover 之前呼叫一個記錄函式,而該記錄程式碼將不會受到 panicking 狀態的影響。
有了我們的恢復模式,do 函式(以及它呼叫的任何內容)都可以透過呼叫 panic 來乾淨地擺脫任何糟糕的情況。我們可以利用這個想法來簡化複雜軟體中的錯誤處理。讓我們看看一個理想化的 regexp 套件版本,它透過呼叫帶有本機錯誤型別的 panic 來報告解析錯誤。以下是 Error 的定義、error 方法以及 Compile 函式。
// Error is the type of a parse error; it satisfies the error interface.
type Error string
func (e Error) Error() string {
return string(e)
}
// error is a method of *Regexp that reports parsing errors by
// panicking with an Error.
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// Compile returns a parsed representation of the regular expression.
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
// doParse will panic if there is a parse error.
defer func() {
if e := recover(); e != nil {
regexp = nil // Clear return value.
err = e.(Error) // Will re-panic if not a parse error.
}
}()
return regexp.doParse(str), nil
}
如果 doParse 觸發了 panic,恢復區塊將設定回傳值為 nil——延遲函式可以修改命名的回傳值。然後它會透過對 err 的賦值來檢查問題是否為解析錯誤,方法是斷言它具有本機型別 Error。如果沒有,型別斷言將失敗,導致一個執行時期錯誤,就像什麼都沒干擾過一樣繼續堆疊展開。這種檢查意味著如果發生了意外情況(如索引越界),即使我們正在使用 panic 和 recover 來處理解析錯誤,程式碼也會失敗。
有了錯誤處理機制,error 方法(因為它是一個繫結到型別的方法,所以即使與內建 error 型別同名也沒關係,甚至很自然)使得報告解析錯誤變得容易,而不必擔心手動展開解析堆疊。
if pos == 0 {
re.error("'*' illegal at start of expression")
}
雖然這個模式很有用,但它應該只在套件內部使用。Parse 將其內部的 panic 呼叫轉換為 error 值;它不會將 panics 暴露給客戶端。這是一個需要遵守的好規則。
順帶一提,如果發生了實際的錯誤,這種重新 panic(re-panic)的慣用語會改變 panic 值。然而,原始的和新的失敗都會呈現在崩潰報告中,因此問題的根本原因仍然可見。因此,這種簡單的重新 panic 方法通常就足夠了——畢竟它已經崩潰了——但如果您只想顯示原始值,可以多寫一點程式碼來過濾意外問題,並使用原始錯誤重新 panic。這就留給讀者作為練習。
網頁伺服器
讓我們用一個完整的 Go 程式作為結束:一個網頁伺服器。這其實是一個網頁轉發伺服器。Google 在 chart.apis.google.com 提供了一項服務,可以將資料自動格式化為圖表和統計圖。不過,它很難互動式使用,因為您需要將資料作為查詢參數放入 URL 中。此處的程式為某種資料提供了一個更好的介面:給定一小段文字,它會呼叫圖表伺服器產生一個 QR 碼,即一個編碼該文字的方塊矩陣。該影像可以用您的手機相機拍攝並解釋為(例如)一個 URL,從而省去您在手機小鍵盤上輸入 URL 的麻煩。
以下是完整的程式。後續會有解釋。
package main
import (
"flag"
"html/template"
"log"
"net/http"
)
var addr = flag.String("addr", ":1718", "http service address") // Q=17, R=18
var templ = template.Must(template.New("qr").Parse(templateStr))
func main() {
flag.Parse()
http.Handle("/", http.HandlerFunc(QR))
err := http.ListenAndServe(*addr, nil)
if err != nil {
log.Fatal("ListenAndServe:", err)
}
}
func QR(w http.ResponseWriter, req *http.Request) {
templ.Execute(w, req.FormValue("s"))
}
const templateStr = `
<html>
<head>
<title>QR Link Generator</title>
</head>
<body>
{{if .}}
<img src="http://chart.apis.google.com/chart?chs=300x300&cht=qr&choe=UTF-8&chl={{.}}" />
<br>
{{.}}
<br>
<br>
{{end}}
<form action="/" name=f method="GET">
<input maxLength=1024 size=70 name=s value="" title="Text to QR Encode">
<input type=submit value="Show QR" name=qr>
</form>
</body>
</html>
`
main 之前的片段應該很容易理解。旗標(flag)為我們的伺服器設定了預設的 HTTP 連接埠。範本變數 templ 是有趣的地方所在。它建立了一個 HTML 範本,該範本將由伺服器執行以顯示頁面;稍後會詳細介紹。
main 函式解析旗標,並使用我們上面討論的機制,將 QR 函式繫結到伺服器的根路徑。然後呼叫 http.ListenAndServe 來啟動伺服器;它會在伺服器執行時保持阻塞。
QR 只是接收包含表單資料的請求,並對名為 s 的表單值中的資料執行範本。
html/template 套件非常強大;此程式僅觸及了其功能的一小部分。本質上,它透過替換傳遞給 templ.Execute 的資料項(在本例中為表單值)所衍生的元素,即時重寫一段 HTML 文字。在範本文字 (templateStr) 中,雙大括號分隔的部分表示範本動作。從 {{if .}} 到 {{end}} 的片段只有在目前資料項(稱為 .,即點)的值非空時才會執行。也就是說,當字串為空時,這部分範本會被隱藏。
兩個 {{.}} 片段表示將傳遞給範本的資料(查詢字串)顯示在網頁上。HTML 範本套件會自動提供適當的轉義,因此文字可以安全地顯示。
範本字串的其餘部分只是頁面載入時顯示的 HTML。如果這個解釋太過簡略,請參閱範本套件的文件以進行更深入的討論。
這就是全部內容:一個只需幾行程式碼外加一些資料驅動 HTML 文字的實用網頁伺服器。Go 足夠強大,可以用極少的程式碼實現很多功能。