字符串有很多实现细节,如果详细介绍每个细节,一篇文章的篇幅显然是不够的。 这篇文章不会面面俱到地讲述细节,而是聚焦于三种不同的语言对字符串遍历和索引的处理逻辑。
假设我们现在有这样的一个字符串 "Hello, 世界"
,我们首先在三种语言中做遍历和求字符串长度操作。
# Python
st = "Hello, 世界"
for item in st:
print(item)
print(len(st))
# Output:
# H
# e
# l
# l
# o
# ,
#
# 世
# 界
# 9
Python 的操作逻辑符合我们直觉,"Hello, 世界"
一共有 9 个字符。
// Go
package main
import "fmt"
func main() {
st := "Hello, 世界"
for _, item := range st {
fmt.Println(item)
}
fmt.Println(len(st))
fmt.Println("-----")
// 把字符串转换为字节数组
for _, item := range []byte(st) {
fmt.Println(item)
}
}
/* Output:
72
101
108
108
111
44
32
19990
30028
13
-----
72
101
108
108
111
44
32
228
184
150
231
149
140
*/
Go 这边和 Python 差距比较大,输出的不是字符而是数字,而且字符串的长度竟然是 13,这是什么情况?首先把问题放到一边,继续看 Rust 的情况。
// Rust
fn main() {
let st = "Hello, 世界";
for item in st.chars() {
println!("{}", item);
}
println!("{}", st.len());
println!("----------");
for item in st.bytes() {
println!("{}", item);
}
println!("{}", st.len());
}
/* Output:
H
e
l
l
o
,
世
界
13
----------
72
101
108
108
111
44
32
228
184
150
231
149
140
13
*/
Rust 不同于上面的两种语言,遍历字符串有两种方式:chars()
和 bytes()
。 输出的结果分别与 Python 和 Go 相似,但是长度是 13。从方法名称和实际的输出效果可以推断出,Python
遍历字符串默认输出的是字符串中的字符,而 Go 默认输出的是字符串中每个字符对应的字节编码。 从结果推断,可以得到下面的对应关系:
字符 | 字节 |
---|---|
H | 72 |
e | 101 |
l | 108 |
l | 108 |
o | 111 |
, | 44 |
空格 | 32 |
世 | 228,184,150 (19990) |
界 | 231, 149,140 (30028) |
如果你足够熟悉 ASCII 码的话,你会发现空格之前的所有字符对应的字节其实就是 ASCII。 而中文“世界” 比较特殊,每个字竟然对应三个字节。这是什么情况呢?
提到这个问题,就不得不提一下字节编码。计算机只能通过 0 1 二进制串的方式来存储数据,字符串也同样如此。 为了存储字符,一个直观的方式就是通过映射,一个字符对应一个键,该键用数字表示(参考 ASCII 码)。但是,现实世界存在多种语言:英语、中文、日语和韩语等等。 所有的语言包含的单词数量要远远超过一个字节所能表达的范围(0-255),为了准确存储字符,UTF-8 编码标准应运而生。与 ASCII 不同,UTF-8 采用四个字节来存储字符, 为了节省空间和确保与 ASCII 兼容,采用一种可变长的方式,存储英文单词用 1 个字节,而中文用 3 个字符。 Python、Rust 和 Go 默认使用 UTF-8 编码来存储字符串。
在 Python 中可以通过 encode()
查看每个字符的 UTF-8 编码,以我们的“世界”为例:
print("世".encode())
print("界".encode())
# b'\xe4\xb8\x96' -> 十进制为:228,184,150 = 19990
# b'\xe7\x95\x8c' -> 十进制为:231,149,140 = 30028
果然”世界“每个字对应三个字节,这样一来,长度是 13 也说得通了。
那为什么 228,184,150
可以转换为 19990 呢? 这就涉及到了 UTF-8 的编码细节,可以参考延伸阅读的第二篇文章。
从遍历字符串的结果可以看出,Python 把字节编码细节封装起来,默认每个字符串的单位是字(无论实际存储该字需要多个字节)。 而 Go 和 Rust 将编码细节暴露给用户。
既然这样,如果我想访问字符串中的某个元素,比如 bt[8]
,会发生什么呢?
# Python
st = "Hello, 世界"
print(st[8])
# Output:
# 界
Python 直接返回了第 8 个字符“界”。
// go
func main() {
st := "Hello, 世界"
fmt.Println(st[8])
}
// Output:
// 184
Go 返回了存储字符串的字节数组的第 8 个字节 184
。
// Rust
fn main() {
let st = "Hello, 世界";
println!("{}", st[8usize]);
}
// Output
// error[E0277]: the type `str` cannot be indexed by `{usize}`
而 Rust 直接报错,表示不能直接通过下标索引的方式来访问字符串元素。
Rust 为了避免出现像 Go 中索引字符串却得到一个数字的“奇怪”现象,杜绝了下标索引访问字符串的可能。
结论
同样的索引字符串,不同语言采取了不同的逻辑来处理:
- Python: 封装编码细节,默认使用 UTF-8 存储字符。字符串由每个字母(符合人直观感受的字)组成。
- Go: 采用 UTF-8 存储字符。字符串本质是一个字节数组,由字节组成,每个字可能由不同数目的字节表示。 在索引的时将字节数组的访问权限放给用户,供用户进一步处理。 这导致在索引访问字符串元素时,会出现返回字节的“奇怪”现象。
- Rust: 采用 UTF-8 存储字符。字符串本质是一个字节数组,由字节组成,每个字可能由不同数目的字节表示。为了杜绝出现 Go 中的奇怪现象,禁止索引字符串。并提供了两种不同的遍历方式(
chars()
、bytes()
)供用户使用。