CodeGym /课程 /Go SELF /string 作为不可变字节

string 作为不可变字节

Go SELF
第 14 级, 课程 0
可用

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
	}
}

微型表格:不同操作会得到什么

有时候把“表达式返回什么”固定在一个地方,会更容易让大脑停止即兴发挥。

表达式 这是什么 结果类型
len(s)
字符串的字节长度
int
s[i]
索引 i 处的一个字节
uint8
(也就是
byte
)
s[a:b]
按字节边界 [a, b) 取子串
string

这直接来自于字符串在索引时表现得像“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)。

1
任务
Go SELF, 第 14 级, 课程 0
已锁定
字节扫描器
字节扫描器
1
任务
Go SELF, 第 14 级, 课程 0
已锁定
索引守卫
索引守卫
1
任务
Go SELF, 第 14 级, 课程 0
已锁定
首字母大写的开头
首字母大写的开头
1
任务
Go SELF, 第 14 级, 课程 0
已锁定
精确替换
精确替换
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION