什么是字符串
在 go 中, string 底层使用的是一个 []byte 切片。下面是 string 结构体定义, 由两部分组成:字符串长度; 字符串对应的 []byte。
type StringHeader struct {
Data uintptr // 指向 []byte 的指针
Len int // 字符串长度
}
如何存储
字符串底层存储的是 []byte 切片, 那当我们声明一个字符串时, go 是如何将其转换为 []byte 的呢?这里就需要了解一下编码。
编码
编码是信息从一种格式转化到另一种格式的过程。我们知道, 计算机数据存储时, 都是使用二进制的形式。编码: 就是将字符「a」转换到二进制「1100001」进行存储; 解码: 就是将二进制「1100001」转换为字符「a」。那如何确定字符 「a」 要转换成 「1100001」 而不是 「1100000」 呢, 这就需要大家约定一个规则「对照表」, 来保证能够正常编码和解码。
Unicode
Unicode 是一个编解码对照表, 它收集了世界上所有的字符, 并为每一个字符分配了一个唯一的 Unicode 码点。对字符进行存储的时候, 可以将一个字符使用 int32「四个字节, 支持 2^32 个字符」 表示。下面是一个使用 Unicode 字符集存储字符 「a」 的例子。
统一使用四个字节表示一个字符的方式比较简单。但是会浪费太多的存储的空间, 从上面的例子可以看出, 对于字符 「a」, 前三个字节其实没有存储任何有意义的信息。那有没有一种更好的编码方式呢?
UTF-8
UTF-8 是一个将 Unicode 码点编码为字节序列的变长编码。在 UTF-8 编码中, 使用 1 到 4 个字节来表示每个Unicode 码点。每个符号编码后第一个字节的高端 bit 位用于表示编码总共有多少个字节。如果第一个字节的高端bit为 0,则表示对应 7bit 的 ASCII 字符; 如果第一个字节的高端 bit 是110, 则说明需要2个字节, 后续的每个高端bit都以 10 开头。更大的 Unicode 码点也是采用类似的策略处理。
使用 UTF-8 可以节省存储空间, 但无法直接判断字节序列包含的字符个数, 也无法直接通过下标访问第 n 个字符。下图展示字符 「a」 使用 UTF-8 编码后的结果, 仅需要一个字节。
字符串操作
在 go 语言中, string 使用的是 UTF-8 编码。
字面量
可以使用 "" 或 `` 声明字符串的值。其中, "" 声明的方式叫做 raw string, 又叫行声明的方式, 对于特殊字符需要进行转义声明「例如:换行 -> \n」; `` 声明的方式不需要做转义处理, 只需要存储原始的值即可, 使用这种方式无法在值中出现 `, 需要单独进行处理。
s1 := "hello\n中国"
s2 := `hello
中国`
fmt.Println(s1 == s2) // true
s3 := `hello` + "`" + `word`
fmt.Println(s3) // hello`word
遍历
上面说过 string 底层存储的是经过 UTF-8 编码后的 []byte。我们可以知道 s1 底层是一个 7 个字节的切片。使用下标的方式其实访问的是 []byte 对应的值。
s1 := "i中国"
fmt.Println(len(s1)) // 7, 对应的字节数
for i := 0; i < len(s1); i++ {
fmt.Printf("index: %d, value: %q\n", i, s1[i])
}
//index: 0, value: 'i'
//index: 1, value: 'ä'
//index: 2, value: '¸'
//index: 3, value: '\u00ad'
//index: 4, value: 'å'
//index: 5, value: '\u009b'
//index: 6, value: '½'
可以使用 for range 的形式遍历出 string 中存储的码点。那么 for range 是如何保证直接输出对应的字符呢?
s1 := "i中国"
for i, v := range s1 {
fmt.Printf("index: %d, value: %q\n", i, v)
}
//index: 0, value: 'i'
//index: 1, value: '中'
//index: 4, value: '国'
通过 go tool compile -S string.go 命令, 看下 for range 编译后的代码。可以看到调用了 rutime.decoderune() 方法。这个方法能够将 string -> []rune。
0x00c5 00197 (str/string.go:9) CALL runtime.decoderune(SB)
rune
rune 本质上是一个 int32「4个字节」, 在 go 中使用 rune 用于标识一个码点。上面说到, 使用 UTF-8 的方式可以极大减少字符串存储所需的空间, 但是无法直接计算字符串包含的码点的个数, 以及通过下标直接访问对应字符。因此, 需要对字节序列进行 utf8-decode, 得到对应的码点数组, 便于计算码点数量或通过下标访问码点。
// 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
在 go 中可以显性的通过 utf8 提供的函数, 对 string 进行 utf8 解码。下面两种写法其实是一个原理。
s1 := "i中国"
for i, v := range s1 {
fmt.Printf("index: %d, value: %q\n", i, v)
}
for i, w := 0, 0; i < len(s1); i += w {
// runeValue: 对应的码点「https://util.unicode.org/UnicodeJsps/character.jsp?a=69&B1=Show」, width: 对应的字节数
runeValue, width := utf8.DecodeRuneInString(s1[i:])
fmt.Printf("index: %d, value: %q\n", i, runeValue)
w = width
}
转换
对于字符串来说, 我们经常会看到 string「不同符号的序列」、[]byte「utf-8 编码后的结果」、[]rune「utf8 解码后的结果, 也就是 unicode 对应的码点」 相互转换。这里我们只需要理解其实都是同一个字符的不同表示即可。
str := "i中国"
sbyte := []byte(str) // string -> []byte
srune := []rune(str) // string -> []rune
fmt.Println(len(sbyte), len(srune)) // 7, 3
str1 := string(sbyte) // []byte -> string
srune2 := make([]rune, 0, len(srune))
for i, w := 0, 0; i < len(sbyte); i += w { // []byte -> []rune
r, width := utf8.DecodeRune(sbyte[i:])
srune2 = append(srune2, r)
w = width
}
fmt.Println(srune, srune2) // [105 20013 22269] [105 20013 22269]
fmt.Println(str1 == str) // true
str3 := ""
for _, r := range srune2 { // []rune -> string
str3 += string(r)
}
fmt.Println(str == str3) // true
sbyte1 := make([]byte, len(sbyte)) // []rune -> []byte
index := 0
for _, r := range srune2 {
w := utf8.EncodeRune(sbyte1[index:], r)
index += w
}
fmt.Println(sbyte, sbyte1) // [105 228 184 173 229 155 189] [105 228 184 173 229 155 189]
当使用 "" 或 `` 声明字符串时, go 会将字符进行 utf8 编码为 []byte, 那如何不进行编码, 直接存储二进制序列呢?可以转化为十六进制 \x 标记
hex := "\xe4\xb8\x96\xe7\x95\x8c"
fmt.Println(len(hex)) // 6 个字节
总结
在使用 string 时, 我们只需记住以下关键点, 就能避免各种坑。
- 声明的 string 串是不可以被更改的
- string 底层存储的是 utf8 编码后的 []byte
- 对 string 求 len, 下标访问, 作用的都是 []byte
- 如果需要访问 string 对应的码点, 可以将其转换成 []rune 再进行操作