Blog

Go 的字符串、字节和 rune

Go 字符串是只读的字节序列,通常是 UTF-8。看清这一点,len 数的是字节、索引得到字节、range 得到 rune、切片会把一个字符切成两半,就都说得通了。

Go 里的文本看起来很简单,直到第一个带重音的字母或 emoji 出现。这时 len 给出一个你没想到的数,对字符串切片还会打印出一个奇怪的菱形符号。

本文讲清楚 Go 字符串里到底装着什么,字节和 rune 跟它是什么关系,以及你每天都会用到的 stringsstrconv 包。下面每个程序都在 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,而 runeint32 的别名,讲值和类型的那一部分已经介绍过。

UTF-8 是把码点变成字节的规则。根据数字的大小,它用 1 到 4 个字节:

码点 字节数 示例
U+0000 到 U+007F 1 ASCII:a7{
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 的字节。但 é 存成了两个字节,c3a9,所以 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 调用,都可以换成它。

这些函数都不会改动 lineclean。每个“修改”字符串的函数都会返回一个新字符串。

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"

Itoaint 变成十进制文本,Atoi 反过来。输入有误时,它返回 0 和一个错误,错误里写明了函数名和输入,直接当日志行就很好用。每次解析查询参数或表单字段时,都要检查这个错误。讲错误的那一部分会介绍怎么处理它。

ParseFloat 接受位数,float64 就传 64Quote 给字符串加上双引号,并转义需要转义的字符,和 %q 的做法一样。QuoteToASCII 还会转义所有非 ASCII 字符。

有一个陷阱:如果 n 是存着 42 的 intstring(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.TrimSpacebytes.Containsbytes.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 解析数字,并检查错误。对 intstring(n) 得到的是一个 rune,不是数字文本。
  • rune 是码点,不一定是读者眼中的一个字符。带肤色的 emoji 是两个 rune。

Go 字符串装的是字节。字符是你从字节里解码出来的。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。