string
string是 go 中一個非常常見的基礎數據類型,也是我在 go 語言中接觸到的第一個數據類型
package main
import "fmt"
func main() {
fmt.Println("hello,world!")
}相信這段代碼大多數人在剛接觸 go 時都有敲過。在builtin/builtin.go中有關於string的簡單描述
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string從上面這段話可以得到以下幾個信息
string是 8 位字節的集合string類型通常是UTF-8編碼string可以是空的,但不會是nilstring是不可變的
這幾個特點對於經常使用 go 的人來說應該早就了熟於心了,那麼下面就來看點不一樣的。
結構
在 go 中,字符串在運行時由runtime.stringStruct結構體表示,不過它並不對外暴露,作為替代可以使用reflect.StringHeader。
TIP
雖然StringHeader在版本go.1.21已經被廢棄了,不過它確實很直觀,下面的內容還是會使用它來進行講述,並不影響理解,詳情見Issues · golang/go (github.com)。
// runtime/string.go
type stringStruct struct {
str unsafe.Pointer
len int
}
// reflect/value.go
type StringHeader struct {
Data uintptr
Len int
}其中的字段釋義如下
Data,是一個指向字符串內存起始地址的指針Len,字符串的字節數
下面是一個通過 unsafe 指針訪問字符串地址的例子
func main() {
str := "hello,world!"
h := *((*reflect.StringHeader)(unsafe.Pointer(&str)))
for i := 0; i < h.Len; i++ {
fmt.Printf("%s ", string(*((*byte)(unsafe.Add(unsafe.Pointer(h.Data), uintptr(i)*unsafe.Sizeof(str[0]))))))
}
}不過 go 現在推薦使用unsafe.StringData來代替
func main() {
str := "hello,world!"
ptr := unsafe.Pointer(unsafe.StringData(str))
for i := 0; i < len(str); i++ {
fmt.Printf("%s ", string(*((*byte)(unsafe.Add(ptr, uintptr(i)*unsafe.Sizeof(str[0]))))))
}
}兩者輸出都是一樣的
h e l l o , w o r l d !
字符串其本質上就是一片連續的內存地址,每一個地址上都存儲著一個字節,換句話說就是一個字節數組,通過len函數獲取的結果是字節的數量,而非字符串中字符的數量,當字符串中的字符是非 ASCII 字符是尤其如此。
string本身只佔很小的內存即一個指向真實數據的指針,這樣一來傳遞字符串的成本就會非常低。個人認為,由於只持有一個內存的引用,如果可以被隨意修改的話,日後很難知道原來的指向是否還是想要的數據(要麼使用反射要麼使用unsafe包),除非說舊數據的使用者在使用過後永遠不再需要這個字符串,另一個優點就是天生並發安全,任何人在常規情況下都無法對其進行修改。
拼接

字符串的拼接語法如下所示,直接使用+運算符進行拼接。
var (
hello = "hello"
dot = ","
world = "world"
last = "!"
)
str := hello + dot + world + last拼接的操作在運行時由runtime.concatstrings函數完成,如果是下面這種字面量拼接,編譯器會直接推斷出結果。
str := "hello" + "," + "world" + "!"
_ = str通過輸出其匯編代碼就能知道結果,部分如下所示
LEAQ go:string."hello,world!"(SB), AX
MOVQ AX, main.str(SP)很顯然的是編譯器直接將其視作一個完整的字符串,其值在編譯期就已經確定了,並不會由runtime.concatstrings在運行時來拼接,只有拼接字符串變量才會在運行時完成,其函數簽名如下,它接收一個字節數組和一個字符串切片。
func concatstrings(buf *tmpBuf, a []string) string當拼接的字符串變量小於 5 時,會使用下面的函數代替(個人猜測:由參數和匿名變量傳遞,它們都是存在棧上,相比於運行時創建的切片更好 GC?),雖然其最後還是由concatstrings來完成拼接。
func concatstring2(buf *tmpBuf, a0, a1 string) string {
return concatstrings(buf, []string{a0, a1})
}
func concatstring3(buf *tmpBuf, a0, a1, a2 string) string {
return concatstrings(buf, []string{a0, a1, a2})
}
func concatstring4(buf *tmpBuf, a0, a1, a2, a3 string) string {
return concatstrings(buf, []string{a0, a1, a2, a3})
}
func concatstring5(buf *tmpBuf, a0, a1, a2, a3, a4 string) string {
return concatstrings(buf, []string{a0, a1, a2, a3, a4})
}下面來看看concatstrings函數裡面干了些什麼
func concatstrings(buf *tmpBuf, a []string) string {
idx := 0
l := 0
count := 0
for i, x := range a {
n := len(x)
// 長度為0跳過
if n == 0 {
continue
}
// 數值計算溢出
if l+n < l {
throw("string concatenation too long")
}
l += n
// 計數
count++
idx = i
}
// 沒有字符串直接返回空串
if count == 0 {
return ""
}
// 如果只有一個字符串的話,直接返回
if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
return a[idx]
}
// 為新字符串開辟內存
s, b := rawstringtmp(buf, l)
for _, x := range a {
// 復制
copy(b, x)
// 截斷
b = b[len(x):]
}
return s
}首先做的事情是統計要拼接字符串的總長度和數量,然後根據總長度分配內存,rawstringtmp函數會返回一個字符串s和字節切片b,雖然其長度是確定的但它們沒有任何內容,因為它們本質上是兩個指向新內存地址的指針,分配內存的代碼如下
func rawstring(size int) (s string, b []byte) {
// 沒有指定類型
p := mallocgc(uintptr(size), nil, false)
// 雖然分配了內存但是上面什麼都沒有
return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}返回的字符串s是為了方便表示,字節切片b是為了方便修改字符串,它們兩個指向的都是同一個內存地址。
for _, x := range a {
// 復制
copy(b, x)
// 截斷
b = b[len(x):]
}copy函數在運行時調用的是runtime.slicecopy ,它所做的工作就是直接把src的內存直接復制到dst的地址,所有字符串都復制完畢後,整個拼接過程也就結束了。倘若復制的字符串非常大,這個過程將會相當消耗性能。
轉換
前面提到過,字符串本身是不可以修改的,如果嘗試修改連編譯都沒法通過,go 會如下報錯
str := "hello" + "," + "world" + "!"
str[0] = '1'cannot assign to string (neither addressable nor a map index expression)想要修改字符串的話,就需要先將其類型轉換至字節切片[]byte,使用起來很簡單
bs := []byte(str)其內部調用了函數runtime.stringtoslicebyte,它的邏輯還是非常簡單的,代碼如下
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
var b []byte
if buf != nil && len(s) <= len(buf) {
*buf = tmpBuf{}
b = buf[:len(s)]
} else {
b = rawbyteslice(len(s))
}
copy(b, s)
return b
}如果字符串長度小於緩沖區長度的話就直接返回緩沖區的字節切片,這樣在小字符串轉換的時候可以節省內存。否則的話,就會開辟一片與字符串長度相當的內存,然後將字符串復制到新的內存地址中,其中函數rawbyteslice(len(s))所做的事與之前rawstring函數類似,都是分配內存。
同樣的,字節切片在語法上也可以很輕易的轉換成字符串
str := string([]byte{'h','e','l','l','o'})其內部調用的是runtime.slicebytetostring函數,也很容易理解,代碼如下
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) string {
if n == 0 {
return ""
}
if n == 1 {
p := unsafe.Pointer(&staticuint64s[*ptr])
if goarch.BigEndian {
p = add(p, 7)
}
return unsafe.String((*byte)(p), 1)
}
var p unsafe.Pointer
if buf != nil && n <= len(buf) {
p = unsafe.Pointer(buf)
} else {
p = mallocgc(uintptr(n), nil, false)
}
memmove(p, unsafe.Pointer(ptr), uintptr(n))
return unsafe.String((*byte)(p), n)
}首先處理切片長度為 0 和 1 的特殊情況,在這種情況不用進行內存復制。然後就是小於緩沖區長度就用緩沖區的內存,否則就開辟新內存,最後再用memmove函數把內存直接復制過去,復制過後的內存與源內存沒有任何關聯,所以可以隨意的修改。
值得注意的是,上面兩種轉換方法,都需要進行內存復制,如果待復制的內存非常大,性能消耗也會很大。在版本更新到go1.20時,unsafe包更新了下面幾個函數。
// 傳入指向內存地址的類型指針和數據長度,返回其切片表達形式
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
// 傳入一個切片,得到指向其底層數組的指針
func SliceData(slice []ArbitraryType) *ArbitraryType
// 根據傳入的地址和長度,返回字符串
func String(ptr *byte, len IntegerType) string
// 傳入一個字符串,返回其起始內存地址,不過返回的字節不能被修改
func StringData(str string) *byte尤其是String和StringData 函數,它們並不涉及內存復制,也可以完成轉換,不過需要注意的是,使用它們的前提是,得確保數據是只讀的,後續不會有任何修改,否則的話字符串就會發生變化,看下面的例子。
func main() {
bs := []byte("hello,world!")
s := unsafe.String((*byte)(unsafe.SliceData(bs)), len(bs))
bs[0] = 'b'
fmt.Println(s)
}首先通過SliceData獲取字節切片的底層數組的地址,然後通過String獲取其字符串表達形式,後續再直接修改字節切片,字符串同樣也會發生變化,這顯然違背了字符串的初衷。再來看個例子
func main() {
str := "hello,world!"
bytes := unsafe.Slice(unsafe.StringData(str), len(str))
fmt.Println(bytes)
// fatal
bytes[0] = 'b'
fmt.Println(str)
}獲取了字符串其切片表達形式後,如果嘗試修改字節切片,就會直接fatal,下面換個聲明字符串的方式看看有什麼區別。
func main() {
var str string
fmt.Scanln(&str)
bytes := unsafe.Slice(unsafe.StringData(str), len(str))
fmt.Println(bytes)
bytes[0] = 'b'
fmt.Println(str)
}hello,world!
[104 101 108 108 111 44 119 111 114 108 100 33]
bello,world!從結果可以看出來,確實修改成功了。之前所以fatal,在於變量str存儲的是字符串字面量,字符串字面量都存儲在只讀數據段,而非堆棧,從根本上就斷絕了字面量聲明的字符串後續會被修改的可能性,對於一個普通的字符串變量而言,本質上來說它確實可以被修改,但是這種寫法編譯器不允許。總之,使用unsafe函數來操作字符串轉換並不安全,除非能保證永遠不會對數據進行修改。
遍歷
s := "hello world!"
for i, r := range s {
fmt.Println(i, r)
}為了處理多字節字符的情況,遍歷字符串一般會使用for range循環。當使用for range遍歷字符串時,編譯器會在編譯期間展開成如下形式的代碼
ha := s
for hv1 := 0; hv1 < len(ha); {
hv1t := hv1
hv2 := rune(ha[hv1])
// 判斷是否是單字節字符
if hv2 < utf8.RuneSelf {
hv1++
} else {
hv2, hv1 = decoderune(ha, hv1)
}
i, r = hv1t, hv2
// 循環體
}在展開的代碼中,for range循環會替換成經典的for循環,在循環中,會判斷當前字節是否是單字節字符,如果是多字節字符的話會調用運行時函數runtime.decoderune來獲取其完整編碼,然後再賦值給i,r,處理完過後就到了源代碼中定義的循環體執行。
負責構造中間代碼的工作由cmd/compile/internal/walk/range.go中的walkRange函數來完成,同時它也負責處理所有能被for range遍歷的類型,這裡就不展開了,感興趣的可以自己去了解。
