原创分享 Go 常见错误集锦 | 字符串底层原理及常见错误

yudotyang · 2021年11月19日 · 272 次阅读
本帖已被设为精华帖!

大家好,我是 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 编码存储在指针指向的数组中的,因此字符串是不可被修改的。在实际项目中,我们尤其要注意字符串和字节切片之间的转换以及在字符串拼接时的性能问题。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 11月21日 00:27
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册