深入Go語言文字型別

墨航發表於2017-08-10

Go的作者Ken Thompson是UTF-8的發明人(也是C,Unix,Plan9等的創始人),因此在關於字元編碼上,Go有著獨到而周全的設計。本文介紹了Go語言中的三種內建文字型別:string, byte,rune的內部表示與相互轉換。

1. 概覽

Go中,字串string是內建型別,與文字處理相關的內建型別還有符文rune和位元組byte

UTF-8編碼在Go語言中有著特殊的位置,無論是原始碼的文字編碼,還是字串的內部編碼都是UTF-8。Go繞開前輩語言們踩過的坑,使用了UTF8作為預設編碼是一個非常明智的選擇。相比之下,Java,Javascript都使用 UCS-2/UTF16作為內部編碼,早期還有隨機訪問的優勢,可當Unicode增長超出BMP之後,這一優勢也蕩然無存了。相比之下,位元組序,Surrogate , 空間冗餘帶來的麻煩卻仍讓人頭大無比。

標準庫

與C語言類似,大多數關於字串處理的函式都放在標準庫裡。Go將大部分字串處理的函式放在了strings,bytes這兩個包裡。因為在字串和整型間沒有隱式型別轉換,字串和其他基本型別的轉換的功能主要在標準庫strconv中提供。unicode相關功能在unicode包中提供。encoding包提供了一系列其他的編碼支援。

摘要

  • Go語言原始碼總是採用UTF-8編碼
  • 字串string可以包含任意位元組序列,通常是UTF-8編碼的。
  • 字串字面值,在不帶有位元組轉義的情況下一定是UTF-8編碼的。
  • Go使用rune代表Unicode碼位。一個字元可能由一個或多個碼位組成(複合字元)
  • Go string是建立在位元組陣列的基礎上的,因此對string使用[]索引會得到位元組byte而不是字元rune
  • Go語言的字串不是正規化(normalized)的,因此同一個字元可能由不同的位元組序列表示。使用unicode/norm解決此類問題。

基礎資料結構

陣列與切片

要討論[]byte[]rune,就必需先解釋Go語言中的陣列(Array)切片(Slice),陣列很好理解,和C語言中的陣列概念一致,切片則是對陣列的引用。

陣列Array是固定長度的資料結構,不存放任何額外的資訊。很少直接使用,往往用作切片的底層儲存。

切片Slice描述了陣列中一個連續的片段,Go語言的切片操作與Python較為類似。在底層實現中,切片可以看成一個由三個word組成的結構體,這裡word是CPU的字長。這三個字分別是ptr,len,cap,分別代表陣列首元素地址,切片的長度,當前切片頭位置到底層陣列尾部的距離。

godata3.png

因此,在函式引數中傳遞十個元素的陣列,那麼就會在棧上覆制這十個元素。而傳遞一個切片,則實際上傳遞的是這個3Word結構體。傳遞切片本身就是傳遞引用。

位元組byte

位元組byte實際上是uint8的別名,只是為了和其他8bit型別相區別才單獨起了別名。通常出現的更多的是位元組切片[]byte與位元組陣列[...]byte

字面值

位元組可以用單引號擴起的單個字元表示,不過這種字面值和rune的字面值很容易搞混。賦予位元組變數一個超出範圍的值,如果在編譯期能檢查出來就會報overflows byte編譯錯誤。

底層結構

對於位元組陣列[]byte,實質上可以看做[]uint8,即一個整形切片,所以位元組陣列的本體結構定義如下:

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

字串string

字串通常是UTF8編碼的文字,由一系列8bit位元組組成。raw string literal和不含轉義符號的string literal一定是UTF-8編碼的,但string其實可以含有任意的位元組序列。

字串是不可變物件,可以空(s=""),但不會是nil

底層結構

string在Go中的實現與Slice類似,但因為字串是不可變型別,因此底層陣列的長度就是字串的長度,所以相比切片,string結構的本體少了一個Cap欄位。只有一個指標和一個長度值,由兩個Word組成。64位機器上佔用16個位元組。

godata2.png

type StringHeader struct {
    Data uintptr
    Len  int
}

雖然字串是不可變型別,但通過指標和強制轉換,還是可以進行一些危險但高效的操作的。不過要注意,編譯器作為常量確定的string會寫入只讀段,是不可以修改的。相比之下,fmt.Sprintf生成的字串分配在堆上,就可以通過黑魔法進行修改。

關於string,有這麼幾點需要注意。

  1. string常量會在編譯期分配到只讀段,對應資料地址不可寫入。
  2. 相同的string常量不會重複儲存,但動態生成的字串即使內容一樣,資料也是在不同的空間。
  3. 常量空字串有資料地址,動態生成的字串沒有設定資料地址 ,只有動態生成的string可以unsafe魔改。
  4. Golang string和[]byte轉換,會將資料複製到堆上,返回資料指向複製的資料。所以string(bytes)存在開銷
  5. string和[]byte通過複製轉換,效能損失接近4倍

符文rune

符文rune其實是int32的別名,表示一個Unicode的碼位

注意一個字元(Character)可以由一個或多個碼位(Code Point)構成。例如帶音調的e,即é,既可以由u00e9單個碼位表示,也可以由e和口音符號u0301複合而成。這涉及到normalization的問題。但通常情況下一個字元就是一個碼位。

>>> print u`u00e9`, u`eu0301`,u`eu0301u0301u0301`
é é é́́

符文的字面值是用單引號括起的一個或多個字元,例如a,,a,141,x61,u0061,U00000061,都是合法的rune literal。其格式定義如下:

rune_lit         = "`" ( unicode_value | byte_value ) "`" .
unicode_value    = unicode_char | little_u_value | big_u_value | escaped_char .
byte_value       = octal_byte_value | hex_byte_value .
octal_byte_value = `` octal_digit octal_digit octal_digit .
hex_byte_value   = `` "x" hex_digit hex_digit .
little_u_value   = `` "u" hex_digit hex_digit hex_digit hex_digit .
big_u_value      = `` "U" hex_digit hex_digit hex_digit hex_digit
                           hex_digit hex_digit hex_digit hex_digit .
escaped_char     = `` ( "a" | "b" | "f" | "n" | "r" | "t" | "v" | `` | "`" | `"` ) .

其中,八進位制的數字範圍是0~255,Unicode轉義字元通常要排除0x10FFFF以上的字元和surrogate字元。

看上去這樣用單引號括起來的字面值像是一個字串,但當原始碼轉換為內部表示時,它其實就是一個int32。所以var b byte = `蛤`,其實就是為uint8賦了一個int32的值,會導致溢位。相應的,一個rune也可以在不產生溢位的條件下賦值給byte

文字型別轉換

三種基本文字型別之間可以相互轉換,當然,有常規的做法,也有指標黑魔法。

string[]byte的轉換

stringbytes的轉換是最常見的,因為通常通過IO得到的都是[]byte,例如io.Reader介面的方法簽名為:Read(p []byte) (n int, err error)。但日常字串操作使用的都是string,這就需要在兩者之間進行轉換。

常規做法

通常[]bytestring可以直接通過型別名強制轉化,但實質上執行了一次堆複製。理論上stringHeader只是比sliceHeader少一個cap欄位,但因為string需要滿足不可變的約束,而[]byte是可變的,因此在執行[]bytestring的操作時會進行一次複製,在堆上新分配一次記憶體。

// byte to string
s := string(b)

// string index -> byte
s[i] = b

// []byte to string
s := string(bytes)

// string to []byte
bytes := []byte(s)

黑魔法

利用unsafe.Pointerreflect包可以實現很多禁忌的黑魔法,但這些操作對GC並不友好。最好不要嘗試。

type Bytes []byte

// 將string轉換為[]byte,`可以修改`,很危險,因為[]byte結構要多一個cap欄位。
func StringBytes(s string) Bytes {
    return *(*Bytes)(unsafe.Pointer(&s))
}

// 不拷貝地將[]byte轉換為string
func BytesString(b []byte) String {
    // 因為[]byte的Header只比string的Header多一個Cap欄位。可以直接強制成`*String` 
    return *(*String)(unsafe.Pointer(&b))
}

// 獲取&s[0],即儲存字串的位元組陣列的地址指標,Go裡不允許這種操作。 
func StringPointer(s string) unsafe.Pointer {
    p := (*reflect.StringHeader)(unsafe.Pointer(&s))
    return unsafe.Pointer(p.Data)
}

// r獲取&b[0],即[]byte底層陣列的地址指標,Go裡不允許這種操作
func BytesPointer(b []byte) unsafe.Pointer {
    p := (*reflect.SliceHeader)(unsafe.Pointer(&b))
    return unsafe.Pointer(p.Data)
}

stringrune的轉換

string是UTF8編碼的字串,因此對於非含有ASCII字元的字串,是沒法簡單的直接索引的。例如

fmt.Printf("%x","hello"[0]),會取出第一個位元組h的相應位元組表示uint8,值為:0x68。然而

fmt.Printf("%s","你好"[0]),也是同理,在UTF-8編碼中,漢字”你”被編碼為0xeE4BDA0由三個位元組組成,因此使用下標0去索引字串,並不會取出第一個漢字字元的int32碼位值0x4f60來,而是這三個位元組中的第一個0xE4

沒有辦法隨機訪問一箇中文漢字是一件很蛋疼的事情。曾經Java和Javascript之類的語言就出於效能考慮使用UCS2/UTF-16來平衡時間和空間開銷。但現在Unicode字元遠遠超過65535個了,這點優勢已經蕩然無存,想要準確的索引一個字元(尤其是帶Emoji的),也需要用特製的API從頭解碼啦,啪啪啪打臉蒼天饒過誰……。

常規方式

stringrune之間也可以通過型別名直接轉換,不過string不能直接轉換成單個的rune

// rune to string
str := string(r)

// range string -> rune
for i,r := range str

// string to []rune
runes := []rune(str)

// []rune to string
str := string(runes)

特殊支援

Go對於UTF-8有特殊的支援和處理(因為UTF-8Go都是Ken發明的……。),這體現在對於stringrange迭代上。

const nihongo = "日本語"
for index, runeValue := range nihongo {
    fmt.Printf("%#U starts at byte position %d
", runeValue, index)
}

U+65E5 `日` starts at byte position 0
U+672C `本` starts at byte position 3
U+8A9E `語` starts at byte position 6

直接索引string會得到位元組序號和相應位元組。而對string進行range迭代,獲得的就是字元rune的索引與相應的rune

byterune的轉換

byte其實是uint8,而rune實際就是int32,所以uint8int32兩者之間的轉換就是整數的轉換。

但是[]uint8[]int32是兩個不同型別的整形陣列,它們之間是沒有直接強制轉換的方法的,好在通過string來曲線救國:runes := []rune(string(bytes))


相關文章