Go 常見錯誤集錦 | 字串底層原理及常見錯誤

yudotyang發表於2021-11-19

大家好,我是 Go 學堂的漁夫子。

string 是 Go 語言的基礎型別,在實際專案中針對字串的各種操作使用頻率也較高。本文就介紹一下在使用 string 時容易犯的一些錯誤以及如何避免。

01 字串的一些基本概念

首先我們看下字串的基本的資料結構:

type stringStruct struct {
    str unsafe.Pointer
    len int
}

由字串的資料結構可知,字串只包含兩個成員:

  • stringStruct.str:一個指向底層資料的指標
  • stringStruct.len:字串的位元組長度,非字元個數。

假設,我們定義了一個字串 “中國”, 如下:

a := "中國"

因為 Go 語言對原始碼預設使用 utf-8 編碼方式,utf-8 對” 中 “使用 3 個位元組,對應的編碼是(我們這裡每個位元組編碼用 10 進製表示):228 184 173。同樣 “國” 的 utf-8 編碼是:229 155 189。如下儲存示意圖:

02 rune 是什麼

要想理解 rune,就會涉及到 unicode 字符集和字元編碼的概念以及二者之間的關係。

unicode 字符集是對世界上多種語言字元的通用編碼,也叫萬國碼。在 unicode 字符集中,每一個字元都有一個對應的編號,我們稱這個編號為 code point,而 Go 中的rune 型別就代表一個字元的 code point

字符集只是將每個字元給了一個唯一的編碼而已。而要想在計算機中進行儲存,則必須要通過特定的編碼轉換成對應的二進位制才行。所以就有了像 ASCII、UTF-8、UTF-16 等這樣的編碼方式。而在 Go 中預設是使用 UTF-8 字元編碼進行編碼的。所有 unicode 字符集合和字元編碼之間的關係如下圖所示:

我們知道,UTF-8 字元編碼是一種變長位元組的編碼方式,用 1 到 4 個位元組對字元進行編碼,即最多 4 個位元組,按位表示就是 32 位。所以,在 Go 的原始碼中,我們會看到對 rune 的定義是 int32 的別名:

// rune is an alias for int32 and is equivalent to int32 in all ways. It is
// used, by convention, to distinguish character values from integer values.
type rune = int32

好,有了以上基礎知識,我們來看看在使用 string 過程中有哪些需要注意的地方。

03 strings.TrimRight 和 strings.TrimSuffix 的區別

strings.TrimRight 函式

該函式的定義如下:

func TrimRight(s, cutset string) string

該函式的功能是:從 s 字串的末尾依次查詢每一個字元,如果該字元包含在 cutset 中,則被移除,直到遇到第一個不在 cutset 中的字元。例如:

fmt.Println(strings.TrimRight("123abbc", "bac"))

執行示例程式碼,會將字串末尾的 abbc 都去除掉,列印出"123"。執行邏輯如下:

strings.TrimSuffix 函式

該函式是將字串指定的字尾字串移除。定義如下:

func TrimSuffix(s, suffix string) string

此函式的實現原理是,從字串 s 中擷取末尾的長度和 suffix 字串長度相等的子字串,然後和 suffix 字串進行比較,如果相等,則將 s 字串末尾的子字串移除,如果不等,則返回原來的 s 字串,該函式只擷取一次。

我們通過如下示例來了解下其執行邏輯:

fmt.Println(strings.TrimSuffix("123abab", "ab"))

我們注意到,該字串末尾有兩個 ab,但最終只有末尾的一個 ab 被去除掉,保留” 123ab"。執行邏輯如下圖所示:

以上的原理同樣適用於 strings.TrimLeft 和 strings.Prefix 的字串操作函式。 而 strings.Trim 函式則同時包含了 strings.TrimLeft 和 strings.TrimRight 的功能。

04 字串拼接效能問題

拼接字串是在專案中經常使用的一個場景。然而,拼接字串時的效能問題會常常被忽略。效能問題其本質上就是要注意在拼接字串時是否會頻繁的產生記憶體分配以及資料拷貝的操作

我們來看一個效能較低的拼接字串的例子:

func concat(ids []string) string {
    s := ""
    for _, id := range ids {
        s += id
    }
    return s
}

這段程式碼執行邏輯上不會有任何問題,但是在進行 s += id 進行拼接時,由於字串是不可變的,所以每次都會分配新的記憶體空間,並將兩個字串的內容拷貝到新的空間去,然後再讓 s 指向新的空間字串。由於分配的記憶體次數多,當然就會對效能造成影響。如下圖所示:

那該如何提高拼接的效能呢?可以通過 strings.Builder 進行改進。strings.Builder 本質上是分配了一個位元組切片,然後通過 append 的操作,將字串的位元組依次加入到該位元組切片中。因為切片預分配空間的特性,可參考切片擴容,以有效的減少記憶體分配的次數,以提高效能

func concat(ids []string) string {
    sb := strings.Builder{} 
    for _, id := range ids {
        _, _ = sb.WriteString(id) 
    }
    return sb.String() 
}

我們看下 strings.Builder 的資料結構:

type Builder struct {
    addr *Builder // of receiver, to detect copies by value
    buf  []byte
}

由此可見,Builder 的結構體中有一個 buf [] byte,當執行 sb.WriteString(id) 方法時,實際上是呼叫了 append 的方法,將字串的每個位元組都儲存到了位元組切片 buf 中。如下圖所示:

上圖中,第一次分配的記憶體空間是 8 個位元組,這跟 Go 的記憶體管理有關係,網上有很多相關文章,這裡不再詳細討論。

如果我們能提前知道要拼接的字串的長度,我們還可以提前使用Builder 的 Grow 方法來預分配記憶體,這樣在整個字串拼接過程中只需要分配一次記憶體就好了,極大的提高了字串拼接的效能。如下圖所示及程式碼:

示例程式碼:

func concat(ids []string) string {
    total := 0
    for i := 0; i < len(ids); i++ { 
        total += len(ids[i])
    }

    sb := strings.Builder{}
    sb.Grow(total) 
    for _, id := range ids {
        _, _ = sb.WriteString(id)
    }
    return sb.String()
}

strings.Builder 的使用場景一般是在迴圈中對字串進行拼接,如果只是拼接兩個或少數幾個字串的話,推薦使用 "+"操作符,例如: s := s1 + s2 + s3,該操作並非每個 + 操作符都計算一次長度,而是會首先計算三個字串的總長度,然後分配對應的記憶體,再將三個字串都拷貝到新申請的記憶體中去。

05 無用字串的轉換

我們在實際專案中往往會遇到這種場景:是選擇位元組切片還是字串的場景。而大多數程式設計師會傾向於選擇字串。但是,很多 IO 的操作實際上是使用位元組切片的。其實,bytes 包中也有很多和 strings 包中相同操作的函式。

我們看這樣一個例子:實現一個 getBytes 函式,該函式接收一個 io.Reader 引數作為讀取的資料來源,然後呼叫 sanitize 函式,該函式的作用是去除字串內容兩端的空白字元。我們看下第一個實現:

func getBytes(reader io.Reader) ([]byte, error) {
 b, err := io.ReadAll(reader)
 if err != nil {
 return nil, err
 }
 // Call sanitize
 return []byte(sanitize(string(b))), nil
}

函式 sanitize 接收一個字串型別的引數的實現:

func sanitize(s string) string {
 return strings.TrimSpace(s)
}

這其實是將位元組切片先轉換成了字串,然後又將字串轉換成位元組切片返回了。其實,在 bytes 包中有同樣的去除空格的函式bytes.TrimSpace,使用該函式就避免了對位元組切片到字串多餘的轉換。

func sanitize(s []byte) []byte {
    return bytes.TrimSpace(s)
}

06 子字串操作及記憶體洩露

字串的切分也會跟切片的切分一樣,可能會造成記憶體洩露。下面我們看一個例子:有一個 handleLog 的函式,接收一個 string 型別的引數 log,假設 log 的前 4 個位元組儲存的是 log 的 message 型別值,我們需要從 log 中提取出 message 型別,並儲存到記憶體中。下面是相關程式碼:

func (s store) handleLog(log string) error {
    if len(log) < 4 {
        return errors.New("log is not correctly formatted")
    }
    message := log[:4]
    s.store(message)
    // Do something
}

我們使用 log[:4] 的方式提取出了 message,那麼該實現有什麼問題嗎?我們假設引數 log 是一個包含成千上萬個字元的字串。當我們使用 log[:4] 操作時,實際上是返回了一個位元組切片,該切片的長度是 4,而容量則是 log 字串的整體長度。那麼實際上我們儲存的 message 不是包含 4 個位元組的空間,而是整個 log 字串長度的空間。所以就有可能會造成記憶體洩露。 如下圖所示:

那怎麼避免呢?使用拷貝。將 uuid 提取後拷貝到一個位元組切片中,這時該位元組切片的長度和容量都是 36。如下:

func (s store) handleLog(log string) error {
 if len(log) < 36 {
 return errors.New("log is not correctly formatted")
 }
 uuid := string([]byte(log[:36])) 
 s.store(uuid)
 // Do something
}

07 小結

字串是 Go 語言的一種基本型別,在 Go 語言中有自己的特性。字串本質上是一個具有長度和指向底層陣列的指標的結構體。在 Go 中,字串是以 utf-8 編碼的位元組序列將每個字元的 unicode 編碼儲存在指標指向的陣列中的,因此字串是不可被修改的。在實際專案中,我們尤其要注意字串和位元組切片之間的轉換以及在字串拼接時的效能問題。

更多原創文章乾貨分享,請關注公眾號
  • Go 常見錯誤集錦 | 字串底層原理及常見錯誤
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章