Go 字符串、byte、rune 与 UTF-8

# Go 字符串、byte、rune 与 UTF-8

本篇目标

理解 Go 字符串的真实存储方式,分清字节、Unicode 码点和用户看到的字符,避免中文截断、长度统计和文本校验中的常见错误。

分清 byte 与 rune理解 UTF-8安全遍历和截断选择字符串构建工具

# 先记住一句话

Go 的 string 是只读字节序列,不是字符数组;len 和下标按字节工作,range 才会按 UTF-8 解码为 rune。

文本 “Go语言”
├─ UTF-8 字节:47 6f e8 af ad e8 a8 80
├─ len:8 个字节
├─ []rune:G、o、语、言,共 4 个 Unicode 码点
└─ 用户看到的字符:通常是 4 个,但不保证总与 rune 数相同
1
2
3
4
5

UTF-8 是什么

Unicode 给字符分配码点,例如 “语” 是 U+8BED;UTF-8 再把码点编码成 1~4 个字节。ASCII 字符只占 1 个字节,大多数常用汉字占 3 个字节。

# string、byte 和 rune 分别表示什么

类型 本质 常见用途
string 不可修改的字节序列 文本、键、路径和协议字段
byte uint8 的别名 原始字节、网络数据和文件内容
rune int32 的别名 一个 Unicode 码点
[]byte 可修改的字节切片 I/O、编码解码和二进制处理
[]rune Unicode 码点切片 按码点遍历、截断或替换文本
// 文件位置:examples/text_basics.go
package textbasics

import "unicode/utf8" // 提供 UTF-8 合法性校验、码点计数和解码工具。

// Inspect 同时返回文本的字节数、Unicode 码点数和第一个字节。
func Inspect(text string) (byteCount int, runeCount int, firstByte byte) {
	// 括号中命名了三个返回值,因此裸 return 会返回它们的当前值。
	byteCount = len(text)                    // len 返回 UTF-8 编码后的字节数。
	runeCount = utf8.RuneCountInString(text) // RuneCountInString 返回码点数。
	if len(text) > 0 {
		firstByte = text[0] // 下标访问得到 byte,不是第一个中文字符。
	}
	return byteCount, runeCount, firstByte // 明确写出三个命名返回值,便于初学时看清顺序。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

对于 Go语言,len(text) 是 8,而 utf8.RuneCountInString(text) 是 4。直接使用 text[:3] 可能从一个汉字的 UTF-8 编码中间截断,得到无效文本。

# range 同时给出字节偏移和 rune

// 文件位置:examples/text_range.go
package textbasics

import "fmt" // 使用 Printf 按指定格式输出字节偏移、字符和 Unicode 码点。

// PrintRunes 按 UTF-8 解码文本,并输出每个 rune 的起始字节位置。
func PrintRunes(text string) {
	// range 遍历字符串时按 UTF-8 解码 rune,而索引仍是字节偏移。
	for byteIndex, currentRune := range text {
		// byteIndex 表示从字符串开头到当前 rune 起始位置已经经过了多少个字节,不是字符序号。
		fmt.Printf("byte=%d rune=%c code=%U\n", byteIndex, currentRune, currentRune)
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13

例如执行 PrintRunes("Go语言"),会输出:

byte=0 rune=G code=U+0047
byte=1 rune=o code=U+006F
byte=2 rune=语 code=U+8BED
byte=5 rune=言 code=U+8A00
1
2
3
4

G 和 o 各占 1 个字节,语 占 3 个字节,因此 言 前面已经有 5 个字节,它的字节偏移就是 5。这个 5 不表示 言 是第 6 个字符。

range 会解码 UTF-8。遇到无效编码时,它会产生 utf8.RuneError,并向前移动相应字节数。接收外部文本时,如果业务要求合法 UTF-8,应先调用 utf8.ValidString 明确拒绝异常输入。

rune 不完全等于用户眼中的一个字符

rune 表示 Unicode 码点。一个用户眼中的字符可能由多个码点组合而成,例如字母和组合音标,也可能是由多个码点组成的 Emoji。[]rune 可以避免截断单个 UTF-8 码点,但要按视觉字符限制长度时,还需要专门的 Unicode 文本分段库,例如 rivo/uniseg (opens new window)。本篇后面的昵称校验会给出完整示例。

# 一个可复用的安全预览函数

下面代码适合放在项目的文本工具包中。它先校验 UTF-8,再按 rune 截断,因此不会切断中文码点。

// 文件位置:internal/textutil/preview.go
package textutil

import (
	"errors"       // 创建可返回给调用方的错误值。
	"strings"      // Builder 用于高效地逐个追加 rune。
	"unicode/utf8" // 校验字符串并统计其中的 Unicode 码点。
)

// 包级哨兵错误让调用方能够稳定判断输入无效这一类别。
var ErrInvalidUTF8 = errors.New("text is not valid UTF-8")

func Preview(text string, maxRunes int) (string, error) {
	// 负数上限没有合理含义,属于调用参数错误。
	if maxRunes < 0 {
		return "", errors.New("maxRunes cannot be negative")
	}
	if !utf8.ValidString(text) {
		return "", ErrInvalidUTF8 // 在业务边界拒绝无效文本,避免错误继续传播。
	}
	if utf8.RuneCountInString(text) <= maxRunes {
		return text, nil // 未超长时直接复用原字符串。
	}

	// Builder 内部维护可复用缓冲区,比循环使用字符串加号更适合逐个构建结果。
	var builder strings.Builder
	for _, currentRune := range text {
		// maxRunes 表示还可以写入多少个码点;减到 0 后停止遍历。
		if maxRunes == 0 {
			break
		}
		builder.WriteRune(currentRune) // 按完整 UTF-8 码点写入。
		maxRunes--
	}
	builder.WriteString("…") // 原文被截断后追加省略号。
	return builder.String(), nil // String 取出最终文本;nil 表示处理成功。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 文件位置:internal/textutil/preview_test.go
package textutil

import "testing" // Go 标准测试包,提供 *testing.T 和失败报告能力。

func TestPreviewDoesNotBreakChineseRune(t *testing.T) {
	// Go 测试函数接收 *testing.T;Fatalf 会记录失败信息并结束当前测试。
	got, err := Preview("Go语言服务", 4)
	if err != nil {
		// 本用例传入合法参数,不期望错误;Fatalf 会标记失败并停止当前测试。
		t.Fatalf("Preview returned error: %v", err)
	}
	// %q 会给字符串加引号,失败信息能够清楚显示空格等不可见差异。
	if got != "Go语言…" {
		t.Fatalf("Preview() = %q, want %q", got, "Go语言…")
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这个函数按码点限制,不等于按页面渲染宽度限制。如果产品规则是 “昵称最多 20 个用户可见字符” ,需要进一步确认 Emoji 和组合字符怎样计数。

# 拼接字符串怎样选择工具

场景 推荐方式 原因
少量固定字符串 a + b 最直观
循环构建文本 strings.Builder 复用可扩容缓冲区,减少重复分配和复制
构建二进制或同时需要 []byte bytes.Buffer 同时支持字节与 I/O 接口
格式化少量展示文本 fmt.Sprintf 可读性好,但通常不是最低开销

为什么循环拼接推荐 strings.Builder

Go 的 string 不可修改。循环中的 result += part 通常不能直接在原字符串末尾追加内容,而要申请新内存,把旧字符串和新内容都复制进去,再用新字符串替换旧字符串。

拼接 "Go"   → 创建 "Go"
再拼 "语言" → 创建 "Go语言",重新复制 "Go"
再拼 "服务" → 创建 "Go语言服务",重新复制 "Go语言"
1
2
3

strings.Builder 内部维护一块可以扩容的缓冲区,多次调用 WriteString 时会尽量复用这块内存,最后再通过 String() 得到结果。因此它适合循环或大量拼接;少量固定字符串直接使用 a + b 更直观。

如果需要修改文本,可以用 []byte(text) 或 []rune(text) 得到可修改切片,修改后再转换回 string;这种转换通常意味着分配和复制,性能敏感路径应通过 Benchmark 验证,而不是凭感觉优化。

# 项目中最常见的错误

  • 用 len(name) 限制中文昵称长度,却实际限制了字节数;
  • 用字节下标截断日志、标题或模型回答,生成无效 UTF-8;
  • 把 rune 当作完整视觉字符,错误计算复杂 Emoji;
  • 在循环中反复使用 result += part,产生大量中间字符串;
  • 默认所有外部 string 都是合法 UTF-8,而没有在输入边界校验。

# 昵称长度应该怎样限制

如果规则是 “昵称为 2~20 个普通字符” ,应先校验 UTF-8,再使用 utf8.RuneCountInString 按 Unicode 码点计数,而不是使用 len:

package nickname

import (
	"errors"       // 创建昵称校验失败时返回的错误。
	"unicode/utf8" // 校验 UTF-8,并按 Unicode 码点统计昵称长度。
)

// Validate 检查昵称编码是否合法,并限制 Unicode 码点数量在 2~20 之间。
func Validate(name string) error {
	// 外部输入不一定是合法 UTF-8,应先在接口边界拒绝异常编码。
	if !utf8.ValidString(name) {
		return errors.New("昵称不是合法 UTF-8")
	}

	// RuneCountInString 按 Unicode 码点计数,不会把一个汉字算成 3 个字节。
	characterCount := utf8.RuneCountInString(name)
	if characterCount < 2 || characterCount > 20 {
		return errors.New("昵称长度必须为 2~20 个字符")
	}
	return nil // 所有规则都通过,nil 表示昵称有效。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

如果产品要求家庭 Emoji 👨‍👩‍👧‍👦 也只算 1 个字符,仅统计 rune 仍不准确。这时要使用字素簇库,例如 rivo/uniseg (opens new window):

package nickname

import "github.com/rivo/uniseg" // 按 Unicode 字素簇规则识别用户看到的完整字符。

func CountVisibleCharacters(name string) int {
	// uniseg 按 Unicode 文本分段规则统计用户眼中的完整字符。
	// 例如由多个码点组成的家庭 Emoji 会被统计为一个字素簇。
	return uniseg.GraphemeClusterCount(name)
}
1
2
3
4
5
6
7
8
9

字素簇是用户视觉上认为的一个完整字符,它可能由多个 Unicode 码点组成。字素簇库负责识别这些码点应该合在一起还是分开,例如不会把 👨‍👩‍👧‍👦 拆成多个昵称字符。

业务字符数与存储字节数是两条不同规则:前者用 rune 或字素簇统计,后者仍可用 len(name) 单独设置上限,防止接口或数据库接收过大的字符串。

# 高频面试题与回答

1. Go 中 string 的长度为什么不等于中文字符数?参考答案

因为 string 本质上是只读字节序列,len 返回字节数。中文通常由多个 UTF-8 字节编码。按 Unicode 码点计数可以使用 utf8.RuneCountInString 或转换为 []rune,但码点数仍不一定等于用户看到的视觉字符数。

2. 遍历字符串时,for range 返回的下标是什么?参考答案

下标是当前 rune 在原字符串中的起始字节偏移,不是第几个字符;值是解码后的 rune。所以下标可能从 0 跳到 3 或 6,并不保证每次加一。

3. string、[]byte 和 []rune 应该怎样选择?参考答案

普通只读文本优先使用 string;文件、网络、编码或需要修改原始字节时使用 []byte;需要按 Unicode 码点处理文本时使用 []rune。转换可能带来分配,热路径要结合基准测试判断。

# 接下来学什么

下一篇学习 Go 函数、闭包与泛型,理解 Go 怎样把行为作为参数,以及怎样用类型参数复用算法。

# 参考资料

上次更新时间: 2026年09月18日 02:14:27