一个字符串引发的血案:从 C 到 Go 的 rune,字符串是怎么一步步进化的
Posted on 一 06 7月 2026 in Tech
| Abstract | 一个字符串引发的血案:从 C 到 Go 的 rune,字符串是怎么一步步进化的 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Version | v1.0 |
| Updated | 2026-07-06 |
| License | CC-BY-NC-ND 4.0 |
一个字符串引发的血案:从 C 到 Go 的 rune,字符串是怎么一步步进化的
先讲个真事。有回处理一批用户昵称,我用 Python 干得好好的,len(name) 数出来是 3 个字。同一份数据挪到一段老 C 服务里,strlen 数出来是 9。中间有人截断字符串取前 6 字节做 key,结果把一个 emoji 劈成了两半,写进数据库变成乱码,下游解析直接崩。
一个"取前几个字"这么朴素的需求,在不同语言里居然能给出完全不同的答案——这不是 bug,这是几十年来"字符串"这个概念自己在偷偷进化,而我们没跟上。
这篇我想按时间线,把 C、C++、Java、Python、Go 的字符串设计串一遍。你会发现整条演进线其实只在跟三个问题较劲:内存谁来管、长度怎么算、"一个字"到底指什么。 看完之后,那个"3 还是 9"的谜题自然就解开了。
先约定几个词,怕有读者不熟: - 字节(byte):内存里最小的存储单位,8 个二进制位。 - 字符编码(encoding):把"人看的字"翻译成"机器存的字节"的规则,比如 ASCII、UTF-8。 - 码点(code point):Unicode 给每个字符分配的唯一编号,比如"中"是 U+4E2D。一个码点存成几个字节,取决于用什么编码。
一、C:字符串根本不是个类型,只是"一段以 0 结尾的字节"
C 语言里没有"字符串类型"这回事。所谓字符串,就是一个 char 数组,末尾放一个 \0 当结束标记。仅此而已。
char *name = "hello"; // 内存里其实是: h e l l o \0
size_t n = strlen(name); // 从头数到 \0,得到 5
这个设计简单到极致,也危险到极致。它把三件事全甩给了程序员:
- 内存谁管? 你自己
malloc、自己free,忘了就泄漏,重复了就崩。 - 长度怎么算? 没有记着长度,每次
strlen都得从头走到\0,O(n)。 - 越界谁拦? 没人拦。
strcpy一个超长的串进短 buffer,直接冲掉后面的内存——著名的缓冲区溢出漏洞,一半以上都出在这儿。
而且 C 的 char 就是一个字节,天然只装得下 ASCII 那 128 个字符。中文、emoji 怎么办?靠 \0 结尾这套机制硬存 UTF-8 字节流。于是"中"这个字占 3 个字节,strlen("中") 返回 3。
C 的字符串不是"字",是"字节流 + 一个句号"。 它把长度、内存、编码全部留白,换来了极致的简单和极致的不安全。
这就是我开头那个故障的根:老 C 服务眼里根本没有"字",只有字节。你让它取前 6 个,它就老老实实切 6 个字节,才不管这刀会不会砍在一个 emoji 中间。
二、C++ 的 std::string:先把"内存"这件事管起来
C++ 出来后,std::string(STL 的一部分)干的第一件大事,是把内存管理收回到类型内部。
std::string name = "hello";
name += " world"; // 自动扩容,不用你操心 malloc
std::cout << name.size(); // O(1),长度是存着的
对比 C,它解决了两个老大难:
| 问题 | C 的做法 | std::string 的做法 |
|---|---|---|
| 内存 | 手动 malloc/free | RAII 自动管理,出作用域自动释放 |
| 长度 | strlen 每次 O(n) | size() 直接读,O(1) |
| 拼接扩容 | 自己算 buffer 大小 | operator+= 自动扩容 |
RAII 这个词可能有人陌生,一句话解释:对象一创建就拿资源,一销毁就还资源,像借还图书馆的书,系统帮你记着还期。这让 C++ 的字符串安全度上了一个台阶。
但 C++ 只解决了"内存"和"长度",没碰"编码"这块硬骨头。 std::string 里装的还是字节,size() 返回的还是字节数。std::string("中").size() 依然是 3。后来 C++ 又加了 std::wstring、char16_t、char32_t、std::u32string 一大堆类型试图应对 Unicode,结果是——把简单问题搞复杂了,平台之间还不统一(Windows 的 wchar_t 是 2 字节,Linux 是 4 字节)。
这也是 C++ 字符串一直被吐槽的点:能用,但心智负担重,一不小心就踩到编码的坑。
三、Java:字符串终于成了"对象",还顺手挖了个 UTF-16 的坑
Java 把字符串彻底"对象化"了。String 是一个不可变(immutable)的类,内部维护自己的字符数组和长度,还带上了一堆方法(substring、indexOf、split……)。
更重要的是,Java 从一开始就想"国际化",所以它做了个当年看起来很先进的决定:内部用 Unicode,char 是 16 位(UTF-16)。
String name = "中文";
name.length(); // 2 —— 终于不是数字节了!
length() 返回 2 而不是 6,这是巨大的进步——它数的是"字"(准确说是 UTF-16 的码元),不是字节。开头那个"取前 N 个字"的需求,在 Java 里一下子舒服多了。
但 Java 挖了个更深的坑,叫"代理对(surrogate pair)"。 UTF-16 用 2 个字节表示一个字符,可 Unicode 后来膨胀到远超 65536 个字符——emoji、生僻字、各种符号,一个 16 位根本装不下。怎么办?用两个 16 位的 char 拼一个字符。于是:
String emoji = "😀";
emoji.length(); // 2 —— 一个 emoji,却算出 2 个 char!
emoji.charAt(0); // 是半个 emoji,单独拿出来毫无意义
emoji.codePointCount(0, emoji.length()); // 1 —— 这才是"真·字数"
看到没?Java 只是把 C 的"字节陷阱"换成了"码元陷阱"。你以为 length() 数的是字,其实数的是 UTF-16 码元;遇到 emoji 和生僻字,一个字照样算成 2。要拿真实字数,得用 codePointCount。
教训很清楚:选 UTF-16 做内部表示,是 Java(和后来的 JavaScript、C#)共同的历史包袱。 当年觉得 16 位够用,结果 Unicode 一扩容,代理对这个补丁就永远甩不掉了。
四、Python:两次大改,把"编码"这件事从根上理顺
Python 在字符串上折腾得最狠,也想得最透。它经历了一次著名的、堪称"伤筋动骨"的大版本迁移。
Python 2 时代:str 是字节串,unicode 是另一个类型,两者混用。这导致了无数 UnicodeDecodeError——你永远不确定手里这个东西是字节还是字符,一拼接就炸。写过 Python 2 中文处理的人,谁没被 'ascii' codec can't decode 折磨过。
Python 3 痛下决心,划了一条清清楚楚的界:
str就是文本,是一串 Unicode 码点,你不用关心底层几个字节。bytes就是字节,是原始二进制。- 两者之间必须显式
encode()/decode(),不许隐式转换。
name = "中文😀"
len(name) # 3 —— 数的是码点,emoji 也算 1 个!
name.encode('utf-8') # b'\xe4\xb8\xad...' 想要字节?自己转
len("😀") 返回 1,这才符合直觉——emoji 就是一个字。Python 3 在概念上把"文本"和"字节"彻底分家,谁是谁一目了然。这是我个人认为最清爽的字符串模型:写业务代码时你根本不用想编码,只在 I/O 边界(读文件、发网络)上做一次 encode/decode 就行。
代价是什么?一是 Python 2 到 3 的迁移,拖了整个社区差不多十年;二是内部实现藏了复杂度(PEP 393 的"灵活字符串表示",按内容在 1/2/4 字节之间动态选),但这些复杂度被封在底层,普通开发者感知不到。把复杂留给自己,把简单留给用户——这是好设计的味道。
五、Go 的 rune:干脆把"字节"和"字"两个概念摊开给你看
轮到 Go 了。Go 的设计者里有 C 语言的亲爹(Ken Thompson、Rob Pike),他们对字符串这事看得很开,做了一个很"诚实"的设计:不假装,直接把两个层次都露出来。
在 Go 里:
string底层就是只读的字节序列,默认按 UTF-8 存。像 C,但不可变、带长度。byte是uint8的别名,代表一个字节。rune是int32的别名,代表一个 Unicode 码点,也就是我们直觉里的"一个字"。
s := "中文😀"
len(s) // 8 —— 字节数(UTF-8 下 3+3+4... 是的 emoji 占 4 字节)
utf8.RuneCountInString(s) // 3 —— 真实字数
for i, r := range s { // range 遍历时,r 是 rune,i 是字节下标
fmt.Printf("%d: %c\n", i, r) // 自动按码点解码,不会切碎 emoji
}
这个设计的高明之处在于它不骗你:
- 你想要字节?
len(s)、s[i]给你字节。 - 你想要字?
for range或转成[]rune,给你码点。 - 两个概念用两个名字(
bytevsrune)明明白白分开,你必须想清楚自己要哪个。
"rune"这个词本身就有意思——它指的是古代北欧的如尼文字/符文。Go 的设计者(顺便说,Rob Pike 和 Ken Thompson 也正是 UTF-8 的发明者)用这个词,是想强调"这是一个有语义的字符,不是一个字节"。相比 Java 那个含糊的 char(到底是字还是码元?),rune 至少诚实地告诉你:"我是一个完整的码点。"
Go 没有消灭字符串的复杂性,它选择把复杂性摆到明面上。 byte 管存储,rune 管语义,你自己按需求选。这是一种"成年人式"的设计:不哄你,但给你清楚的工具。
当然它也不是没代价:s[i] 取到的是字节不是字,新手很容易踩;想随机访问第 N 个字,得先转 []rune,有一次 O(n) 的开销。但比起藏着掖着,我更喜欢这种"把刀递给你,同时告诉你刀口在哪"的坦诚。
六、一个反直觉的坑:Go 有 GC,子字符串却能"泄漏"内存
这里插一个很多人不知道的实战坑。你可能会问:Go 不是有垃圾回收(GC)吗?怎么一个普通的子字符串操作,还能内存泄漏?
答案很反直觉,先记住一句话:GC 回收的是"没人引用的对象",而不是"你没在用的对象"。 只要还有一个指针指着某块内存——哪怕它只有 10 个字节——GC 就认为它还活着,不敢动。
要讲清楚,得先看 Go 里 string 的底层长啥样。它本质是个"胖指针":一个指向底层字节数组的指针 Data,加一个长度 Len。关键在于——切子串 s[a:b] 不复制数据,它只是新建一个 header,Data 指针指向同一块底层数组的某个位置。 这是为了性能:切子串 O(1),不用拷贝。[]byte、[]T 切片也是一样的机制。
平时这没问题。坑出在"从一大块数据里切一小段,还长期留着"的场景:
func extractID(bigData string) string {
// bigData 是 10MB 的大文件内容
return bigData[100:110] // 只要 10 个字节
}
// 调用方把返回值长期存进缓存/全局 map
cache[key] = extractID(hugeString)
// hugeString 本身你已经用完了,以为能被回收
问题来了:cache[key] 里那个 10 字节的子串,它的 Data 指针仍然指向那块 10MB 的底层数组。GC 一看:"这 10MB 还有人引用着,不能回收。"于是——
你只想留 10 个字节,却让整块 10MB 内存永远赖在了内存里。 这不是 GC 失灵,而是你无意中让一个小子串"绑架"了一大块底层数组。
修法是强制拷贝一份,切断对大数组的引用:
// Go 1.18+ 官方专门加的,语义最清晰
return strings.Clone(bigData[100:110])
// 老写法:通过 []byte 往返,一样能强制复制
return string([]byte(bigData[100:110]))
strings.Clone 保证返回的字符串有自己独立的底层内存,原来那 10MB 就能被 GC 正常回收了。
但注意,别一看到子串就无脑 Clone,那是白拷贝、伤性能。判断标准是"生命周期错配":
| 场景 | 要不要 Clone |
|---|---|
| 子串临时用一下,很快丢弃 | 不用,原串和子串一起被回收 |
| 从大数据切一小段,且长期持有(存进 map、全局变量、缓存、struct 字段) | 要 Clone,否则大数组永远回收不掉 |
| 原串本身就很小 | 不用,省的那点内存不值当 |
一句话收尾:GC 管的是"引用",不是"使用"。 子字符串泄漏的本质,是"共享底层数组"这个性能优化,在"长期持有小切片"时反过来咬了你一口。这也提醒我们——有 GC 不等于不用懂内存,只是坑换了个地方藏。
七、一张表看懂:三个问题,五种答案
把这一路走下来的账,记在一张表上:
| 语言 | 内存谁管 | len/长度数的是 |
"一个字"的概念 | 主要遗留坑 |
|---|---|---|---|---|
| C | 你自己 | 字节(到 \0) |
没有,只有字节 | 溢出、泄漏、O(n) 长度 |
| C++ (std::string) | RAII 自动 | 字节 | 没有(靠一堆 wstring 打补丁) | 编码类型太多、平台不一致 |
| Java | GC 自动 | UTF-16 码元 | char(含糊,遇 emoji 变 2) |
代理对、UTF-16 包袱 |
| Python 3 | GC 自动 | Unicode 码点 | 清晰,str=文本 |
2→3 迁移代价大 |
| Go | GC 自动 | 字节(len)/码点(rune) |
byte 与 rune 明确分开 |
s[i] 取字节易误用、子串共享底层数组致泄漏 |
顺着这张表看,那条演进主线特别清楚:
- 内存管理:从 C 的"全靠自己",到 C++ 的 RAII,到 Java/Python/Go 的 GC——这条线早就收敛了,现代语言基本不让你手动管字符串内存。
- 长度语义:从"数字节"慢慢走向"数字"。但 Java 半路被 UTF-16 绊了一下,Python 走到了"数码点"的清爽终点,Go 干脆两个都给你、让你自己选。
- "字"是什么:这是最难、最晚才想明白的问题。C 压根没这概念,一直到 Python 3 的
str和 Go 的rune,业界才算真正把"字节"和"字符"这两个层次讲清楚。
八、所以那个"3 还是 9"的血案,到底怪谁?
回到开头。Python 说 3,C 说 9(假设是三个中文字,UTF-8 下每个 3 字节)。谁对?
都对,只是它们回答的根本不是同一个问题。 Python 数的是"几个字",C 数的是"几个字节"。真正的错,是那段截断代码——它在一个"字节世界"的 API 上,套用了"字符世界"的假设,以为切 6 就是切 2 个字,结果一刀砍在了多字节字符的腰上。
这类 bug 的通用防身术,给你一份可抄的清单:
字符串处理避坑清单
- 先分清你在哪个世界:手里这个东西是字节(byte/
bytes/[]byte)还是字符(码点/str/rune)?搞不清就先查类型。 - 截断永远按"字"截,别按字节截:除非你 100% 确定是纯 ASCII。Python 用切片(
s[:6]切的是字符),Go 先转[]rune再切,Java 用codePointCount配合offsetByCodePoint。 - 编码转换只在 I/O 边界做一次:读进来立刻 decode 成文本,发出去之前才 encode 成字节。中间的业务逻辑一律用文本,别让字节乱窜。
length/len的语义一定要查文档:别想当然。Java 的length()是码元,Go 的len()是字节,Python 的len()是码点——三个语言三种答案。- 测试用例里塞一个 emoji 和一个生僻字:纯英文纯中文都测不出代理对、多字节的坑。一个 😀 能帮你提前抓出一堆隐藏 bug。
九、写在最后:好的抽象,是替你把复杂藏对地方
复盘这几十年的字符串设计,我最大的感受是:技术演进很少是"发明新东西",更多是"把一个被低估的复杂问题,一层层剥清楚"。
字符串看着最土,是每个程序员第一天就会用的东西。可正因为太熟,我们很容易忘了它背后压着的三座大山——内存、长度、编码。C 把这三座山原封不动压给了程序员;C++ 搬走了内存;Java 想搬编码却搬歪了;Python 3 咬牙理顺了文本与字节;Go 则选择不藏,把 byte 和 rune 摊开让你自己判断。
没有哪个设计是"最终答案"。Python 的清爽有迁移的血泪,Go 的诚实有误用的门槛。但它们的共同方向,是让程序员少踩坑、多干活——这也是我判断一个 API、一个框架好不好的老标准:它是替我把复杂藏对了地方,还是把复杂甩回给了我?
下次你再写 len(s) 的时候,不妨多停半秒,问自己一句:我这会儿,到底想数的是字节,还是字?
思维导图
mindmap
root((字符串设计演进))
三大核心问题
内存谁管
长度怎么算
一个字是什么
C
char数组+\0
全靠手动
只有字节
C++ std::string
RAII管内存
size O(1)
编码没解决
Java
对象化+不可变
UTF-16码元
代理对坑
Python3
str文本/bytes字节
len数码点
2到3迁移
Go
string=UTF-8字节
byte管存储
rune管语义
子串共享底层数组
长期持有需Clone
避坑清单
分清字节还是字
按字截断
IO边界转编码
测试塞emoji
