《100 Go Mistakes and How to Avoid Them》之字符串篇
礼物说5.1 不理解rune
的概念
我们需要先理解两个基础概念。
- 字符集 从名字理解就是字符的集合,比如
Unicode
字符集包含了2^21字符。 - 编码 是一个字符列表在二进制种的翻译。比如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-16
和UTF-32
.
:= "hello"
s .Println(len(s)) // 5
fmt
:= "汉"
s .Println(len(s)) // 3 fmt
hello
字母都是使用的单个字节,而汉字使用了三个字节编码。len
内置函数返回的不是字符的数量而是字节的数量
5.2 不精准的string
迭代
有的时候我我们想要获取每个rune
或者去搜索某个子串。这个时候我们就需要迭代rune
。以下是个错误的例子。
:= "hêllo"
s for i := range s {
.Printf("position %d: %c\n", i, s[i])
fmt}
.Printf("len=%d\n", len(s)) fmt
输出是:
0: h
position 1: Ã
position 3: l
position 4: l
position 5: o
position len=6
有三个问题
Ã
代替了ê
.跳过了 position 2
len返回了6 但是s只包含了5个字符。
rune的数量可以使用utf8.RuneCountInString(s)
去计算。在上面的例子中只是迭代了每个rune的第一个字节。我们可以把s[i]改成r,这样虽然position不对,但是可以正常迭代rune.当然我们也我们可以先把string转换成[]rune.
:= "hêllo"
s := []rune(s)
runes for i, r := range runes {
.Printf("position %d: %c\n", i, r)
fmt}
0: h
position 1: ê
position 2: l
position 3: l
position 4: o position
需要注意的是,上述的解决方案需要额外分配内存并且O(n) 的时间复杂度取决于s的长度。所以如果我们只需要使用第i个rune,可以直接r := []rune(s)[4]
5.3 错误使用trim
函数
Go开发者经常会混淆strings
标准库中的TrimRight
和TrimSuffix
。
.Println(strings.TrimRight("123oxo", "xo")) fmt
结果会返回123
.因为TrimRight
会循环移除在集合中的rune
,直到遇到一个字符不在集合中。然后返回剩余的字符串。
.Println(strings.TrimSuffix("123oxo", "xo")) fmt
结果会返回123o
.因为TrimSuffix
只会移除相同的后缀,且不会循环处理。 Trim
,TrimLeft
,TrimPrefix
同样如此
5.4 未优化的字符串拼接
func concat(values []string) string {
:= ""
s for _, value := range values {
+= value
s }
return s
}
+=
操作,上文提到过string是不可改变的,所以每一次拼接都会申请新的内存地址,非常影响性能。比较好的方案是使用strings.Builder
.
func concat(values []string) string {
:= strings.Builder{}
sb 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) {
, err := io.ReadAll(reader)
bif 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内存泄露类似。
:= "Hello, World!"
s1 := s1[:5] // Hello s2
如果s2一直被引用,则s1也永远无法被GC。解决的方法其实很简单,生成一份拷贝,string([]byte(s1[:5]))
在Go1.18之后,标准库也提供了strings.Clone
函数。通过值拷贝的方式去避免子串的内存泄露问题。