高效 Go 程式設計

引言

Go 是一門新語言。儘管它借鑑了現有語言的理念,但它具有一些不尋常的特性,使得高效的 Go 程式在風格上與用其“親戚”語言編寫的程式不同。將 C++ 或 Java 程式直接翻譯成 Go 程式,不太可能產生令人滿意的結果——Java 程式是用 Java 編寫的,而不是 Go。另一方面,從 Go 的角度思考問題,可能會產生一個成功但截然不同的程式。換句話說,要寫好 Go,理解它的特性和慣用法很重要。瞭解 Go 程式設計的既定約定也很重要,例如命名、格式、程式結構等,這樣你編寫的程式才能更容易被其他 Go 程式設計師理解。

本文件提供了編寫清晰、地道 Go 程式碼的技巧。它補充了語言規範Go 語言之旅如何編寫 Go 程式碼,你應該首先閱讀所有這些內容。

2022 年 1 月補充說明:本文件是為 Go 在 2009 年釋出時編寫的,此後沒有進行重大更新。儘管它是一個理解如何使用語言本身的良好指南,但由於語言的穩定性,它很少提及庫,並且完全沒有提及自編寫以來 Go 生態系統發生的重大變化,例如構建系統、測試、模組和多型性。目前沒有計劃更新它,因為已經發生了太多變化,並且大量且不斷增長的文件、部落格和書籍在描述現代 Go 用法方面做得很好。《高效 Go 程式設計》仍然有用,但讀者應該理解它遠非一個完整的指南。有關上下文,請參閱問題 28782

示例

Go 包的原始碼不僅作為核心庫,還旨在作為如何使用該語言的示例。此外,許多包包含可執行的、自包含的可執行示例,你可以直接從 go.dev 網站執行,例如這個(如有必要,點選“Example”一詞以展開)。如果你對如何處理問題或如何實現某個功能有疑問,庫中的文件、程式碼和示例可以提供答案、想法和背景。

格式化

格式化問題最具爭議但影響最小。人們可以適應不同的格式化風格,但如果不需要適應會更好,如果每個人都遵循相同的風格,花在這個話題上的時間就會更少。問題是如何在沒有冗長的規範風格指南的情況下實現這種理想狀態。

在 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 格式化。

一些格式化細節仍需注意。簡要概述如下:

縮排
我們使用製表符進行縮排,gofmt 預設會輸出製表符。只在必要時才使用空格。
行長度
Go 沒有行長度限制。不必擔心超出打孔卡的限制。如果一行感覺太長,請將其換行並額外縮排一個製表符。
括號
Go 比 C 和 Java 需要更少的括號:控制結構(ifforswitch)的語法中沒有括號。此外,運算子優先順序層次結構更短、更清晰,因此
x<<8 + y<<16
與其它語言不同,其含義與間距所暗示的一致。

註釋

Go 提供 C 風格的 /* */ 塊註釋和 C++ 風格的 // 行註釋。行註釋是常用的;塊註釋主要出現在包註釋中,但在表示式內部或停用大段程式碼時很有用。

在頂層宣告之前、沒有中間換行符的註釋被視為文件化該宣告本身。這些“文件註釋”是給定 Go 包或命令的主要文件。有關文件註釋的更多資訊,請參閱“Go 文件註釋”。

命名

在 Go 中,命名與其他任何語言一樣重要。它們甚至具有語義效應:名稱在包外的可見性取決於其首字母是否大寫。因此,值得花一些時間討論 Go 程式中的命名約定。

包名

當一個包被匯入時,包名就成為了訪問其內容的修飾符。在

import "bytes"

匯入包可以使用 bytes.Buffer。如果所有使用該包的人都可以使用相同的名稱來引用其內容,這將很有幫助,這意味著包名應該良好:短小、簡潔、富有啟發性。按照慣例,包使用小寫、單字名稱;不應使用下劃線或駝峰命名法。傾向於簡潔,因為每個使用你的包的人都將輸入這個名稱。並且不要預先擔心衝突。包名只是匯入的預設名稱;它不需要在所有原始碼中都是唯一的,在極少數衝突情況下,匯入包可以選擇一個不同的名稱在本地使用。無論如何,混淆很少發生,因為匯入中的檔名決定了正在使用哪個包。

另一個慣例是,包名是其源目錄的基本名稱;src/encoding/base64 中的包被匯入為 "encoding/base64",但其名稱是 base64,而不是 encoding_base64encodingBase64

包的匯入者將使用該名稱來引用其內容,因此包中匯出的名稱可以利用這一事實來避免重複。(不要使用 import . 這種符號,它可能會簡化必須在其測試包之外執行的測試,但除此之外應避免使用。)例如,bufio 包中的緩衝讀取器型別被命名為 Reader,而不是 BufReader,因為使用者將其視為 bufio.Reader,這是一個清晰、簡潔的名稱。此外,由於匯入的實體總是透過其包名來定址,bufio.Reader 不會與 io.Reader 衝突。同樣,用於建立 ring.Ring 新例項的函式——這是 Go 中“建構函式”的定義——通常會被命名為 NewRing,但由於 Ring 是該包唯一匯出的型別,並且該包名為 ring,它被簡單地命名為 New,包的客戶端將其視為 ring.New。利用包的結構來幫助你選擇好的名稱。

另一個簡短的例子是 once.Doonce.Do(setup) 讀起來很順暢,寫成 once.DoOrWaitUntilDone(setup) 也不會更好。長名稱並不能自動提高可讀性。一個有用的文件註釋通常比一個超長名稱更有價值。

Getter 方法

Go 不提供對 getter 和 setter 的自動支援。自己提供 getter 和 setter 並沒有錯,而且通常也很合適,但將 Get 放入 getter 的名稱中既不符合 Go 慣用法,也不是必需的。如果你有一個名為 owner(小寫,未匯出)的欄位,那麼 getter 方法應該命名為 Owner(大寫,已匯出),而不是 GetOwner。使用大寫名稱進行匯出提供了區分欄位和方法的鉤子。如果需要 setter 函式,它很可能被命名為 SetOwner。這兩個名稱在實踐中都易於閱讀

owner := obj.Owner()
if owner != user {
    obj.SetOwner(user)
}

介面名稱

按照慣例,單方法介面的命名方式是:方法名加上“-er”字尾或類似的修飾,以構成一個施動名詞:ReaderWriterFormatterCloseNotifier 等。

有許多這樣的名稱,遵守它們及其所捕獲的函式名是富有成效的。ReadWriteCloseFlushString 等都具有規範的簽名和含義。為避免混淆,除非你的方法具有相同的簽名和含義,否則不要給它起這些名稱。反之,如果你的型別實現的方法與某個知名型別上的方法具有相同的含義,則給它相同的名稱和簽名;將你的字串轉換方法命名為 String,而不是 ToString

駝峰命名

最後,Go 的慣例是使用 MixedCapsmixedCaps 而不是下劃線來編寫多詞名稱。

分號

與 C 語言類似,Go 的正式語法使用分號來終止語句,但與 C 語言不同的是,這些分號不會出現在原始碼中。相反,詞法分析器在掃描時使用一個簡單的規則自動插入分號,因此輸入文字中大部分都沒有分號。

規則如下:如果換行符前的最後一個詞法單元是識別符號(包括 intfloat64 等關鍵字)、基本字面量(例如數字或字串常量),或下列詞法單元之一

break continue fallthrough return ++ -- ) }

詞法分析器總是在該詞法單元后面插入一個分號。這可以總結為:“如果換行符出現在可能結束一個語句的詞法單元之後,就插入一個分號”。

分號也可以在緊接右大括號之前省略,所以像這樣一條語句

    go func() { for { dst <- <-src } }()

不需要分號。地道的 Go 程式只在 for 迴圈子句等地方使用分號,用於分隔初始化器、條件和延續元素。如果你以這種方式編寫程式碼,它們也需要分隔同一行上的多個語句。

分號插入規則的一個結果是,你不能將控制結構(ifforswitchselect)的左大括號放在下一行。如果你這樣做,分號將插入在大括號之前,這可能會導致意外效果。像這樣編寫它們

if i < f() {
    g()
}

而不是這樣

if i < f()  // wrong!
{           // wrong!
    g()
}

控制結構

Go 的控制結構與 C 語言的控制結構相關,但在重要方面有所不同。Go 沒有 dowhile 迴圈,只有一個稍微泛化的 for 迴圈;switch 更靈活;ifswitch 接受一個可選的初始化語句,類似於 for 迴圈的初始化語句;breakcontinue 語句接受一個可選的標籤來標識要中斷或繼續的迴圈;並且有新的控制結構,包括型別開關和多路通訊多路複用器 select。語法也略有不同:沒有括號,並且程式碼塊必須始終用大括號括起來。

If

在 Go 中,一個簡單的 if 語句看起來像這樣

if x > 0 {
    return y
}

強制性的花括號鼓勵將簡單的 if 語句寫在多行上。無論如何,這樣做都是一個好習慣,特別是當主體包含控制語句(如 returnbreak)時。

由於 ifswitch 接受初始化語句,因此常見的是使用它來設定區域性變數。

if err := file.Chmod(0664); err != nil {
    log.Print(err)
    return err
}

在 Go 庫中,你會發現當 if 語句不流向下一個語句時——也就是說,主體以 breakcontinuegotoreturn 結束時——不必要的 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)

此語句聲明瞭兩個變數:ferr。幾行之後,呼叫 f.Stat 的程式碼是:

d, err := f.Stat()

這看起來好像聲明瞭 derr。但請注意,err 出現在兩個語句中。這種重複是合法的:err 由第一個語句宣告,但在第二個語句中只是被重新賦值。這意味著呼叫 f.Stat 使用了上面宣告的現有 err 變數,並只是給它賦了一個新值。

:= 宣告中,即使變數 v 已經宣告,也可以再次出現,前提是:

這種不尋常的特性純粹是實用主義,使得在長 if-else 鏈中輕鬆使用單個 err 值成為可能。你會經常看到它被使用。

§ 值得注意的是,在 Go 中,函式引數和返回值的範圍與函式體相同,儘管它們在詞法上出現在包含函式體的大括號之外。

For

Go 的 for 迴圈與 C 語言的類似——但不完全相同。它統一了 forwhile,並且沒有 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
}

如果遍歷陣列、切片、字串或對映,或者從通道讀取資料,range 子句可以管理迴圈。

for key, value := range oldMap {
    newMap[key] = value
}

如果你只需要 range 中的第一項(鍵或索引),則省略第二項

for key := range m {
    if key.expired() {
        delete(m, key)
    }
}

如果你只需要 range 中的第二項(值),請使用空白識別符號(下劃線)來丟棄第一項

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 進行切換。因此,將 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 在表示式中聲明瞭一個變數,則該變數將在每個子句中具有相應的型別。在這種情況下重用名稱也是慣用的做法,實際上是在每個 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 程式中一些笨拙的慣用法:帶內錯誤返回(例如 EOF-1 表示)和修改透過地址傳遞的引數。

在 C 語言中,寫入錯誤透過負數計數和隱藏在易失位置的錯誤碼來表示。在 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 語句安排一個函式呼叫(被延遲的函式)在執行 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 執行時求值,而不是在 call 執行時求值。除了避免擔心函式執行時變數值發生變化外,這意味著單個延遲呼叫站點可以延遲多個函式執行。這是一個愚蠢的例子。

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 可能看起來很特殊,但其最有趣和最強大的應用正是來自於它不是基於塊而是基於函式的事實。在 panicrecover 一節中,我們將看到其可能性的另一個例子。

資料

使用 new 分配

Go 有兩個分配原語,內建函式 newmake。它們做不同的事情並應用於不同的型別,這可能會令人困惑,但規則很簡單。我們先談談 new。它是一個內建函式,用於分配記憶體,但與某些其他語言中同名的函式不同,它不初始化記憶體,它只將其置零。也就是說,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 的值在分配或僅聲明後也立即可用。在下面的程式碼片段中,pv 都無需進一步安排即可正常工作。

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
}

其中有很多樣板程式碼。我們可以使用複合字面量來簡化它,複合字面量是一個每次求值都會建立新例項的表示式。

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}

複合字面量的欄位按順序排列,並且必須全部存在。但是,透過將元素明確標記為欄位:對,初始化器可以以任何順序出現,缺失的元素將保留其各自的零值。因此我們可以說

    return &File{fd: fd, name: name}

作為一種極端情況,如果複合字面量不包含任何欄位,它將為該型別建立一個零值。表示式 new(File)&File{} 是等價的。

複合字面量也可以為陣列、切片和對映建立,欄位標籤分別為索引或對映鍵。在這些示例中,初始化工作與 EnoneEioEinval 的值無關,只要它們是不同的。

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) 不同。它只建立切片、對映和通道,並且它返回一個已初始化(而不是清零)的型別 T 的值(而不是 *T)。這種區別的原因是這三種類型在底層表示對資料結構的引用,這些資料結構在使用前必須進行初始化。例如,一個切片是一個包含指向資料(在陣列內部)的指標、長度和容量的三項描述符,在這些項被初始化之前,切片是 nil。對於切片、對映和通道,make 初始化內部資料結構並準備好值以供使用。例如,

make([]int, 10, 100)

分配一個包含 100 個 int 值的陣列,然後建立一個切片結構,其長度為 10,容量為 100,指向陣列的前 10 個元素。(建立切片時,容量可以省略;有關更多資訊,請參閱切片部分。)相反,new([]int) 返回一個指向新分配的、已清零的切片結構的指標,也就是說,一個指向 nil 切片值的指標。

這些例子說明了 newmake 之間的區別。

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 僅適用於對映、切片和通道,並且不返回指標。要獲取顯式指標,請使用 new 進行分配或顯式獲取變數的地址。

陣列

陣列在規劃記憶體的詳細布局時很有用,有時可以幫助避免分配,但它們主要用作切片的構建塊,切片是下一節的主題。為了為該主題奠定基礎,這裡有幾句關於陣列的話。

Go 和 C 中陣列的工作方式存在重大差異。在 Go 中,

值屬性可能很有用但也可能很昂貴;如果你想要類似 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 語言。請改用切片。

切片

切片封裝了陣列,為資料序列提供了更通用、更強大、更方便的介面。除了具有明確維度(如變換矩陣)的專案外,Go 中的大多數陣列程式設計都使用切片而不是簡單的陣列。

切片持有對底層陣列的引用,如果你將一個切片賦值給另一個切片,兩者都引用同一個陣列。如果函式接受一個切片引數,它對切片元素所做的更改將對呼叫者可見,類似於傳遞指向底層陣列的指標。因此,Read 函式可以接受一個切片引數而不是一個指標和一個計數;切片內的長度設定了要讀取的最大資料量。這是 os 包中 File 型別的 Read 方法的簽名

func (f *File) Read(buf []byte) (n int, err error)

該方法返回讀取的位元組數和任何錯誤值。要讀取到更大的緩衝區 buf 的前 32 個位元組中,請切片(這裡用作動詞)緩衝區。

    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 獲取)報告切片可能達到的最大長度。這是一個向切片附加資料的函式。如果資料超出容量,則重新分配切片。返回結果切片。該函式利用了 lencap 在應用於 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:]
}

對映

對映是一種方便而強大的內建資料結構,它將一種型別的值()與另一種型別的值(元素)關聯起來。鍵可以是定義了相等運算子的任何型別,例如整數、浮點數和複數、字串、指標、介面(只要動態型別支援相等性)、結構體和陣列。切片不能用作對映鍵,因為它們未定義相等性。與切片一樣,對映持有對底層資料結構的引用。如果你將對映傳遞給修改對映內容的函式,則更改將在呼叫者中可見。

對映可以使用常見的複合字面量語法(帶冒號分隔的鍵值對)來構造,因此在初始化期間很容易構建它們。

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,
}

賦值和獲取對映值的語法看起來與陣列和切片相同,只是索引不必是整數。

offset := timeZone["EST"]

嘗試使用對映中不存在的鍵獲取對映值將返回對映中條目型別的零值。例如,如果對映包含整數,查詢不存在的鍵將返回 0。集合可以實現為值型別為 bool 的對映。將對映條目設定為 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" 有條目嗎?還是因為它根本不在對映中所以是 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
}

要在不關心實際值的情況下測試對映中是否存在,你可以使用空白識別符號 (_) 代替通常用於值變數的識別符號。

_, present := timeZone[tz]

要刪除對映條目,請使用內建函式 delete,其引數是對映和要刪除的鍵。即使鍵已從對映中缺失,執行此操作也是安全的。

delete(timeZone, "PDT")  // Now on Standard Time

列印

Go 中的格式化列印使用類似於 C 語言 printf 系列的風格,但更豐富、更通用。這些函式位於 fmt 包中,並使用大寫名稱:fmt.Printffmt.Fprintffmt.Sprintf 等。字串函式(Sprintf 等)返回一個字串,而不是填充提供的緩衝區。

你不需要提供格式字串。對於 PrintfFprintfSprintf 的每一個,都有另外一對函式,例如 PrintPrintln。這些函式不接受格式字串,而是為每個引數生成一個預設格式。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.Stdoutos.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(表示“值”);結果與 PrintPrintln 生成的完全相同。此外,這種格式可以列印任何值,甚至是陣列、切片、結構體和對映。這是上一節中定義的時區對映的列印語句。

fmt.Printf("%v\n", timeZone)  // or just fmt.Println(timeZone)

輸出為

map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]

對於對映,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 格式也適用於整數和符文,生成一個單引號符文常量。)此外,%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 的接收者必須是值型別;這個例子使用指標是因為對於結構體型別來說,這種方式更高效且符合慣例。有關更多資訊,請參閱下面關於指標與值接收者的部分。)

我們的 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 型別。

初始化

儘管 Go 中的初始化在表面上與 C 或 C++ 中的初始化看起來沒有太大區別,但它更強大。可以在初始化期間構建複雜的結構,並且正確處理了已初始化物件之間的排序問題,甚至不同包之間的問題。

常量

Go 中的常量就是常量。它們在編譯時建立,即使在函式中定義為區域性變數,並且只能是數字、字元(rune)、字串或布林值。由於編譯時限制,定義它們的表示式必須是常量表達式,可由編譯器求值。例如,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 來實現 ByteSizeString 方法是安全的(避免無限遞迴),不是因為型別轉換,而是因為它使用 %f 呼叫 Sprintf,而 %f 不是字串格式: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")
}

方法

指標與值

正如我們在 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。關於接收者是使用指標還是值的規則是:值方法可以在指標和值上呼叫,但指標方法只能在指標上呼叫。

這條規則之所以產生,是因為指標方法可以修改接收者;如果在值上呼叫它們,方法將接收到值的副本,因此任何修改都將被丟棄。因此,語言禁止這種錯誤。然而,有一個方便的例外。當值是可定址的時,語言會透過自動插入地址運算子來處理在值上呼叫指標方法的常見情況。在我們的示例中,變數 b 是可定址的,因此我們只需用 b.Write 即可呼叫其 Write 方法。編譯器會將其重寫為 (&b).Write

順便說一下,在位元組切片上使用 Write 的想法是 bytes.Buffer 實現的核心。

介面及其他型別

介面

Go 中的介面提供了一種指定物件行為的方式:如果某個東西能做這件事,那麼它就可以在這裡使用。我們已經看到了一些簡單的例子;自定義印表機可以透過 String 方法實現,而 Fprintf 可以向任何具有 Write 方法的物件生成輸出。在 Go 程式碼中,只有一兩個方法的介面很常見,並且通常以方法名加上派生詞命名,例如實現 Write 的物件稱為 io.Writer

一個型別可以實現多個介面。例如,如果一個集合實現了 sort.Interface(包含 Len()Less(i, j int) boolSwap(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 + "]"
}

型別轉換

SequenceString 方法正在重複 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 實現多個介面(排序和列印),而是利用資料項可以轉換為多種型別(Sequencesort.IntSlice[]int)的能力,每種型別都完成部分工作。這在實踐中更不常見,但可以很有效。

介面轉換和型別斷言

型別開關是一種轉換形式:它們接受一個介面,並在開關的每個 case 中,以某種方式將其轉換為該 case 的型別。這是 fmt.Printf 下的程式碼如何使用型別開關將值轉換為字串的簡化版本。如果它已經是字串,我們想要介面持有的實際字串值,而如果它有一個 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 的型別開關可以做到,但是型別斷言也可以做到。型別斷言接受一個介面值,並從中提取指定顯式型別的值。語法借鑑了型別開關的子句開頭,但是使用顯式型別而不是 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 語句,它等同於本節開頭介紹的型別開關。

if str, ok := value.(string); ok {
    return str
} else if str, ok := value.(Stringer); ok {
    return str.String()
}

通用性

如果一個型別僅為了實現介面而存在,並且除了該介面之外永遠不會有匯出的方法,則無需匯出該型別本身。僅匯出介面可以清楚地表明該值除了介面中描述的行為之外沒有其他有趣的行 為。它還避免了在每個公共方法例項上重複文件的需要。

在這種情況下,建構函式應該返回一個介面值而不是實現型別。例如,在雜湊庫中,crc32.NewIEEEadler32.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 的訪問需要防止併發訪問。有關建議,請參閱 syncatomic 包。

作為參考,以下是如何將此類伺服器連線到 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 迴圈對映的上下文中。空白識別符號可以被賦值或宣告為任何型別的值,其值被無害地丟棄。它有點像寫入 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)
}

未使用的匯入和變數

匯入包或宣告變數而不使用它是錯誤的。未使用的匯入會使程式膨脹並減慢編譯速度,而初始化但未使用的變數至少是一種浪費的計算,並且可能預示著一個更大的 bug。然而,在程式積極開發期間,未使用的匯入和變數經常出現,為了讓編譯繼續進行而刪除它們,然後又在稍後再次需要它們,這可能會令人煩惱。空白識別符號提供了一種解決方案。

這個半成品程式有兩個未使用的匯入(fmtio)和一個未使用的變數(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
}

按照慣例,消除匯入錯誤的全域性宣告應緊隨匯入之後並添加註釋,這既是為了方便查詢,也是為了提醒以後清理。

為了副作用而匯入

上例中像 fmtio 這樣的未使用匯入最終應該被使用或移除:空白賦值表明程式碼仍在開發中。但有時為了副作用而匯入包,而沒有任何顯式使用,這很有用。例如,在 init 函式中,net/http/pprof 包註冊 HTTP 處理程式以提供除錯資訊。它有一個匯出的 API,但大多數客戶端只需要處理程式註冊並透過網頁訪問資料。為了僅為了副作用而匯入包,將包重新命名為空白識別符號

import _ "net/http/pprof"

這種匯入形式清楚地表明該包是為了其副作用而匯入的,因為該包沒有其他可能的用途:在此檔案中,它沒有名稱。(如果它有名稱,而我們沒有使用該名稱,編譯器將拒絕該程式。)

介面檢查

正如我們在上面關於介面的討論中看到的,型別不需要明確宣告它實現了介面。相反,型別只需實現介面的方法即可實現介面。實際上,大多數介面轉換都是靜態的,因此在編譯時進行檢查。例如,將 *os.File 傳遞給期望 io.Reader 的函式將無法編譯,除非 *os.File 實現了 io.Reader 介面。

然而,有些介面檢查確實在執行時發生。一個例子是在 encoding/json 包中,它定義了 Marshaler 介面。當 JSON 編碼器接收到一個實現該介面的值時,編碼器會呼叫該值的 marshaling 方法將其轉換為 JSON,而不是執行標準轉換。編碼器在執行時透過型別斷言檢查此屬性,例如

m, ok := val.(json.Marshaler)

如果只需要詢問一個型別是否實現了介面,而實際上不使用介面本身(可能是作為錯誤檢查的一部分),請使用空白識別符號來忽略型別斷言的值

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 介面發生變化,此包將不再編譯,我們將收到通知,需要對其進行更新。

在此構造中空白識別符號的出現表明該宣告僅用於型別檢查,而不是建立變數。但是,不要對每個滿足介面的型別都這樣做。按照慣例,此類宣告僅在程式碼中不存在靜態轉換時使用,這種情況很少見。

嵌入

Go 不提供典型的、型別驅動的子類概念,但它確實能夠透過在結構體或介面中嵌入型別來“借用”實現片段。

介面嵌入非常簡單。我們之前已經提到過 io.Readerio.Writer 介面;這裡是它們的定義。

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

io 包還匯出了其他幾個介面,它們指定了可以實現多個此類方法的物件。例如,有 io.ReadWriter,一個包含 ReadWrite 的介面。我們可以透過顯式列出這兩個方法來指定 io.ReadWriter,但更簡單、更具啟發性的方法是嵌入這兩個介面以形成新的介面,如下所示

// ReadWriter is the interface that combines the Reader and Writer interfaces.
type ReadWriter interface {
    Reader
    Writer
}

這正說明了它的意思:一個 ReadWriter 既可以做 Reader 所做的事情,也可以做 Writer 所做的事情;它是嵌入介面的並集。只有介面可以嵌入介面。

同樣的基本思想也適用於結構體,但影響更為深遠。bufio 包有兩個結構體型別,bufio.Readerbufio.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.Readerbufio.Writer 的方法,它還滿足所有三個介面:io.Readerio.Writerio.ReadWriter

嵌入與子類化有一個重要的區別。當我們嵌入一個型別時,該型別的方法成為外部型別的方法,但當它們被呼叫時,方法的接收者是內部型別,而不是外部型別。在我們的例子中,當呼叫 bufio.ReadWriterRead 方法時,它的效果與上面寫出的轉發方法完全相同;接收者是 ReadWriterreader 欄位,而不是 ReadWriter 本身。

嵌入也可以僅僅是一種方便。這個例子展示了一個嵌入欄位與一個常規的、命名欄位並存的情況。

type Job struct {
    Command string
    *log.Logger
}

Job 型別現在擁有 *log.LoggerPrintPrintfPrintln 和其他方法。當然,我們可以給 Logger 一個欄位名,但沒必要這樣做。現在,一旦初始化,我們就可以記錄到 Job

job.Println("starting now...")

LoggerJob 結構體的一個常規欄位,所以我們可以像這樣在 Job 的建構函式中以通常的方式初始化它:

func NewJob(command string, logger *log.Logger) *Job {
    return &Job{command, logger}
}

或者使用複合字面量,

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 的欄位或方法,那麼 JobCommand 欄位將優先。

其次,如果同一名稱出現在同一巢狀級別,通常是一個錯誤;如果 Job 結構體包含另一個名為 Logger 的欄位或方法,則嵌入 log.Logger 將是錯誤的。但是,如果重複的名稱在型別定義之外的程式中從未被提及,那麼它是允許的。這種限定為防止外部嵌入型別發生更改提供了一些保護;如果添加了一個與另一個子型別中的另一個欄位衝突的欄位,並且這兩個欄位都從未使用過,則沒有問題。

併發

透過通訊共享

併發程式設計是一個很大的主題,這裡只介紹一些 Go 特有的亮點。

在許多環境中,併發程式設計之所以困難,是因為實現對共享變數的正確訪問需要精妙的技巧。Go 鼓勵一種不同的方法,即共享值在通道上傳遞,並且實際上從不被獨立的執行執行緒主動共享。在任何給定時間,只有一個 Goroutine 可以訪問該值。按設計,資料競爭不會發生。為了鼓勵這種思維方式,我們將其總結為一句口號:

不要透過共享記憶體來通訊;相反,透過通訊來共享記憶體。

這種方法可能有點過頭了。例如,引用計數最好透過在整數變數周圍放置一個互斥鎖來完成。但是作為一種高階方法,使用通道來控制訪問可以更容易地編寫清晰、正確的程式。

思考這個模型的一種方法是考慮一個在單個 CPU 上執行的典型單執行緒程式。它不需要同步原語。現在執行另一個這樣的例項;它也不需要同步。現在讓這兩個例項進行通訊;如果通訊是同步器,那麼仍然不需要其他同步。例如,Unix 管道完美地符合這個模型。儘管 Go 的併發方法起源於 Hoare 的通訊順序程序(CSP),但它也可以被看作是 Unix 管道的一種型別安全泛化。

Goroutines

它們被稱為 *goroutines*,因為現有術語——執行緒、協程、程序等等——傳達了不準確的含義。Goroutine 有一個簡單的模型:它是一個在同一地址空間中與其他 goroutine 併發執行的函式。它是輕量級的,開銷僅僅是分配棧空間。並且棧一開始很小,所以很便宜,並根據需要透過分配(和釋放)堆儲存來增長。

Goroutines 被多路複用到多個作業系統執行緒上,因此如果其中一個阻塞(例如等待 I/O),其他 goroutine 將繼續執行。它們的設計隱藏了執行緒建立和管理的許多複雜性。

在函式或方法呼叫前加上 go 關鍵字,即可在新的 goroutine 中執行該呼叫。當呼叫完成時,該 goroutine 會默默地退出。(效果類似於 Unix shell 中用於在後臺執行命令的 & 符號。)

go list.Sort()  // run list.Sort concurrently; don't wait for it.

函式字面量在 goroutine 呼叫中非常方便。

func Announce(message string, delay time.Duration) {
    go func() {
        time.Sleep(delay)
        fmt.Println(message)
    }()  // Note the parentheses - must call the function.
}

在 Go 中,函式字面量是閉包:其實現確保函式引用的變數在其活動期間持續存在。

這些示例不太實用,因為函式無法發出完成訊號。為此,我們需要通道。

通道

與對映一樣,通道也是使用 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

無緩衝通道將通訊(值的交換)與同步(保證兩個計算(goroutine)處於已知狀態)結合起來。

通道有很多不錯的用法。這裡有一個入門示例。在上一節中,我們在後臺啟動了一個排序。通道可以允許啟動 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.

接收者總是阻塞,直到有資料可接收。如果通道是無緩衝的,傳送者會阻塞,直到接收者收到值。如果通道有緩衝區,傳送者只會阻塞,直到值被複制到緩衝區;如果緩衝區已滿,這意味著要等待直到某個接收者取回一個值。

帶緩衝的通道可以像訊號量一樣使用,例如限制吞吐量。在這個例子中,傳入的請求被傳遞給 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 個 goroutine 可以執行。結果是,如果請求來得太快,程式可能會消耗無限資源。我們可以透過修改 Serve 來限制 goroutine 的建立,從而解決這個缺陷

func Serve(queue chan *Request) {
    for req := range queue {
        sem <- 1
        go func() {
            process(req)
            <-sem
        }()
    }
}

(請注意,在 Go 1.22 之前的版本中,這段程式碼存在一個錯誤:迴圈變數在所有 goroutine 中共享。詳情請參閱 Go wiki。)

另一種管理資源的有效方法是啟動固定數量的 handle goroutine,它們都從請求通道讀取。goroutine 的數量限制了對 process 的同時呼叫次數。這個 Serve 函式也接受一個通道,它將透過該通道被告知退出;在啟動 goroutine 後,它會阻塞並從該通道接收。

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 最重要的特性之一是通道是一個第一類值,可以像其他任何值一樣被分配和傳遞。這個特性的一個常見用途是實現安全、並行的解複用。

在上一節的示例中,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 系統的框架,並且沒有看到一個互斥鎖。

並行化

這些想法的另一個應用是將計算並行化到多個 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 一個。它們可以以任何順序完成,但這並不重要;我們只需在啟動所有 goroutine 後透過排空通道來計算完成訊號。

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 環境變數或以正數呼叫該函式來覆蓋。以零呼叫它只會查詢該值。因此,如果我們要尊重使用者的資源請求,我們應該這樣寫:

var numCPU = runtime.GOMAXPROCS(0)

務必不要混淆併發(將程式構造為獨立執行的元件)和並行(為了效率在多個 CPU 上並行執行計算)的概念。儘管 Go 的併發特性可以使一些問題很容易構造為平行計算,但 Go 是一種併發語言,而不是並行語言,並且並非所有並行化問題都符合 Go 的模型。有關區別的討論,請參閱這篇部落格文章中引用的演講。

一個有漏的緩衝區

併發程式設計的工具甚至可以使非併發的概念更容易表達。這裡有一個從 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 子句在沒有其他情況就緒時執行,這意味著 selects 永遠不會阻塞。)此實現僅用幾行程式碼就構建了一個“漏桶”空閒列表,依靠帶緩衝的通道和垃圾回收器進行簿記。

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

PathErrorError 生成的字串如下所示:

open /etc/passwx: no such file or directory

這種錯誤,包含了有問題的檔名、操作以及它觸發的作業系統錯誤,即使在遠離引起它的呼叫處列印也很有用;它比簡單的“沒有此類檔案或目錄”資訊量大得多。

在可行的情況下,錯誤字串應識別其來源,例如透過在錯誤生成的操作或包名稱前加上字首。例如,在 image 包中,由於未知格式導致的解碼錯誤的字串表示為 "image: unknown format"。

關心精確錯誤細節的呼叫者可以使用型別開關或型別斷言來查詢特定錯誤並提取細節。對於 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。如果問題可以被掩蓋或解決,讓事情繼續執行總是比關閉整個程式更好。一個可能的反例是初始化期間:如果庫確實無法設定自身,那麼“恐慌”可能是合理的。

var user = os.Getenv("USER")

func init() {
    if user == "" {
        panic("no value for $USER")
    }
}

恢復(Recover)

當呼叫 panic 時,包括由於執行時錯誤(例如切片索引越界或型別斷言失敗)而隱式呼叫時,它會立即停止當前函式的執行,並開始解開 goroutine 的棧,沿途執行任何延遲函式。如果解開過程到達 goroutine 棧的頂部,程式就會終止。但是,可以使用內建函式 recover 來重新獲得 goroutine 的控制權並恢復正常執行。

呼叫 recover 會停止棧展開,並返回傳遞給 panic 的引數。因為在棧展開期間唯一執行的程式碼在延遲函式內部,所以 recover 只在延遲函式內部有用。

recover 的一個應用是在伺服器內部關閉一個失敗的 goroutine,而不殺死其他正在執行的 goroutine。

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 將乾淨地退出,而不會干擾其他 goroutine。在延遲閉包中無需做其他任何事情;呼叫 recover 可以完全處理這種情況。

因為 recover 除非直接從延遲函式呼叫,否則總是返回 nil,所以延遲程式碼可以呼叫那些本身使用 panicrecover 的庫例程而不會失敗。例如,safelyDo 中的延遲函式可以在呼叫 recover 之前呼叫日誌函式,並且該日誌程式碼將在不受 panic 狀態影響的情況下執行。

有了我們的恢復模式,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。如果不是,型別斷言將失敗,導致執行時錯誤,繼續棧展開,就像沒有任何中斷一樣。這個檢查意味著,如果發生意想不到的事情,例如索引越界,即使我們使用 panicrecover 來處理解析錯誤,程式碼也會失敗。

有了錯誤處理,error 方法(因為它是一個繫結到型別的方法,所以它與內建的 error 型別同名是很好的,甚至是自然的)使得報告解析錯誤變得容易,而無需手動處理解析棧的展開。

if pos == 0 {
    re.error("'*' illegal at start of expression")
}

儘管這種模式很有用,但它應該只在包內部使用。Parse 將其內部的 panic 呼叫轉換為 error 值;它不會將 panic 暴露給其客戶端。這是一個很好的遵循規則。

順便說一下,如果發生實際錯誤,這種重新恐慌(re-panic)的慣用法會改變恐慌值。然而,原始的和新的故障都將呈現在崩潰報告中,因此問題的根本原因仍然可見。因此,這種簡單的重新恐慌方法通常是足夠的——畢竟這是一次崩潰——但如果你只想顯示原始值,你可以編寫更多的程式碼來過濾意外問題並用原始錯誤重新恐慌。這留作讀者的練習。

一個 Web 伺服器

最後,我們來看一個完整的 Go 程式——一個 Web 伺服器。這個伺服器實際上是一種 Web 中繼伺服器。Google 在 chart.apis.google.com 提供了一項服務,可以將資料自動格式化為圖表。然而,互動式使用它很困難,因為你需要將資料作為查詢放入 URL。這裡的程式為一種資料形式提供了一個更好的介面:給定一小段文字,它呼叫圖表伺服器生成一個二維碼,一個編碼文字的方塊矩陣。這個影像可以透過你的手機攝像頭捕捉並解釋為,例如,一個 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 函式的部分應該很容易理解。那個標誌為我們的伺服器設定了一個預設的 HTTP 埠。模板變數 templ 是有趣的部分。它構建了一個 HTML 模板,伺服器將執行該模板來顯示頁面;稍後會詳細介紹。

main 函式解析標誌,並使用我們上面討論的機制,將 QR 函式繫結到伺服器的根路徑。然後呼叫 http.ListenAndServe 來啟動伺服器;它在伺服器執行期間會阻塞。

QR 只是接收包含表單資料的請求,並根據表單值中名為 s 的資料執行模板。

html/template 模板包功能強大;這個程式只涉及其功能的一小部分。本質上,它透過替換從傳遞給 templ.Execute 的資料項(在本例中是表單值)派生的元素,即時重寫一段 HTML 文字。在模板文字(templateStr)中,雙大括號分隔的部分表示模板操作。從 {{if .}}{{end}} 的部分僅噹噹前資料項(稱為 . (點))的值非空時執行。也就是說,當字串為空時,模板的這部分會被抑制。

兩個片段 {{.}} 表示在網頁上顯示呈現給模板的資料——查詢字串。HTML 模板包會自動提供適當的轉義,以便文字可以安全顯示。

模板字串的其餘部分只是頁面載入時要顯示的 HTML。如果這個解釋太快,請參閱模板包的文件以獲取更詳細的討論。

就這樣:一個有用的 Web 伺服器,只需幾行程式碼加上一些資料驅動的 HTML 文字。Go 的強大足以讓很多事情在幾行程式碼中實現。