[譯]如何避免golang的坑

astaxie發表於2016-11-23

如何避免golang的坑

坑是系統,程式或程式語言中有效的結構,它可以按指定的方式工作,但是以反直覺的方式工作,並且總是引發錯誤,因為很容易被呼叫,並且總是觸發異常

Go程式語言有一些坑,有很多好文章解釋這些坑。我發現這些文章非常重要,特別是對於go新手來說,因為我看到人們經常踩這些坑。

然而有一個問題困擾了我很長時間 - 為什麼我從來沒踩過這些坑?其中最有名的,像“nil interface”或“slice append”問題從來沒有困擾我。我從第一天寫go就在某種程度上避免了這些問題。為什麼會這樣?

答案其實很簡單。我很幸運,我讀過一些關於Go資料結構的內部表示的文章,並學習過Go內部工作機制的一些基礎知識。這些知識足構建避開那些坑的直覺。

記住,“坑是有效的構造,但是是反直覺的”?這就對了。您只有兩個選項:

  • “修復”語言
  • 修正直覺

第二個實際上視為構建直覺會更好。一旦你有一個清晰的認識,如何interface或slice如何工作,幾乎是不可能犯這些錯誤。

這種方式對我起作用,也應該對別人起作用。這就是為什麼我決定在這篇文章中收集一些Go內部執行機制的基礎知識,並幫助人們建立關於不同結構的記憶體表示的直覺。

讓我們從基本的瞭解如何在記憶體中表示事物開始。以下是我們將要學習的內容:

  • 指標
  • 陣列和切片
  • Append
  • 介面
  • 空介面

指標

Go非常接近硬體。 當建立64位整數(int64)變數時,您確切知道它需要多少記憶體,您可以使用unsafe.Sizeof()計算任何其他型別的大小。

我經常使用記憶體塊的視覺化方式來“看到”變數,陣列和資料結構的大小。 視覺表示給你一個簡單的方法來獲得關於型別的直覺,並通常有助於推理其行為和效能。

讓我們以顯示golang的大多數基本型別來熱身: 假設你使用32位機器(現在可能是false),你可以看到int64的記憶體是int32的兩倍。

還有更復雜指標的內部表示,它是記憶體中的一個塊,其中包含記憶體中的一些其他區域的地址,而該地址儲存實際資料。 當你聽到花哨的詞如“dereferencing a pointer”時,它實際上意味著“通過儲存在指標變數中的地址獲得實際的記憶體塊”。 你可以想象它是這樣的: 存中的地址通常由十六進位制值表示,因此圖片中的地址是“0x ...”。 但是知道“指標的值”可能在一個地方,而“由指標引用的實際資料” - 在另一個地方,將在未來幫助我們避免錯誤。

現在,Go中的初學者的“坑”之一,是由於沒有帶指標語言的先驗知識,因為功能引數的“值傳遞”導致的。 你可能知道,在Go中,一切都是通過“值”,舉例來說通過複製。一旦你試圖視覺化這種複製過程就更容易理解了: 在第一種情況下,你複製所有這些記憶體塊 - 在現實中,記憶體大小通常遠遠超過2 - 很可能是200萬個記憶體塊,你必須複製它們,這是最昂貴的操作之一。 但在第二種情況下,你只複製一個記憶體塊 - 它包含實際資料的地址,非常快並且開銷低。

現在,你可以看到,在函式Foo()中修改p不會在第一種情況下修改原始資料,但是在第二種情況下肯定會修改,因為儲存在p中的地址引用了原始資料塊。

好吧,如果你知道為何瞭解go內部表示可以幫助你避免常見問題了吧,讓我們深入一點。

陣列和切片

新手經常混淆切片與陣列。 讓我們來看看陣列。

陣列

var arr [5]int
var arr [5]int{1,2,3,4,5}
var arr [...]int{1,2,3,4,5}

陣列只是連續的記憶體塊,如果你檢查Go執行時原始碼(src / runtime / malloc.go),你可能會看到建立一個陣列本質上是分配給定大小的一塊記憶體。 類似malloc,只是更聰明:)

// newarray allocates an array of n elements of type typ.
func newarray(typ *_type, n int) unsafe.Pointer {
    if n < 0 || uintptr(n) > maxSliceCap(typ.size) {
        panic(plainError("runtime: allocation size out of range"))
    }
    return mallocgc(typ.size*uintptr(n), typ, true)
}

這對我們意味著什麼? 這意味著我們可以簡單地將陣列表示為一組在記憶體中相鄰的塊: 陣列元素總是用其型別的零值初始化,在[5] int的情況下為0。 我們可以索引它們,並使用len()內建命令獲取長度。 當你通過索引引用陣列中的單個元素並執行這樣的操作時:

var arr [5]int
arr[4] = 42

你正在獲取第五(4 + 1)元素並更改其值: 現在我們來探索切片。

切片

第一眼看到切片與陣列類似,宣告方式真的類似:

var foo []int

但是如果我們去Go原始碼(src/runtime/slice.go),我們會看到,Go的切片是具有三個欄位的結構體 - 指向陣列的指標,長度和容量:

type slice struct {
    array unsafe.Pointer
    len int
    cap int
}

當你建立一個新的切片,Go執行時將建立這個三塊物件在記憶體中的指標設定為nil,len和cap設定為0.讓我們直觀地表示: 讓我們使用make來初始化給定大小的切片:

foo = make([]int, 5)

將建立一個具有5個元素的底層陣列的切片,初始化為0,並將len和cap設定為5. Cap意味著容量,並有助於為未來增長預留更多空間。 您可以使用make([] int,len,cap)語法來指定容量。你幾乎沒有必要設定cap,但重要的是要了解cap的概念。

foo = make([]int, 3, 5)

我們看看兩者的圖示: 現在,當您更新切片的一些元素時,實際上是更改底層陣列中的值。

foo = make([]int, 5)
foo[3] = 42
foo[4] = 100

很簡單。 但是,如果你建立另一個子切片並更改一些元素,會發生什麼? 我們們試試吧:

foo = make([]int, 5)
foo[3] = 42
foo[4] = 100
bar := foo[1:4]
bar[1] = 99

通過修改bar,你實際修改了底層陣列,它也被slice foo引用。你可能寫這樣的程式碼:

var digitRegexp = regexp.MustCompile("[0-9]+")
func FindDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return digitRegexp.Find(b)
}

讀10MB的資料到切片,並且只搜尋3位,你可以假設你返回3個位元組,但實際上,底層陣列將儲存在記憶體中。 這可能是你最常見的Go坑之一。 但是一旦你有這種內部片段表示的影像,我敢打賭,幾乎不可能會再踩坑!

Append

有一些坑與內建的通用函式append()相關。 append函式本質上做一個操作 - 新增一個值到切片,但在內部它做了很多複雜的工作,以智慧和高效的方式分配記憶體。

讓我們來看下面的程式碼:

a := make([]int, 32)
a = append(a, 1)

記住cap 代表成長的能力。 append檢查該切片是否有更多的容量用於增長,如果沒有,則分配更多的記憶體。 分配記憶體是一個相當昂貴的操作,因此append嘗試對該操作進行預估,一次增加原始容量的兩倍。 一次分配較多的記憶體通常比多次分配較少的記憶體更高效和更快。

由於許多原因,分配更多的記憶體通常意味著分配新記憶體並從舊陣列拷貝資料到新陣列。 這意味著切片中基礎陣列的地址也將改變。 讓我們想象一下: 很容易看到兩個底層陣列 - 舊的和新的。 舊的陣列將被GC釋放,除非另一個slice引用它。 這種情況是append的坑之一。 如果你建立子切片b,然後append一個值到a(假設他們都共享共同的底層陣列)?

a := make([]int, 32)
b := a[1:16]
a = append(a, 1)
a[2] = 42

你會得到如下結果: 你會有兩個不同的底層陣列,這對初學者來說可能是相當不經意的。 所以,作為一個經驗法則,當你使用子切片,特別是sublices與append時,要小心。

順便說一下,append通過將它的容量增加一倍來增加slice,最多隻有1024,之後它將使用所謂的記憶體大小類來保證增長不超過〜12.5%。 請求64位元組為32位元組陣列是確定,但如果你的切片是4GB,分配另一個4GB新增1元素是相當昂貴的,所以這是有道理的。

介面

新手需要一些時間來正確使用Go中的介面,特別是在有基於類的語言經驗後。 混亂產生原因之一是在介面的上下文中nil關鍵字的不同含義。

為了幫助理解這個主題,讓我們再來看看Go原始碼。 這裡是一個來自src/runtime/runtime2.go的程式碼:

type iface struct {
    tab *itab
    data unsafe.Pointer
}

itab代表介面表,也是一種儲存有關介面和底層型別的所需元資訊的結構:

type itab struct {
    inter *interfacetype
    _type *_type
    link *itab
    bad int32
    unused int32
    fun [1]uintptr // variable sized
}

我們不會學習介面型別斷言如何工作,重要的是理解介面是介面和靜態型別資訊的複合,加上指向實際變數(iface中的欄位資料)的指標。 讓我們建立error介面的變數err並直觀地表示它:

var err error

事實上,你在這張圖片中看到的是nil介面。 當在返回error型別的函式中返回nil時,將返回此物件。 該物件中有關於介面(itab.inter)的資訊,但在data和itab.type欄位中為nil。 此物件將在if err == nil {}條件中求值為true。

func foo() error {
    var err error // nil
    return err
}
err := foo()
if err == nil {...} // true

臭名昭著的坑是返回一個* os.PathError變數,它是nil。

func foo() error {
    var err *os.PathError // nil
    return err
}
err := foo()
if err == nil {...} // false

這兩段程式碼很相似,除非你知道介面內部是什麼結構。 讓我們繼續用圖表示這個 os.PathError型別(包裝了error介面)的nil變數: ![](https://cdn-images-1.medium.com/max/800/01cosW9WkQq1AqKHm.png) 你可以清楚地看到* os.PathError變數 - 它只是一個值為nil記憶體塊。 但是我們foo()函式返回值實際錯誤是一個非常複雜的結構,包含相關介面的資訊,該塊記憶體的底層型別和記憶體地址,並都設定為nil。

在這兩種情況下,我們都返回nil,但“有一個變數的值等於nil的介面”和“沒有變數的介面”之間有一個巨大的區別。 有了這些介面的內部結構的知識,就可以弄清楚以下兩個例子: 現在更難踩坑了吧。

空介面

關於空介面 - interface {}。 在Go原始碼(src/runtime/ malloc.go它實現使用自己的結構 - eface:

type eface struct {
    _type *_type
    data unsafe.Pointer
}

正如你看到的,它類似於iface,但缺乏介面表。 它不需要一個介面表,因為空介面可以是由任何靜態型別實現。 所以當你包裝一些東西 - 顯式或隱式(通過傳遞作為一個函式的引數,例如) - 到interface {},你實際上使用這個結構:

func foo() interface{} {
    foo := int64(42)
    return foo
}

interface{}相關的坑之一你不能輕易地分配介面切片的具體型別,反之亦然。 就像是這樣:

func foo() []interface{} {
    return []int{1,2,3}
}

編譯時會報錯:

$ go build
cannot use []int literal (type []int) as type []interface {} in return argument

為什麼我可以在單變數上做這個轉換,但不能切片上做同樣的事情? 但是一旦你知道什麼是空介面(再看看上面的圖片),過程就變得很清楚,這個“轉換”實際上是一個相當昂貴的操作,涉及分配一堆記憶體。 Go設計中常用的方法之一是“如果你想做一些昂貴的操作- 明確地做”。 希望以上內容對你有意義。

結論

不是每個坑都可以通過學習內部機制避免。 其中一些坑只是和你過去的經驗不同,我們都有某種不同的背景和經驗。 然而,有很多的坑,可以簡單地通過了解Go如何工作而成功避免。 我希望這篇文章中的解釋將幫助你建立起程式內部機制的直覺,並將使你成為一個更好的開發人員。 Go是你的朋友,對它瞭解的越多越好。

如果你有興趣閱讀更多關於Go內部機制,這裡有一個連結列表幫助你:

有用之物的永恆來源:)

相關文章