閱讀453 返回首頁    go 阿裏雲 go 技術社區[雲棲]


深入Go語言文本類型

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'e\u0301',u'e\u0301\u0301\u0301'
é é é́́

符文的字麵值是用單引號括起的一個或多個字符,例如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\n", 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))

最後更新:2017-08-13 22:23:22

  上一篇:go  MySQL replication partial transaction
  下一篇:go  茶道長:為什麼加粉對於你的微商團隊來說這麼難?