Go 字符串
string 是不可变字节序列;len 计字节,range 产 rune,与 []byte 转换有成本;UTF-8 与码点、字素簇不可混为一谈。
[!info] 关联笔记
Go 字符串
这个概念为什么会出现
文本处理几乎无处不在:日志、路径、协议、UI 文案、配置键。若语言把“字符”定义含糊,国际化与切片会一起爆炸。
Go 的选择非常明确:
string= 不可变的字节序列- 源码与惯例编码 = UTF-8
byte=uint8rune=int32,表示 Unicode 码点
它不把 string 伪装成“可索引的字符数组对象”,而是逼你分清:字节、码点、字素簇三层。
[!abstract] 一句话理解
string不可变,len(s)是字节数;用下标得到 byte,用range得到 rune(码点)与其起始字节下标;string↔[]byte转换在语义上是复制,构建大量文本优先strings.Builder。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:用户昵称校验与展示截断
注册接口收到昵称 "Hello, 世界"。产品规则:
- 统计“字符数”做长度限制(不能只看
len字节) - 审计日志要按字符遍历
- 错误地按字节截断预览会切坏汉字
- 脱敏时改的是拷贝,不能动到原字符串
- 拼欢迎语时用
Builder,避免循环+分配
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
func main() {
// 用户提交的昵称:ASCII + 中文混排
nickname := "Hello, 世界"
// 1) 长度限制:产品说“最多 20 个字”,必须数 rune,不是字节
fmt.Println("bytes:", len(nickname)) // 13
fmt.Println("runes:", utf8.RuneCountInString(nickname)) // 9
// 2) 按字节切片:下标是字节偏移;“世”占 3 字节,从 7 起
fmt.Printf("hanzi bytes: %x\n", nickname[7:10]) // e4b896
// 3) range 字符串:i 是字节下标,r 是解码后的 rune
// 业务:审计每个字符;注意 i 不是 0,1,2… 连续 +1
for i, r := range nickname {
fmt.Printf("at byte %d: %c %U\n", i, r, r)
}
// 4) 错误截断:按字节切到 8,可能切断多字节字符 → 非法 UTF-8
brokenPreview := nickname[:8]
fmt.Printf("broken=%q valid=%v\n", brokenPreview, utf8.ValidString(brokenPreview))
// 5) 字符串不可变:转 []byte 得到可改副本;改副本不影响原昵称
buf := []byte(nickname)
buf[0] = 'h'
fmt.Println("original:", nickname, "copy:", string(buf))
// 6) 拼装欢迎语:多次追加用 Builder
var b strings.Builder
b.WriteString("welcome, ")
b.WriteString(nickname)
fmt.Println(b.String())
}
建议运行:
go run .
期望输出要点:bytes: 13、runes: 9、broken 非法、original 仍以 H 开头、欢迎语完整。
结合场景再看四个关注点
-
len是字节数,不是“字数”
中文昵称长度校验必须用utf8.RuneCountInString或按 rune 遍历。 -
range产 rune,索引是字节偏移
不要假设下标每次 +1。 -
按字节乱切会破坏 UTF-8
预览截断应先转[]rune或用安全截断库。 -
string 不可变;拼接用 Builder
[]byte(s)是拷贝;热路径少写s = s + x。
核心概念与准确模型
语言中的 string
规范:String types
- 值是 8 位字节的不可变序列
- 可包含 任意 字节,不保证一定是合法 UTF-8
len(s)→ 字节数s[i]→ 第 i 个字节(byte),i 越界 panic- 字符串常量/比较/切片
s[low:high]按字节下标
运行时内部常用类似 (ptr, len) 的表示,但是否共享、是否只读映射属于实现;可移植代码依赖语义而非 header 布局。
不可变
// s[0] = 'x' // 编译错误
s = s + "!" // 得到新字符串值
不可变带来:
- 并发只读共享安全(在无 data race 写入底层的前提下)
- “修改”必有新值或转到
[]byte
byte 与 rune
| 类型 | 含义 | 典型用途 |
|---|---|---|
byte (uint8) | 一个字节 | 协议、二进制、UTF-8 单元 |
rune (int32) | Unicode 码点 | 文本解码后的字符处理 |
字符字面量:
var r rune = '世'
var b byte = 'A'
字面量三态:引号怎么选
源码里 ''、""、` 是三种不同类型的字面量,Go 靠引号字符本身区分类型,而不是「只是风格不同」:
| 写法 | 类型 | 含义 | 适合 |
|---|---|---|---|
'a' | rune (int32) | 单个 Unicode 码点 | 单字符、'\n'、'\xFF' |
"hello\n" | string | 普通字符串,支持转义 | 绝大多数字符串 |
`raw` | string | 原始字符串,不转义、可换行 | 正则、SQL、多行文本、含大量反斜杠或引号 |
要点:
'x'只能包一个码点;'ab'编译报illegal rune literal(从 JS/Python 带来单引号字符串习惯的新手最常见报错)。`C:\Users\baker`里反斜杠不转义,写 Windows 路径、正则、JSON 串不必层层转义。- 反引号字符串原样保留换行与引号,适合多行模板。
'a' // rune, 值 97
'中' // rune, 一个汉字也是单个码点
'\n' // rune, 换行
'ab' // 编译错误:illegal rune literal
"ab" // string, OK
`C:\Users\baker` // string, 反斜杠未转义
[!warning] 常见误区:从 JS/Python 带来单引号字符串 错误:
fmt.Println('line count', n)。
正确:fmt.Println("line count", n)(或反引号原始串)。
range 与 for 字节循环
for i, r := range s { /* r 是 rune,i 是该码点起始字节索引 */ }
for i := 0; i < len(s); i++ { /* s[i] 是 byte */ }
非法 UTF-8 序列在 range 时会变成 0xFFFD(替换字符)并前进一字节——解码规则见规范与 unicode/utf8。
码点 ≠ 用户感知字符
- 一个用户看到的字可能由多个码点组成(组合音标、emoji ZWJ 序列等)
utf8.RuneCountInString数码点,不是字素簇- 需要字素簇时用
golang.org/x/text/unicode/norm等扩展库思路,不在本篇展开
string 与 []byte / []rune
b := []byte(s) // 语义:复制字节
s2 := string(b) // 语义:复制为不可变字符串
r := []rune(s) // 解码为码点切片(成本更高)
s3 := string(r) // 重新编码为 UTF-8
注意:
- 编译器可能在证明安全时优化掉拷贝,但这是优化不是许可证:不要用
unsafe改 string 底层。 - 热路径上反复
string([]byte)会显眼分配;能传[]byte就保持[]byte。 []rune往返适合“按码点编辑”,不适合当默认存储。
拼接与 Builder
// 少量:+
// 循环大量:
var b strings.Builder
b.Grow(n) // 可选预分配
b.WriteString(s)
_ = b.String()
bytes.Buffer 更偏二进制缓冲;纯字符串累积优先 strings.Builder。
strings 与 unicode/utf8 包
- strings:
ContainsSplitJoinTrimReplaceMap… - unicode/utf8:
ValidDecodeRuneInStringRuneCountInString - strconv:数字与字符串转换
Split 等仍是字节/分隔符语义;按 rune 边界处理要用 utf8 或专门逻辑。
比较与有序
字符串可比较,按字节字典序(不是语言 locale 排序)。作 map key 很常见。
设计动机
- 数据就是字节
网络与文件本就是字节;string 不撒谎。 - UTF-8 默认
与互联网主编码一致,ASCII 兼容。 - 不可变简化共享
只读传递代价低、心智简单。
边界情况与反直觉行为
len("世") == 3s[0]对中文首字节不是完整字- 字节切片切断 UTF-8 中间 → 非法字符串
range的 index 有空洞(多字节)- 空字符串与 nil
[]byte
string(nil []byte)→"";[]byte(nil string)相关转换注意 nil/empty 差异 - 映射文件/超大字符串
仍受不可变与转换成本约束
常见误区
[!warning] 常见误区:用 len 当“字符数” 错误:校验用户名长度直接
len(s) <= 10当“10 个字”。
正确:先定义要限制的是字节、码点还是字素;再用对应 API。
[!warning] 常见误区:for i := range 当 rune 循环却用 s[i:] 乱切 错误:假设每个 i 都是等宽字符边界。
正确:使用 range 的 rune,或utf8.DecodeRuneInString。
[!warning] 常见误区:热循环 string + string 错误:每次分配新字符串导致平方级拷贝。
正确:strings.Builder或[]byte累积。
[!warning] 常见误区:用 unsafe 改 string 底层 错误:为了零拷贝直接写 string header 指向的内存。
正确:保持不可变契约;需要改就用[]byte。
工程实践
- API 边界:文本用
string,二进制用[]byte,别混。 - 校验 UTF-8:读入外部数据时
utf8.ValidString。 - 日志与截断:按字节截断可能切断中文;展示层要按 rune/字素策略。
- 比较:鉴权 token 等敏感比较考虑恒定时间(
subtle.ConstantTimeCompare),普通业务用==。 - 国际化:不要假设 1 rune = 1 显示宽度。
- 性能:减少热路径类型来回转;能借用
[]byte的库函数优先。
可验证实验
- 打印
"Go语言"的len与RuneCountInString。 - 对比
for i := range s与for i, r := range s。 s[:n]切在多字节中间,ValidString为 false。- 循环
+与Builder的 benchmark(见 go-benchmarking)。 - 修改
[]byte(s)后原s是否变化。
本节总结
- 本质:不可变字节序列;UTF-8 是惯例与源码编码。
- 三层:byte / rune(码点) / 字素簇。
- 关键操作:len、下标、range、转换、Builder。
- 最易错:字符计数、乱切片、拼接分配、破坏不可变。
- 下一步:slice 字节缓冲与 io 文本处理。
自测题
概念题
len(s)数的是什么?range s中的r是什么?索引i的含义?- 为什么
string与[]byte转换可能贵?
代码推理题
s := "a中"
fmt.Println(len(s))
for i, r := range s {
fmt.Println(i, string(r), r)
}
大致输出结构是什么?
工程思考题
HTTP 头、文件路径、用户评论三种字符串,校验“长度”时分别更可能用字节限制还是码点限制?为什么?
参考答案
展开
- 字节数。
r是码点(rune);i是该码点第一个字节的下标。- 语义上需要得到独立可变/不可变底层,通常伴随复制与分配(实现优化除外)。
代码:len为 1+3=4;先打印0 a,再打印1 中(索引跳到 1 而非 2)。
工程:协议/头/路径常有字节上限(线宽、系统 API);用户可见文案更常谈码点/字素;需按威胁模型与平台约束选择并写清文档。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Spec — String types | 规范 | 不可变字节序列 |
| Spec — For statements / range | 规范 | 字符串 range |
| Strings, bytes, runes and characters in Go | 博客 | 权威概念文 |
| Package strings | 标准库 | 文本工具 |
| Package unicode/utf8 | 标准库 | UTF-8 编解码 |