【Go進階—資料結構】string

與昊 發表於 2021-10-12
資料結構 Go

特性

從標準庫檔案 src/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 位元位元組的集合,通常但並不一定是 UTF-8 編碼的文字。另外,string 可以為空(長度為0),但不會是 nil,並且 string 物件不可修改。

字串可以使用雙引號賦值,也可以使用反單引號賦值。使用雙引號宣告的字串和其他語言中的字串沒有太多的區別,它只能用於單行字串的初始化,如果字串內部出現換行符或雙引號等特殊符號,需要使用 \ 符號轉義;而反引號宣告的字串可以擺脫單行的限制,並且可以在字串內部直接使用特殊符號,在遇到需要手寫 JSON 或者其他複雜資料格式的場景下非常方便。

實現原理

資料結構

原始碼包 src/runtime/string.go:stringStruct 定義了 string 的資料結構:

type stringStruct struct {
    str unsafe.Pointer
    len int
}

結構很簡單,兩個欄位分別表示字串的首地址和長度。

生成字串時,會先構建 stringStruct 物件,再轉換成 string,程式碼如下:

func gostringnocopy(str *byte) string {
    ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
    s := *(*string)(unsafe.Pointer(&ss))
    return s
}

相關操作

字串拼接

在 runtime 包中,使用 concatstrings 函式來拼接字串,所有待拼接字串被組織到一個切片中傳入,核心原始碼如下:

func concatstrings(buf *tmpBuf, a []string) string {
    // 計算待拼接字串切片長度及個數,以此申請記憶體
    idx := 0
    l := 0
    count := 0
    for i, x := range a {
        n := len(x)
        if n == 0 {
            continue
        }
        if l+n < l {
            throw("string concatenation too long")
        }
        l += n
        count++
        idx = i
    }
    if count == 0 {
        return ""
    }

    // 如果非空字串的數量為 1 且當前字串不在棧上,直接返回該字串
    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
}

需要注意的是,在正常情況下,執行時會呼叫 copy 將輸入的多個字串拷貝到目標字串所在的記憶體空間。一旦需要拼接的字串非常大,拷貝帶來的效能損失是無法忽略的。

型別轉換

當我們使用 Go 語言解析和序列化 JSON 等資料格式時,經常需要將資料在 string 和 []byte 之間來回轉換。

從位元組陣列到字串的轉換需要使用 slicebytetostring 函式,核心原始碼如下:

func slicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string) {
    // 位元組陣列長度為 0 或 1 時特殊處理
    if n == 0 {
        return ""
    }
    if n == 1 {
        p := unsafe.Pointer(&staticuint64s[*ptr])
        if sys.BigEndian {
            p = add(p, 7)
        }
        stringStructOf(&str).str = p
        stringStructOf(&str).len = 1
        return
    }

    var p unsafe.Pointer
    // 根據傳入的緩衝區大小決定是否需要為新字串分配記憶體空間
    if buf != nil && n <= len(buf) {
        p = unsafe.Pointer(buf)
    } else {
        p = mallocgc(uintptr(n), nil, false)
    }
    stringStructOf(&str).str = p
    stringStructOf(&str).len = n
    // 將原 []byte 中的位元組全部複製到新的記憶體空間中
    memmove(p, unsafe.Pointer(ptr), uintptr(n))
    return
}

當我們想要將字串轉換成 []byte 型別時,需要使用 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
}

[]byte 轉換成 string 的場景有很多,出於效能上的考慮,有時候只是臨時需要字串的情景下,此時不會發生拷貝,而是直接返回一個 string,其中的指標指向 []byte 的地址。而且,我們需謹記:型別轉換的開銷並沒有想象中那麼小,經常會成為程式的效能熱點。