Skip to content

string

string是 go 中一個非常常見的基礎數據類型,也是我在 go 語言中接觸到的第一個數據類型

go
package main

import "fmt"

func main() {
  fmt.Println("hello,world!")
}

相信這段代碼大多數人在剛接觸 go 時都有敲過。在builtin/builtin.go中有關於string的簡單描述

go
// 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可以是空的,但不會是nil

  • string是不可變的

這幾個特點對於經常使用 go 的人來說應該早就了熟於心了,那麼下面就來看點不一樣的。

結構

在 go 中,字符串在運行時由runtime.stringStruct結構體表示,不過它並不對外暴露,作為替代可以使用reflect.StringHeader

TIP

雖然StringHeader在版本go.1.21已經被廢棄了,不過它確實很直觀,下面的內容還是會使用它來進行講述,並不影響理解,詳情見Issues · golang/go (github.com)

go
// runtime/string.go
type stringStruct struct {
  str unsafe.Pointer
  len int
}

// reflect/value.go
type StringHeader struct {
  Data uintptr
  Len  int
}

其中的字段釋義如下

  • Data,是一個指向字符串內存起始地址的指針
  • Len,字符串的字節數

下面是一個通過 unsafe 指針訪問字符串地址的例子

go
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來代替

go
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包),除非說舊數據的使用者在使用過後永遠不再需要這個字符串,另一個優點就是天生並發安全,任何人在常規情況下都無法對其進行修改。

拼接

字符串的拼接語法如下所示,直接使用+運算符進行拼接。

go
var (
    hello = "hello"
    dot   = ","
    world = "world"
    last  = "!"
)
str := hello + dot + world + last

拼接的操作在運行時由runtime.concatstrings函數完成,如果是下面這種字面量拼接,編譯器會直接推斷出結果。

go
str := "hello" + "," + "world" + "!"
_ = str

通過輸出其匯編代碼就能知道結果,部分如下所示

LEAQ    go:string."hello,world!"(SB), AX
MOVQ    AX, main.str(SP)

很顯然的是編譯器直接將其視作一個完整的字符串,其值在編譯期就已經確定了,並不會由runtime.concatstrings在運行時來拼接,只有拼接字符串變量才會在運行時完成,其函數簽名如下,它接收一個字節數組和一個字符串切片。

go
func concatstrings(buf *tmpBuf, a []string) string

當拼接的字符串變量小於 5 時,會使用下面的函數代替(個人猜測:由參數和匿名變量傳遞,它們都是存在棧上,相比於運行時創建的切片更好 GC?),雖然其最後還是由concatstrings來完成拼接。

go
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函數裡面干了些什麼

go
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,雖然其長度是確定的但它們沒有任何內容,因為它們本質上是兩個指向新內存地址的指針,分配內存的代碼如下

go
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是為了方便修改字符串,它們兩個指向的都是同一個內存地址。

go
for _, x := range a {
    // 復制
    copy(b, x)
    // 截斷
    b = b[len(x):]
}

copy函數在運行時調用的是runtime.slicecopy ,它所做的工作就是直接把src的內存直接復制到dst的地址,所有字符串都復制完畢後,整個拼接過程也就結束了。倘若復制的字符串非常大,這個過程將會相當消耗性能。

轉換

前面提到過,字符串本身是不可以修改的,如果嘗試修改連編譯都沒法通過,go 會如下報錯

go
str := "hello" + "," + "world" + "!"
str[0] = '1'
cannot assign to string (neither addressable nor a map index expression)

想要修改字符串的話,就需要先將其類型轉換至字節切片[]byte,使用起來很簡單

go
bs := []byte(str)

其內部調用了函數runtime.stringtoslicebyte,它的邏輯還是非常簡單的,代碼如下

go
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函數類似,都是分配內存。

同樣的,字節切片在語法上也可以很輕易的轉換成字符串

go
str := string([]byte{'h','e','l','l','o'})

其內部調用的是runtime.slicebytetostring函數,也很容易理解,代碼如下

go
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包更新了下面幾個函數。

go
// 傳入指向內存地址的類型指針和數據長度,返回其切片表達形式
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

// 傳入一個切片,得到指向其底層數組的指針
func SliceData(slice []ArbitraryType) *ArbitraryType

// 根據傳入的地址和長度,返回字符串
func String(ptr *byte, len IntegerType) string

// 傳入一個字符串,返回其起始內存地址,不過返回的字節不能被修改
func StringData(str string) *byte

尤其是StringStringData 函數,它們並不涉及內存復制,也可以完成轉換,不過需要注意的是,使用它們的前提是,得確保數據是只讀的,後續不會有任何修改,否則的話字符串就會發生變化,看下面的例子。

go
func main() {
  bs := []byte("hello,world!")
  s := unsafe.String((*byte)(unsafe.SliceData(bs)), len(bs))
  bs[0] = 'b'
  fmt.Println(s)
}

首先通過SliceData獲取字節切片的底層數組的地址,然後通過String獲取其字符串表達形式,後續再直接修改字節切片,字符串同樣也會發生變化,這顯然違背了字符串的初衷。再來看個例子

go
func main() {
  str := "hello,world!"
  bytes := unsafe.Slice(unsafe.StringData(str), len(str))
  fmt.Println(bytes)
    // fatal
  bytes[0] = 'b'
  fmt.Println(str)
}

獲取了字符串其切片表達形式後,如果嘗試修改字節切片,就會直接fatal,下面換個聲明字符串的方式看看有什麼區別。

go
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函數來操作字符串轉換並不安全,除非能保證永遠不會對數據進行修改。

遍歷

go
s := "hello world!"
for i, r := range s {
  fmt.Println(i, r)
}

為了處理多字節字符的情況,遍歷字符串一般會使用for range循環。當使用for range遍歷字符串時,編譯器會在編譯期間展開成如下形式的代碼

go
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遍歷的類型,這裡就不展開了,感興趣的可以自己去了解。

Golang學習網由www.golangdev.cn整理維護