1. string 模型:字节与不可变性
当你刚开始编程时,字符串看起来很直观:“不就是文本吗”。然后你会觉得“字符串的第一个字符”就是 s[0],“字符串长度”就是 len(s),“替换一个字母”就是“直接按索引赋值”。在某些语言里,这几乎就是这样工作的——直到你遇到非 ASCII 文本,并且花上一整晚调试“为什么我看到的不是字母,而是一堆乱码”。
Go 的做法更诚实,也更直接:字符串是字节,语言不会假装“字符”总是等于一个字节。我们会建立一个模型,它能帮你摆脱一整类奇怪的 bug:从长度不对,到“字符串一截就变乱码”。
Go 中的 string 是什么
如果尽量通俗地说,string 类型的值保存的是某个字节序列。这些字节通常表示 UTF‑8 编码的文本,但现在对我们更重要的是“机制”:字符串不是字符数组,而是字节数组,只不过被封装在一种特殊类型里。
字符串不可变
定义的第二部分几乎比第一部分更重要:字符串是不可变的(immutable)。这意味着创建之后,你不能在字符串内部修改某个“元素”。你只能基于旧字符串创建一个新字符串。
这听起来像限制,但在实践中它让字符串的行为更可预测。你把字符串传给函数后,可以放心该函数不会“意外地”按索引把内容改掉。
2. 长度、索引与切片
len(s) 对字符串:数的是字节,不是“字母”
先从你已经熟悉的开始:len(s)。对初学者来说,惊喜在于:字符串的 len 返回的是字节数。
在 ASCII 文本上,这通常和“可见字符数”一致,所以会让你觉得一切都很好。但只要字符串里出现了在 UTF‑8 中占用多个字节的字符,len(s) 就会开始“比你想的更大”。这不是 len 的 bug——而是你脑子里数的是“字母”,程序数的是字节。
一个“安全”的 ASCII 小例子:
package main
import "fmt"
func main() {
s := "Go"
fmt.Println(len(s)) // 2
}
再看一个目前只是观察、不做深入解释的例子:
package main
import "fmt"
func main() {
s := "Hi, 世界"
fmt.Println(len(s)) // 会比“可见字符数”更大
}
这里重要的不是具体答案,而是这个事实:len 返回的是字节大小。这也是为什么如果你真的在处理人类语言,不能天真地把 len 当作“文本中的字母数”。
字符串索引 s[i]:你得到的是 byte(也就是一个数字)
现在来到主角:表达式 s[i]。
当你写 s[i] 时,Go 不会返回“长度为 1 的字符串”。它返回的是这个序列中的一个字节,也就是 0..255 范围内的一个数。这个表达式的类型是 uint8(日常里它就是 byte)。
我们从 fmt 的角度来看:
package main
import "fmt"
func main() {
s := "Go"
fmt.Println(s[0], s[1]) // 71 111
fmt.Printf("%T\n", s[0]) // uint8
}
为什么输出的是 71 和 111?因为它们是 ASCII 中字符 "G" 和 "o" 的字节编码。
如果你想“看见字符”,可以用 "%c" 按字符方式打印字节。这对 ASCII 非常有效:
package main
import "fmt"
func main() {
s := "Go"
fmt.Printf("%c\n", s[0]) // G
fmt.Printf("%c\n", s[1]) // o
}
但要记住:你打印的是一个字节,只是把它当作字符编码来显示。对于 ASCII,这和现实一致;对于 UTF‑8 文本,就不一定了,因为一个“人类字符”可能由多个字节编码。
为什么不能按索引修改字符串
当你看到 s[0] 之后,手可能会很自然地想写:
s[0] = 'G'
Go 会很明确地说:不行。字符串是不可变的。
我把它写成“注释掉的禁止操作”,这样代码还能编译:
package main
import "fmt"
func main() {
s := "go"
// s[0] = 'G' // 不行:string 是不可变类型
fmt.Println(s) // go
}
为什么 Go 要这样设计?从“人的角度”看,是为了让字符串安全、可预测;从“工程角度”看,是因为字符串常常会在程序的不同部分之间共享,如果还能在内部修改,会让编译器、运行时以及未来那个要在周五晚上调试的你都更痛苦。
如何正确地“修改字符串”:拼出一个新的
既然字符串不能“原地修改”,那如果你想改第一字母怎么办?创建一个新字符串。
最简单的方法,就是把片段拼起来——这已经是你目前能掌握的方法了。
package main
import "fmt"
func main() {
s := "go"
// 取出 "G",再加上从字节 1 开始的剩余部分
s = "G" + s[1:]
fmt.Println(s) // Go
}
注意两件事。第一,我们不是在“内部修改 s”,而是把 s 重新赋值为一个新字符串。第二,s[1:] 是字符串切片,而且它也是按字节工作的。
字符串切片 s[a:b]:同样按字节,而且也是半开区间
字符串切片看起来和切片的切片一样:s[a:b]。边界规则也一样:取半开区间 [a, b),也就是包含 a,不包含 b。
非常重要的一点:a 和 b 是字节索引,不是“第几个字母”。
在 ASCII 字符串上,一切都和你的直觉一致:
package main
import "fmt"
func main() {
s := "golang"
fmt.Println(s[:2]) // go
fmt.Println(s[2:]) // lang
fmt.Println(s[1:4]) // ola
}
但如果字符串里有由多个字节编码的字符,那么“按字节切”就可能意味着“把一个字符切开”。这也是为什么我们一直强调“字节”这个词。
3. 边界与运算结果
边界检查:s[i] 可能会把程序弄崩
还有一个问题,新手很快就会碰到:越界。
如果 i < 0 或者 i >= len(s),那么 s[i] 就会触发运行时 panic。编译器救不了你,因为 i 往往是在运行时才计算出来的。
一个小例子(演示用):
package main
import "fmt"
func main() {
s := "Go"
i := 2
// fmt.Println(s[i]) // panic: index out of range
fmt.Println(len(s)) // 2
}
正确的习惯是:在按可变索引访问字符串之前,先确认索引有效。刚开始时,可以用最简单的 if 来做:
package main
import "fmt"
func main() {
s := "Go"
i := 1
if i >= 0 && i < len(s) {
fmt.Printf("%c\n", s[i]) // o
}
}
微型表格:不同操作会得到什么
有时候把“表达式返回什么”固定在一个地方,会更容易让大脑停止即兴发挥。
| 表达式 | 这是什么 | 结果类型 |
|---|---|---|
|
字符串的字节长度 | |
|
索引 i 处的一个字节 | (也就是 ) |
|
按字节边界 [a, b) 取子串 | |
这直接来自于字符串在索引时表现得像“byte 类型元素的序列”。
4. 实践:在命令行应用中规范化命令
现在我们往实践方向迈一小步:在教学用命令行应用中(假设它叫 Tasky)加一个小函数,用来“规范化命令”,也就是把首字母变成大写。这至少对漂亮输出有用(“add” → “Add”),同时也很适合演示:索引得到的是字节,字符串不可变,因此必须拼出一个新字符串。
先诚实说明:这会是一个ASCII 方案。遇到中文字符时,它不会按你预期工作。没关系——我们现在还不提前进入 Unicode 的复杂世界。
先从“如果是拉丁字母,就把首字母变大写”开始:
package main
import "fmt"
func titleASCII(s string) string {
if len(s) == 0 {
return s
}
b := s[0] // byte
if b >= 'a' && b <= 'z' {
b = b - ('a' - 'A')
}
return string(b) + s[1:]
}
func main() {
fmt.Println(titleASCII("add")) // Add
fmt.Println(titleASCII("list")) // List
}
这里同时包含了几个想法。
首先,我们检查 len(s) == 0,否则 s[0] 会因为 index out of range 而崩溃。然后取出 b := s[0]——它是一个字节。比较 b >= 'a' 和 b <= 'z' 可以工作,是因为单引号中的字符字面量是数值编码,而在 ASCII 中 'a'..'z' 的范围是连续的。然后我们拼出一个新字符串:string(b)(ASCII 中长度为 1 的字符串)加上尾部 s[1:]。
也就是说,要“改一个字母”,最终还是在构造一个新字符串。只是这样做更诚实、更可预测,而且没有隐藏魔法。
5. 诊断:把字符串按字节打印出来
打印字符串字节以检查内容
有时候字符串看起来没问题,但你怀疑里面有多余空格、制表符或者一些“看不见”的字节。即使现在还没有用 strings 包,也没有逐个读取整行,你已经可以把字符串按字节诊断出来。
下面是一个简短例子:打印每个字节及其“字符视图”(对 ASCII 正确):
package main
import "fmt"
func main() {
s := "Go!"
for i := 0; i < len(s); i++ {
fmt.Printf("i=%d byte=%d char=%c\n", i, s[i], s[i])
// i=0 byte=71 char=G
// i=1 byte=111 char=o
// i=2 byte=33 char=!
}
}
这个练习非常有助于把这样一个观念“刻进脑子里”:字符串是按字节索引的,而不是按“字符”索引的。
图示:“字符串就是字节”
有时候,先看一张图会更容易理解,也就不必再和现实争论了。
flowchart LR
A["string: \"Go\""] --> B["len(s) = 2 bytes"]
A --> C["s[0] = 71 (byte)"]
A --> D["s[1] = 111 (byte)"]
A --> E["s[:1] = 'G' (string)"]
A --> F["s[1:] = 'o' (string)"]
这张图强调的重点是:s[i] 是一个字节数,而 s[a:b] 仍然是一个字符串。
6. 把字符串当字节处理时的典型错误
错误 1:以为 len(s) 就是“字符数”。
在 ASCII 文本上这经常“碰巧正确”,所以这个错误会潜伏很久。但 len 统计的是字节,一旦字符串变成“人类文本”(不只是拉丁字母),字节数和字符数就会分开。最好提前在脑子里固定下来:len 说的是字节。
错误 2:期待 s[i] 返回“字符”或“长度为 1 的字符串”。
字符串索引返回的是字节(uint8)。如果你之后写 fmt.Println(s[i]) 并看到一个数字,这不是 Go 在捉弄你,而是你要的是“字节”,它就老老实实给了你。若想临时按“字符”输出,可以用 "%c",但本质上它还是字节语义。
错误 3:试图按索引修改字符串。
字符串是不可变的,尝试 s[0] = 'X' 根本不会通过编译。正确的方式是由几部分重新构造新字符串:前半段、新片段、后半段。
错误 4:忘记边界,然后得到 panic: index out of range。
在空字符串上访问 s[0] 是经典错误。另一个经典错误是把索引算成 len(s),然后以为“这不就是最后一个吗”。不是,最后一个索引是 len(s) - 1。如果索引来自外部(用户输入、计算结果),最好先检查 0 <= i && i < len(s)。
错误 5:把字符串 s[a:b] 切开,然后假设“a 和 b 是字母编号”。
字符串切片按字节工作。在 ASCII 上这看不出来,但在任意文本上,你很容易得到“坏掉的”输出。即使你现在还不会按“字符”处理,也至少要记住:切片是字节半开区间 [a, b)。
GO TO FULL VERSION