掘金 后端 ( ) • 2024-03-28 10:25

highlight: a11y-dark

前言

对字符串进行迭代是一个非常常见的操作,例如我们想要对字符串中的每个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))

通过range迭代字符串s,输出每个rune的位置和内容如下。

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

啥?输出是这样的吗,怎么与我们预期的不一致。主要在以下三个方面:

  • 第二个rune是Ã而不是字符串中的ê
  • 输出的rune位置直接从1跳到了3,没有位置2
  • len输出字符串长度是6,但是打印的只有5个字符

原因分析

len输出的值为啥是6而不是5呢?因为len返回的是字符串中byte的数量,而不是字符的个数。将字符串赋值给s后,s被编码为UTF-8。字符ê不是一个简单字符,编码后占用2个字节而不是1个字节。

那如果我们想要统计一个字符串中字符的个数而不是byte数量,怎么办呢?处理方法依赖于字符串采用的编码,像本文中字符串s采用的是UTF-8编码,可以使用 unicode/utf8 包提供的统计字符串数量函数。

fmt.Println(utf8.RuneCountInString(s)) // 5

现在回头来分析上述迭代输出内容为啥是这样的原因。如下图所示,打印s[i]的值并不是打印第i个rune的内容,而是从索引位置i开始UTF-8编码后的内容,所以输出的是hÃllo而不是hêllo。

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

5-1.jpg

解决方法

如果我们想原样输出每个字符,有两种方法。

方法一是采用range返回的每个字符,实现代码如下。与最初版本实现的不同点是,打印变量r的值而不是s[i]. range作用于字符串时会返回两个值,值1是rune的下标索引,值2是rune本身。

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

上述程序输出结果如下:

position 0: h
position 1: ê
position 3: l
position 4: l
position 5: o

方法二是将字符串转成rune切片,然后遍历rune切片,实现代码如下。相比最初版本,这里直接打印每个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

思考总结

相比方法一,方法二引入了运行时开销,将字符串转为rune切片需要额外分配内存,将byte转为rune也有O(n)的时间复杂度开销(n为字符串长度)。因此,如果我们想迭代全部的rune,推荐使用方法一。

但是,如果我们想通过下标索引访问字符串中第i个rune字符,此时我们并不知道它准确的起始位置,例如在hêllo中,第一个 l 字符的起始位置是3而不是2. 相反,通过字符串的rune切片,直接访问切片中的第i个值便是我们想要获取的第i个rune字符,这种情况下,我们应该选用方法二。

下面的代码直接输出字符串s中第5个字符。

s := "hêllo"
r := []rune(s)[4]
fmt.Printf("%c\n", r) // o

NOTE: 访问字符串中某个字符优化方法:如果字符串中所有的字符都是单字节的,例如,字符串中所有的字符都在A-Z或a-z,我们可以直接访问第i个字符,而不用先将字符串转为rune切片。实例代码如下。

s := "hello"
fmt.Printf("%c\n", rune(s[4])) // o