在 Go 執行時中的 Strings
動機(Movivation)
可以認為這是對ofollow,noindex" target="_blank">Rob Pike 的一篇關於 Go 字串的部落格 的解釋。以 Go 程式員的思維來寫。
在部落格文章中,他說沒有什麼證據可以證明 Go 字串是以 slices 的方式實現。我猜這不是他的疏忽,但起碼可以證明一點,這不是這篇部落格的核心。再說,任何想要自己研究的都可以在使用 Go 程式碼的過程中探索。我就是這樣做的。所以在這篇文章中,我盡力解釋 Go 字串實現與 slice 實現的關係。
這裡不討論字串編碼。這不是在 runtime 中我想要探索的東西。但我模仿 slice 的常見字串操作,如字串的長度(len("go")),串聯("go" + "lang"),索引("golang"[0])和切片("golang"[0:2])。老實說,索引或者切片是在他們自己的內部範圍中的操作,這意味著,他們在字串上的可用性與字串上的性質沒有任何關係(或很少)。這可能並不完全正確,但請(先)接受它,因為事實上,這將通過底層所謂的基本型別返回,把我們帶入到 Go 編譯器中。再說了,這篇文章又不是我的誓言(我需要對所說的一切負責)。
字串的本質(The Nature of Strings)
我還沒有遇到一種程式語言,其中的字串具有不同的底層記憶體結構:記憶體為連續的槽位。這意味著字串的位元組在記憶體中都是彼此相鄰,兩者之間沒有其他的東西。也就是說,如果你在程式中使用了著名,12 個位元組的字串:"hello, world",並且有機會在記憶體中檢查他們,你會發現他們位於同一行,每個位元組(或字元)緊跟著另一個,他們之間沒有空格或外來的位元組。據我所知,Go 並沒有偏離這個原則。
但這都是關於記憶體,物理上的東西。當我們站在這個角度上看,所有事情都是相同的,也就是說程式語言之間的差異不見了。因此,讓我們返回執行時的上一層,在這裡我們將看到不同語言如何處理它們的業務,我們也將在這裡找到 Go 是如何實現它的字串資料的細節。幸運的是,Go 執行時的一個重要部分是用 Go 編寫的,感謝上帝的保佑(不然我得研究個半死),(已存在的)大量的討論已經解釋了明顯和不那麼明顯的實現細節。在編寫本文時,可以在 Github 上找到執行時字串的實現。讓我們一起走近它吧。
Go 字串(Go's String)
在 Go 執行時,字串是 stringStruct 型別:
type stringStruct struct { str unsafe.Pointer len int }
它由一個 str、指向實際位元組所在的記憶體塊的指標和 len(字串的長度)組成。由於字串是不可變的,因此前面說的這些不會改變。
建立一個新的字串(Creating a New String)
負責在執行時建立新字串的函式為 rawstring。以下是它具體的實現(程式碼中的註釋是我的):
func rawstring(size int) (s string, b []byte) { // 1. 分配符合字串大小的記憶體塊,並返回指標給它: p := mallocgc(uintptr(size), nil, false) // 2. 用剛返回的指標建立一個元資料(stringStruct),並指定該字串的大小。 stringStructOf(&s).str = p stringStructOf(&s).len = size // 3. 準備一個位元組型別的切片,實際上會將字串資料儲存在這裡。 *(*slice)(unsafe.Pointer(&b)) = slice{p, size, size} }
rawstring 返回一個字串和一個位元組切片,其中應該儲存字串的實際位元組,並且這個位元組切片將用於字串的所有操作。我們可以安全地將它們稱為資料([]byte)和元資料(stringStruct)。
但這並不是結束。也許這是唯一一次,你需要研究字串裡面實際的非零位元組切片。事實上,對 rawstring 的註釋已經提示呼叫者,它只使用一次位元組切片(寫入字串的內容),然後就刪除它。其餘的時間,字串結構本身就已經足夠了。
知道了這一點,讓我們看看如何實現一些常見的字串操作。這對我們也是有意義的,這裡將會介紹為什麼不建議大家通過舊的串連來構建大字串。
相同的字串操作(Common String Operations)
長度(len("go"))
由於字串是不可變的,也就是說字串的長度將保持不變。其實,當我們儲存字串時,我們就已經知道這就是我們儲存在 stringStruct 的 len 欄位中的內容。因此,無論字串的大小如何,對字串長度的請求都花費相同的時間。在 Big-O 術語中,它是一個 O(1) 操作。
串聯("go" + "lang")(Concatenation ("go" + "lang"))
這是一個簡單的過程。Go 首先通過對要連線的所有字串的長度求和來確定結果字串的長度。然後它請求連續記憶體塊的大小。(這裡)有優化檢查,更重要的是安全檢查。安全檢查確保結果字串的長度不超過 Go 的最大整數值。
然後,該過程的下一步開始。各個字串的位元組將一個接一個地複製到新字串中。也就是說,儲存器中不同位置的位元組被複制到新位置。這項工作不是很合理,應該儘可能避免。因此建議使用 strings.Builder,因為它最小化了記憶體複製。這將使我們的效能最接近可變字串。
索引("golang"[0])(Indexing ("golang"[0]))
Go 的索引運算子為 [index],其中 index 是一個整數。在撰寫本文時,它可用於資料,切片,字串和一些指標。
什麼是陣列,切片和字串具有的共同的基礎型別?在實體記憶體方面,它是一個連續的記憶體塊。在 Go 用語中,是一個數組。對於字串,這是 rawstring 返回的位元組切片,它是儲存字串內容的位置。也就是我們的索引。不言而喻,我上面提到的與索引操作符相容的 “一些指標” 是具有陣列底層型別的那些。
請注意,它在 map 上的語法相同,但行為不同。對於 map,鍵的型別確定了括號之間的值的型別。
切片("golang"[0:2])(Slicing ("golang"[0:2]))
slice 運算子與索引運算子具有相同的相容性:運算元必須具有基礎型別的陣列。因此它適用於同一組型別:陣列,切片,字串和一些指標。
在字串上有一個警告。完整切片運算子為[low:high:capacity]。一次性它允許您建立切片並設定底層陣列的容量。但是請記住字串是不可變的,因此永遠不需要(分配)基礎陣列大於字串內容所需的位元組。也因此,字串不存在切片運算子。
strings 和 strconv 包(The strings and strconv packages)
Go 提供了用於處理字串的 string 和 strconv 包。我已經提到了用於構建大字串的更高效的 Builder。它由 strings 包提供。還有其他的細節。他們一起為字串轉換,比較,搜尋和替換等提供調整功能。它會在構建自己的字串之前檢查它們。
誤解的根源(Source of Confusion)
cap(slice) vs cap(string)
內建函式 cap 返回切片底層陣列的容量。在切片的整個生命週期中,底層陣列的容量可以不斷變化。通常它會增長以容納新元素。如果字串是切片,為什麼它不能返回 cap 查詢?答案很簡單:Go 字串是不可變的。也就是說,它的大小永遠不會增長或縮小,這反過來意味著如果實現了 cap,它將與 len 相同。