深度解密 Go 的字符串
Go 字符串實現原理
Go 的字符串有個特性,不管長度是多少,大小都是固定的 16 字節。
package main
import (
"fmt"
"unsafe"
)
func main() {
fmt.Println(
unsafe.Sizeof("komeiji satori"),
) // 16
fmt.Println(
unsafe.Sizeof("satori"),
) // 16
}
顯然用鼻子也能猜到原因,Go 的字符串底層並沒有實際保存這些字符,而是保存了一個指針,該指針指向的內存區域負責存儲具體的字符。由於指針的大小是固定的,所以不管字符串多長,大小都是相等的。
另外字符串大小是 16 字節,指針是 8 字節,那麼剩下的 8 字節是什麼呢?不用想,顯然是長度。下面來驗證一下我們結論:
以上是 Go 字符串的底層結構,位於 runtime/string.go 中。字符串在底層是一個結構體,包含兩個字段,其中 str 是一個 8 字節的萬能指針,指向一個數組,數組裏面存儲的就是實際的字符;而 len 則表示長度,也是 8 字節。
因此結構很清晰了:
str 指向的數組裏面存儲的就是所有的字符,並且類型是 uint8,因爲 Go 的字符串默認採用 utf-8 編碼。所以一個漢字在 Go 裏面佔 3 字節,我們先用 Python 舉個例子:
>>> name = "琪露諾"
>>> [c for c in name.encode("utf-8")]
[231, 144, 170, 233, 156, 178, 232, 175, 186]
>>>
那麼對於 Go 而言,底層就是這麼存儲的:
我們驗證一下:
package main
import "fmt"
func main() {
name := "琪露諾"
// 長度是 9,不是 3
fmt.Println(len(name)) // 9
// 查看底層數組存儲的值
// 可以轉成切片查看
fmt.Println(
[]byte(name),
) // [231 144 170 233 156 178 232 175 186]
}
結果和我們想的一樣,並且內置函數 len 在統計字符串長度時,計算的是底層數組的長度。
字符串的截取
如果要截取字符串的某個子串,要怎麼做呢?如果是 Python 的話很簡單:
>>> name = "琪露諾"
>>> name[0]
'琪'
>>> name[: 2]
'琪露'
>>>
因爲 Python 字符串裏面的每個字符的大小都是相同的,可能是 1 字節、2 字節、4 字節。但不管是哪種,一個字符串裏面的所有字符都具有相同的大小,因此才能通過索引準確定位。
但在 Go 裏面這種做法行不通,Go 的字符串採用 utf-8 編碼,不同字符佔用的大小不同,ASCII 字符佔 1 字節,漢字佔 3 字節,所以無法通過索引準確定位。
package main
import "fmt"
func main() {
name := "琪露諾"
fmt.Println(
name[0], name[1], name[2],
) // 231 144 170
fmt.Println(name[: 3]) // 琪
}
如果一個字符串裏面既有英文又有中文,那麼想通過索引準確定位是不可能的。因此這個時候我們需要進行轉換,讓它像 Python 一樣,每個字符都具有相同的大小。
package main
import "fmt"
func main() {
name := "琪露諾"
// rune 等價於 int32
// 此時每個元素統一佔 4 字節
// 並且 []rune(name) 的長度纔是字符串的字符個數
fmt.Println(
[]rune(name),
) // [29738 38706 35834]
// 然後再進行截取
fmt.Println(
string([]rune(name)[0]),
string([]rune(name)[: 2]),
) // 琪 琪露
}
所以對於字符串 "憨 pi" 而言,如果是 utf-8 存儲,那麼只需要 5 個字節。但很明顯,基於索引查找指定的字符是不可能的,除非事先知道字符串長什麼樣子。如果是轉成 []rune 的話,那麼需要 12 字節存儲,內存佔用變大了,但可以很方便地查找某個字符或者某個子串。
字符串和切片的轉換
字符串和切片之間是可以互轉的,但切片只能是 uint8 或者 int32 類型,另外 uint8 也可以寫成 byte,int32 可以寫成 rune。
由於 byte 是 1 字節,那麼當字符串包含漢字,轉成 []byte 切片時,一個漢字需要 3 個 byte 表示。因此字符串 " 憨 pi" 轉成 []byte 之後,長度爲 5。
而 rune 是 4 字節,可以容納所有的字符,那麼轉成 []rune 切片時,不管什麼字符,都只需要一個 rune 表示即可。所以字符串 " 憨 pi" 轉成 []rune 之後,長度爲 3。
因此當你想統計字符串的字符個數時,最好轉成 []rune 數組之後再統計。如果是字節個數,那麼直接使用內置函數 len 即可。
我們舉例說明,先來看一段 Python 代碼:
>>> s = "憨pi"
# 採用utf-8編碼(等價於Go的[]byte數組)
# "憨" 需要 230 134 168 三個整數來表示
# 而 "p" 和 "i" 均只需 1 個字節,分別爲112和105
>>> [c for c in s.encode("utf-8")]
[230, 134, 168, 112, 105]
# 採用 unicode 編碼(類似於Go的[]rune數組)
# 所有字符都只需要1個整數表示
# 但對於ASCII字符而言,不管什麼編碼,對應的數值不變
>>> [ord(c) for c in s]
[25000, 112, 105]
我們用 Go 再演示一下:
package main
import "fmt"
func main() {
s := "憨pi"
fmt.Println(
[]byte(s),
) // [230 134 168 112 105]
fmt.Println(
[]rune(s),
) // [25000 112 105]
}
結果是一樣的,當然這個過程我們也可以反向進行:
package main
import "fmt"
func main() {
s1 := []byte{230, 134, 168, 112, 105}
fmt.Println(string(s1)) // 憨pi
s2 := []rune{25000, 112, 105}
fmt.Println(string(s2)) // 憨pi
}
結果沒有任何問題。
字符串和切片共享底層數組
我們知道字符串和切片內部都有一個指針,指針指向一個數組,該數組存放具體的元素。
// runtime/string.go
type stringStruct struct {
str unsafe.Pointer
len int
}
// runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
假設有一個字符串 "abc",然後基於該字符串創建一個切片,那麼兩者的結構如下:
字符串在轉成切片的時候,會將底層數組也拷貝一份。那麼問題來了,在基於字符串創建切片的時候,能不能不拷貝數組呢?也就是下面這個樣子:
如果字符串比較大,或者說需要和切片之間來回轉換的話,這種方式無疑會減少大量開銷。Go 提供了萬能指針幫我們實現這一點,所以先來了解一下什麼是萬能指針。
什麼是萬能指針
我們知道 C 的指針不僅可以相互轉換,而且還可以參與運算,但 Go 不行,因爲 Go 的指針是類型安全的。Go 編譯器對類型的檢測非常嚴格,讓你在享受指針帶來的便利時,又給指針施加了很多制約來保證安全。因此 Go 的指針不可以相互轉換,也不可以參與運算。
但保證安全是需要以犧牲效率爲代價的,如果你能保證寫出的程序就是安全的,那麼可以使用 Go 中的萬能指針,從而繞過類型系統的檢測,讓程序運行的更快。
萬能指針在 Go 裏面叫做 unsafe.Pointer,它位於 unsafe 包下面。當然這個包名看起來有點怪怪的,因爲這個包可以讓我們繞過 Go 類型系統的檢測,直接訪問內存,從而提升效率。所以它有點危險,而 Go 官方也不推薦開發者使用,於是起了這個名字。
但實際上 unsafe 包在底層被大量使用,所以不要被名字誤導了,這個包是一定要掌握的。
回到萬能指針上面來,Go 的指針不可以相互轉換,但是它們都可以和萬能指針轉換。舉個例子:
package main
import (
"fmt"
"unsafe"
)
func main() {
// 一個 []int8 類型的切片
s1 := []int8{1, 2, 3, 4}
// 如果直接轉成 []int16 是會報錯的
// 因爲 Go 的類型系統不允許這麼做
// 但是有萬能指針,任何指針都可以和它轉換
// 我們可以先將 s1 的指針轉成萬能指針
// 然後再將萬能指針轉成 *[]int16,最後再解引用
s2 := *(*[]int16)(unsafe.Pointer(&s1))
// 那麼問題來了,指針雖然轉換了
// 但是內存地址沒變,內存裏的值也沒變
// 由於 s2 是 []int16 類型,s1 是 []int8 類型
// 所以它會把 s1[0] 和 s1[1] 整體作爲 s2[0]
// 會把 s1[2] 和 s1[3] 整體作爲 s2[1]
fmt.Println(s2) // [513 1027 0 0]
// int8 類型的 1 和 2 組合成 int16
// int8 類型的 3 和 4 組合成 int16
fmt.Println(2 << 8 + 1) // 513
fmt.Println(4 << 8 + 3) // 1027
}
因此把 Go 的萬能指針想象成 C 的空指針 void * 即可。
那麼讓字符串和切片共享數組,我們就可以這麼做:
package main
import (
"fmt"
"unsafe"
)
func main() {
str := "abc"
slice := *(*[]byte)(unsafe.Pointer(&str))
fmt.Println(slice) // [97 98 99]
fmt.Println(cap(slice)) // 10036576
}
雖然轉換成功了,但是還有點問題,容量不太對勁。至於原因也很簡單,字符串和切片在底層都是結構體,並且它們的前兩個字段相同,所以轉換之後打印沒有問題。但字符串沒有容量的概念,它是定長的,所以轉成切片的時候 cap 就丟失了,打印的就是亂七八糟的值。
所以我們需要再完善一下:
package main
import (
"fmt"
"unsafe"
)
func StringToBytes(s string) []byte {
// 既然字符串轉切片,會丟失容量
// 那麼加上去就好了,做法也很簡單
// 新建一個結構體,將容量(等於長度)加進去
return *(*[]byte)(unsafe.Pointer(
&struct {
string
Cap int
}{s, len(s)},
))
}
func BytesToString(b []byte) string {
// 切片轉字符串就簡單了,直接轉即可
// 轉的過程中,切片的 Cap 字段會丟棄
return *(*string)(unsafe.Pointer(&b))
}
func main() {
fmt.Println(
StringToBytes("abc"),
) // [97 98 99]
fmt.Println(
BytesToString([]byte{97, 98, 99}),
) // abc
}
結果沒有問題,但我們怎麼證明它們是共享數組的呢?很簡單:
package main
import (
"fmt"
"unsafe"
)
func main() {
slice := []byte{97, 98, 99}
str := *(*string)(unsafe.Pointer(&slice))
fmt.Println(str) // abc
slice[0] = 'A'
fmt.Println(str) // Abc
}
操作切片等於操作底層數組,而 str 前後的打印結果不一致,所以確實是共享同一個數組。但需要注意的是,這裏是先創建的切片,因此底層數組是可以修改的,沒有問題。
但如果創建的是字符串,然後基於字符串得到切片,那麼切片就不可以修改了。因爲字符串是不可修改的,所以底層數組也不可修改,也意味着切片不可以修改。
字符串和其它數據結構的轉化
以上我們就介紹完了字符串的原理,再來看看工作中一些常見的字符串操作。
整數和字符串相互轉換
如果想把一個整數轉成字符串,那麼該怎做呢?比如將 97 轉成字符串。有過 Python 經驗的,應該下意識會想到 string(97),但這是不行的,它返回的是字符串 "a",因爲 97 對應的字符是'a'。
如果將整數轉成字符串,應該使用 strconv 包下的 Itoa 函數,這個和 C 語言類似。
package main
import (
"fmt"
"strconv"
)
func main() {
fmt.Println(strconv.Itoa(97))
fmt.Println(strconv.Itoa(97) == "97")
/*
97
true
*/
// 同理,將字符串轉成整數則是 Atoi
s := "97"
if num, err := strconv.Atoi(s); err != nil {
fmt.Println(err)
} else {
fmt.Println(num == 97) // true
}
s = "97xx"
if num, err := strconv.Atoi(s); err != nil {
fmt.Println(
err,
) // strconv.Atoi: parsing "97xx": invalid syntax
} else {
fmt.Println(num)
}
}
Atoi 和 Itoa 專門用於整數和字符串之間的轉換,strconv 這個包還提供了 Format 系列和 Parse 系列的函數,用於其它數據結構和字符串之間的轉換,當然裏面也包括整數。
Parse 系列函數
Parse 一類函數用於轉換字符串爲給定類型的值。
ParseBool
將指定字符串轉換爲對應的 bool 類型,只接受 1、0、t、f、T、F、true、false、True、False、TRUE、FALSE,否則返回錯誤;
package main
import (
"fmt"
"strconv"
)
func main() {
//因爲字符串轉換時可能發生失敗,因此都會帶一個error
//而這裏解析成功了,所以 error 是 nil
fmt.Println(strconv.ParseBool("1")) // true <nil>
fmt.Println(strconv.ParseBool("F")) // false <nil>
}
ParseInt
函數原型:func ParseInt(s string, base int, bitSize int) (i int64, err error)
-
s:轉成 int 的字符串;
-
base:指定進制 (2 到 36),如果 base 爲 0,那麼會從字符串的前綴來判斷,如 0x 表示 16 進制等等,如果前綴也沒有那麼默認是 10 進制;
-
bistSize:整數類型,0、8、16、32、64 分別代表 int、int8、int16、int32、int64;
返回的 err 是 *NumErr 類型,如果語法有誤,err.Error = ErrSyntax;如果結果超出範圍,err.Error = ErrRange。
package main
import (
"fmt"
"strconv"
)
func main() {
fmt.Println(
strconv.ParseInt("0x16", 0, 0),
) // 22 <nil>
fmt.Println(
strconv.ParseInt("16", 16, 0),
) // 22 <nil>
fmt.Println(
strconv.ParseInt("16", 0, 0),
) // 16 <nil>
fmt.Println(
strconv.ParseInt("016", 0, 0),
) // 14 <nil>
//進製爲 2,但是字符串出現了 6,無法解析
fmt.Println(
strconv.ParseInt("16", 2, 0),
) // 0 strconv.ParseInt: parsing "16": invalid syntax
//只指定 8 位,顯然存不下
fmt.Println(
strconv.ParseInt("257", 0, 8),
) // 127 strconv.ParseInt: parsing "257": value out of range
//還可以指定正負號
fmt.Println(
strconv.ParseInt("-0x16", 0, 0),
) // -22 <nil>
fmt.Println(
strconv.ParseInt("-016", 0, 0),
) // -14 <nil>
}
ParseUint
ParseUint 類似 ParseInt,但不接受正負號,用於無符號整型。
ParseFloat
函數原型:func ParseFloat(s string, bitSize int) (f float64, err error),其中 bitSize 爲:32、64,表示對應精度的 float
package main
import (
"fmt"
"strconv"
)
func main() {
fmt.Println(
strconv.ParseFloat("3.14", 64),
) //3.14 <nil>
}
Format 系列函數
Format 系列函數就比較簡單了,就是將指定類型的數據格式化成字符串,Parse 則是將字符串解析成指定數據類型,這兩個是相反的。另外轉成字符串的話,則不需要擔心 error 了。
FormatBool
package main
import (
"fmt"
"strconv"
)
func main() {
// 如果是 Parse 系列的話會返回兩個值, 因爲可能會出錯
// 所以多一個 error, 因此需要兩個變量來接收
// 而 Format 系列則無需擔心, 因爲轉成字符串是不會出錯的
// 所以只返回一個值, 接收的時候只需要一個變量即可
fmt.Println(
strconv.FormatBool(true),
) //true
fmt.Println(
strconv.FormatBool(false) == "false",
) //true
}
FormatInt
傳入字符串和指定的進制。
package main
import (
"fmt"
"strconv"
)
func main() {
// 數值是 24,但它是 16 進制的
// 所以對應成 10 進制是 18
fmt.Println(
strconv.FormatInt(24, 16),
) // 18
}
FormatUint
是 FormatInt 的無符號版本,兩者差別不大。
FormatFloat
函數原型:func FormatFloat(f float64, fmt byte, prec, bitSize int) string,作用是將浮點數轉成爲字符串並返回。
-
f:浮點數;
-
fmt:表示格式,'f'(-ddd.dddd)、'b'(-ddddp±ddd,指數爲二進制)、'e'(-d.dddde±dd,十進制指數)、'E'(-d.ddddE±dd,十進制指數)、'g'(指數很大時用'e'格式,否則'f'格式)、'G'(指數很大時用'E'格式,否則'f'格式);
-
prec:prec 控制精度(排除指數部分),當 fmt 爲 'f'、'e'、'E',它表示小數點後的數字個數;爲'g'、'G',它表示總的數字個數。如果 prec 爲 -1,則代表使用最少數量的、但又必需的數字來表示 f;
-
bitSize:f 是哪一種精度的 float,32 或者 64;
package main
import (
"fmt"
"strconv"
)
func main() {
fmt.Println(
strconv.FormatFloat(3.1415, 'f', -1, 64))
fmt.Println(
strconv.FormatFloat(3.1415, 'e', -1, 64))
fmt.Println(
strconv.FormatFloat(3.1415, 'E', -1, 64))
fmt.Println(
strconv.FormatFloat(3.1415, 'g', -1, 64))
/*
3.1415
3.1415e+00
3.1415E+00
3.1415
*/
}
小結
-
字符串底層是一個結構體,內部不存儲實際數據,而是隻保存一個指針和一個長度;
-
字符串採用 utf-8 編碼,這種編碼的特點是省內存,但是無法通過索引準確定位字符和截取子串;
-
字符串可以和 []byte、[]rune 類型的切片互相轉換,特別是 []rune,如果想計算字符長度或者截取子串,需要轉成 []rune;
-
字符串和切片之間可以共享底層數組,其實現的核心就在於萬能指針;
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/knqzIiZy2asgih0HKsH9mg