關於Go,你可能不注意的7件事

weixin_33958585發表於2018-04-19

轉自: https://tonybai.com/2015/09/17/7-things-you-may-not-pay-attation-to-in-go/

 

Go以簡潔著稱,但簡潔中不乏值得玩味的小細節。這些小細節不如goroutine、interface和channel那樣"高大上","屌 絲"得可能不經常被人注意到,但它們卻對理解Go語言有著重要的作用。這裡想挑出一些和大家一起通過詳實的例子來逐一展開和理解。本文內容較為基礎,適合初學者,高手可飄過:)

一、原始檔字符集和字符集編碼

Go原始碼檔案預設採用Unicode字符集,Unicode碼點(code point)和記憶體中位元組序列(byte sequence)的變換實現使用了UTF-8:一種變長多位元組編碼,同時也是一種事實字符集編碼標準,為Linux、MacOSX 上的預設字符集編碼,因此使用Linux或MacOSX進行Go程式開發,你會省去很多字符集轉換方面的煩惱。但如果你是在Windows上使用 預設編輯器編輯Go原始碼文字,當你編譯以下程式碼時會遇到編譯錯誤:

//hello.go
package main

import "fmt"

func main() {
    fmt.Println("中國人")
}

$ go build hello.go
# command-line-arguments
hello.go:6 illegal UTF-8 sequence d6 d0
hello.go:6 illegal UTF-8 sequence b9
hello.go:6 illegal UTF-8 sequence fa c8
hello.go:6 illegal UTF-8 sequence cb 22
hello.go:6 newline in string
hello.go:7 syntax error: unexpected }, expected )

這是因為Windows預設採用的是CP936字符集編碼,也就是GBK編碼,“中國人”三個字的記憶體位元組序列為:

“d0d6    fab9    cbc8    000a” (通過iconv轉換,然後用od -x檢視)

這個位元組序列並非utf-8位元組序列,Go編譯器因此無法識別。要想通過編譯,需要將該原始檔轉換為UTF-8編碼格式。

字符集編碼對字元和字串字面值(Literal)影響最大,在Go中對於字串我們可以有三種寫法:

1) 字面值

var s = "中國人"

2) 碼點表示法

var s1 = "\u4e2d\u56fd\u4eba"

or

var s2 = "\U00004e2d\U000056fd\U00004eba"

3) 位元組序列表示法(二進位制表示法)

var s3 = "\xe4\xb8\xad\xe5\x9b\xbd\xe4\xba\xba"

這三種表示法中,除字面值轉換為位元組序列儲存時根據編輯器儲存的原始碼檔案編碼格式之外,其他兩種均不受編碼格式影響。我們可以通過逐位元組輸出來查 看位元組序列的內容:

    fmt.Println("s byte sequence:")
    for i := 0; i < len(s); i++ {
        fmt.Printf("0x%x ", s[i])
    }
    fmt.Println("")

二、續行

良好的程式碼style一般會要求程式碼中不能有太long的程式碼行,否則會影響程式碼閱讀者的體驗。在C中有續行符"\"專門用於程式碼續行處理;但在 Go中沒有專屬續行符,如何續行需要依據Go的語法規則(參見Go spec)。

Go與C一樣,都是以分號(";")作為語句結束的標識。不過大多數情況下,分號無需程式設計師手工輸入,而是由編譯器自動識別語句結束位置,並插入 分號。因此續行要選擇合法的位置。下面程式碼展示了一些合法的續行位置:(別嫌太醜,這裡僅僅是展示合法位置的demo)

//details-in-go/2/newline.go
… …
var (
    s = "This is an example about code newline," +
        "for string as right value"
    d = 5 + 4 + 7 +
        4
    a = [...]int{5, 6, 7,
        8}
    m = make(map[string]int,
        100)
    c struct {
        m1     string
        m2, m3 int
        m4     *float64
    }

    f func(int,
        float32) (int,
        error)
)

func foo(int, int) (string, error) {
    return "",
        nil
}

func main() {
    if i := d; i >
        100 {
    }

    var sum int
    for i := 0; i < 100; i = i +
        1 {
        sum += i
    }

    foo(1,
        6)

    var i int
    fmt.Printf("%s, %d\n",
        "this is a demo"+
            " of fmt Printf",
        i)
}

實際編碼中,我們可能經常遇到的是fmt.Printf系列方法中format string太長的情況,但由於Go不支援相鄰字串自動連線(concatenate),只能通過+來連線fmt字串,且+必須放在前一行末尾。另外Gofmt工具會自動調整一些不合理的續行處理,主要針對 for, if等控制語句。

三、Method Set

Method Set是Go語法中一個重要的隱式概念,在為interface變數做動態型別賦值、embeding struct/interface、type alias、method expression時都會用到Method Set這個重要概念。

1、interface的Method Set

根據Go spec,interface型別的Method Set就是其interface(An interface type specifies a method set called its interface)。

type I interface {
    Method1()
    Method2()
}

I的Method Set包含的就是其literal中的兩個方法:Method1和Method2。我們可以通過reflect來獲取interface型別的 Method Set:

//details-in-go/3/interfacemethodset.go
package main

import (
    "fmt"
    "reflect"
)

type I interface {
    Method1()
    Method2()
}

func main() {
    var i *I
    elemType := reflect.TypeOf(i).Elem()
    n := elemType.NumMethod()
    for i := 0; i < n; i++ {
        fmt.Println(elemType.Method(i).Name)
    }
}

執行結果:
$go run interfacemethodset.go
Method1
Method2

2、除interface type外的型別的Method Set

對於非interface type的型別T,其Method Set為所有receiver為T型別的方法組成;而型別*T的Method Set則包含所有receiver為T和*T型別的方法。

// details-in-go/3/othertypemethodset.go
package main

import "./utils"

type T struct {
}

func (t T) Method1() {
}

func (t *T) Method2() {
}

func (t *T) Method3() {
}

func main() {
    var t T
    utils.DumpMethodSet(&t)

    var pt *T
    utils.DumpMethodSet(&pt)
}

我們要dump出T和*T各自的Method Set,執行結果如下:

$go run othertypemethodset.go
main.T's method sets:
     Method1

*main.T's method sets:
     Method1
     Method2
     Method3

可以看出型別T的Method set僅包含一個receiver型別為T的方法:Method1,而*T的Method Set則包含了T的Method Set以及所有receiver型別為*T的Method。

如果此時我們有一個interface type如下:

type I interface {
    Method1()
    Method2()
}

那下面哪個賦值語句合法呢?合不合法完全依賴於右值型別是否實現了interface type I的所有方法,即右值型別的Method Set是否包含了I的 所有方法。

var t T
var pt *T

var i I = t

or

var i I = pt

編譯錯誤告訴我們:

     var i I = t // cannot use t (type T) as type I in assignment:
                  T does not implement I (Method2 method has pointer receiver)

T的Method Set中只有Method1一個方法,沒有實現I介面中的 Method2,因此不能用t賦值給i;而*T實現了I的所有介面,賦值合 法。不過Method set校驗僅限於在賦值給interface變數時進行,無論是T還是*T型別的方法集中的方法,對於T或*T型別變數都是可見且可以呼叫的,如下面程式碼 都是合法的:

    pt.Method1()
    t.Method3()

因為Go編譯器會自動為你的程式碼做receiver轉換:

    pt.Method1() <=> (*pt).Method1()
    t.Method3() <=> (&t).Method3()

很多人糾結於method定義時receiver的型別(T or *T),個人覺得有兩點考慮:

1) 效率
   Go方法呼叫receiver是以傳值的形式傳入方法中的。如果型別size較大,以value形式傳入消耗較大,這時指標型別就是首選。

2) 是否賦值給interface變數、以什麼形式賦值
   就像本節所描述的,由於T和*T的Method Set可能不同,我們在設計Method receiver type時需要考慮在interface賦值時通過對Method set的校驗。

3、embeding type的Method Set

interface embeding

我們先來看看interface型別embeding。例子如下:

//details-in-go/3/embedinginterface.go
package main

import "./utils"

type I1 interface {
    I1Method1()
    I1Method2()
}
type I2 interface {
    I2Method()
}

type I3 interface {
    I1
    I2
}

func main() {
    utils.DumpMethodSet((*I1)(nil))
    utils.DumpMethodSet((*I2)(nil))
    utils.DumpMethodSet((*I3)(nil))
}

$go run embedinginterface.go
main.I1's method sets:
     I1Method1
     I1Method2

main.I2's method sets:
     I2Method

main.I3's method sets:
     I1Method1
     I1Method2
     I2Method

可以看出嵌入interface type的interface type I3的Method Set包含了被嵌入的interface type:I1I2的Method Set。很多情況下,我們Go的interface type中僅包含有少量方法,常常僅是一個Method,通過interface type embeding來定義一個新interface,這是Go的一個慣用法,比如我們常用的io包中的Reader, Writer以及ReadWriter介面:

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

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

type ReadWriter interface {
    Reader
    Writer
}

【struct embeding interface】

在struct中嵌入interface type後,struct的Method Set中將包含interface的Method Set:

type T struct {
    I1
}

func (T) Method1() {

}

… …
func main() {
    … …
    var t T
    utils.DumpMethodSet(&t)
    var pt = &T{
        I1: I1Impl{},
    }
    utils.DumpMethodSet(&pt)

}

輸出結果與預期一致:

main.T's method sets:
     I1Method1
     I1Method2
     Method1

*main.T's method sets:
     I1Method1
     I1Method2
     Method1

【struct embeding struct】

在struct中embeding struct提供了一種“繼承”的手段,外部的Struct可以“繼承”嵌入struct的所有方法(無論receiver是T還是*T型別)實現,但 Method Set可能會略有不同。看下面例子:

//details-in-go/3/embedingstructinstruct.go
package main

import "./utils"

type T struct {
}

func (T) InstMethod1OfT() {

}

func (T) InstMethod2OfT() {

}

func (*T) PtrMethodOfT() {

}

type S struct {
}

func (S) InstMethodOfS() {

}

func (*S) PtrMethodOfS() {
}

type C struct {
    T
    *S
}

func main() {
    var c = C{S: &S{}}
    utils.DumpMethodSet(&c)
    var pc = &C{S: &S{}}
    utils.DumpMethodSet(&pc)

    c.InstMethod1OfT()
    c.PtrMethodOfT()
    c.InstMethodOfS()
    c.PtrMethodOfS()
    pc.InstMethod1OfT()
    pc.PtrMethodOfT()
    pc.InstMethodOfS()
    pc.PtrMethodOfS()
}

$go run embedingstructinstruct.go
main.C's method sets:
     InstMethod1OfT
     InstMethod2OfT
     InstMethodOfS
     PtrMethodOfS

*main.C's method sets:
     InstMethod1OfT
     InstMethod2OfT
     InstMethodOfS
     PtrMethodOfS
     PtrMethodOfT

可以看出:
型別C的Method Set = T的Method Set + *S的Method Set
型別*C的Method Set = *T的Method Set + *S的Method Set

同時通過例子可以看出,無論是T還是*S的方法,C或*C型別變數均可呼叫(編譯器甜頭),不會被侷限在Method Set中。

4、alias type的Method Set

Go支援為已有型別定義alias type,如:

type MyInterface I
type Mystruct T

對於alias type, Method Set是如何定義的呢?我們看下面例子:

//details-in-go/3/aliastypemethodset.go
package main

import "./utils"

type I interface {
    IMethod1()
    IMethod2()
}

type T struct {
}

func (T) InstMethod() {

}
func (*T) PtrMethod() {

}

type MyInterface I
type MyStruct T

func main() {
    utils.DumpMethodSet((*I)(nil))

    var t T
    utils.DumpMethodSet(&t)
    var pt = &T{}
    utils.DumpMethodSet(&pt)

    utils.DumpMethodSet((*MyInterface)(nil))

    var m MyStruct
    utils.DumpMethodSet(&m)
    var pm = &MyStruct{}
    utils.DumpMethodSet(&pm)
}

$go run aliastypemethodset.go
main.I's method sets:
     IMethod1
     IMethod2

main.T's method sets:
     InstMethod

*main.T's method sets:
     InstMethod
     PtrMethod

main.MyInterface's method sets:
     IMethod1
     IMethod2

main.MyStruct's method set is empty!
*main.MyStruct's method set is empty!

從例子的結果上來看,Go對於interface和struct的alias type給出了“不一致”的結果:

MyInterface的Method Set與介面型別I Method Set一致;
而MyStruct並未得到T的哪怕一個Method,MyStruct的Method Set為空。

四、Method Type、Method Expression、Method Value

Go中沒有class,方法與物件通過receiver聯絡在一起,我們可以為任何非builtin型別定義method:

type T struct {
    a int
}

func (t T) Get() int       { return t.a }
func (t *T) Set(a int) int { t.a = a; return t.a }

在C++等OO語言中,物件在呼叫方法時,編譯器會自動在方法的第一個引數中傳入this/self指標,而對於Go來 說,receiver也是同樣道理,將T的method轉換為普通function定義:

func Get(t T) int       { return t.a }
func Set(t *T, a int) int { t.a = a; return t.a }

這種function形式被稱為Method Type,也可以稱為Method的signature

Method的一般使用方式如下:

var t T
t.Get()
t.Set(1)

不過我們也可以像普通function那樣使用它,根據上面的Method Type定義:

var t T
T.Get(t)
(*T).Set(&t, 1)

這種以直接以型別名T呼叫方法M的表達方法稱為Method Expression。型別T只能呼叫T的Method Set中的方法;同理*T只能呼叫*T的Method Set中的方法。上述例子中T的Method Set中只有Get,因此T.Get是合法的。但T.Set則不合法:

    T.Set(2) //invalid method expression T.Set (needs pointer receiver: (*T).Set)

我們只能使用(*T).Set(&t, 11)

這樣看來Method Expression有些類似於C++中的static方法(以該類的某個物件例項作為第一個引數)。

另外Method express自身型別就是一個普通function,可以作為右值賦值給一個函式型別的變數:

    f1 := (*T).Set //函式型別:func (t *T, int)int
    f2 := T.Get //函式型別:func(t T)int
    f1(&t, 3)
    fmt.Println(f2(t))

Go中還定義了一種與Method有關的語法:如果一個表示式t具有靜態型別T,M是T的Method Set中的一個方法,那麼t.M即為Method Value。注意這裡是t.M而不是T.M。

    f3 := (&t).Set //函式型別:func(int)int
    f3(4)
    f4 := t.Get
//函式型別:func()int   
    fmt.Println(f4())

可以看出,Method value與Method Expression不同之處在於,Method value繫結了T物件例項,它的函式原型並不包含Method Expression函式原型中的第一個引數。完整例子參見:details-in-go/4/methodexpressionandmethodvalue.go

五、for range“坑”大閱兵

for range的引入提升了Go的表達能力,但for range顯然不是”免費的午餐“,在享用這個美味前,需要搞清楚for range的一些坑。

1、iteration variable重用

for range的idiomatic的使用方式是使用short variable declaration(:=)形式在for expression中宣告iteration variable,但需要注意的是這些variable在每次迴圈體中都會被重用,而不是重新宣告。

//details-in-go/5/iterationvariable.go
… …
    var m = [...]int{1, 2, 3, 4, 5}

    for i, v := range m {
        go func() {
            time.Sleep(time.Second * 3)
            fmt.Println(i, v)
        }()
    }

    time.Sleep(time.Second * 10)
… …

在我的Mac上,輸出結果如下:

$go run iterationvariable.go
4 5
4 5
4 5
4 5
4 5

各個goroutine中輸出的i,v值都是for range迴圈結束後的i, v最終值,而不是各個goroutine啟動時的i, v值。一個可行的fix方法:

    for i, v := range m {
        go func(i, v int) {
            time.Sleep(time.Second * 3)
            fmt.Println(i, v)
        }(i, v)
    }

2、range expression副本參與iteration

range後面接受的表示式的型別包括:array, pointer to array, slice, string, map和channel(有讀許可權的)。我們以array為例來看一個簡單的例子:

//details-in-go/5/arrayrangeexpression.go
func arrayRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("a = ", a)

    for i, v := range a {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }
        r[i] = v
    }

    fmt.Println("r = ", r)
}

我們期待輸出結果:

a =  [1 2 3 4 5]
r =  [1 12 13 4 5]

a =  [1 12 13 4 5]

但實際輸出結果卻是:

a =  [1 2 3 4 5]
r =  [1 2 3 4 5]
a =  [1 12 13 4 5]

我們原以為在第一次iteration,也就是i = 0時,我們對a的修改(a[1] = 12,a[2] = 13)會在第二次、第三次迴圈中被v取出,但結果卻是v取出的依舊是a被修改前的值:2和3。這就是for range的一個不大不小的坑:range expression副本參與迴圈。也就是說在上面這個例子裡,真正參與迴圈的是a的副本,而不是真正的a,虛擬碼如 下:

    for i, v := range a' {//a' is copy from a
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }
        r[i] = v
    }

Go中的陣列在內部表示為連續的位元組序列,雖然長度是Go陣列型別的一部分,但長度並不包含的陣列的內部表示中,而是由編譯器在編譯期計算出 來。這個例子中,對range表示式的拷貝,即對一個陣列的拷貝,a'則是Go臨時分配的連續位元組序列,與a完全不是一塊記憶體。因此無論a被 如何修改,其副本a'依舊保持原值,並且參與迴圈的是a',因此v從a'中取出的仍舊是a的原值,而非修改後的值。

我們再來試試pointer to array:

func pointerToArrayRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("pointerToArrayRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range &a {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }

        r[i] = v
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
    fmt.Println("")
}

這回的輸出結果如下:

pointerToArrayRangeExpression result:
a =  [1 2 3 4 5]
r =  [1 12 13 4 5]
a =  [1 12 13 4 5]

我們看到這次r陣列的值與最終a被修改後的值一致了。這個例子中我們使用了*[5]int作為range表示式,其副本依舊是一個指向原陣列 a的指標,因此後續所有迴圈中均是&a指向的原陣列親自參與的,因此v能從&a指向的原陣列中取出a修改後的值。

idiomatic go建議我們儘可能的用slice替換掉array的使用,這裡用slice能否實現預期的目標呢?我們來試試:

func sliceRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("sliceRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range a[:] {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }

        r[i] = v
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
    fmt.Println("")
}

pointerToArrayRangeExpression result:
a =  [1 2 3 4 5]
r =  [1 12 13 4 5]
a =  [1 12 13 4 5]

顯然用slice也能實現預期要求。我們可以分析一下slice是如何做到的。slice在go的內部表示為一個struct,由(*T, len, cap)組成,其中*T指向slice對應的underlying array的指標,len是slice當前長度,cap為slice的最大容量。當range進行expression複製時,它實際上覆制的是一個 slice,也就是那個struct。副本struct中的*T依舊指向原slice對應的array,為此對slice的修改都反映到 underlying array a上去了,v從副本struct中*T指向的underlying array中獲取陣列元素,也就得到了被修改後的元素值。

slice與array還有一個不同點,就是其len在執行時可以被改變,而array的len是一個常量,不可改變。那麼len變化的 slice對for range有何影響呢?我們繼續看一個例子:

func sliceLenChangeRangeExpression() {
    var a = []int{1, 2, 3, 4, 5}
    var r = make([]int, 0)

    fmt.Println("sliceLenChangeRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range a {
        if i == 0 {
            a = append(a, 6, 7)
        }

        r = append(r, v)
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
}

輸出結果:

a =  [1 2 3 4 5]
r =  [1 2 3 4 5]
a =  [1 2 3 4 5 6 7]

在這個例子中,原slice a在for range過程中被附加了兩個元素6和7,其len由5增加到7,但這對於r卻沒有產生影響。這裡的原因就在於a的副本a'的內部表示struct中的 len欄位並沒有改變,依舊是5,因此for range只會迴圈5次,也就只獲取a對應的underlying陣列的前5個元素。

range的副本行為會帶來一些效能上的消耗,尤其是當range expression的型別為陣列時,range需要複製整個陣列;而當range expression型別為pointer to array或slice時,這個消耗將小得多,僅僅需要複製一個指標或一個slice的內部表示(一個struct)即可。我們可以通過 benchmark test來看一下三種情況的消耗情況對比:

對於元素個數為100的int陣列或slice,測試結果如下:

//details-in-go/5/arraybenchmark
go test -bench=.
testing: warning: no tests to run
PASS
BenchmarkArrayRangeLoop-4             20000000           116 ns/op
BenchmarkPointerToArrayRangeLoop-4    20000000            64.5 ns/op
BenchmarkSliceRangeLoop-4             20000000            70.9 ns/op

可以看到range expression型別為slice或pointer to array的效能相近,消耗都近乎是陣列型別的1/2。

3、其他range expression型別

對於range後面的其他表示式型別,比如string, map, channel,for range依舊會製作副本。

【string】
對string來說,由於string的內部表示為struct {*byte, len),並且string本身是immutable的,因此其行為和消耗和slice expression類似。不過for range對於string來說,每次迴圈的單位是rune(code point的值),而不是byte,index為迭代字元碼點的第一個位元組的position:

    var s = "中國人"

    for i, v := range s {
        fmt.Printf("%d %s 0x%x\n", i, string(v), v)
    }

輸出結果:
0 中 0x4e2d
3 國 0x56fd
6 人 0x4eba

如果s中存在非法utf8位元組序列,那麼v將返回0xFFFD這個特殊值,並且在接下來一輪迴圈中,v將僅前進一個位元組:

//byte sequence of s: 0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba
    var sl = []byte{0xe4, 0xb8, 0xad, 0xe5, 0x9b, 0xbd, 0xe4, 0xba, 0xba}
    for _, v := range sl {
        fmt.Printf("0x%x ", v)
    }
    fmt.Println("\n")

    sl[3] = 0xd0
    sl[4] = 0xd6
    sl[5] = 0xb9

    for i, v := range string(sl) {
        fmt.Printf("%d %x\n", i, v)
    }

輸出結果:

0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba

0 4e2d
3 fffd
4 5b9
6 4eba

以上例子原始碼在details-in-go/5/stringrangeexpression.go中可以找到。

map

對於map來說,map內部表示為一個指標,指標副本也指向真實map,因此for range操作均操作的是源map。

for range不保證每次迭代的元素次序,對於下面程式碼:

 var m = map[string]int{
        "tony": 21,
        "tom":  22,
        "jim":  23,
    }

    for k, v := range m {
        fmt.Println(k, v)
    }

輸出結果可能是:

tom 22
jim 23
tony 21

也可能是:

tony 21
tom 22
jim 23

或其他可能。

如果map中的某項在迴圈到達前被在迴圈體中刪除了,那麼它將不會被iteration variable獲取到。
    counter := 0
    for k, v := range m {
        if counter == 0 {
            delete(m, "tony")
        }
        counter++
        fmt.Println(k, v)
    }
    fmt.Println("counter is ", counter)

反覆執行多次,我們得到的兩個結果:

tony 21
tom 22
jim 23
counter is  3

tom 22
jim 23
counter is  2

如果在迴圈體中新建立一個map元素項,那該項元素可能出現在後續迴圈中,也可能不出現:

    m["tony"] = 21
    counter = 0

    for k, v := range m {
        if counter == 0 {
            m["lucy"] = 24
        }
        counter++
        fmt.Println(k, v)
    }
    fmt.Println("counter is ", counter)

執行結果:

tony 21
tom 22
jim 23
lucy 24
counter is  4

or

tony 21
tom 22
jim 23
counter is  3

以上程式碼可以在details-in-go/5/maprangeexpression.go中可以找到。

【channel】

對於channel來說,channel內部表示為一個指標,channel的指標副本也指向真實channel。

for range最終以阻塞讀的方式阻塞在channel expression上(即便是buffered channel,當channel中無資料時,for range也會阻塞在channel上),直到channel關閉:

//details-in-go/5/channelrangeexpression.go
func main() {
    var c = make(chan int)

    go func() {
        time.Sleep(time.Second * 3)
        c <- 1
        c <- 2
        c <- 3
        close(c)
    }()

    for v := range c {
        fmt.Println(v)
    }
}

執行結果:

1
2
3

如果channel變數為nil,則for range將永遠阻塞。

六、select求值 

golang引入的select為我們提供了一種在多個channel間實現“多路複用”的一種機制。select的執行機制這裡不贅述,但select的case expression的求值順序我們倒是要通過一個例子來了解一下:

// details-in-go/6/select.go

func takeARecvChannel() chan int {
    fmt.Println("invoke takeARecvChannel")
    c := make(chan int)

    go func() {
        time.Sleep(3 * time.Second)
        c <- 1
    }()

    return c
}

func getAStorageArr() *[5]int {
    fmt.Println("invoke getAStorageArr")
    var a [5]int
    return &a
}

func takeASendChannel() chan int {
    fmt.Println("invoke takeASendChannel")
    return make(chan int)
}

func getANumToChannel() int {
    fmt.Println("invoke getANumToChannel")
    return 2
}

func main() {
    select {
    //recv channels
    case (getAStorageArr())[0] = <-takeARecvChannel():
        fmt.Println("recv something from a recv channel")

        //send channels
    case takeASendChannel() <- getANumToChannel():
        fmt.Println("send something to a send channel")
    }
}

執行結果:

$go run select.go
invoke takeARecvChannel
invoke takeASendChannel
invoke getANumToChannel

invoke getAStorageArr
recv something from a recv channel

通過例子我們可以看出:
1) select執行開始時,首先所有case expression的表示式都會被求值一遍,按語法先後次序。

invoke takeARecvChannel
invoke takeASendChannel
invoke getANumToChannel

例外的是recv channel的位於賦值等號左邊的表示式(這裡是:(getAStorageArr())[0])不會被求值。

2) 如果選擇要執行的case是一個recv channel,那麼它的賦值等號左邊的表示式會被求值:如例子中當goroutine 3s後向recvchan寫入一個int值後,select選擇了recv channel執行,此時對=左側的表示式 (getAStorageArr())[0] 開始求值,輸出“invoke getAStorageArr”。

七、panic的recover過程

Go沒有提供“try-catch-finally”這樣的異常處理設施,而僅僅提供了panic和recover,其中recover還要結合 defer使用。最初這也是被一些人詬病的點。但和錯誤碼返回值一樣,漸漸的大家似乎適應了這些,征討之聲漸稀,即便有也是排在“缺少generics” 之後了。

【panicking】

在沒有recover的時候,一旦panic發生,panic會按既定順序結束當前程式,這一過程成為panicking。下面的例子模擬了這一過程:

//details-in-go/7/panicking.go
… …
func foo() {
    defer func() {
        fmt.Println("foo defer func invoked")
    }()
    fmt.Println("foo invoked")

    bar()
    fmt.Println("do something after bar in foo")
}

func bar() {
    defer func() {
        fmt.Println("bar defer func invoked")
    }()
    fmt.Println("bar invoked")

    zoo()
    fmt.Println("do something after zoo in bar")
}

func zoo() {
    defer func() {
        fmt.Println("zoo defer func invoked")
    }()

    fmt.Println("zoo invoked")
    panic("runtime exception")
}

func main() {
    foo()
}

執行結果:

$go run panicking.go
foo invoked
bar invoked
zoo invoked
zoo defer func invoked
bar defer func invoked
foo defer func invoked
panic: runtime exception

goroutine 1 [running]:
… …
exit status 2

從結果可以看出:
    panic在zoo中發生,在zoo真正退出前,zoo中註冊的defer函式會被逐一執行(FILO),由於zoo defer中沒有捕捉panic,因此panic被拋向其caller:bar。
    這時對於bar而言,其函式體中的zoo的呼叫就好像變成了panic呼叫似的,zoo有些類似於“黑客帝國3”中里奧被史密斯(panic)感 染似的,也變成了史密斯(panic)。panic在bar中擴充套件開來,bar中的defer也沒有捕捉和recover panic,因此在bar中的defer func執行完畢後,panic繼續拋給bar的caller: foo;
    這時對於foo而言,bar就變成了panic,同理,最終foo將panic拋給了main
    main與上述函式一樣,沒有recover,直接異常返回,導致程式異常退出。
 

【recover】

recover只有在defer函式中呼叫才能起到recover的作用,這樣recover就和defer函式有了緊密聯絡。我們在zoo的defer函式中捕捉並recover這個panic:

//details-in-go/7/recover.go
… …
func zoo() {
    defer func() {
        fmt.Println("zoo defer func1 invoked")
    }()

    defer func() {
        if x := recover(); x != nil {
            log.Printf("recover panic: %v in zoo recover defer func", x)
        }
    }()

    defer func() {
        fmt.Println("zoo defer func2 invoked")
    }()

    fmt.Println("zoo invoked")
    panic("zoo runtime exception")
}

… …

這回的執行結果如下:

$go run recover.go
foo invoked
bar invoked
zoo invoked
zoo defer func2 invoked
2015/09/17 16:28:00 recover panic: zoo runtime exception in zoo recover defer func
zoo defer func1 invoked
do something after zoo in bar
bar defer func invoked
do something after bar in foo
foo defer func invoked

由於zoo在defer裡恢復了panic,這樣在zoo返回後,bar不會感知到任何異常,將按正常邏輯輸出函式執行內容,比如:“do something after zoo in bar”,以此類推。

但若如果在zoo defer func中recover panic後,又raise another panic,那麼zoo對於bar來說就又會變成panic了。

Last、參考資料

1、The Go Programming Language Specification (Version of August 5, 2015,Go 1.5);
2、Effective Go (Go 1.5);
3、Rob Pike: Go Course Day 1~3

本文實驗環境:Go 1.5 darwin_amd64。示例程式碼在這裡可以下載。

我就是這樣一種人:對任何自己感興趣且有極大熱情去做的事情都喜歡刨根問底,徹底全面地瞭解其中細節,否則我就會有一種“不安全 感”。我不知道在心理學範疇這樣的我屬於那種類別^_^。

© 2015, bigwhite. 版權所有.

 

Related posts:

  1. Go程式設計語言(二)
  2. Go中的系統Signal處理
  3. Go程式設計語言(三)
  4. Go語言標準庫概覽
  5. Golang的演化歷程

 

 

 

關於Go,你可能不注意的7件事

Go以簡潔著稱,但簡潔中不乏值得玩味的小細節。這些小細節不如goroutine、interface和channel那樣"高大上","屌 絲"得可能不經常被人注意到,但它們卻對理解Go語言有著重要的作用。這裡想挑出一些和大家一起通過詳實的例子來逐一展開和理解。本文內容較為基礎,適合初學者,高手可飄過:)

一、原始檔字符集和字符集編碼

Go原始碼檔案預設採用Unicode字符集,Unicode碼點(code point)和記憶體中位元組序列(byte sequence)的變換實現使用了UTF-8:一種變長多位元組編碼,同時也是一種事實字符集編碼標準,為Linux、MacOSX 上的預設字符集編碼,因此使用Linux或MacOSX進行Go程式開發,你會省去很多字符集轉換方面的煩惱。但如果你是在Windows上使用 預設編輯器編輯Go原始碼文字,當你編譯以下程式碼時會遇到編譯錯誤:

//hello.go
package main

import "fmt"

func main() {
    fmt.Println("中國人")
}

$ go build hello.go
# command-line-arguments
hello.go:6 illegal UTF-8 sequence d6 d0
hello.go:6 illegal UTF-8 sequence b9
hello.go:6 illegal UTF-8 sequence fa c8
hello.go:6 illegal UTF-8 sequence cb 22
hello.go:6 newline in string
hello.go:7 syntax error: unexpected }, expected )

這是因為Windows預設採用的是CP936字符集編碼,也就是GBK編碼,“中國人”三個字的記憶體位元組序列為:

“d0d6    fab9    cbc8    000a” (通過iconv轉換,然後用od -x檢視)

這個位元組序列並非utf-8位元組序列,Go編譯器因此無法識別。要想通過編譯,需要將該原始檔轉換為UTF-8編碼格式。

字符集編碼對字元和字串字面值(Literal)影響最大,在Go中對於字串我們可以有三種寫法:

1) 字面值

var s = "中國人"

2) 碼點表示法

var s1 = "\u4e2d\u56fd\u4eba"

or

var s2 = "\U00004e2d\U000056fd\U00004eba"

3) 位元組序列表示法(二進位制表示法)

var s3 = "\xe4\xb8\xad\xe5\x9b\xbd\xe4\xba\xba"

這三種表示法中,除字面值轉換為位元組序列儲存時根據編輯器儲存的原始碼檔案編碼格式之外,其他兩種均不受編碼格式影響。我們可以通過逐位元組輸出來查 看位元組序列的內容:

    fmt.Println("s byte sequence:")
    for i := 0; i < len(s); i++ {
        fmt.Printf("0x%x ", s[i])
    }
    fmt.Println("")

二、續行

良好的程式碼style一般會要求程式碼中不能有太long的程式碼行,否則會影響程式碼閱讀者的體驗。在C中有續行符"\"專門用於程式碼續行處理;但在 Go中沒有專屬續行符,如何續行需要依據Go的語法規則(參見Go spec)。

Go與C一樣,都是以分號(";")作為語句結束的標識。不過大多數情況下,分號無需程式設計師手工輸入,而是由編譯器自動識別語句結束位置,並插入 分號。因此續行要選擇合法的位置。下面程式碼展示了一些合法的續行位置:(別嫌太醜,這裡僅僅是展示合法位置的demo)

//details-in-go/2/newline.go
… …
var (
    s = "This is an example about code newline," +
        "for string as right value"
    d = 5 + 4 + 7 +
        4
    a = [...]int{5, 6, 7,
        8}
    m = make(map[string]int,
        100)
    c struct {
        m1     string
        m2, m3 int
        m4     *float64
    }

    f func(int,
        float32) (int,
        error)
)

func foo(int, int) (string, error) {
    return "",
        nil
}

func main() {
    if i := d; i >
        100 {
    }

    var sum int
    for i := 0; i < 100; i = i +
        1 {
        sum += i
    }

    foo(1,
        6)

    var i int
    fmt.Printf("%s, %d\n",
        "this is a demo"+
            " of fmt Printf",
        i)
}

實際編碼中,我們可能經常遇到的是fmt.Printf系列方法中format string太長的情況,但由於Go不支援相鄰字串自動連線(concatenate),只能通過+來連線fmt字串,且+必須放在前一行末尾。另外Gofmt工具會自動調整一些不合理的續行處理,主要針對 for, if等控制語句。

三、Method Set

Method Set是Go語法中一個重要的隱式概念,在為interface變數做動態型別賦值、embeding struct/interface、type alias、method expression時都會用到Method Set這個重要概念。

1、interface的Method Set

根據Go spec,interface型別的Method Set就是其interface(An interface type specifies a method set called its interface)。

type I interface {
    Method1()
    Method2()
}

I的Method Set包含的就是其literal中的兩個方法:Method1和Method2。我們可以通過reflect來獲取interface型別的 Method Set:

//details-in-go/3/interfacemethodset.go
package main

import (
    "fmt"
    "reflect"
)

type I interface {
    Method1()
    Method2()
}

func main() {
    var i *I
    elemType := reflect.TypeOf(i).Elem()
    n := elemType.NumMethod()
    for i := 0; i < n; i++ {
        fmt.Println(elemType.Method(i).Name)
    }
}

執行結果:
$go run interfacemethodset.go
Method1
Method2

2、除interface type外的型別的Method Set

對於非interface type的型別T,其Method Set為所有receiver為T型別的方法組成;而型別*T的Method Set則包含所有receiver為T和*T型別的方法。

// details-in-go/3/othertypemethodset.go
package main

import "./utils"

type T struct {
}

func (t T) Method1() {
}

func (t *T) Method2() {
}

func (t *T) Method3() {
}

func main() {
    var t T
    utils.DumpMethodSet(&t)

    var pt *T
    utils.DumpMethodSet(&pt)
}

我們要dump出T和*T各自的Method Set,執行結果如下:

$go run othertypemethodset.go
main.T's method sets:
     Method1

*main.T's method sets:
     Method1
     Method2
     Method3

可以看出型別T的Method set僅包含一個receiver型別為T的方法:Method1,而*T的Method Set則包含了T的Method Set以及所有receiver型別為*T的Method。

如果此時我們有一個interface type如下:

type I interface {
    Method1()
    Method2()
}

那下面哪個賦值語句合法呢?合不合法完全依賴於右值型別是否實現了interface type I的所有方法,即右值型別的Method Set是否包含了I的 所有方法。

var t T
var pt *T

var i I = t

or

var i I = pt

編譯錯誤告訴我們:

     var i I = t // cannot use t (type T) as type I in assignment:
                  T does not implement I (Method2 method has pointer receiver)

T的Method Set中只有Method1一個方法,沒有實現I介面中的 Method2,因此不能用t賦值給i;而*T實現了I的所有介面,賦值合 法。不過Method set校驗僅限於在賦值給interface變數時進行,無論是T還是*T型別的方法集中的方法,對於T或*T型別變數都是可見且可以呼叫的,如下面程式碼 都是合法的:

    pt.Method1()
    t.Method3()

因為Go編譯器會自動為你的程式碼做receiver轉換:

    pt.Method1() <=> (*pt).Method1()
    t.Method3() <=> (&t).Method3()

很多人糾結於method定義時receiver的型別(T or *T),個人覺得有兩點考慮:

1) 效率
   Go方法呼叫receiver是以傳值的形式傳入方法中的。如果型別size較大,以value形式傳入消耗較大,這時指標型別就是首選。

2) 是否賦值給interface變數、以什麼形式賦值
   就像本節所描述的,由於T和*T的Method Set可能不同,我們在設計Method receiver type時需要考慮在interface賦值時通過對Method set的校驗。

3、embeding type的Method Set

interface embeding

我們先來看看interface型別embeding。例子如下:

//details-in-go/3/embedinginterface.go
package main

import "./utils"

type I1 interface {
    I1Method1()
    I1Method2()
}
type I2 interface {
    I2Method()
}

type I3 interface {
    I1
    I2
}

func main() {
    utils.DumpMethodSet((*I1)(nil))
    utils.DumpMethodSet((*I2)(nil))
    utils.DumpMethodSet((*I3)(nil))
}

$go run embedinginterface.go
main.I1's method sets:
     I1Method1
     I1Method2

main.I2's method sets:
     I2Method

main.I3's method sets:
     I1Method1
     I1Method2
     I2Method

可以看出嵌入interface type的interface type I3的Method Set包含了被嵌入的interface type:I1I2的Method Set。很多情況下,我們Go的interface type中僅包含有少量方法,常常僅是一個Method,通過interface type embeding來定義一個新interface,這是Go的一個慣用法,比如我們常用的io包中的Reader, Writer以及ReadWriter介面:

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

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

type ReadWriter interface {
    Reader
    Writer
}

【struct embeding interface】

在struct中嵌入interface type後,struct的Method Set中將包含interface的Method Set:

type T struct {
    I1
}

func (T) Method1() {

}

… …
func main() {
    … …
    var t T
    utils.DumpMethodSet(&t)
    var pt = &T{
        I1: I1Impl{},
    }
    utils.DumpMethodSet(&pt)

}

輸出結果與預期一致:

main.T's method sets:
     I1Method1
     I1Method2
     Method1

*main.T's method sets:
     I1Method1
     I1Method2
     Method1

【struct embeding struct】

在struct中embeding struct提供了一種“繼承”的手段,外部的Struct可以“繼承”嵌入struct的所有方法(無論receiver是T還是*T型別)實現,但 Method Set可能會略有不同。看下面例子:

//details-in-go/3/embedingstructinstruct.go
package main

import "./utils"

type T struct {
}

func (T) InstMethod1OfT() {

}

func (T) InstMethod2OfT() {

}

func (*T) PtrMethodOfT() {

}

type S struct {
}

func (S) InstMethodOfS() {

}

func (*S) PtrMethodOfS() {
}

type C struct {
    T
    *S
}

func main() {
    var c = C{S: &S{}}
    utils.DumpMethodSet(&c)
    var pc = &C{S: &S{}}
    utils.DumpMethodSet(&pc)

    c.InstMethod1OfT()
    c.PtrMethodOfT()
    c.InstMethodOfS()
    c.PtrMethodOfS()
    pc.InstMethod1OfT()
    pc.PtrMethodOfT()
    pc.InstMethodOfS()
    pc.PtrMethodOfS()
}

$go run embedingstructinstruct.go
main.C's method sets:
     InstMethod1OfT
     InstMethod2OfT
     InstMethodOfS
     PtrMethodOfS

*main.C's method sets:
     InstMethod1OfT
     InstMethod2OfT
     InstMethodOfS
     PtrMethodOfS
     PtrMethodOfT

可以看出:
型別C的Method Set = T的Method Set + *S的Method Set
型別*C的Method Set = *T的Method Set + *S的Method Set

同時通過例子可以看出,無論是T還是*S的方法,C或*C型別變數均可呼叫(編譯器甜頭),不會被侷限在Method Set中。

4、alias type的Method Set

Go支援為已有型別定義alias type,如:

type MyInterface I
type Mystruct T

對於alias type, Method Set是如何定義的呢?我們看下面例子:

//details-in-go/3/aliastypemethodset.go
package main

import "./utils"

type I interface {
    IMethod1()
    IMethod2()
}

type T struct {
}

func (T) InstMethod() {

}
func (*T) PtrMethod() {

}

type MyInterface I
type MyStruct T

func main() {
    utils.DumpMethodSet((*I)(nil))

    var t T
    utils.DumpMethodSet(&t)
    var pt = &T{}
    utils.DumpMethodSet(&pt)

    utils.DumpMethodSet((*MyInterface)(nil))

    var m MyStruct
    utils.DumpMethodSet(&m)
    var pm = &MyStruct{}
    utils.DumpMethodSet(&pm)
}

$go run aliastypemethodset.go
main.I's method sets:
     IMethod1
     IMethod2

main.T's method sets:
     InstMethod

*main.T's method sets:
     InstMethod
     PtrMethod

main.MyInterface's method sets:
     IMethod1
     IMethod2

main.MyStruct's method set is empty!
*main.MyStruct's method set is empty!

從例子的結果上來看,Go對於interface和struct的alias type給出了“不一致”的結果:

MyInterface的Method Set與介面型別I Method Set一致;
而MyStruct並未得到T的哪怕一個Method,MyStruct的Method Set為空。

四、Method Type、Method Expression、Method Value

Go中沒有class,方法與物件通過receiver聯絡在一起,我們可以為任何非builtin型別定義method:

type T struct {
    a int
}

func (t T) Get() int       { return t.a }
func (t *T) Set(a int) int { t.a = a; return t.a }

在C++等OO語言中,物件在呼叫方法時,編譯器會自動在方法的第一個引數中傳入this/self指標,而對於Go來 說,receiver也是同樣道理,將T的method轉換為普通function定義:

func Get(t T) int       { return t.a }
func Set(t *T, a int) int { t.a = a; return t.a }

這種function形式被稱為Method Type,也可以稱為Method的signature

Method的一般使用方式如下:

var t T
t.Get()
t.Set(1)

不過我們也可以像普通function那樣使用它,根據上面的Method Type定義:

var t T
T.Get(t)
(*T).Set(&t, 1)

這種以直接以型別名T呼叫方法M的表達方法稱為Method Expression。型別T只能呼叫T的Method Set中的方法;同理*T只能呼叫*T的Method Set中的方法。上述例子中T的Method Set中只有Get,因此T.Get是合法的。但T.Set則不合法:

    T.Set(2) //invalid method expression T.Set (needs pointer receiver: (*T).Set)

我們只能使用(*T).Set(&t, 11)

這樣看來Method Expression有些類似於C++中的static方法(以該類的某個物件例項作為第一個引數)。

另外Method express自身型別就是一個普通function,可以作為右值賦值給一個函式型別的變數:

    f1 := (*T).Set //函式型別:func (t *T, int)int
    f2 := T.Get //函式型別:func(t T)int
    f1(&t, 3)
    fmt.Println(f2(t))

Go中還定義了一種與Method有關的語法:如果一個表示式t具有靜態型別T,M是T的Method Set中的一個方法,那麼t.M即為Method Value。注意這裡是t.M而不是T.M。

    f3 := (&t).Set //函式型別:func(int)int
    f3(4)
    f4 := t.Get
//函式型別:func()int   
    fmt.Println(f4())

可以看出,Method value與Method Expression不同之處在於,Method value繫結了T物件例項,它的函式原型並不包含Method Expression函式原型中的第一個引數。完整例子參見:details-in-go/4/methodexpressionandmethodvalue.go

五、for range“坑”大閱兵

for range的引入提升了Go的表達能力,但for range顯然不是”免費的午餐“,在享用這個美味前,需要搞清楚for range的一些坑。

1、iteration variable重用

for range的idiomatic的使用方式是使用short variable declaration(:=)形式在for expression中宣告iteration variable,但需要注意的是這些variable在每次迴圈體中都會被重用,而不是重新宣告。

//details-in-go/5/iterationvariable.go
… …
    var m = [...]int{1, 2, 3, 4, 5}

    for i, v := range m {
        go func() {
            time.Sleep(time.Second * 3)
            fmt.Println(i, v)
        }()
    }

    time.Sleep(time.Second * 10)
… …

在我的Mac上,輸出結果如下:

$go run iterationvariable.go
4 5
4 5
4 5
4 5
4 5

各個goroutine中輸出的i,v值都是for range迴圈結束後的i, v最終值,而不是各個goroutine啟動時的i, v值。一個可行的fix方法:

    for i, v := range m {
        go func(i, v int) {
            time.Sleep(time.Second * 3)
            fmt.Println(i, v)
        }(i, v)
    }

2、range expression副本參與iteration

range後面接受的表示式的型別包括:array, pointer to array, slice, string, map和channel(有讀許可權的)。我們以array為例來看一個簡單的例子:

//details-in-go/5/arrayrangeexpression.go
func arrayRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("a = ", a)

    for i, v := range a {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }
        r[i] = v
    }

    fmt.Println("r = ", r)
}

我們期待輸出結果:

a =  [1 2 3 4 5]
r =  [1 12 13 4 5]

a =  [1 12 13 4 5]

但實際輸出結果卻是:

a =  [1 2 3 4 5]
r =  [1 2 3 4 5]
a =  [1 12 13 4 5]

我們原以為在第一次iteration,也就是i = 0時,我們對a的修改(a[1] = 12,a[2] = 13)會在第二次、第三次迴圈中被v取出,但結果卻是v取出的依舊是a被修改前的值:2和3。這就是for range的一個不大不小的坑:range expression副本參與迴圈。也就是說在上面這個例子裡,真正參與迴圈的是a的副本,而不是真正的a,虛擬碼如 下:

    for i, v := range a' {//a' is copy from a
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }
        r[i] = v
    }

Go中的陣列在內部表示為連續的位元組序列,雖然長度是Go陣列型別的一部分,但長度並不包含的陣列的內部表示中,而是由編譯器在編譯期計算出 來。這個例子中,對range表示式的拷貝,即對一個陣列的拷貝,a'則是Go臨時分配的連續位元組序列,與a完全不是一塊記憶體。因此無論a被 如何修改,其副本a'依舊保持原值,並且參與迴圈的是a',因此v從a'中取出的仍舊是a的原值,而非修改後的值。

我們再來試試pointer to array:

func pointerToArrayRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("pointerToArrayRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range &a {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }

        r[i] = v
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
    fmt.Println("")
}

這回的輸出結果如下:

pointerToArrayRangeExpression result:
a =  [1 2 3 4 5]
r =  [1 12 13 4 5]
a =  [1 12 13 4 5]

我們看到這次r陣列的值與最終a被修改後的值一致了。這個例子中我們使用了*[5]int作為range表示式,其副本依舊是一個指向原陣列 a的指標,因此後續所有迴圈中均是&a指向的原陣列親自參與的,因此v能從&a指向的原陣列中取出a修改後的值。

idiomatic go建議我們儘可能的用slice替換掉array的使用,這裡用slice能否實現預期的目標呢?我們來試試:

func sliceRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("sliceRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range a[:] {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }

        r[i] = v
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
    fmt.Println("")
}

pointerToArrayRangeExpression result:
a =  [1 2 3 4 5]
r =  [1 12 13 4 5]
a =  [1 12 13 4 5]

顯然用slice也能實現預期要求。我們可以分析一下slice是如何做到的。slice在go的內部表示為一個struct,由(*T, len, cap)組成,其中*T指向slice對應的underlying array的指標,len是slice當前長度,cap為slice的最大容量。當range進行expression複製時,它實際上覆制的是一個 slice,也就是那個struct。副本struct中的*T依舊指向原slice對應的array,為此對slice的修改都反映到 underlying array a上去了,v從副本struct中*T指向的underlying array中獲取陣列元素,也就得到了被修改後的元素值。

slice與array還有一個不同點,就是其len在執行時可以被改變,而array的len是一個常量,不可改變。那麼len變化的 slice對for range有何影響呢?我們繼續看一個例子:

func sliceLenChangeRangeExpression() {
    var a = []int{1, 2, 3, 4, 5}
    var r = make([]int, 0)

    fmt.Println("sliceLenChangeRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range a {
        if i == 0 {
            a = append(a, 6, 7)
        }

        r = append(r, v)
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
}

輸出結果:

a =  [1 2 3 4 5]
r =  [1 2 3 4 5]
a =  [1 2 3 4 5 6 7]

在這個例子中,原slice a在for range過程中被附加了兩個元素6和7,其len由5增加到7,但這對於r卻沒有產生影響。這裡的原因就在於a的副本a'的內部表示struct中的 len欄位並沒有改變,依舊是5,因此for range只會迴圈5次,也就只獲取a對應的underlying陣列的前5個元素。

range的副本行為會帶來一些效能上的消耗,尤其是當range expression的型別為陣列時,range需要複製整個陣列;而當range expression型別為pointer to array或slice時,這個消耗將小得多,僅僅需要複製一個指標或一個slice的內部表示(一個struct)即可。我們可以通過 benchmark test來看一下三種情況的消耗情況對比:

對於元素個數為100的int陣列或slice,測試結果如下:

//details-in-go/5/arraybenchmark
go test -bench=.
testing: warning: no tests to run
PASS
BenchmarkArrayRangeLoop-4             20000000           116 ns/op
BenchmarkPointerToArrayRangeLoop-4    20000000            64.5 ns/op
BenchmarkSliceRangeLoop-4             20000000            70.9 ns/op

可以看到range expression型別為slice或pointer to array的效能相近,消耗都近乎是陣列型別的1/2。

3、其他range expression型別

對於range後面的其他表示式型別,比如string, map, channel,for range依舊會製作副本。

【string】
對string來說,由於string的內部表示為struct {*byte, len),並且string本身是immutable的,因此其行為和消耗和slice expression類似。不過for range對於string來說,每次迴圈的單位是rune(code point的值),而不是byte,index為迭代字元碼點的第一個位元組的position:

    var s = "中國人"

    for i, v := range s {
        fmt.Printf("%d %s 0x%x\n", i, string(v), v)
    }

輸出結果:
0 中 0x4e2d
3 國 0x56fd
6 人 0x4eba

如果s中存在非法utf8位元組序列,那麼v將返回0xFFFD這個特殊值,並且在接下來一輪迴圈中,v將僅前進一個位元組:

//byte sequence of s: 0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba
    var sl = []byte{0xe4, 0xb8, 0xad, 0xe5, 0x9b, 0xbd, 0xe4, 0xba, 0xba}
    for _, v := range sl {
        fmt.Printf("0x%x ", v)
    }
    fmt.Println("\n")

    sl[3] = 0xd0
    sl[4] = 0xd6
    sl[5] = 0xb9

    for i, v := range string(sl) {
        fmt.Printf("%d %x\n", i, v)
    }

輸出結果:

0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba

0 4e2d
3 fffd
4 5b9
6 4eba

以上例子原始碼在details-in-go/5/stringrangeexpression.go中可以找到。

map

對於map來說,map內部表示為一個指標,指標副本也指向真實map,因此for range操作均操作的是源map。

for range不保證每次迭代的元素次序,對於下面程式碼:

 var m = map[string]int{
        "tony": 21,
        "tom":  22,
        "jim":  23,
    }

    for k, v := range m {
        fmt.Println(k, v)
    }

輸出結果可能是:

tom 22
jim 23
tony 21

也可能是:

tony 21
tom 22
jim 23

或其他可能。

如果map中的某項在迴圈到達前被在迴圈體中刪除了,那麼它將不會被iteration variable獲取到。
    counter := 0
    for k, v := range m {
        if counter == 0 {
            delete(m, "tony")
        }
        counter++
        fmt.Println(k, v)
    }
    fmt.Println("counter is ", counter)

反覆執行多次,我們得到的兩個結果:

tony 21
tom 22
jim 23
counter is  3

tom 22
jim 23
counter is  2

如果在迴圈體中新建立一個map元素項,那該項元素可能出現在後續迴圈中,也可能不出現:

    m["tony"] = 21
    counter = 0

    for k, v := range m {
        if counter == 0 {
            m["lucy"] = 24
        }
        counter++
        fmt.Println(k, v)
    }
    fmt.Println("counter is ", counter)

執行結果:

tony 21
tom 22
jim 23
lucy 24
counter is  4

or

tony 21
tom 22
jim 23
counter is  3

以上程式碼可以在details-in-go/5/maprangeexpression.go中可以找到。

【channel】

對於channel來說,channel內部表示為一個指標,channel的指標副本也指向真實channel。

for range最終以阻塞讀的方式阻塞在channel expression上(即便是buffered channel,當channel中無資料時,for range也會阻塞在channel上),直到channel關閉:

//details-in-go/5/channelrangeexpression.go
func main() {
    var c = make(chan int)

    go func() {
        time.Sleep(time.Second * 3)
        c <- 1
        c <- 2
        c <- 3
        close(c)
    }()

    for v := range c {
        fmt.Println(v)
    }
}

執行結果:

1
2
3

如果channel變數為nil,則for range將永遠阻塞。

六、select求值 

golang引入的select為我們提供了一種在多個channel間實現“多路複用”的一種機制。select的執行機制這裡不贅述,但select的case expression的求值順序我們倒是要通過一個例子來了解一下:

// details-in-go/6/select.go

func takeARecvChannel() chan int {
    fmt.Println("invoke takeARecvChannel")
    c := make(chan int)

    go func() {
        time.Sleep(3 * time.Second)
        c <- 1
    }()

    return c
}

func getAStorageArr() *[5]int {
    fmt.Println("invoke getAStorageArr")
    var a [5]int
    return &a
}

func takeASendChannel() chan int {
    fmt.Println("invoke takeASendChannel")
    return make(chan int)
}

func getANumToChannel() int {
    fmt.Println("invoke getANumToChannel")
    return 2
}

func main() {
    select {
    //recv channels
    case (getAStorageArr())[0] = <-takeARecvChannel():
        fmt.Println("recv something from a recv channel")

        //send channels
    case takeASendChannel() <- getANumToChannel():
        fmt.Println("send something to a send channel")
    }
}

執行結果:

$go run select.go
invoke takeARecvChannel
invoke takeASendChannel
invoke getANumToChannel

invoke getAStorageArr
recv something from a recv channel

通過例子我們可以看出:
1) select執行開始時,首先所有case expression的表示式都會被求值一遍,按語法先後次序。

invoke takeARecvChannel
invoke takeASendChannel
invoke getANumToChannel

例外的是recv channel的位於賦值等號左邊的表示式(這裡是:(getAStorageArr())[0])不會被求值。

2) 如果選擇要執行的case是一個recv channel,那麼它的賦值等號左邊的表示式會被求值:如例子中當goroutine 3s後向recvchan寫入一個int值後,select選擇了recv channel執行,此時對=左側的表示式 (getAStorageArr())[0] 開始求值,輸出“invoke getAStorageArr”。

七、panic的recover過程

Go沒有提供“try-catch-finally”這樣的異常處理設施,而僅僅提供了panic和recover,其中recover還要結合 defer使用。最初這也是被一些人詬病的點。但和錯誤碼返回值一樣,漸漸的大家似乎適應了這些,征討之聲漸稀,即便有也是排在“缺少generics” 之後了。

【panicking】

在沒有recover的時候,一旦panic發生,panic會按既定順序結束當前程式,這一過程成為panicking。下面的例子模擬了這一過程:

//details-in-go/7/panicking.go
… …
func foo() {
    defer func() {
        fmt.Println("foo defer func invoked")
    }()
    fmt.Println("foo invoked")

    bar()
    fmt.Println("do something after bar in foo")
}

func bar() {
    defer func() {
        fmt.Println("bar defer func invoked")
    }()
    fmt.Println("bar invoked")

    zoo()
    fmt.Println("do something after zoo in bar")
}

func zoo() {
    defer func() {
        fmt.Println("zoo defer func invoked")
    }()

    fmt.Println("zoo invoked")
    panic("runtime exception")
}

func main() {
    foo()
}

執行結果:

$go run panicking.go
foo invoked
bar invoked
zoo invoked
zoo defer func invoked
bar defer func invoked
foo defer func invoked
panic: runtime exception

goroutine 1 [running]:
… …
exit status 2

從結果可以看出:
    panic在zoo中發生,在zoo真正退出前,zoo中註冊的defer函式會被逐一執行(FILO),由於zoo defer中沒有捕捉panic,因此panic被拋向其caller:bar。
    這時對於bar而言,其函式體中的zoo的呼叫就好像變成了panic呼叫似的,zoo有些類似於“黑客帝國3”中里奧被史密斯(panic)感 染似的,也變成了史密斯(panic)。panic在bar中擴充套件開來,bar中的defer也沒有捕捉和recover panic,因此在bar中的defer func執行完畢後,panic繼續拋給bar的caller: foo;
    這時對於foo而言,bar就變成了panic,同理,最終foo將panic拋給了main
    main與上述函式一樣,沒有recover,直接異常返回,導致程式異常退出。
 

【recover】

recover只有在defer函式中呼叫才能起到recover的作用,這樣recover就和defer函式有了緊密聯絡。我們在zoo的defer函式中捕捉並recover這個panic:

//details-in-go/7/recover.go
… …
func zoo() {
    defer func() {
        fmt.Println("zoo defer func1 invoked")
    }()

    defer func() {
        if x := recover(); x != nil {
            log.Printf("recover panic: %v in zoo recover defer func", x)
        }
    }()

    defer func() {
        fmt.Println("zoo defer func2 invoked")
    }()

    fmt.Println("zoo invoked")
    panic("zoo runtime exception")
}

… …

這回的執行結果如下:

$go run recover.go
foo invoked
bar invoked
zoo invoked
zoo defer func2 invoked
2015/09/17 16:28:00 recover panic: zoo runtime exception in zoo recover defer func
zoo defer func1 invoked
do something after zoo in bar
bar defer func invoked
do something after bar in foo
foo defer func invoked

由於zoo在defer裡恢復了panic,這樣在zoo返回後,bar不會感知到任何異常,將按正常邏輯輸出函式執行內容,比如:“do something after zoo in bar”,以此類推。

但若如果在zoo defer func中recover panic後,又raise another panic,那麼zoo對於bar來說就又會變成panic了。

Last、參考資料

1、The Go Programming Language Specification (Version of August 5, 2015,Go 1.5);
2、Effective Go (Go 1.5);
3、Rob Pike: Go Course Day 1~3

本文實驗環境:Go 1.5 darwin_amd64。示例程式碼在這裡可以下載。

我就是這樣一種人:對任何自己感興趣且有極大熱情去做的事情都喜歡刨根問底,徹底全面地瞭解其中細節,否則我就會有一種“不安全 感”。我不知道在心理學範疇這樣的我屬於那種類別^_^。

© 2015, bigwhite. 版權所有.

 

Related posts:

  1. Go程式設計語言(二)
  2. Go中的系統Signal處理
  3. Go程式設計語言(三)
  4. Go語言標準庫概覽
  5. Golang的演化歷程

相關文章