Effective Go 中文版

aaanly 10年前發布 | 87K 次閱讀 Golang Google Go/Golang開發

Effective Go幾乎是學習Go語言所必須閱讀的重要的文檔,以下是本人對該文檔的翻譯。由于涉及內容較多,翻譯過程中不可避免地會產生一些錯誤,希望讀到的朋友在評論中指出。隨著Go新版本的發布,我將繼續保持此文檔的更新。

最后更新時間:2014/07/13 10:19

介紹

Go是一個新語言,盡管它從已有的語言中借用了一些概念,但是Go語言獨有的特征使實際的Go程序與其他語言編寫的程序不盡相同。將C++或Java程序直譯為Go程序將無法得到滿意的結果——Java程序是用Java所寫,而不是Go。另一方面,以Go的視角考慮問題能產生一個成功的但也相當不同的程序。換句話說,要想寫好Go程序,必須要理解其特征和習慣用法。同樣重要的是了解Go既定的規范,如命名、格式化、程序結構等等,這樣你所編寫的程序將能很容易地被其他Go程序員所理解。

此文檔將就如何編寫清晰的、符合語言習慣的Go程序給出一些提示。它是對Go語言規范Go語言之旅以及如何編寫Go程序的補充,在閱讀本文之前,你應該先閱讀這些文檔。

示例

Go語言包源代碼不僅是核心庫,同時也是關于演示如何使用此語言的示例代碼。并且許多包還包含可獨立運行的示例,你可以直接從golang.org網站運行他們,例如這個例子(點擊“Example”打開它)。 如果你有關于如何解決問題和某些東西如何實現方面的疑問,他們可以給出答案、思路和背景。

格式化

格式化總是最易引起爭論但很難爭論出結果。人們總是需要適應多種不同的格式化樣式,但如果所有人都遵循同一種樣式,那么在該議題上將花費更少的時間,這樣或許更好一點。問題在于如何實現這個理想并且不需要一個冗長的樣式說明手冊。

在Go中我們可以使用一個特別的方法,即讓機器來處理大部分的格式化問題。gofmt程序(也可以使用go fmt,它在包的級別上而非源文件的級別上進行操作)讀取一個Go程序并生成標準樣式的源代碼,這些樣式調整包括縮進、垂直對齊、保留注釋并在需要時重新格式化注釋。如果你想要知道如何處理一些新的布局情況,請運行gofmt,如果結果看起來不太對,請重新調整你的程序(或提交一個關于gofmt的bug),不要自個去調整gofmt代碼。

舉例來說,對于結構體中的注釋,并不需要花費時間將他們對齊。gofmt將為你做這些事。對于如下的聲明

type T struct {
    name string // name of the object
    value int // its value
}

gofmt將對齊各列:

type T struct {
    name    string // name of the object
    value   int    // its value
}

在標準包中的所有Go代碼已經使用gofmt格式化過了。

還有一些格式化細節。這些都非常簡明

  • 縮進
    我們使用制表符(tab)進行縮進,gofmt生成的代碼默認也使用它。你也可以特意地使用空格縮進。
  • 行長
    Go沒有行長限制。不要擔心代碼會在穿孔卡片上溢出。如果一行實在有點太長,可以進行換行并使用一個額外的制表符將其縮進。
  • 圓括號
    與C和Java相比,Go很少使用圓括號:控制結構(ifforswitch)在語法上不需要圓括號。另外,操作符優先級別變得更短更清楚,因此

    x<<8 + y<<16

所表達的意思和其中空格暗示的一樣,這一點與其他語言也不相同。

注釋

Go提供了C樣式的/* */塊注釋和C++樣式的//行注釋。行注釋是標準的;塊注釋主要用于包注釋,但也用于表達式內部或禁用一大段代碼。

godoc是一個程序,也是一個網絡服務器,它對Go的源代碼文件進行處理,提取其中的包文檔內容。出現在頂層聲明前方,中間不包括空行的注釋與聲明一起提取出來作為一個所聲明項的解釋文本。這些注釋的內容和樣式決定了godoc生成的文檔的質量。

每個包都應該有一個包注釋,它是一個放在包語句(package)前方的塊注釋。對于包含多個文件的包,包注釋只需要出現在一個文件中,任何一個文件都可以。包注釋應該在整體上對此包進行介紹,并提供包的相關信息。它將在godoc頁面中率先出現并為其下的內容建立詳細的文檔。

/*
Package regexp implements a simple library for regular expressions.

The syntax of the regular expressions accepted is:

    regexp:
        concatenation { '|' concatenation }
    concatenation:
        { closure }
    closure:
        term [ '*' | '+' | '?' ]
    term:
        '^'
        '$'
        '.'
        character
        '[' [ '^' ] character-ranges ']'
        '(' regexp ')'
*/
package regexp

如果包很簡單,包的注釋可以很簡短。

// Package path implements utility routines for
// manipulating slash-separated filename paths.

注釋不需要額外的格式化,如星號橫幅。生成的輸出甚至可能無法以固定寬度的字體顯示,因此不要依賴空格進行對齊,就像gofmt一樣,godoc會自動對這些進行處理。注釋是不被解析的純文本,HTML或其他類似的東西如_this_照原樣輸出,因此不應該使用他們。godoc所做的另一項工作是以固定寬度字體顯示縮進的文本,因此可以用縮進文本來顯示代碼片段。fmt包的注釋就利用了此項功能達到了好的顯示效果。

根據上下文,godoc甚至可能不會重新格式化注釋,因此必須保證他們看上去是直白的:使用正確的拼寫、標點和句子結構,折疊長行,等等。

在一個包內,任何直接放在頂層聲明前方的的注釋都被認為是該聲明的文檔注釋。在一個程序中每個導出的(首字母大寫)名稱都應該有一個文檔注釋。

文檔注釋最好是一個完整的句子,這樣它就適用于各種自動化的展示。第一個句子應該是一個概括性的單句,并以被聲明事物的名稱起頭。

// Compile parses a regular expression and returns, if successful, a Regexp
// object that can be used to match against text.
func Compile(str string) (regexp *Regexp, error error) {

如果注釋總是以被聲明事物的名稱起頭,就可以在godoc的輸出中使用grep命令。假設你不記得Compile這個名稱,但知道自己在找正則表達式的parsing功能,因此可以用以下方法運行命令,

$ godoc regexp | grep parse

要是包中的所有文檔注釋都以“This function…”開頭,grep就不會幫你找到想要的函數名稱。但若每個文檔注釋都以名稱起頭,你將會看到如下結果,它使你回想起你正要找的東西。

$ godoc regexp | grep parse
    Compile parses a regular expression and returns, if successful, a Regexp
    parsed. It simplifies safe initialization of global variables holding
    cannot be parsed. It simplifies safe initialization of global variables
$

Go的聲明語法允許進行成組聲明。一個單一的文檔注釋可以介紹一組相關的常量或變量。由于是進行整體聲明,這種注釋往往是概括性的。

// Error codes returned by failures to parse an expression.
var (
    ErrInternal      = os.NewError("regexp: internal error")
    ErrUnmatchedLpar = os.NewError("regexp: unmatched '('")
    ErrUnmatchedRpar = os.NewError("regexp: unmatched ')'")
    ...
)

同時也可以使用成組聲明來表明項目之間的關系,例如,一組被一個互斥對象保護著的變量。

var (
    countLock   sync.Mutex
    inputCount  uint32
    outputCount uint32
    errorCount  uint32
)

命名

命名在Go中與在其他語言中一樣重要。在一些情況下,他們甚至對語義有影響:例如,一個名稱在包外的可見性是由其首個字符是否為大寫字母決定的。因此有必要花點時間來討論Go語言中的命名約定。

包的名稱

當一個包被導入時,包的名稱變成其內容的訪問器。在

import "bytes"

之后,被導入的包可以使用bytes.Buffer。所有使用此包的人都以一個相同的名稱引用其內容將是非常有好處的,這意味著應該給包起一個好名稱:簡短、簡明且容易理解。按照常規,包的名稱是小寫的、單個單詞的名稱;并不需要下劃線或大小寫混寫。err的命名就是出于簡潔考慮的,由于任何使用你的包的人都將鍵入其名稱。不必擔心其與已有的東西沖突。包的名稱是僅有的需要導入的默認名稱;它并不要求在整個源代碼中都是獨一無二的,即便在少數發生沖突的情況,也可以將包以一個不同的名稱導入以便局部使用。在所有情況下,由于可根據文件名判定所使用的是哪個包,因此不會造成混淆。

另一個命名常規就是包名應該是其源代碼所在目錄的基本名稱(譯注:去掉路徑中最后一個/之前所有東西之后所剩的名稱);在src/pkg/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名稱中既不合常規也不必要。如果你有一個叫做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()  // 錯!
{           // 錯!
    g()
}

控制結構

Go的控制結構與C有關,但在一些重要的方面又有所不同。其中沒有dowhile循環,而僅有一個更廣義的forswitch要更靈活一點;ifswitchfor一樣接受可選的初始化語句;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可出現在:=聲明中,即便是該變量已經被聲明過了:

  • 該聲明的作用域與已有的v(如果v已經在更靠外一級的作用域內被聲明,則此聲明會創建一個新的變量 §)的聲明的作用域相同,
  • v所賦的值是類型匹配的,并且
  • 在聲明中至少有另外一個變量是新聲明的。

這種不尋常的特性純粹是出于實用主義,這使我們可以很方便地只使用一個err值,比如用于一個長if-else語句鏈中。這種用法會經常被看到。

§ 值得注意的是,在Go中,盡管函數參數、返回值在詞法上出現在大括號的外面,而函數體則包含在大括號內部,但他們的作用域一樣的。

For

Go的for循環與C的相似但卻不一樣。它統一了forwhile,因此也就不再有do-while了。for語句有三種形式,只有一種具有分號。

// 和 C 的 for 語句類似
for init; condition; post { }

// 和 C 的 while 語句類似
for condition { }

// 和 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 expired(key) {
        delete(m, key)
    }
}

如果你只需要range返回的第二個項目(值),可使用空白標識符(一個下劃線)來拋棄第一個:

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

空白標志符很有用,在稍后的一節中將對其講解。

對于字符串,range將為你做更多的工作,它可解析UTF-8并分解成單個Unicode代碼點。錯誤的編碼占用一個字節并用一個rune(有時被譯作“符文”)字符U+FFFD替代。(rune(它對于一個內建類型)名稱是一個Go的術語,它代表單個Unicode代碼點。參見語言規范。)以下循環

for pos, char := range "日本\x80語" { // \x80 是一個非法的 UTF-8 編碼
    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中使用多變量,你應該使用并列賦值(這種情況無法使用++--)。

// 反轉 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更常見。其表達式不需要是常量甚至是整數,從上到下對每個分支的值進行比較,直到發現一個匹配的值,如果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,即找到一個分支入口后不再進行判斷而執行其下面的分支),但多個分支可以通過以逗號分割的列表來呈現。

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

仍然可以使用break語句來提早結束一個switch,但Go中這樣用不如其他類C語言那么普遍。有時只是需要break其所包圍的循環,而不是switch語句,在Go中可以通過給循環一個標簽,并且break到這個標簽。下例同時顯示了這兩方面的用法。

Loop:
    for n := 0; n &lt; len(src); n += size {
        switch {
        case src[n] &lt; sizeOne:
            if validateOnly {
                break
            }
            size = 1
            update(src[n])

        case src[n] &lt; sizeTwo:
            if n+1 &gt;= len(src) {
                err = errShortInput
                break Loop
            }
            if validateOnly {
                break
            }
            size = 2
            update(src[n] + src[n+1]&lt;&lt;shift)
        }
    }

當然,continue語句同樣可接受這樣一個可選的標簽,但它只能用于循環。

以下程序通過使用兩個switch語句對字節數組進行對比:

// Compare 按照字典順序比較兩個字節切片.
// 若 a == b,返回 0;若 a < b,返回 -1;如果 a > b,返回 +1
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實現類型斷言語法。如果在開關的表達式中聲明了一個變量,在每個從句中將有該變量對應的類型。在這種情況下通常會重用變量的名稱,即聲明一個具有同樣名稱但卻有不同類型的變量。

var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
    fmt.Printf("unexpected type %T", t)       // %T 打印 t 的類型
case bool:
    fmt.Printf("boolean %t\n", t)             // t 的類型為 bool
case int:
    fmt.Printf("integer %d\n", t)             // t 的類型為 int
case *bool:
    fmt.Printf("pointer to boolean %t\n", *t) // t 的類型為 *bool
case *int:
    fmt.Printf("pointer to integer %d\n", *t) // t 的類型為 *int
}

函數

多個返回值

Go的一個特有性質就是函數和方法具有多個返回值。這種特性使C程序中各種笨拙習慣用法得以改善:帶內(譯注:同一個返回變量內)返回錯誤(例如-1代表EOF)和通過傳遞地址修改一個參量。

在C中,一個寫錯誤是使用一個負數來標志,該錯誤代碼隱藏在另外的不確定的位置。在Go中,Write可以返回一個數值一個錯誤:“是的,您寫入了一些字節,但并沒有全部寫入,因為設備已滿”。在os包中Write方法的簽名是:

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

正如文檔所述,當n != len(b)時,它返回被寫入的字節的數目以及一個非nilerror;這是一個常用的方式;參見錯誤處理一節獲得更多示例。

以往一般通過傳遞一個指針到一個返回值以模擬引用參數,現在一個相似的方法使這樣不再必須。以下簡單的函數從字節切片的特定位置獲取一個數字,它返回該數字和下一個位置。

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 以字符串形式返回文件的內容.
func Contents(filename string) (string, error) {
    f, err := os.Open(filename)
    if err != nil {
        return "", err
    }
    defer f.Close()  // 當結束時將運行 f.Close.

    var result []byte
    buf := make([]byte, 100)
    for {
        n, err := f.Read(buf[0:])
        result = append(result, buf[0:n]...) // append 將在隨后討論.
        if err != nil {
            if err == io.EOF {
                break
            }
            return "", err  // 如果在此處返回, f 將被關閉.
        }
    }
    return string(result), nil // 如果在此處返回, f 將被關閉.
}

對像Close這樣的函數的延期調用有兩個優點。第一,它確保你不會忘記關閉文件,在一段時間之后編輯函數以便向其中添加新的返回路徑時,往往會發生此種錯誤。第二,它意味著關閉與打開靠得很近,這要比將關閉放在函數結尾處更為清楚明了。

被延期函數的參量(如果函數是一個方法,將還包括接收者)是在進行延期時被估值,而不是在調用時被估值。這樣不僅可不必擔心變量值被改變,同時也意味著單個延期調用可以延期多個函數執行。以下是一個不太聰明的例子:

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

// 以如下方法使用他們:
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所作的工作,這里它只是將內存清零,而不是初始化內存。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)  // *SyncedBuffer 類型
var v SyncedBuffer      // SyncedBuffer 類型

構造器和復合文字

有時零值并不足夠好,這就需要一個初始化構造器,如來自os包的這段代碼所示。

func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }
    f := new(File)
    f.fd = fd
    f.name = name
    f.dirinfo = nil
    f.nepipe = 0
    return f
}

這里有很多的類似的語句。我們可以使用復合文字(composite literal,或譯作“復合字面”)來對其進行簡化,以下是一個在每次求值時創建一個新實例的表達式。

func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }
    f := File{fd, name, nil, 0}
    return &f
}

注意,返回一個局部變量的地址是完全沒問題的,這一點與C不同;與此變量對應的存儲在函數返回后仍然存在。事實上,每當進行獲取一個復合文字的地址運算時,都將為一個新的實例分配內存,因此以上代碼的最后兩行可以被合并起來。

return &File{fd, name, nil, 0}

復合文字的字段必須按順序全部給出。但如果顯式地用字段:來標記元素,他們在初始化器中出現的順序可以是任意的,沒有給出的字段則為零值。因此我們可以用

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個整數的數組分配內存并創建一個長度為10、容量為100并指向此數組前10個元素的切片構造。(生成切片時,其容量可以省略;詳見切片一節。)相反,new([]int)返回一個指向新分配內存的零切片結構的指針,也即一個指向nil切片值的指針。

以下示例說明了newmake的不同。

var p *[]int = new([]int)       // 為切片結構分配內存;*p == nil;很少使用
var v  []int = make([]int, 100) // 切片v現在是對一個新的有100個整數的數組的引用

// 毫無必要地使問題復雜化:
var p *[]int = new([]int)
*p = make([]int, 100, 100)

// 習慣用法:
v := make([]int, 100)

請記住make只適用于映射、切片和信道,并且其返回值不是指針。要顯式地獲得一個指針,請用new分配內存,或顯式地取得一個變量的地址。

數組

在詳細地規劃內存布局時,數組是很有用的,有時使用數組避免進行內存分配,但數組主要用作切片的構建塊,這將是下一節討論的主題。作為對該主題的一個鋪墊,這里先對數組說上幾句。

Go和C中的數組的主要區別在于,在Go中,

  • 數組是值。將一個數組賦值給另一個將復制其所有的元素。
  • 特別地,如果你傳遞一個數組給函數,它將收到此數組的一個副本,而不是一個指向它的指針。
  • 數組的尺寸是其類型的一部分。[10]int[20]int是不同的類型。

數組是值的屬性很有用,但代價昂貴;如果你想要類似C的行為和效率,你可以傳遞一個指向數組的指針。

func Sum(a *[3]float64) (sum float64) {
    for _, v := range *a {
        sum += v
    }
    return
}

array := [...]float64{7.0, 8.5, 9.1}
x := Sum(&array)  // 注意顯式給定的取地址操作符

但這種風格并不是Go的習慣用法。切片才是。

切片

切片(slice)通過包裝數組而給出了對數據序列的通用、強大和方便的接口。除了如矩陣變換這樣顯式要求尺寸的情況,多數情況下,Go中的數組編程是通過切片而非簡單數組來完成的。

切片保存了對其底層數組的引用,如果你將一個切片賦值給另外一個,這兩個切片將引用同一個底層數組。例如,如果一個函數獲取了一個切片參量,其對切片元素的改變對調用者來說是可見的,這與傳遞一個指向底層數組的指針相類似。因此,一個Read函數可接受一個切片參量而不是一個指針和一個計數;切片的長度設定了可被讀取數據的上限。以下是osFileRead方法的簽名:

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

該方法返回讀取的字節數和一個錯誤值(如果有的話)。要讀入一個大緩沖器b的前32字節,切分(slice,這里的slice是一個動詞)該緩沖器就可以了。

n, err := f.Read(buf[0:32])

這種切分方法常用且高效。事實上,暫不考慮效率問題,以下片段同樣讀取緩沖器的前32字節。

var n int
var err error
for i := 0; i < 32; i++ {
    nbytes, e := f.Read(buf[i:i+1])  // 讀取一個字節.
    if nbytes 0 || e != nil {
        err = e
        break
    }
    n += nbytes
}

切片的長度是可以改變的,只要它不超出底層數組的長度極限;只需將其自身的一個切片賦值給它就可以了。切片的容量可通過內建的cap函數來訪問,它將給出此切片可被賦予的最大長度。以下是一個為切片追加數據的函數。如果數據超出容量,將重新為切片分配內存。返回值為所得的切片。該函數中所使用的lencap在當應用于nil切片時,將返回0。

func Append(slice, data[]byte) []byte {
    l := len(slice)
    if l + len(data) > cap(slice) {  // 重新分配內存
        // 分配所需的兩倍的內存, 以便適應將來的增長.
        newSlice := make([]byte, (l+len(data))*2)
        // copy 函數已被預先聲明了, 它對任何切片類型都適用.
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:l+len(data)]
    for i, c := range data {
        slice[l+i] = c
    }
    return slice
}

我們必須在最后返回切片,這是因為盡管Append可以修改slice的元素,但切片自身(其運行時數據結構包含指針、長度和容量)是通過值傳遞的。

為切片追加東西的想法相當不錯,因此有一個專門的內建函數append實現了此功能。要理解此函數的設計,還需要更多一點信息,我們將稍候再介紹它。

二維切片

Go的數組和切片是一維的。要創建二維數組或切片,需要定義一個數組的數組或切片的切片,如下

type Transform [3][3]float64  // 一個 3x3 的數組, 其實是數組的數組.
type LinesOfText [][]byte     // 一個字節切片的切片.

由于切片的長度時可變的,這樣每個切片元素的長度可以各不相同。以下給出的LinesOfText示例就是一種常見的情況:每行的長度都各不相同。

text := LinesOfText{
    []byte("Now is the time"),
    []byte("for all good gophers"),
    []byte("to bring some fun to the party."),
}

有些時候,確實需要使用二維的切片,例如,用其處理像素的掃描線。有兩種方法可實現這些。其一是獨立地對每個切片分配內存;其二是先分配單個數組的內存,然后將各個獨立的切片指向其內部。具體使用何種方法取決于你的程序。如果切片可能增長或減小,應獨立地分配內存以避免覆蓋寫入下一行;反之,使用單個數組構建對象將更加高效。以下給出了兩種方法的草案以供參考。首先是一次一行的方法:

// 為頂層的切片分配內存.
picture := make([][]uint8, YSize) // y 方向的每個單位分配一行.
// 對行進行掃描, 為每行的切片分配內存.
for i := range picture {
    picture[i] = make([]uint8, XSize)
}

然后是一次分配內存,再將其切分為多行的方法:

// 為頂層的切片分配內存, 和前面的代碼一樣.
picture := make([][]uint8, YSize) // y 方向的每個單位分配一行.
// 分哦在涂個大的切片來保存所有像素.
pixels := make([]uint8, XSize*YSize) // 盡管 picture 的類型是[][]uint8, 這里使用的類型是 []uint8.
// 通過循環從剩下的 pixels 切片的前部逐次切分出各行.
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] { // 如果 person 不在映射中, 將為 false
    fmt.Println(person, "was at the meeting")
}

有時你需要區分不存在的項目和零值。如對一個值本應為零的"UTC"項,也可能是由于不存在該項而得到零值。你可以使用多重賦值的形式來分辨這種情況。

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

如果你只想要默認的轉換,例如使用十進制的整數,你可以使用通用的格式%vv指“值”);其結果與PrintPrintln產生的輸出是完全一樣的。另外,此格式可打印任何值,甚至包括數組、結構體和映射。以下是針對上一節定義的時區映射的打印語句。

fmt.Printf("%v\n", timeZone)  // 或者只是使用 fmt.Println(timeZone)

這將輸出

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

當然映射中的鍵可能以任意的順序輸出。當打印結構體時,改進的格式%+v將使用字段的名稱標明結構體的字段,而另外一個格式%#v將完全以Go語法打印任意值。

type T struct {
    a int
    b float
    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, "PST":-28800, "EST":-18000, "UTC":0, "MST":-25200}

(請留意其中的&符號。)當遇到類型為string[]byte的值時,可以使用%q產生引號包括的字符串;而格式%#q將盡可能使用反引號。(%q格式也可以應用于整數和rune類型,產生一個單引號包括的rune常量。)另外,%x可被用于字符串、字節數組、字節切片以及整數,并產生一個長十六進制字符串,而帶空格的格式(% x)還會在字節之間插入空格。

另一個趁手的格式是%T,它會打印值的類型

fmt.Printf("%T\n", timeZone)

將打印

map[string] int

如果你需要控制一個自定義類型的默認格式,你只需要為此類型定義一個具有String() string簽名的方法。對前面定義的簡單T類型,可進行如下操作。

func (t *T) String() string {
    return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)

它將以格式化的形式打印

7/-2.35/"abc\tdef"

(如果你要像指向T的指針那樣打印類型TString的接收者必須是值類型;上例中的接收者是一個指針,因為這對結構體類型來說更高效和通用。詳見下節的指針接收者和值接收者。

String方法可以調用Sprintf,這是因為打印例程是完全可重入并以這種方式封裝。但是,理解這種方式的細節時,有一條重要的細節需要遵守:在通過調用Sprintf構建String方法時,不能無限遞歸地調用你的String方法。這種情況可能出現在當Sprintf調用試圖直接以字符串打印接收者時,進而再次調用該方法時。這是一個常見且易犯的錯誤,如下例所示。

type MyString string

func (m MyString) String() string {
    return fmt.Sprintf("MyString=%s", m) // 錯誤: 將永遠遞歸下去.
}

同時這種錯誤也容易修復:將參量轉變為基礎字符串類型,這樣就不再調用此方法

type MyString string

func (m MyString) String() string {
    return fmt.Sprintf("MyString=%s", string(m)) // OK: 注意其中的轉換.
}

在初始化一節中,我們將學到另外一種避免這種遞歸調用的技術。

另一種打印技術是將一個打印例程的參量直接傳遞給另一個這樣的例程。Printf的簽名是...interface{},這樣出現在其格式化字符串后面的參量就可以是任意類型任意個數的參數。

func Printf(format string, v ...interface{}) (n int, errno 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函數并不相同。從原理上來將,其簽名就如:

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卻更強大。在初始化過程中可以構建復雜的結構,并且在不同包中的不同被初始化對象間的排序問題能夠很好地處理。

常量

Go中的常量就是不變常數。他們在編譯時被創建,即便在函數中定義的局部常量也是如此,常量只能是數字、字符(rune)、字符串或布爾值。由于編譯時的限制,定義他們的表達式必須是可以被編譯器求值的常量表達式。例如1<<3是常量表達式,而math.Sin(math.Pi/4)則不是,這是由于函數調用math.Sin是在運行時發生的。

在Go中,枚舉常量使用枚舉符iota創建。由于iota可以是一個表達式的一部分,并且表達式可以被隱含地重復,這樣就更容易構建復雜的值集。

type ByteSize float64

const (
    _           = iota // 通過將其賦值給空標識符而忽略第一個值
    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", float64(b/YB))
    case b >= ZB:
        return fmt.Sprintf("%.2fZB", float64(b/ZB))
    case b >= EB:
        return fmt.Sprintf("%.2fEB", float64(b/EB))
    case b >= PB:
        return fmt.Sprintf("%.2fPB", float64(b/PB))
    case b >= TB:
        return fmt.Sprintf("%.2fTB", float64(b/TB))
    case b >= GB:
        return fmt.Sprintf("%.2fGB", float64(b/GB))
    case b >= MB:
        return fmt.Sprintf("%.2fMB", float64(b/MB))
    case b >= KB:
        return fmt.Sprintf("%.2fKB", float64(b/KB))
    }
    return fmt.Sprintf("%.2fB", float64(b))
}

表達式YB的打印形式為1.00YB,而ByteSize(1e13)則打印9.09TB

注意在ByteSizeString方法中調用Sprintf函數是安全的(要避免無限遞歸調用),這不僅是因為使用了轉換,同時因為它通過%f調用Sprintf%f不是一個字符串格式,它需要匹配一個浮點數:Sprintf僅在需要匹配一個字符串時才調用String方法。

變量

變量的初始化與常量類似,但初始化器可以是在運行時被計算的普通表達式。

var (
    HOME = os.Getenv("HOME")
    USER = os.Getenv("USER")
    GOROOT = os.Getenv("GOROOT")
)

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 可能被命令行中的 --gopath 標記覆蓋.
    flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}

方法

指針與值的對比

如同我們在前面看到的ByteSize那樣,可以針對不是指針或接口的其他任何具有名稱的類型定義方法;其接收者可以不是結構體。

在前面討論切片時,我們曾寫了一個Append函數。我們也可以將其定義為切片的方法。要這樣做,首先需要聲明一個具有名稱的類型來綁定該方法,然后使此方法的接收者為該類型的一個值。

type ByteSlice []byte

func (slice ByteSlice) Append(data []byte) []byte {
    // 方法主體部分與前面的函數完全相同.
}

這里仍然要求此方法返回更新過的切片。為了消除這種不便,我們可以重定義此方法,使其接受一個指向ByteSlice指針作為其接收者,這樣此方法就可以覆蓋調用者的切片了。

func (p *ByteSlice) Append(data []byte) {
    slice := *p
    // 方法主體部分與前面一樣, 但沒有 return 語句.
    *p = slice
}

事實上,我們可以做得更好。我們可以修改此函數使其看起來更像一個標準的Write方法,如下所示:

func (p *ByteSlice) Write(data []byte) (n int, err error) {
    slice := *p
    // 仍是和前面一樣.
    *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。關于接收者為指針還是值的規則是:值的方法可以被通過指針和值進行調用,但指針的方法只能被通過指針調用。

之所以這樣做是因為指針方法可能修改接收者;通過值調用指針的方法將使此方法收到一個值的副本,而對此副本的任何改動將會被拋棄。Go語言這樣規定可以避免發生這種錯誤。為了方便,這里有個特列。當值是可尋址時,該語言會自動插入取地址操作符,從能能根據常見情況通過值調用指針方法。在上例種,變量b是可尋址的,因此我們可僅使用b.Write方法調用其Write方法。編譯器將將其重寫為(&b).Write

順便說一下,在一個字節切片上使用Write的想法已由bytes.Buffer實現了。

接口和其他類型

接口

Go中的接口提供了一個指定對象行為的方法:如果某樣東西可以完成這個,則它可被用于此處。我們已經見過許多簡單的示例了;自定義的打印函數可以通過String實現,而Fprintf能對任何實現了Write的東西產生輸出。只有一兩個方法的接口在Go代碼中很常見,并且其名稱常常從方法得來,如io.Writer就是實現了Write的接口。

一個類型可實現多個接口。例如,一個實現了sort.Interface接口的集合就可以使用sort對其排序,該接口包括Len()Less(i, j int) boolSwap(i, j int),另外,該集合仍然可以有一個自定義的格式化器。以下特意構建的例子Sequence就同時滿足這兩種情況。

type Sequence []int

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

// 打印時用到的方法 - 在打印之前先要對其元素進行排序.
func (s Sequence) String() string {
    sort.Sort(s)
    str := "["
    for i, elem := range s {
        if i > 0 {
            str += " "
        }
        str += fmt.Sprint(elem)
    }
    return str + "]"
}

轉換

SequenceString方法重復做了Sprint針對切片已經實現了的工作。如果將Sequence轉換為一個普通的[]int,就能夠使用Sprint的這種功能。

func (s Sequence) String() string {
    sort.Sort(s)
    return fmt.Sprint([]int(s))
}

此方法是在String方法中安全地調用Sprintf所使用的轉換技術的另一個示例。由于在忽略類型名稱的情況下,Sequence[]int這兩種類型是相同的,因此在這兩者之間進行轉換是合法的。該轉換并不會創建一個新值,它只是暫時地認為已有的值具有一個新類型。(還有另外一些合法的轉換也不創建新值,如從整數轉換為浮點數。)

Go程序常常轉換一個表達式的類型以使用不同的方法。例如,我們可以使用已有的sort.IntSlice將以上整個示例縮減成這樣:

type Sequence []int

// 打印時用到的方法 - 在打印之前先要對其元素進行排序.
func (s Sequence) String() string {
    sort.IntSlice(s).Sort()
    return fmt.Sprint([]int(s))
}

現在,不必讓Sequence實現多個接口,我們可以轉而通過將數據項轉換為多種類型(Sequencesort.IntSlice[]int)而使用相應的功能,每次轉換都完成一部分工作。在實際使用中,這種做法顯得怪怪的,但卻很有效。

接口轉換和類型斷言

類型切換是轉換的一種:他們接受一個接口,然后對switch語句的每個case,在某種意義上將其轉變為此種case下的類型。以下是fmt.Printf函數代碼如何使用類型切換將一個值轉變為字符串的簡化版本。如果接口已經是一個字符串,我們就取得該接口的實際字符串值;如果該接口有一個String方法,我們就取得調用此方法的結果。

type Stringer interface {
    String() string
}

var value interface{} // 由調用者提供的值.
switch str := value.(type) {
case string:
    return str
case Stringer:
    return str.String()
}

第一個case試圖找到一個具體的值;而第二個case則將此接口轉換為另外一個接口。這種方式對混合類型是非常完美的。

如果我們只關心一種類型,情況又會怎么樣呢?如果我們知道某個值保存了一個字符串,我們只是想得到它又該怎么辦呢?這時可以使用只有一個caseswitch,但最好使用類型斷言。一個類型斷言接受一個接口值,并從中取得一個明確指定類型的值。其語法借用自類型切換語句的開頭部分,但具有一個明確的類型而不是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程序中,將CRC-32算法替換為Adler-32只需要更改構造器調用,而其余的代碼則不受算法更改的影響。

同樣的方式使各個crypto包中的流加密算法與他們鏈接起來形成的塊加密區分開來。crypto/cipher包中的Block接口指定了一個塊加密行為,它提供對單個數據塊的加密。然后,與bufio包類似,實現此接口的加密包可以被用于構建流加密,這由Stream表示,并且不必知道塊加密的細節。

crypto/cipher接口是這樣的:

type Block interface {
    BlockSize() int
    Encrypt(src, dst []byte)
    Decrypt(src, dst []byte)
}

type Stream interface {
    XORKeyStream(dst, src []byte)
}

這里是計數模式(CTR)流的定義,它將一個塊加密轉變為流加密;注意塊加密的細節是抽象的:

// NewCTR 返回一個流, 該流使用在計數器模式中給定的Block加密/解密.
// iv 的長度必須等于 Block 中的塊尺寸.
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;這種簡化并不影響處理程序(handler)的構建方式。以下是一個很小但卻完整的處理程序實現,它可以對頁面的訪問次數進行計數。

// 簡單的計數器服務器。
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中。)作為參考,這里演示了如何將這樣一個服務器程序加到URL樹的一個節點上。

import "net/http"
...
ctr := new(Counter)
http.Handle("/counter", ctr)

但為什么要使Counter成為一個結構體呢?其實只需要一個整數就夠了。(接收者必須是一個指針,這樣該增加值對調用者是可見的。)

// 簡單的計數器服務器。
type Counter int

func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    *ctr++
    fmt.Fprintf(w, "counter = %d\n", *ctr)
}

當頁面被訪問后怎樣通知程序去更新一些內部狀態呢?請為web頁面連上一個信道。

// 信道可以在每次訪問時發送一個通知.
// (將來可能需要緩存此信道.)
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包中包含以下代碼:

// HandlerFunc 類型是一個適配器, 它允許將普通的函數作為 HTTP 處理程序
// 使用. 如果 f 是一個具有合適簽名的函數, HandleFunc(f) 就是一個調用
// f 的處理程序對象.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP 調用 f(c, req).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
    f(w, req)
}

HandlerFunc類型有一個ServeHTTP方法,因此該類型的值可以為HTTP請求提供服務。查看該方法的實現:其接收者是一個函數f,并且該方法調用了f。這看起來有點古怪,但卻與接收者為信道而方法發送到此信道并沒有什么不同。

要使ArgServer變為一個HTTP服務器,需要對其進行修改,使其擁有正確的簽名。

// 參量服務器.
func ArgServer(w http.ResponseWriter, req *http.Request) {
    for i, s := range os.Args {
        fmt.Fprintln(w, s)
    }
}

現在ArgServer的簽名與HandlerFunc一樣了,這樣就能其轉化為此類型以訪問其方法,就像將Sequence轉換為IntSlice并訪問IntSlice.Sort一樣。設置代碼很簡潔:

http.Handle("/args", http.HandlerFunc(ArgServer))

當某人訪問/args頁面時,對應此頁面的處理程序的值為ArgServer,類型為HandlerFunc。HTTP服務器將調用該類型的ServeHTTP方法,并以ArgServer作為接收者,這將相應地調用ArgServer(通過調用HandlerFunc.ServeHTTP中的f(c, req)顯示)。進而將顯示各參量。

在該小節中,我們從一個結構體、整數、信道及函數構建了一個HTTP服務器,這全都因為接口就是方法的集合,它可以針對(幾乎是)任何類型定義。

空白標識符

空白標識符通常使用在多重賦值時,而在一個for range循環中使用只是其應用情景之一。

如果賦值的左側需要多個值,但其中一個值在程序中并沒有被用到,在賦值語句的左側可以使用一個空白標識符來避免創建一個無用的變量,同時明確說明此值被丟棄。例如,如果要調用的函數將返回一個值和一個錯誤,但只有錯誤是重要的,就可以使用空白標識符來舍棄不相關的值。

if _, err := os.Stat(path); os.IsNotExist(err) {
    fmt.Printf("%s does not exist\n", path)
}

有的代碼會舍棄錯誤值,這是為了忽略錯誤;不過這種做法通常很糟糕。請始終對錯誤返回值進行檢查;之所以返回錯誤,都是有原因的。

// 糟糕的方法! 如果路徑不存在, 此代碼將崩潰.
fi, _ := os.Stat(path)
if fi.IsDir() {
    fmt.Printf("%s is a directory\n", path)
}

未使用的導入和變量

Go把導入一個包或聲明一個變量后而不使用它的行為看作是一個錯誤。未使用的導入使程序變大并降低編譯速度,而初始化一個變量卻不使用它首先會浪費計算,并有可能導致一個更大的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: 使用 fd.
}

為了避免編譯器對未使用導入的抱怨,可以使用一個空白標識符來關聯來自導入包的一個符號。同樣地,將未使用fd變量賦值給一個空白標識符也將阻止出現未使用變量的錯誤。以下的程序就能通過編譯。

package main

import (
    "fmt"
    "io"
    "log"
    "os"
)

var _ = fmt.Printf // 僅用作調試; 在調試結束后就應該刪除.
var _ io.Reader    // 僅用作調試; 在調試結束后就應該刪除.

func main() {
    fd, err := os.Open("test.go")
    if err != nil {
        log.Fatal(err)
    }
    // TODO: 使用 fd.
    _ = fd
}

按照慣例,對于為了防止導入錯誤而加入的全局空白標識符聲明,應被緊放在導入之后并加注聲明,這樣使他們便于被找到,并提醒我們需要在今后對他們進行清理。

為了次要作用而導入

在前面的例子中,如fmtio這些未使用的導入最終應被使用或刪除:空白賦值語句只是為了標明代碼的工作進度。但有時并不需要顯式地使用包,而只是為了使用包的一些次要作用而導入包。例如,net/http/pprof包的init在執行過程中將對提供調試信息的HTTP處理程序進行注冊。雖然該包具有一個導出的API,但多數客戶端僅需要處理程序注冊并通過一個網頁訪問數據。如果只為了其次要作用而導入包,可將其名稱更改為空白標識符:

import _ "net/http/pprof"

這種形式的導入明確說明了只是為了使用包的次要作用才導入包,由于該包已不可能有其他用途,因此在此文件中,該包不需要具備名稱。(如果該包有名稱,但我們卻沒有使用此名稱,編譯器將拒絕編譯此程序。)

接口檢查

正如前面討論接口時所講得那樣,一個類型并不需要明確聲明其所實現的接口。要實現某個接口,該類型只需要實現此接口的方法即可。實際上多數接口轉換是靜態的,并需要在編譯時進行檢查。例如,如果一個函數接收的東西需要實現io.Reader接口,并將一個*os.File傳遞給此函數,則只有在*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.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 接口組合了 Reader 和 Writer 接口.
type ReadWriter interface {
    Reader
    Writer
}

正如該代碼看起來那樣:ReadWriter能夠完成ReaderWriter所完成的工作;它是被嵌入接口(他們的方法不能有交集)的一個并集。只有接口才能被嵌入到接口中。

同樣的理念也可被應用于結構體中,但卻有更多的涵義。bufio包有兩個結構體類型,bufio.Readerbufio.Writer,兩者各自實現了來自io的對應接口。另外bufio還實現了一個緩沖的reader/writer,這是使用嵌入將一個reader和一個writer組合成一個結構體:它在結構體內列出了這些類型但并沒有給出這些類型的字段名稱。

// ReadWriter 包含了指向一個 Reader 和一個 Writer 的指針.
// 它實現了 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.LoggerLogLogf等方法。我們當然也可以給Logger一個字段名稱,但卻沒有必要這么做。現在,一旦完成初始化,我們就可以對Job進行日志記錄:

job.Log("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)}

如果我們需要直接引用嵌入的字段,就使用字段的類型名稱,省略包限定詞,其作用和字段名稱一樣,如同我們在ReaderWriter結構體的Read方法。如果我們需要訪問Job類型的job變量的*log.Logger,寫成job.Logger就行了。當我們想要精確控制Logger的方法時,這種方式將很有用。

func (job *Job) Logf(format string, args ...interface{}) {
    job.Logger.Logf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}

嵌入類型會引入類型沖突的問題,但解決的規則也很簡單。首先,一個字段或方法X將隱藏更深層嵌入類型的X項。如果log.Logger包含一個名稱為Command的字段或方法,將只使用JobCommand字段。

其次,如果相同的嵌套級別上出現相同的名稱,這將出現一個錯誤;如果Job結構體包含另外一個名稱為Logger的字段或方法,則再嵌入log.Logger將產生錯誤。但是,如果重復的名字從未在類型定義以外的程序中用到過,就不會出現問題。這種限定對外部嵌入類型修改的一些保護;如果所添加的一個字段與另一個次級類型中的另一個字段產生沖突,但兩個字段都沒有被用到過,則就不會發生問題。

并發

通過通信共享

并發編程是一個大論題,由于篇幅限制,這里只討論一些Go特有的東西。

要實現對共享變量的正確訪問非常復雜,這使得多數環境中的并發編程都很困難。Go嘗試一種不同的方法,其中共享的值通過信道(channel)進行傳遞,事實上,從來沒有什么東西會被多個執行的線程一直共享。在任何給定的時間,只能有一個goroutine訪問該值。這樣就從設計上杜絕了數據競爭。為了鼓勵這種思考方式,我們將其簡化為一個口號:

不要通過共享內存來進行通信,而應通過通信來共享內存。

這種方式有很多好處。例如,雖然通過將一個整型變量設置為互斥量來實現引用計數是一種非常好的方法。但在高級的方法中,使用信道來控制訪問可以更容易地編寫整潔且正確的程序。

來說明此模型的一個方法是考慮一個運行在單CPU上的典型的單線程程序。它不需要什么同步機制。現在運行另外一個同樣的程序;它同樣也不需要同步。然后讓這兩個程序通信;如果通信正好合拍,同樣也不需要其他的同步。例如,Unix的管道就完美地符合此種模型。盡管Go的并發概念始自Hoare的通信序列處理(CSP),它同樣可被看作是實現了類型安全的Unix管道。

goroutine

之所以叫goroutine(有些地方翻譯為Go程)是因為已有的一些術語——線程、協程、進程等——可能會傳達不準確的涵義。goroutine具有簡單的模型:它是一個與其他goroutine在同一地址空間中并發執行的函數。相對于在棧空間分配內存,它更輕量級且消耗少。這樣起始時棧就可以更小,因此也更輕省,隨著程序的增長,可以根據需要在堆上分配(和釋放)存儲空間。

goroutine可復用多個操作系統線程,因此如果其中的一個被阻塞,比如等待I/O,其他的會繼續運行。這種設計隱藏了許多線程創建和管理的復雜性。

在函數或方法調用的前面加上go關鍵字可在一個新的goroutine中運行調用。當調用完成后,此goroutine將會靜默地退出。(這種效果與Unix shell的&符號可在后臺運行命令的概念相似。)

go list.Sort()  // 并發地運行 list.Sort concurrently; 不必等待其運行結束. 

在goroutine調用中使用函數文字(function literal)會非常方便。

func Announce(message string, delay time.Duration) {
    go func() {
        time.Sleep(delay)
        fmt.Println(message)
    }()  // 注意這里的圓括號 - 必須要調用此函數.
}

在Go中,函數文字就是閉包:其實現能確保被此函數引用的變量只要是活動的(能再次被使用)就一直存在。

由于函數在完成后沒辦法發出信號,這些例子并沒有什么實用性。要做到更有用,就需要信道。

信道

與映射一樣,信道(channel)是引用類型,需要使用make分配內存,結果將得到一個對底層數據結構的引用。如果同時提供了一個可選的整型參數,它將為信道設置緩存的大小。其默認值是零,相當于一個無緩存的或同步的信道。

ci := make(chan int)            // 無緩存的整數類型信道
cj := make(chan int, 0)         // 無緩存的整數類型信道
cs := make(chan *os.File, 100)  // 緩存的信道, 指向 File 的指針

無緩存的信道將以下東西組合在一起:通信——值的交換——同步——確保兩個計算(goroutine)的狀態可知。

使用信道有許多章法。這里先從其中的一個出發。在上一節中我們在后臺啟動了一個排序goroutine。可以通過使用一個信道來使得啟動該排序例程的goroutine等待著排序的完成。

c := make(chan int)  // 為一個信道分配內存
// 在一個 goroutine 中啟動排序; 當其完成后, 向信道發消息.
go func() {
    list.Sort()
    c <- 1  // 發送一個消息; 值是什么不重要
}()
doSomethingForAWhile()
<-c   // 等待排序結束; 丟棄所發送的值

接收者將一直被阻塞,直到收到數據。如果此信道無緩存,發送者將一直被阻塞直到接收者收到了此值。如果信道有一個緩存,發送者僅在值被復制到緩存之前被阻塞;如果緩存滿了,這就意味著需要等到一些接收者取回了一個值。

一個緩存的信道可以被像“臂板信號機”那樣使用,如進行限制通過。在下例中,進入的請求被傳遞到handle,該handle向信道發送一個值,然后處理請求,再然后從信道接收一個值使此“臂板信號機”對下一個消費者可用。信道緩存的容量限制了同時調用process的數量,因此在初始化時我們需要將信道填充到特定的容量來準備信道。

var sem = make(chan int, MaxOutstanding)

func handle(r *Request) {
    sem <- 1    // 等待活動隊列(queue)耗盡.
    process(r)  // 可能要花費很長時間.
    <-sem       // 完成; 使下一個請求可以運行.
}

func Serve(queue chan *Request) {
    for {
        req := <-queue
        go handle(req)  // 不必等待 handle 運行結束.
    }
}

當正在執行process的處理程序數目達到MaxOutstanding時,任何其他的發向此填滿的信道緩存的東西都將被阻塞,直到其中一個已有的處理程序結束執行并再次從信道緩存接收東西。

這種設計有一個問題:Serve為每個進入的請求創建一個新的goroutine,即便是這些信道共同使用的MaxOutstanding隨時都能運行時也是如此(TODO: 此處翻譯需要再斟酌)。這樣如果請求發生太快,可能導致程序無限制地消耗資源。我們可以通過更改Serve來控制goroutine的創建,從而解決這個問題。以下是一個簡單的解決方法,但要注意此段代碼存在一個bug,我們在稍后對其進行修正:

func Serve(queue chan *Request) {
    for req := range queue {
        sem <- 1
        go func() {
            process(req) // Buggy; 請看下面的解釋
            <-sem
        }()
    }
}

其bug就是在Go的for循環中,每次迭代都要重用循環變量,因此req變量會在所有goroutine之間共享。這可不是我們想要的結果。我們需要確保req對每個goroutine都是唯一的。以下是解決此問題的一條途徑,即將req的值作為一個參量到goroutine的閉包中:

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

可將此版本與上個版本進行差異性對比來看閉包是如何聲明和運行的。另一個解決途徑是創建一個同名的新變量,如下例所示:

func Serve(queue chan *Request) {
    for req := range queue {
        req := req // 為 goroutine 創建一個新的 req 實例
        sem <- 1
        go func() {
            process(req)
            <-sem
        }()
    }
}

這樣寫看起來很奇怪

req := req

但它是合法的,也是Go的慣用法。通過這樣你將獲得相同名字的全新的變量,可以特意地在局部位置隱藏循環變量,確保循環變量對每個goroutine都是唯一的。

再次回到寫服務器的常規問題,另一個很好地管理資源的方法是啟動固定數目的handle goroutine,這些goroutine都從請求的信道中讀取。goroutine的數目限制了同時調用的process的數目。這里的Serve函數同樣接受一個信道,該信道將告知此函數何時退出;在啟動goroutine后,它將不再從該信道上接收東西。

func handle(queue chan *Request) {
    for r := range queue {
        process(r)
    }
}

func Serve(clientRequests chan *Request, quit chan bool) {
    // 啟動處理程序
    for i := 0; i < MaxOutstanding; i++ {
        go handle(clientRequests)
    }
    <-quit  // 等待被告知可以退出.
}

信道的信道

Go最重要的特性之一就是信道根本就是值,它可以像其他值一樣被分配內存并進行傳遞。常使用這種特性來實現安全、并行的多路復用。

在上一節的例子中,handle是針對請求的理想化的處理程序,但我們并沒有定義其所處理請求的類型。如果該類型包括它要回復的一個信道,每個客戶端都能提供自己的回答路徑。以下是對類型定義的一個示意。

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)}
// 發送請求
clientRequests <- request
// 等待響應
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

// 將操作應用到 v[i], v[i+1] ... 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    // 發信號表明此操作片段已完成
}

我們在一個循環中獨立地啟動各個計算塊,每個CPU對應對應一塊。他們的完成順序可以是任意的;我們只需要在啟動所有goroutine后通過從信道得到的信號計算出已完成計算塊的數量就行了。

const NCPU = 4  // CPU核心的個數

func (v Vector) DoAll(u Vector) {
    c := make(chan int, NCPU)  // Buffering optional but sensible.
    for i := 0; i < NCPU; i++ {
        go v.DoSome(i*len(v)/NCPU, (i+1)*len(v)/NCPU, u, c)
    }
    // Drain the channel.
    for i := 0; i < NCPU; i++ {
        <-c    // 等待一項任務完成
    }
    // 所有工作已完成.
}

Go運行時的當前實現將默認不并行運行此代碼。它對用戶級處理只投入一個核心。任意數量的goroutine在系統調用時會被阻塞,但默認在任何時候只能有一個內核能執行用戶級代碼。它本該更聰明一點,并且有一天這一點會實現,但目前如果你想使多個CPU并行運行,就必須明確告訴運行時你想要同時執行的goroutine的數目。有兩種相關方法來實現這一目的。要么是使用環境變量GOMAXPROCS設定將要使用的核心數目來運行你的工作,要么是導入runtime包并調用runtime.GOMAXPROCS(NCPU)runtime.NumCPU()的值很有用,它會報告在本地機子上的CPU數目。另外,隨著開發進度的完成以及運行時的改善,將來會不再要求這樣做。

請主要不要混淆并發——將一個程序構造成多個可獨立執行的組件——和并行——為了提高效率在多個CPU上進行并行計算。盡管Go的并發特征可以像并行計算一樣使一些問題更容易構造,但Go是一個并發語言,不是一個并行語言,并非所有的并行問題都適合Go的模型。要了解兩者的區別,請參見這篇博客的討論。

一個漏桶緩存

并發編程工具甚至能使非并發的思想更容易被表達。以下示例的概念是從RPC包抽象而來。客戶端goroutine循環從一些源(可能為一個網絡)接收數據。為了避免對緩存分配和釋放內存,它維持了一個自由列表,并使用一個緩存的信道來代表它。如果該信道是空的,將分配一個新的緩存。一旦此消息緩存就緒,它將通過serverChan送給服務器。

var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)

func client() {
    for {
        var b *Buffer
        // 如果已有緩存,則獲取它,否則對其分配內存。
        select {
        case b = <-freeList:
            // 獲的了一個,其他什么也不做。
        default:
            // 不存在,分配一個新的。
            b = new(Buffer)
        }
        load(b)              // 從網絡讀取下一條消息。
        serverChan <- b      // 發送到服務器。
    }
}

服務器在循環中從客戶端接收每條消息,并對其進行處理,然后向自由列表返回此緩存。

func server() {
    for {
        b := <-serverChan    // 等待工作任務。
        process(b)
        // 在有空間時重用緩存。
        select {
        case freeList <- b:
            // 有緩存在自由列表;其他什么也不做。
        default:
            // 自由列表滿了,繼續下去。
        }
    }
}

客戶端試圖從freeList檢出一個緩存;如果沒有可用的,它將分配一個新的。如果自由列表沒有滿,服務器向freeList的發送將b放回自由列表;如果自由列表滿了,則自由列表下方位置的緩存將被刪除進而被垃圾回收器回收。(當其他的case都不可用時,select語句中的default從句將被執行,這樣selects將永遠不會被阻塞。)這種實現基于緩存的信道和垃圾回收器記賬,僅通過區區幾行代碼,構建了一個漏桶算法的自由列表。

錯誤

計算機庫程序必須經常向調用者返回一些錯誤指示。前面已經提到過,Go的多值返回使其在返回一個常規的返回值之外,還能輕易地返回一個詳細的錯誤描述。通常情況下,錯誤的類型是error,這是一個內建的接口。

type error interface {
    Error() string
}

庫的作者可以在此封裝之下自由地用更豐富的模型實現此接口,從而不僅看到錯誤,并且提供一些上下文。例如,os.Open不僅返回了一個常規的*os.File,它同時還返回了一個錯誤值os.PathError。如果文件被成功打開,該錯誤值為nil,但如果出現問題,該問題將會被保存在os.PathError中:

// PathError 記錄了一個錯誤以及引發此錯誤的操作和文件路徑.
type PathError struct {
    Op string    // "open", "unlink" 等等.
    Path string  // 對應的文件.
    Err error    // 由系統調用返回.
}

func (e *PathError) Error() string {
    return e.Op + " " + e.Path + ": " + e.Err.Error()
}

PathErrorerror產生的字符串樣式為:

open /etc/passwx: no such file or directory

這種錯誤包含了有問題的文件名稱、操作以及其所觸發的操作系統錯誤,打印出這些信息非常有用,即便距離引發此錯誤的調用很遠時也是如此;相對于平白的“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()  // 恢復一些空間.
        continue
    }
    return
}

上面第二個if語句在Go中很常見。通過“逗號ok”的習慣用法(先前已經在檢查映射的上下文中提到過)。如果類型斷言失敗,ok將為假(false),并且e將為nil。如果成功,ok將為真(true),這意味著此錯誤正屬于*os.PathError類型,即為e,我們可以用它對此錯誤的更多信息進行檢查。

嚴重錯誤(Panic)

通常通過返回一個額外的error值來向調用者報告一個錯誤。標準的Read方法就是一個大家熟知的例子;它返回一個字節統計數和一個error。但當錯誤不可恢復時會怎么樣呢?有時只是簡單地讓程序停止運行。

對此,有一個內建的panic函數,它將創建一個運行時錯誤并使程序停止(請繼續看下一節)。該函數接收一個任意類型——往往是字符串——的參量作為程序死亡時要打印的東西。它同樣也是標明已經發生了一些不可能完成事件的一種方法,例如,退出無限循環。事實上,當編譯器在函數的結尾處檢查到一個panic時,就會停止進行常規的return語句檢查。

// 隨意寫的用牛頓方法求解立方根的代碼.
func CubeRoot(x float64) float64 {
    z := x/3   // 任意的初值
    for i := 0; i < 1e6; i++ {
        prevz := z
        z -= (z*z*z-x) / (3*z*z)
        if veryClose(z, prevz) {
            return z
        }
    }
    // 出錯了, 沒有完成百萬次的迭代.
    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)發生嚴重錯誤(panic),其結果將被記錄下來,goroutine會干凈地退出,并不會打斷其他的goroutine。在被延期的閉包中并不需要做其他事情;對recover的調用徹底地處理了這種情況。

由于除非recover直接被一個延期的函數調用,它將總是返回nil,因此被延期的代碼可以調用使用了panicrecover的庫程序而不發生錯誤。例如,在safelyDo中的被延期函數可能在調用recover之前先調用一個日志記錄函數,此記錄代碼的運行并不受錯誤處理(panicking)狀態的影響。

通過合理地使用恢復模式,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再進行檢查,這種檢查是通過斷言err具有局部的Error類型來斷定出現的問題是一個解析錯誤。如果不是解析錯誤,此類型斷言將會失敗,這將引起一個運行時錯誤,從而使堆棧的解開繼續進行下去,就如同不曾有什么打斷過此項解開工作一樣。這種檢查意味著如果發生了一些未遇到到的事情,例如數組索引超限,則即便我們已經使用了panicrecover來處理用戶觸發的錯誤,代碼仍將失敗。

通過合理地使用錯誤處理,error方法使其能很容易地報告解析錯誤而不必操心需要手動解開解析堆棧。

盡管這種模式很有用,它應該只被用于包內。Parse將其內部的panic調用轉變為error值;它沒有向客戶端暴露panics。這是一種需要遵循的好原則。

順被提一下,如果一個實際的錯誤發生了,這種重新觸發嚴重錯誤(re-panic)的習慣用法改變了嚴重錯誤(panic)的值。但是,原來的和新的錯誤都將會出現在崩潰報告中,因此引發問題的根仍然是可見的。因此這種簡單的重新觸發嚴重錯誤的方法通常已經足夠了——它畢竟是一個意外事故——但如果你只想顯示原始值,你可以稍微多寫點代碼來篩選出未遇到的問題并重新觸發此原始的嚴重錯誤。這就在為練習留給讀者了。

一個web服務器

讓我們以一個完整的Go程序作為結束,一個web服務器。該程序實際上是一種web服務的重用。Google在http://chart.apis.google.com提供了一個自動將數據格式化為圖表和圖形的服務。該服務很難交互地使用,這是因為你需要將數據放入URL構成一個查詢。此處的程序為一種數據形式提供了一個更好的接口:給出一小段文本,它將調用圖表服務器產生一個二維碼(QR code),即一個對此文本進行編碼的矩陣框。此圖像可以通過你的手機攝像頭獲取,并被解釋為一個URL,免除了你從手機的小鍵盤上輸入URL的麻煩。

以下是完整的程序。其后的文字是對它的解釋。

package main

import (
    "flag"
    "html/template"
    "log"
    "net/http"
)

var addr = flag.String("addr", ":1718", "http service address") // Q=17, R=18

var templ = template.Must(template.New("qr").Parse(templateStr))

func main() {
    flag.Parse()
    http.Handle("/", http.HandlerFunc(QR))
    err := http.ListenAndServe(*addr, nil)
    if err != nil {
        log.Fatal("ListenAndServe:", err)
    }
}

func QR(w http.ResponseWriter, req *http.Request) {
    templ.Execute(w, req.FormValue("s"))
}

const templateStr = `
<html>
<head>
<title>QR Link Generator</title>
</head>
<body>
{{if .}}
<img src="http://chart.apis.google.com/chart?chs=300x300&cht=qr&choe=UTF-8&chl={{.}}" />
<br>
{{.}}
<br>
<br>
{{end}}
<form action="/" name=f method="GET"><input maxLength=1024 size=70
name=s value="" title="Text to QR Encode"><input type=submit
value="Show QR" name=qr>
</form>
</body>
</html>
`

main程序之前的部分應該很容易理解。一個標記(flag)設置了我們服務器的默認端口。模板變量templ正是有趣之處。它構建了一個將被解析并顯示的網頁的HTML模板;過會兒再細講。

main解析了標記并使用我們在前面已講過的機制將QR函數綁定到服務器的根路徑。然后調用http.ListenAndServe啟動服務器;當服務器運行時它將保持阻塞。

QR只是接收包含表單數據的請求,并在名稱為s的表單值所包含的數據上執行模板。

template包非常強大;該程序將使用了它功能的一點皮毛。本質上,它通過將文本中的元素替換為傳遞到templ.Execute的數據項(上例為表單值)元素來重寫一段文本。在模板文本中(templateStr),雙達括號包括起來的文本標明了模板的行為。而從{{if .}}{{end}}間的文本片段僅在當前數據項(稱作點.)的值為非空時才被執行。也就是說,當此字符串為空時,此部分模板就會被忽略。

{{urlquery .}}片段告知urlquery函數去處理數據,它使查詢字符串可以安全地在web頁面上顯示。

余下的模板字符串只是在頁面加載時將要顯示的HTML。如果你無理解這種快速入門解釋,請參看模板包的template文檔以得到一個更徹底的討論。

這就是你現在得到的:一個僅通過少數幾行代碼實現的有用的web服務器以及一些數據驅動的HTML文本。Go很強大,使其能僅用少數幾行代碼完成大量的工作。

來源:http://www.chingli.com/coding/effective-go/

 本文由用戶 aaanly 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!