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

yudotyang發表於2021-11-20

大家好,我是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編碼儲存在指標指向的陣列中的,因此字串是不可被修改的。在實際專案中,我們尤其要注意字串和位元組切片之間的轉換以及在字串拼接時的效能問題。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
大家好,我是Go學堂的漁夫子,歡迎大家關注Go學堂,一起系統化的分享、學習Go相關的知識。

相關文章