《100 Go Mistakes and How to Avoid Them》之字符串篇

2023-06-01 ⏳2.4分钟(1.0千字)

5.1 不理解rune的概念

我们需要先理解两个基础概念。

  1. 字符集 从名字理解就是字符的集合,比如 Unicode字符集包含了2^21字符。
  2. 编码 是一个字符列表在二进制种的翻译。比如UTF-8就是一种标准的编码格式,所有的字符都用1-4个字节表示。

Unicode中,使用一个概念代码点去表示一个单值。比如这个字符的代码点就是U+6C49.使用UTF-8,被编码成3个字节:0xE6, 0xB1, 0x89。在Go中,rune就是一个代码点。所以在Go中,一个rune就是int32的别名。

type rune = int32

Go中,字面量都是UTF-8.在golang.org/x的标准库中也有一些包使用UTF-16UTF-32.

s := "hello"
fmt.Println(len(s)) // 5

s := "汉" 
fmt.Println(len(s)) // 3

hello字母都是使用的单个字节,而汉字使用了三个字节编码。len内置函数返回的不是字符的数量而是字节的数量

5.2 不精准的string迭代

有的时候我我们想要获取每个rune或者去搜索某个子串。这个时候我们就需要迭代rune。以下是个错误的例子。

s := "hêllo"
for i := range s {
    fmt.Printf("position %d: %c\n", i, s[i])
}
fmt.Printf("len=%d\n", len(s))

输出是:

position 0: h
position 1: Ã
position 3: l
position 4: l
position 5: o
len=6

有三个问题

  1. Ã 代替了 ê.

  2. 跳过了 position 2

  3. len返回了6 但是s只包含了5个字符。

rune的数量可以使用utf8.RuneCountInString(s)去计算。在上面的例子中只是迭代了每个rune的第一个字节。我们可以把s[i]改成r,这样虽然position不对,但是可以正常迭代rune.当然我们也我们可以先把string转换成[]rune.

s := "hêllo"
runes := []rune(s)
for i, r := range runes {
    fmt.Printf("position %d: %c\n", i, r)
}
position 0: h
position 1: ê
position 2: l
position 3: l
position 4: o

需要注意的是,上述的解决方案需要额外分配内存并且O(n) 的时间复杂度取决于s的长度。所以如果我们只需要使用第i个rune,可以直接r := []rune(s)[4]

5.3 错误使用trim函数

Go开发者经常会混淆strings标准库中的TrimRightTrimSuffix

fmt.Println(strings.TrimRight("123oxo", "xo"))

结果会返回123.因为TrimRight会循环移除在集合中的rune,直到遇到一个字符不在集合中。然后返回剩余的字符串。

fmt.Println(strings.TrimSuffix("123oxo", "xo"))

结果会返回123o.因为TrimSuffix只会移除相同的后缀,且不会循环处理。 TrimTrimLeftTrimPrefix同样如此

5.4 未优化的字符串拼接

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

+=操作,上文提到过string是不可改变的,所以每一次拼接都会申请新的内存地址,非常影响性能。比较好的方案是使用strings.Builder.

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

strings.Builder底层维护了一个[]byte,每次写入其实就是append。并且我们可以配合Grow(n int)方法,初始化好bytes的长度。

strings.Builder唯一的缺点可能就是比+=缺少了可读性,但在循环拼接中性能可以提高两个数量级。还是要选择合适的方式。

Note : 关于字符串的拼接效率,推荐之前看过的一篇文章,benchmark了各种方式的优劣,有兴趣可以一看。

5.5 无用的字符串转换

很多开发者都倾向于使用string处理业务。但是大部分都I/O都返回的[]byte。使用string意味着有额外的性能损耗。比如:

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

其实很多strings包中的函数,bytes包中也有。

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

再比如Split, Count, Contains, Index。所以我们在使用string的时候可以考虑下是否可以使用[]byte来避免额外的转换。

5.6 子字符串的内存泄露

与之前提到的slice内存泄露类似。

s1 := "Hello, World!"
s2 := s1[:5] // Hello

如果s2一直被引用,则s1也永远无法被GC。解决的方法其实很简单,生成一份拷贝,string([]byte(s1[:5]))在Go1.18之后,标准库也提供了strings.Clone函数。通过值拷贝的方式去避免子串的内存泄露问题。