深度剖析Go語言數據結構
當向一個新程序員解釋Go語言時,我發現如果解釋Go的數據是如何在內存中表示的,將有助于建立編寫高效程序的良好直覺。
基礎類型
讓我們從一些簡單的例子開始:
變量i是int類型,在內存中占用一個32位的存儲單位。(上圖拿32位系統來舉例;對以上的例子,只有指針才會在64位的機器上占用更多的空間——int始終是32位——然而我們仍然可以選擇64位的系統。)
變量j是int32類型,因為它經過了顯式的類型轉化。盡管i和j有著同樣的內存布局,但它們的類型是不一樣的:像這樣的賦值i = j會產生類型異常,必須通過顯式的類型轉換:i = int(j) 。
變量f是個浮點類型,上例中它代表著占用32位的浮點值。它的內存占用跟int32一樣,但內部布局不同。
結構與指針
變量bytes是[5]byte類型,一個具有5個字節的數組。它的內存表示就只有5個緊挨著的字節,就像C里的數組一樣。相似地,primes變量是一個擁有4個int數值的數組。
Go就像C而不像Java,它讓程序員決定什么是或者不是一個指針。拿這個類型定義來舉例:
1 |
type Point struct { X , Y int } |
定義一個叫Point的簡單的結構類型,意味著內存里是兩個相鄰的int。
Point{ 10, 20 }這句復合語法表示一個被初始化的Point對象。而&Point{ 10, 20 }這句則表示一個指向被初始化的Point對象的指針。前者在內存中有兩個數據塊,而后者則存放著一個指向兩個數據塊的指針。
結構中的字段被依次地排列在內存里面。
1 |
type Rect1 struct { Min, Max Point } |
2 |
type Rect2 struct { Min, Max *Point } |
Rect1是一個擁有兩個Point類型字段的結構,它的一條記錄包含了兩條Point記錄——共4個int。Rect2是一個擁有兩個Point類型指針的結構,在內存里它占兩個Point指針的空間。
用過C的程序員也許對Point字段和*Point字段的區別并不陌生,而只用過Java或者Python(或者其他)則可能為需要做出選擇而驚訝。通過 為程序員提供控制基礎內存布局的可能,Go語言讓程序員可以操控所有數據結構總尺寸、所分配變量的總數和內存訪問的模式,這些對于建造高性能系統都至關重 要。
字符串
接下來我們繼續看一些更有趣的數據類型。
(灰色箭頭意味著實現上的真實表示方式,但這在編程過程中是不可見的)
一個字符串在內存中的表示被分成兩段,一個指向字符串數據的指針和一個長度值。因為字符串是可枚舉的,所以多個字符串共享同一段存儲空間也是安全的,因此 如果對s字符串進行一個切片選擇,將得到一個可能不一樣的指針和長度,但它們也指向同一段字節序列。這意味著,切片并不需要分配空間或者是復制數據,創建 切片很容易,只需要傳遞明確的下標值就行了。
(順帶一句,在Java和某些與嚴重有一個著名的缺陷,當你對一個字符串進行切片并保存其中的一小部分,引用將在內存中保存原字符串的完整內容,即使只有 很小的一部分是被用到的。Go也有這個缺陷。要不然(我們嘗試但最終舍棄了),我們將對切片采取昂貴的做法——分配內存并拷貝數據——大部分語言都避免這 種做法。
切片
切片是對數組中一段數據的引用。在內存中它有三段數據組成:一個指向數據頭的指針、切片的長度、切片的容量。長度是索引操作的上界,如:x[i] 。容量是切片操作的上界,如:x[i:j] 。
跟對字符串做切片一樣,對數組進行切片也不會導致復制:它只創建一個存放指針、長度和容量的結構體。在這個例子中,語句[ ] int { 2, 3, 5, 7, 11 } 創建了一個包含5個值的新數組,并為x切片設置了對應的值來描述那個數組。切片表達式x[1:3]并不為數據分配內存:它只填充切片結構的字段,用以復用 數組的存儲空間。在這個例子中,長度是2,y[0]和y[1]是僅有的合法數據;但容量是4,y[0:4]是個合法的切片表達式。(查看高效GO獲取更多關于長度、容量,以及如何使用切片的信息。)
由于切片不是指針而是多字段的結構,切片操作并不需要分配內存,即使對于切片頭也是這樣,它可以常駐在棧中。這種表示法讓切片的使用的代價很低,就像C中 傳遞精確的指針和長度一樣。Go原生地在切片中使用了指針,這也意味著每個切片操作都分配一個內存對象。即使有了一個更快的內存分配器,這為垃圾回收帶來 了不必要的工作,并且我們發現,就像字符串那個例子一樣,給于精確的下標,比進行切片操作好。大多數情況下,避免不必要的間接引用和內存分配可以讓切片足 夠高效了。
new和make
Go有兩種創建數據結構的方法:new和make 。它們的區別是常見的早期困惑,但很快就會變得自然。基礎的區別在于,new(T)返回一個*T類型,一個可以被隱性反向引用的指針(如圖中的黑色指 針),而make(T,args)返回一個原始的T,它并不是一個指針。T中常有寫隱性的指針(如圖中的灰色指針)。new返回一個指向初始化為全0值的 指針,而make返回一個復雜的結構。
有一種方式讓兩者統一起來,它對于傳統的C和C++是一個重大的改變:定義make(*T)來返回一個指向新分配的T的指針,因此new(Point)和 make(*Point)的效果是一致的。我們用這種方法嘗試了一段日子,但最終覺得這對于一些期待一個分配函數的人來說,實在太難以接受了。