Go 字符串是只读的字节序列,通常是 UTF-8。看清这一点,len 数的是字节、索引得到字节、range 得到 rune、切片会把一个字符切成两半,就都说得通了。
Go 里的文本看起来很简单,直到第一个带重音的字母或 emoji 出现。这时 len 给出一个你没想到的数,对字符串切片还会打印出一个奇怪的菱形符号。
本文讲清楚 Go 字符串里到底装着什么,字节和 rune 跟它是什么关系,以及你每天都会用到的 strings 和 strconv 包。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
字符串是一排字节
Go 字符串是一个不可变的字节序列。Go 不强制要求这些字节是 UTF-8,但 Go 源码里的字符串字面量是 UTF-8,你要处理的文本几乎也都是。
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
for _, s := range []string{"hello", "héllo", "日本語", "go🚀"} {
fmt.Println(s, len(s), utf8.RuneCountInString(s))
}
}
输出:
hello 5 5
héllo 6 5
日本語 9 3
go🚀 6 3
len 数的是字节,不是字符。"hello" 是纯 ASCII,每个字母占一个字节,所以两个数一样。é 占两个字节,所以 "héllo" 有五个字母、六个字节。三个日文字符每个占三个字节。火箭占四个。
utf8.RuneCountInString 数的是 rune,通常更接近人们说的“字符”。为此它得把整个字符串走一遍,所以耗时和长度成正比。len 是瞬间完成的,因为字节数和字符串存在一起。
用十岁孩子能懂的话说
想象一串穿在线上的珠子。每颗珠子是一个字节。
普通的英文字母很小,每个只占一颗珠子。一个 é 要两颗珠子,一个日文字符要三颗,一个 emoji 要四颗。同一个字母的珠子总是挨在一起,而且每个字母的第一颗珠子形状特别,所以你能看出新字母从哪里开始。
len 数的是珠子。range(马上就会讲到)数的是字母:它把属于同一个字母的珠子一起拿起来,把整个字母交给你。
准确的说法
Unicode 给每个字符分配一个数字,叫码点,比如 é 写作 U+00E9。Go 把码点叫作 rune,而 rune 是 int32 的别名,讲值和类型的那一部分已经介绍过。
UTF-8 是把码点变成字节的规则。根据数字的大小,它用 1 到 4 个字节:
| 码点 | 字节数 | 示例 |
|---|---|---|
| U+0000 到 U+007F | 1 | ASCII:a、7、{ |
| U+0080 到 U+07FF | 2 | é、ñ、希腊字母、西里尔字母 |
| U+0800 到 U+FFFF | 3 | 日,以及大多数其他文字 |
| U+10000 到 U+10FFFF | 4 | emoji,比如 🚀 |
每个编码后的 rune,第一个字节会说明后面还跟着几个字节。后面的字节都以二进制位 10 开头,所以绝不会被误认为是一个 rune 的开头。正因如此,Go 不需要查表就能找到 rune 的边界。
这个比喻的局限:人眼看到的一个字符可能由好几个码点组成,所以用 rune“数字母”,不一定和读者数出来的一样。下面讲 emoji 的那一节有一个真实的例子。
索引得到的是字节
用 s[i] 对 Go 字符串做索引,返回的是位置 i 上的字节,而不是第 i 个字符:
package main
import "fmt"
func main() {
s := "héllo"
fmt.Println(s[0], s[1], s[2])
fmt.Printf("%c %c\n", s[0], s[1])
fmt.Printf("% x\n", s)
}
输出:
104 195 169
h Ã
68 c3 a9 6c 6c 6f
s[0] 是 104,也就是 h 的字节。但 é 存成了两个字节,c3 和 a9,所以 s[1] 是 195,只是它的前一半。
用 %c 打印这一半,显示的是 Ã。第一次见会吓一跳。%c 把 195 当作码点 U+00C3,而它恰好是 Ã。这个字节本来就不该单独出现,所以 Go 打印出了一个完全不同的字母。
% x 动词(% 和 x 之间有个空格)把每个字节都按十六进制打印出来。想看字符串里到底装着什么,这是最快的办法。
for range 得到的是 rune
用 for range 遍历字符串,会边走边解码 UTF-8,把每个 rune 和它起始位置的字节偏移量一起交给你:
package main
import "fmt"
func main() {
for i, r := range "hé日🚀!" {
fmt.Printf("byte %d: %c (U+%04X)\n", i, r, r)
}
}
输出:
byte 0: h (U+0068)
byte 1: é (U+00E9)
byte 3: 日 (U+65E5)
byte 6: 🚀 (U+1F680)
byte 10: ! (U+0021)
注意偏移量的跳跃:0、1、3、6、10。这个索引不是字符计数器,而是每个 rune 在字节中的起始位置,到下一个 rune 的间隔就是这个 rune 占了几个字节。
所以关心字节时用 s[i],比如解析 ASCII 协议。关心字符时用 range。
对字符串切片可能把字符切成两半
对字符串切片 s[low:high] 和索引一样,按字节位置操作。Go 不会检查切口是否落在 rune 边界上:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "héllo"
bad := s[:2]
fmt.Printf("%q len=%d valid=%v\n", bad, len(bad), utf8.ValidString(bad))
for i, r := range bad {
fmt.Printf("byte %d: %c (U+%04X)\n", i, r, r)
}
good := s[:3]
fmt.Printf("%q len=%d valid=%v\n", good, len(good), utf8.ValidString(good))
r := []rune(s)
fmt.Println(string(r[:2]))
}
输出:
"h\xc3" len=2 valid=false
byte 0: h (U+0068)
byte 1: � (U+FFFD)
"hé" len=3 valid=true
hé
s[:2] 保留了 h 和 é 的第一个字节。没有 panic,也没有任何警告。结果只是不再是合法的 UTF-8 了。%q 把这个落单的字节显示为 \xc3。
range 遇到一个不能作为合法 rune 开头的字节时,会给你 U+FFFD,也就是 Unicode 替换字符,然后前进一个字节。显示在屏幕上就是那个 � 菱形。如果在网页或日志里看到它,说明上游某处在错误的位置切开或解码了字符串。
s[:3] 在整个 é 之后切开,所以没问题。如果你想要“前两个字符”,又不想数字节,就先转换成 []rune,就像最后一行那样。
[]byte 和 []rune 转换会复制
把字符串转换成 []byte 或 []rune,得到的是一个新切片,里面有一份自己的数据副本,所以你才能修改它:
package main
import "fmt"
func main() {
s := "日本語"
b := []byte(s)
r := []rune(s)
fmt.Println(len(b), b)
fmt.Println(len(r), r)
b[0] = 'X'
r[0] = '月'
fmt.Println(s, string(r))
}
输出:
9 [230 151 165 230 156 172 232 170 158]
3 [26085 26412 35486]
日本語 月本語
[]byte(s) 装着九个 UTF-8 字节。[]rune(s) 装着三个码点,每个都是 int32。修改哪个切片都不会动到 s,因为每次转换都把数据复制到了一个新数组里。用 string(...) 转换回来,又会复制一次。
这些复制耗费的内存和时间都和长度成正比。请求处理函数里的短字符串无所谓,但在遍历大文件的循环里就可能有影响,这时本文末尾介绍的 bytes 包就能帮上忙。另外 []rune 每个 rune 占 4 个字节,所以以 ASCII 为主的文本转成 []rune 后,大约是原字符串的四倍大。
在少数编译器能证明安全的情况下,它会省掉复制,比如 string(b) 只用作 map 的键。别写依赖这一点的代码。
字符串不能修改
Go 字符串是只读的,所以你不能给它的某个字节赋值:
package main
import "fmt"
func main() {
s := "hello"
s[0] = 'H'
fmt.Println(s)
}
构建失败,报错:
./main.go:7:2: cannot assign to s[0] (neither addressable nor a map index expression)
报错信息没有说“字符串不可变”,而是说 s[0] 不可寻址。这是 Go 的说法,意思是那里没有允许你写入的格子。
正是不可变性让字符串传来传去很便宜。一个字符串值只是一个指向字节的指针加上一个长度。复制它、切片它、把它传给函数,都不会复制字节,因为谁也没法在你背后改动它们。
想得到修改后的字符串,就构建一个新的:
package main
import "fmt"
func main() {
s := "hello"
t := "H" + s[1:]
b := []byte(s)
b[0] = 'J'
u := string(b)
fmt.Println(s, t, u)
}
输出:
hello Hello Jello
t 把一个新的首字母和旧字符串的切片拼在一起。u 先复制成 []byte,修改后再转换回来。无论哪种方式,s 仍然是 hello。
构建字符串:循环里的 + 和 strings.Builder
在循环里用 + 拼接字符串,和用 strings.Builder,结果一样,但做的工作量差别很大:
package main
import (
"fmt"
"strings"
)
func main() {
words := []string{"strings", "are", "immutable"}
s := ""
for i, w := range words {
if i > 0 {
s += " "
}
s += w
}
var sb strings.Builder
for i, w := range words {
if i > 0 {
sb.WriteByte(' ')
}
sb.WriteString(w)
}
fmt.Fprintf(&sb, " (%d words)", len(words))
fmt.Println(s)
fmt.Println(sb.String())
fmt.Println(strings.Join(words, " "))
}
输出:
strings are immutable
strings are immutable (3 words)
strings are immutable
三种写法都正确,区别在于复制。因为字符串不能修改,每次 s += w 都会创建一个全新的字符串,把目前拼好的所有内容复制进去。用这种方式一次一个字节地构建一个 10,000 字节的字符串,要复制 1 + 2 + … + 10,000 个字节,也就是 50,005,000 个字节,才得到 10,000 个。
strings.Builder 内部维护一个不断增长的 []byte,增长方式和讲切片的那一部分介绍的一样。String() 把这个缓冲区作为字符串交回,不会再复制一次。Builder 还可以当作 io.Writer 用,所以 fmt.Fprintf 可以直接写进去。
只有几段时,+ 就够了,读起来也更清楚。拼接一个现成的切片时,strings.Join 最简短。在循环里拼很多段时,用 Builder。
strings 包一览
以前要自己写循环处理的文本工作,strings 包大多已经包办了。下面是你最先会用到的几个函数,拿一段看起来像 HTTP 请求行的文本来演示:
package main
import (
"fmt"
"strings"
)
func main() {
line := " GET /users/42 HTTP/1.1 "
clean := strings.TrimSpace(line)
fmt.Printf("%q\n", clean)
fmt.Println(strings.Contains(clean, "/users"), strings.HasPrefix(clean, "GET "))
fmt.Printf("%q\n", strings.Split("a,b,,c", ","))
fmt.Printf("%q\n", strings.Fields(line))
parts := strings.Fields(clean)
fmt.Println(strings.Join(parts, " | "))
fmt.Println(strings.Replace("a-b-c", "-", "+", 1), strings.ReplaceAll("a-b-c", "-", "+"))
fmt.Println(strings.ToUpper("héllo, 日本"))
key, value, found := strings.Cut("Content-Type: text/html", ": ")
fmt.Printf("%q %q %v\n", key, value, found)
_, _, found = strings.Cut("no colon here", ": ")
fmt.Println(found)
}
输出:
"GET /users/42 HTTP/1.1"
true true
["a" "b" "" "c"]
["GET" "/users/42" "HTTP/1.1"]
GET | /users/42 | HTTP/1.1
a+b-c a+b+c
HÉLLO, 日本
"Content-Type" "text/html" true
false
有几个细节值得了解:
TrimSpace只去掉两端的空白,中间的空格保留。Split会保留空片段。"a,b,,c"的两个逗号之间有一个空字符串。Fields按任意连续的空白分割,从不返回空片段,所以拆分单词时它是更好的选择。Replace接受一个次数参数。-1表示全部替换,而ReplaceAll是更好读的写法。ToUpper理解 Unicode,所以é变成了É。日文没有大写,所以保持不变。Cut在 Go 1.18 中加入,它在第一个匹配处分割,并告诉你有没有找到。很多用Index做下标计算、或者只拆成两段的Split调用,都可以换成它。
这些函数都不会改动 line 或 clean。每个“修改”字符串的函数都会返回一个新字符串。
用 strconv 转换数字
strconv 包负责字符串和数字之间的转换。它的解析函数会返回错误,因为来自程序外部的文本常常不是数字:
package main
import (
"fmt"
"strconv"
)
func main() {
s := strconv.Itoa(42)
fmt.Printf("%q\n", s)
n, err := strconv.Atoi("123")
fmt.Println(n, err)
n, err = strconv.Atoi("12a")
fmt.Println(n, err)
f, err := strconv.ParseFloat("3.25", 64)
fmt.Println(f, err)
fmt.Println(strconv.Quote("tab\there, \"quotes\", é"))
fmt.Println(strconv.QuoteToASCII("é🚀"))
}
输出:
"42"
123 <nil>
0 strconv.Atoi: parsing "12a": invalid syntax
3.25 <nil>
"tab\there, \"quotes\", é"
"\u00e9\U0001f680"
Itoa 把 int 变成十进制文本,Atoi 反过来。输入有误时,它返回 0 和一个错误,错误里写明了函数名和输入,直接当日志行就很好用。每次解析查询参数或表单字段时,都要检查这个错误。讲错误的那一部分会介绍怎么处理它。
ParseFloat 接受位数,float64 就传 64。Quote 给字符串加上双引号,并转义需要转义的字符,和 %q 的做法一样。QuoteToASCII 还会转义所有非 ASCII 字符。
有一个陷阱:如果 n 是存着 42 的 int,string(n) 得到的不是 "42"。它把这个数当作码点 U+002A,也就是 *。编译器接受这种写法,但 go vet 会拦住你,提示 conversion from int to string yields a string of one rune, not a string of digits。你想要的是 strconv.Itoa。
原始字符串字面量
原始字符串字面量写在反引号之间,Go 会原样接受里面的所有内容:反斜杠还是反斜杠,换行还是换行。
package main
import (
"fmt"
"regexp"
)
func main() {
interpreted := "C:\\temp\\new\n"
raw := `C:\temp\new\n`
fmt.Print(interpreted)
fmt.Println(raw)
re := regexp.MustCompile(`^\d{3}-\d{4}$`)
fmt.Println(re.MatchString("555-1234"))
usage := `Usage:
tool [flags]
tool help`
fmt.Println(usage)
}
输出:
C:\temp\new
C:\temp\new\n
true
Usage:
tool [flags]
tool help
在双引号字符串里,\\ 表示一个反斜杠,\n 表示换行。在反引号字符串里,\n 就只是一个反斜杠加一个 n。
文本里满是反斜杠或者跨越多行时,就用反引号:正则表达式、Windows 路径、SQL、测试里的 JSON,或者用法说明。原始字符串唯一不能包含的是反引号。
一个字符不止一个 rune 的时候
一个 rune 是一个码点,但人眼看到的一个字符可能由好几个码点组成。带肤色的 emoji,以及带独立重音符号的字母,是最常见的情况:
package main
import "fmt"
func main() {
wave := "👋🏽"
fmt.Println(wave, len(wave), len([]rune(wave)))
for i, r := range wave {
fmt.Printf("byte %d: U+%X\n", i, r)
}
composed := "caf\u00e9"
decomposed := "cafe\u0301"
fmt.Println(composed, decomposed, composed == decomposed)
fmt.Println(len([]rune(composed)), len([]rune(decomposed)))
}
输出:
👋🏽 8 2
byte 0: U+1F44B
byte 4: U+1F3FD
café café false
4 5
挥手的手在屏幕上是一个符号,但它是两个 rune:手 U+1F44B,加上肤色修饰符 U+1F3FD。每个占四个字节,所以 len 是 8,[]rune 的长度是 2。
两个 café 看起来一模一样,但并不相等。第一个用的是单个码点 é。第二个是普通的 e 后面跟着 U+0301,一个叠加在前一个字母上的组合重音符。所以一个有四个 rune,另一个有五个。
标准库能帮你做的就到这里为止了。按读者的视角数字符,或者把两个 café 当作相等,需要 Unicode 规范化和字素簇切分。这两样都不在标准库里。规范化在 golang.org/x/text/unicode/norm 包中,由 Go 团队在标准库之外维护。对大多数程序来说,知道 rune 并不完全等于字符,就足以避开这个 bug。
bytes 包和 strings 一一对应
bytes 包的函数和 strings 一样,只不过作用于 []byte,所以处理来自文件或网络连接的数据时,不必先转换成字符串再转回来:
package main
import (
"bytes"
"fmt"
"strings"
)
func main() {
data := []byte(" hello, world ")
fmt.Printf("%q\n", bytes.TrimSpace(data))
fmt.Println(bytes.Contains(data, []byte("world")), strings.Contains(string(data), "world"))
fmt.Printf("%q\n", bytes.ToUpper(data))
var buf bytes.Buffer
buf.WriteString("status: ")
buf.WriteString("ok")
fmt.Println(buf.String(), buf.Len())
}
输出:
"hello, world"
true true
" HELLO, WORLD "
status: ok 10
bytes.TrimSpace、bytes.Contains 和 bytes.ToUpper 的作用和 strings 里的同名函数一样。bytes.Buffer 类似 strings.Builder,但还能从里面读出数据。Go 的大多数 I/O 都以 []byte 为单位,所以本系列讲到 HTTP 处理函数时,你还会再见到这个包。
要点
- 字符串是不可变的一排字节,通常是 UTF-8。
len数字节,utf8.RuneCountInString数 rune。 s[i]和s[low:high]按字节操作。切片可能把一个 rune 切成两半,损坏的字节会显示为�。- 用
for range遍历字符串会解码出 rune,并给出每个 rune 起始位置的字节偏移量。 []byte(s)、[]rune(s)和string(...)都会复制数据。- 字符串不能修改。要构建新的字符串,在循环里添加很多段时用
strings.Builder。 - 用
strconv解析数字,并检查错误。对int做string(n)得到的是一个 rune,不是数字文本。 - rune 是码点,不一定是读者眼中的一个字符。带肤色的 emoji 是两个 rune。
Go 字符串装的是字节。字符是你从字节里解码出来的。