Effective Go

簡介

Go 是一種新的語言。雖然它從現有的語言中借用概念,但它有獨特的特性,使有效的 Go 程式不同於由其親屬所撰寫的程式。直接將 C++ 或 Java 程式轉換成 Go 不太可能產生令人滿意的結果,因為 Java 程式是使用 Java 撰寫的,而非 Go。但從 Go 的觀點來思考問題,可能會產生一種成功但截然不同的程式。換言之,若要寫出好的 Go 程式,瞭解其特性和慣用語十分重要。深入瞭解 Go 程式設計中既定的慣例(例如命名、格式設定、程式建構等),也很重要,這樣您編寫的程式才能讓其他 Go 程式設計人員容易理解。

本文提供了撰寫清楚且慣用語的 Go 程式碼的提示。它補充了 語言規範Go 導覽如何撰寫 Go 程式碼,而您應先閱讀所有這些內容。

2022 年 1 月新增備註:本文件為 2009 年 Go 發佈時所撰寫,自此之後並未更新多少。儘管本文件有助於瞭解該程式語言本身的使用方式,但由於該語言的穩定性,其對於函式庫的說明相當少,且對於撰寫完成後 Go 生態體系中的重大變更(例如建置系統、測試、模組和多型性)也未多加說明。由於已發生許多事情,且數量龐大且持續增加的文件、部落格和書籍皆精確地描述了現代 Go 的用法,因此沒有任何更新計畫。Effective Go 仍具有用處,但讀者應了解該文件遠非完整指南。參閱 第 28782 號問題 以瞭解背景資訊。

範例

Go 套件程式碼 不但可用作核心函式庫,也能作為程式語言使用方式的範例。此外,許多套件包含可運作且獨立的執行範例,你可以直接從 go.dev 網站執行,例如 這個範例(如需用,請點選「範例」開啟)。如果你對於如何處理問題或執行某些操作的方式存有疑問,函式庫的說明文件、程式碼和範例提供解答、點子和背景資訊。

格式化

格式化問題具爭議性,但影響力卻最少。人們可以適應不同的格式化樣式,但若不必適應就更好,而且,如果每個人都遵循相同的樣式,便不必將時間浪費於此議題。問題在於,沒有具備規範性指導方針,要如何朝這個烏托邦邁進。

對於 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 預設會輸出 tab。如果你必須使用,請只使用空白。
行長
Go 沒有行長限制。不必擔心超過穿孔卡片的寬度。如果覺得某行太長,請加入換行符號,並縮排一個 tab。
括號
Go 需要比 C 和 Java 更少的圓括號:控制結構 (ifforswitch) 的語法中不包含圓括號。此外,運算式優先順序階層較短且清楚,因此
x<<8 + y<<16
表示的含意等於間距暗示的含意,這與其他語言不同。

說明

Go 提供 C 式的 /* */ 塊狀註解和 C++ 式的 // 程式碼註解。通常使用程式碼註解;塊狀註解大多作為套件註解出現,但可用於表示式中或停用程式碼的大量片段。

頂層宣告前出現的註解,中間沒有新行,會被視為宣告本身的說明文件。這些「文件註解」是特定 Go 套件或指令的主要說明文件。有關文件註解的詳細資料,請參閱「Go 文件註解」。

名稱

在 Go 中,名稱與在任何其他語言中一樣重要。它們甚至具有語意效果:名稱在套件外是否可見由其第一個字元是否為大寫來決定。因此,在 Go 程式中討論命名約定時值得花一點時間。

套件名稱

匯入套件時,套件名稱會變成內容的存取器。之後

import "bytes"

匯入的套件便能說明 bytes.Buffer。如果使用套件的每個人都能使用相同的名稱來表示其內容,會很有用,這表示套件名稱應該要好:簡短、精簡、具啟發性。依慣例,套件會給予小寫、單字的名稱;不需使用底線或混合大小寫。在簡潔度方面偏向保留,因為每位使用套件的人都會輸入該名稱。且不必擔心問題

套件名稱只是匯入的預設名稱;它不必在所有原始碼中都是唯一的,且在發生衝突的罕見情況下,匯入的套件可以選擇在當地使用不同的名稱。無論如何,困惑的情況很少,因為匯入中的檔案名稱會明確指出正在使用的套件。

封裝的匯入者會使用名稱來參考其內容,因此封裝中的匯出名稱可以使用此事實來避免重複。(不要使用 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) 加以改善。很長的名稱無法自動提高可讀性。有用的文件註解通常會比過於冗長的名稱更具價值。

取得函式

Go 未提供自動支援取得函式與設定函式。自行提供取得函式與設定函式並無不妥,且通常適宜如此,但是將 Get 放在取得函式的名稱中既不符合慣例,也不必要。如果您有稱為 owner 的欄位(小寫,未匯出),則取得函式方法應稱為 Owner(大寫,已匯出),而非 GetOwner。將大寫名稱用於匯出提供了辨別欄位與方法的標記。如果需要設定函式,則可能稱為 SetOwner。兩者名稱在實務上都讀取良好

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

介面名稱

根據慣例,單一方法介面以方法名稱加上 -er 副詞或類似的變更來命名,以建構代理名詞:ReaderWriterFormatterCloseNotifier 等。

此類名稱很多,並且採用它們及其擷取的方法名稱是有益的。ReadWriteCloseFlushString 等等具有標準簽章和意義。為了避免混淆,切勿將方法名稱指定為其中之一,除非具有相同的簽章和意義。相反地,如果您的類型實作的方法與已知類型上的方法具有相同的意義,請賦予它相同的名稱和簽章;將字串轉換器方法稱為 String,而非 ToString

混合大小寫

最後,Go 中的慣例是使用 MixedCapsmixedCaps 而不是底線符號來寫入多字名稱。

分號

與 C 類似,Go 的正式語法使用分號來終止句子,但與 C 不同的是,這些分號不會出現在原始碼中。取而代之的是,詞法分析器使用一條簡單的規則,在掃描時自動插入分號,因此輸入文字大多不含它們。

該規則如下。如果換行符號之前最後的 token 是標識符(包括字詞如 intfloat64)、一個基本文字,例如數字或字串常數,或以下 token 之一

break continue fallthrough return ++ -- ) }

詞法分析器會在 token 後面總是插入分號。這可以概括為:「如果換行符號出現在可以結束句子的 token 後面,則插入分號」。

分號也可以省略在閉合大括號之前,因此,像這樣的句子

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

不需要分號。慣用的 Go 程式只在 for 迴圈子句中使用分號,以分隔初始值、條件和延續元素。如果您按此方式寫程式碼,它們對於分隔多個句子也很有必要。

分號插入規則的一個後果是,您不能在下一行放控制結構的開啟大括號(ifforswitchselect)。如果您這樣做,將會在該大括號之前插入分號,這可能會導致不需要的效果。像這樣寫

if i < f() {
    g()
}

不要這樣寫

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

控制結構

Go 的控制結構與 C 的控制結構相關,但有重要的不同。沒有 dowhile 迴圈,只有一個稍為廣義的 forswitch 更靈活;ifswitch 接受像 for 那樣的初始化句子;breakcontinue 句子採用選擇性標籤來識別要中斷或繼續的項目;且有新的控制結構,包括類型切換和多路通訊多路復用器 select。語法也有點不同:沒有括號,主體必須總是以大括號分隔。

如果

在 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 可以出現,即使它已經宣告,只要:

這個 необы的屬性源自純粹的實用主義,讓你可以輕鬆使用一個單一的 err 值,例如在一個長的 if-else 鏈中。你會經常看到它被使用。

§ 這裡值得注意的是,在 Go 中函數參數和回傳值的範圍與函數主體相同,即使它們在字面上出現在函數主體外圍的花括號之外。

對於

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
}

如果你只需要範圍中的第一個項目(金鑰或索引),請捨棄第二個

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

如果你只需要範圍中的第二個項目(值),請使用空白識別碼,一個底線,來捨棄第一個

sum := 0
for _, value := range array {
    sum += value
}

空白識別碼有許多用途,後續一段中會描述 空白識別碼

對於字串,範圍可協助您完成更多工作,它會藉由剖析 UTF-8 來分解個別的 Unicode 編碼點。錯誤的編碼會採用一個位元組並產生替換符 U+FFFD。(名稱(搭配內建類型)符號是 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]
}

切換

Go 的 切換比 C 的更為普遍。運算式不一定是常數,甚至不一定是整數;系統會由上到下評估情況,直到找到符合的結果,且如果 切換沒有運算式,它就會切換為 true。因此,將 if-else-if-else 鏈結寫成 切換既有可能也是習慣用法。

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
}

沒有自動下降,但情況可以用逗號分隔的清單呈現。

func shouldEscape(c byte) bool {
    switch c {
    case ' ', '?', '&', '=', '#', '+', '%':
        return true
    }
    return false
}

儘管在 Go 中它們不像其他一些類似 C 的語言那麼常見,但 中斷陳述式可用於提早終止 切換。不過,有時候需要中斷外圍迴圈(而非切換),在 Go 中可以透過為迴圈加上標籤,並「中斷」到該標籤來完成此動作。本範例說明了兩種用法。

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

當然,繼續陳述式也接受選用的標籤,但它僅套用於迴圈。

為了結束本節,以下是使用兩個 切換陳述式的位元組切片的比較常式

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

類型切換

也可以使用切換來找出介面變數的動態類型。此類型的 類型切換使用類型斷言的句法,其括弧中含有關鍵字 類型。如果切換在運算式中宣告變數,變數會在每個子句中具有相應的類型。在這種情況下,習慣用法是用回相同的變數名稱,實際上會用相同的名稱在每個情況宣告一個具有不同類型的變數。

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 中,寫入錯誤是以錯誤碼報錯,錯誤碼保存在臨時位置。在 Go 中,Write 可以回傳一個計數 一個錯誤:「是的,你寫入了一些位元組,但不是全部,因為你填滿了裝置」。在封裝 os 中,檔案上的 Write 方法簽章如下

func (file *File) Write(b []byte) (n int, err error)

文件說明中說,如果 n != len(b),將回傳寫入的位元組數和一個非零的 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) {

因為具名結果會初始化並連結到未加修飾的回傳值,因此它們可以簡化並弄清楚。以下是一個使用具名結果的 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
}

延後

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` 可能看起來很奇怪,但是它最有趣和強大的應用程式正是來自於它不是區塊型而是函數型的。在 `panic` 和 `recover` 部分,我們可以看到另一個它的可能性範例。

資料

使用 `new` 配置

Go 有兩個配置基本函式,內建函式 `new` 和 `make`。它們執行不同的工作,並套用至不同的類型,這可能會令人困惑,但它的規則很簡單。讓我們先討論 `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` 的值在配置或宣告後也都可以立即使用。在以下程式碼片段中,`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
}

這裡有很多樣板程式碼。我們可以使用複合文字簡化它,複合文字是一個在每次評估時都會建立新實例的表達式。

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}

複合字面量的欄位以順序排列,而且必須都要有值。不過,只要明確將元素標記為欄位配對,初始值就可以採用任何順序,而缺少的欄位會保留為各自的 0 值。因此,我們可以表示

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

作為一個限制案例,如果複合字面量不包含任何欄位,就會建立一個該類型的 0 值。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 函數可以接受切片參數,而不是指標和計數;切片內的長度會設定要讀取多少資料的上限。以下是套件 osFile 類型的 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 存取,它會報告切片可能達到的最大長度。以下是將資料附加到切片的函數。如果資料超過容量,切片會重新配置。會傳回結果切片。函數會使用以下事實:當套用在 nil 切片時,lencap 是合法的,且會傳回 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 的陣列和切片是一維的。若要建立相當於 2D 陣列或切片的元素,您必須定義陣列的陣列或切片的切片,如下所示

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."),
}

有時有必要配置 2D 切片,例如處理掃描線畫素時便會出現這種情況。有兩種達成此目標的方法。一種方法是獨立配置每個切片;另一種方法是配置單一陣列並把個別切片指向該陣列。應該視您的應用程式而定要使用哪一種方法。如果切片可能會增長或縮減,您應該獨立配置它們以避免覆寫下一行;如果不會,使用單一配置來建構物件可能會較有效率。以下列出這兩種方法的摘要,供您參考。首先,逐行

// 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]

出於很明顯的原因,這稱為「comma 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}

(請注意插入符號。) 當應用於類型為字串或 []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 作為單個片段參數傳遞。

列印還有更多內容,超出我們在此探討的範圍。有關詳細資訊,請參閱套件 fmtgodoc 文件。

順帶一提,... 參數可以屬於特定類型,例如 ...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 函式不同。示意圖如下

func append(slice []T, elements ...T) []T

其中 T 是任何給定類型的佔位符。你無法在 Golang 中撰寫函式,讓 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++ 中的初始化並沒有太大的不同,但 Golang 中的初始化更強大。可以在初始化期間建立複雜結構,並正確處理不同套件中初始化的物件之間,甚至是不同套件之間的排序問題。

常數

Golang 中的常數就是常數。它們會在編譯時建立,即使在函式中定義為區域變數,而且只能是數字、字元(符文)、字串或布林值。由於編譯時間的限制,定義常數的運算式也必須是常數運算式,編譯器可以評估。例如,1<<3 是常數運算式,而 math.Sin(math.Pi/4) 則不是,因為呼叫 math.Sin 的函式需要在執行時發生。

在 Golang 中,使用 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`,`%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)的能力,每個型態完成一部分的工作。在實務中較不常見,但可能有效。

介面轉換和類型斷言

類型轉換 是一種類型的轉換:它們接受一個介面,並在轉換中於每個情況將其轉換成該情況的類型。這是使用類型轉換將值轉換成字串的 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()
}

第一個情況會找出具體值;第二個情況則會將介面轉換為另一個介面。用這種方式混用類型完全沒問題。

如果我們只關心一種,該怎麼辦?如果我們知道該值儲存一個字串,而且我們只想擷取出它嗎?一種類型會做到,但「類型斷言」也可以。類型斷言接受介面值,並從中擷取指定明確類型的值。句法借用了開啟類型轉換的子句,但使用明確類型而不是 type 關鍵字

value.(typeName)

結果是具備靜態類型 typeName 的新值。該類型可能是介面提供的具體類型,或該值可以轉換到的第二個介面類型。為了擷取我們知道在該值中的字串,我們可以寫

str := value.(string)

但是,如果該值事實上不包含字串,程式會隨執行時期錯誤而當機。為防止發生這種情況,請使用「逗號,好的」慣用語法來安全地測試該值是否為字串

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 程式中,將 CRC-32 演算法替換為 Adler-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)
}

未使用的匯入和變數

未經使用便匯入套件或宣告變數是錯誤的。未使用的匯入會讓程式膨脹,並減慢編譯速度,而已初始化但未使用的變數至少是一種浪費運算,或許也表示更大的問題。不過,當程式仍在積極開發中時,未使用的匯入和變數通常會發生,而將它們刪除只是為了讓編譯繼續進行,之後又需要它們,可能會很惱人。空白識別碼可提供解決方法。

這個半寫完的程式具有兩個未使用的匯入內容 (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 等未使用的 import 最後都應使用或移除:空白賦值將程式碼標識為正在進行中。但有時只為其副作用匯入套件會有幫助,而沒有任何明確用途。例如,在 init 函式期間,net/http/pprof 套件會註冊提供除錯資訊的 HTTP 處理常式。它有公開的 API,但大多數客戶端只需要處理常式註冊,並透過網頁存取資料。若只想為其副作用匯入套件,請將套件重新命名為空白識別碼。

import _ "net/http/pprof"

此種匯入形式清楚指出套件是因其副作用而匯入,因為不可能有其他套件用途:在此檔案中,沒有名稱。(如果有,而我們沒有使用那個名稱,編譯器會拒絕該程式。)

介面檢查

如同在上面〈介面〉的論述中所見,類型不需要明確宣告實作介面。相反地,類型會藉由實作介面的方法來實作介面。實際上,大多數介面轉換都是靜態的,因此會在編譯時檢查。例如,傳遞 *os.File 給預計 io.Reader 的函式會無法編譯,除非 *os.File 實作 io.Reader 介面。

不過,有些介面檢查會在執行階段發生。一個範例是 encoding/json 套件,定義了 Marshaler 介面。當 JSON 編碼器收到實作該介面的值時,編碼器會呼叫值編碼方法,將它轉換成 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.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 是包含 ReadWrite 的介面。我們可以透過明確列出這兩個方法來指定   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
}

但接著,若要提升欄位的 method 並滿足   io   介面,我們也需要提供轉送方法,如下所示

func (rw *ReadWriter) Read(p []byte) (n int, err error) {
    return rw.reader.Read(p)
}

透過直接嵌入結構,我們可以避免此簿記作業。嵌入型態的方法會自動產生,這表示   bufio.ReadWriter   不僅具有   bufio.Reader 和   bufio.Writer   的方法,它也滿足所有三個介面:io.Readerio.Writerio.ReadWriter

嵌入與子類化有一種重要的不同之處。當我們嵌入型態時,該型態的方法會變成外層型態的方法,但當它們被呼叫時,方法的接收者會是內部型態,而不是外部型態。在我們的範例中,當呼叫   bufio.ReadWriter   的   Read   方法時,它會產生與上面寫出的轉送方法完全一樣的效果;接收者是   ReadWriter   的   reader   欄位,而不是   ReadWriter   本身。

嵌入也可以是非常方便的。此範例顯示嵌入式欄位與常規、命名字段並列。

type Job struct {
    Command string
    *log.Logger
}

Job 類型現在具有 PrintPrintfPrintln*log.Logger 的其他方法。當然,我們可以給 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 會在任何特定時間存取值。資料競爭不論在設計上是否會發生,都可以避免。為了鼓勵這種思考方式,我們將其簡化為一個口號

不要透過共用記憶體進行溝通;透過溝通共用記憶體。

這種方法會變得很極端。舉個例子來說,引用計數法最好透過將一個整數變數放入一個互斥鎖(mutex)中來完成。但是,就高階方法而言,使用通道來控制存取會更容易撰寫清晰且正確的程式。

思考這個模型的方式之一是考慮在一個 CPU 上執行典型的單執行緒程式。它不需要同步機制。現在再執行另一個這樣的實例;它也不需要同步。現在讓這兩個實例進行通訊;如果通訊是同步器,則仍然不需要其他同步。例如,Unix 管道完美符合這個模型。雖然 Go 的並行運算方法源自霍爾的通信順序處理(CSP),但也可以視為 Unix 管道的型別安全概括。

goroutine

之所以稱為 goroutine,是因為現有的詞彙(執行緒、協程、處理等等)傳達了不準確的含義。goroutine 有一個簡單模型:它是一個在與同一個位址空間內的其他 goroutine 並發執行的函數。它很輕量,成本僅比堆疊空間配置的成本略高。而且,堆疊會從小變大,因此很便宜,並會依需要配置(並釋放)堆儲存體來成長。

Goroutine 可以複用到多個作業系統執行緒上,因此如果一個 goroutine 被封鎖,例如在等待 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.

接收器會一直封鎖,直到有資料能夠接收。如果通道是非緩衝,則傳送方會封鎖,直到接收方接收了這個值。如果通道有緩衝區,則傳送方僅會封鎖,直到這個值複製到緩衝區為止;如果緩衝區已滿,這表示要等到一個接收方已擷取一個值。

緩衝管道可用於像 semaphore 一樣的功能,例如用來限制吞吐量。在此範例中,將傳入要求傳遞至 handle,它會將值傳送到管道、處理要求,然後從管道接收值來為下一個消費者準備「semaphore」。管道緩衝區的容量限制同時呼叫 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 中,這段程式碼有一個錯誤:所有 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 最重要的其中一個屬性是管道是一個一級值,可以像任何其他函式一樣進行配置和四處傳遞。這種屬性的常見用途是實作安全的平行 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 系統框架,而且看不到任何 mutex。

平行化

這些概念的另一個應用是將計算平行化到多個 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 環境變數,或以正數值呼叫函式來覆寫。用零呼叫它只是查詢值。因此,如果我們要尊重使用者的資源請求,我們應該撰寫

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 絕不會封鎖。)此實作僅透過幾行文字建立一個滲漏儲桶可用清單,仰賴快取通道和垃圾回收程式進行簿記。

錯誤

函式庫常規會將某種類型的錯誤指示傳回給呼叫者。如同之前提到的,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:未知的格式」。

在乎精確錯誤詳細資料的呼叫者,可以使用型態切換或型態斷言來尋找特定的錯誤並擷取詳細資料。對於 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 也是,我們就能透過它來深入了解錯誤的資訊。

恐慌

用來向呼叫者報告錯誤的常見方式,是回傳一個 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")
    }
}

復原

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) 發生驚慌,結果會被記錄,且 goroutine 將會乾淨地離開,而不會干擾其他 goroutine。在延遲閉包中無須執行任何其他動作;呼叫 recover 會完全處理條件。

因為 recover 始終傳回 nil,除非直接從延遲函式呼叫,延遲程式碼可以在不失敗的情況下呼叫使用 panicrecover 的函式庫例程。舉個例子,safelyDo 中的延遲函式可以在呼叫 recover 之前呼叫登錄函式,而該登錄程式碼會在不受驚慌狀態影響的情況下執行。

隨著復原模式就緒,do 函式(以及它所呼叫的任何函式)可以透過呼叫 panic 乾淨地離開任何不良狀況。我們可以使用這個概念簡化複雜軟體中的錯誤處理。讓我們來看看 regexp 套件的理想版本,它會透過使用當地錯誤型態呼叫 panic 來報告解析錯誤。以下是 Errorerror 方法和 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 發生驚慌,復原區塊會將回傳值設為 nil;延遲函式可以修改具名稱的回傳值。它會在指派給 err 時確認問題是否為解析錯誤,方式是斷言它有本機型態 Error。如果沒有,型態斷言會失敗,造成執行時期錯誤,並繼續堆疊解除,就好像什麼也沒中斷它一樣。這個確認表示,如果發生意外狀況,例如超出範圍的索引,程式碼會失敗,即使我們正在使用 panicrecover 來處理解析錯誤。

有了錯誤處理,error 方法(因為它是一個跟類型連結的方法,所以它擁有和內建 error 類型相同的名稱,即使是自然現象,對它來說也沒問題)可以輕鬆回報解析錯誤,並且不用擔心是否要親自解析堆疊

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

儘管此模式很有用,仍應該只在封包內使用。Parse 將其內部的 panic 呼叫轉換成 error 值;它不會對其客戶端顯示 panics。這是個不錯的追隨規則。

順帶一提,這種 re-panic 慣用語法會在實際發生錯誤時變更 panic 值。但是,原始和新的失敗都會顯示在崩潰報告中,因此問題的根本原因仍然可見。因此,這種簡單的 re-panic 方式通常就已足夠,畢竟最後還是會崩潰,但是如果您只想顯示原始值,可以編寫更多程式碼來過濾意外的問題並使用原始錯誤重新執行 panic。此部分留待讀者自行練習。

網路伺服器

讓我們先完成一個完整的 Go 程式,一個網路伺服器。這一種實際上是一種網路再伺服器。Google 在 chart.apis.google.com 提供一項服務,可以將資料自動格式化為圖表。不過,互動操作比較困難,因為您需要將資料作為查詢放入網址。此程式為一種資料提供更友善的介面:假設有一小段文字,程式會呼叫圖表伺服器產生 QR Code,這是一組編碼文字的方框矩陣。您可以使用手機相機取得該影像,然後將其解讀為網址,例如,可以為您省去在手機小鍵盤中輸入網址的步驟。

以下是完整的程式。後面會提供說明。

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。如果這個解釋太簡短,請參閱範本套件的說明文件,以取得更詳盡的說明。

這就是全部了:幾行程式碼以及一些資料驅動的 HTML 文字,你就可以擁有一個好用的網路伺服器。Go 夠強大,讓你用幾行程式碼完成許多事情。