字符串,编码及字符串抽象
前言
在日常开发中,我们经常需要接触字符串,字符串涉及了许多细微的要点知识,会在意想不到的时候攻击我们,值得总结提醒。同时,关于字符串的不同特性以及功能,不同语言做出了不同的抽象,我们也可以通过它们组合方式,一窥这些语言的设计理念。
编码
编码是 字符集中的字符 到 二进制 的映射。
- 编码 (Encode): 字符 → 二进制
- 解码 (Decode): 二进制 → 字符
因此,如果解码没有选择正确的对应编码,那么就不能正确翻译被编码后生成的字节。
演进历史
编码随着时间,技术的发展和业务本身的需求不断演进。
这里还有两个概念,标准与实现。如:Unicode 是标准,而 UTF-8 是它的一个实现(其他实现还有 UTF-16)。而 ASCII 既是标准又是实现。
ASCII
ASCII(American Standard Code for Information Interchange,美国信息互换标准代码)是一套基于拉丁字母的字符编码,共收录了 128 个字符,用一个字节就可以存储,它等同于国际标准 ISO/IEC 646。
当我们操作单字节的 char,甚至做 char 的字面值的算术操作的时候,就是在操作 ASCII。
很明显,128 个字符是不够使用的。
ISO-8859
ASCII收录了空格及94个“可印刷字符”,足以给英语使用。但是,其他使用拉丁字母的语言(主要是欧洲国家的语言),都有一定数量的附加符号字母,故可以使用ASCII及控制字符以外的区域来储存及表示。ISO-8859 是兼容 ASCII 的。
ISO-8859 字符集是不包含中文的。而 Spring 的旧配置中,很多地方默认的 charset 就是 ISO-8859。当我们直接依赖 Spring 组件进行反序列化时,就有可能出现字符集转换的问题。

同时,中文仍然需要表示和字符集。
GBK
GBK 编码,全称为《汉字内码扩展规范》(”Guojia Biaozhun Kuozhan”,国家标准扩展),是一种在中国大陆广泛使用的字符编码方案。用两个字节来表示一个汉字,兼容 ASCII。
在今天,虽然 UTF-8 已经成为 Web 和现代应用开发的绝对主流,但在一些老的 Windows 系统、特定行业的软件或历史数据中,你仍然可能会遇到 GBK 编码。
我们日常生活中常见的乱码就是 GBK,UTF-8,ISO-8859 的冲突了,他们三者都兼容 ASCII,同时互相之间都会冲突。
Unicode
为了解决这些冲突,我们需要一个统一的标准。
Unicode,全称为Unicode标准(The Unicode Standard),其官方机构Unicode联盟所用的中文名称为统一码,又译作万国码、统一字符码、统一字符编码,是信息技术领域的业界标准,其整理、编码了世界上大部分的文字系统,使得电脑能以通用划一的字符集来处理和显示文字,不但减轻在不同编码系统间切换和转换的困扰,更提供了一种跨平台的乱码问题解决方案。
注意,虽然 unicode 被列在这里,但它是一种标准更甚一个编码,直接使用 Unicode 存储会浪费很多空间。
由它作为标准,才有了后续的许多编码方式,比如我们所广泛使用的 UTF-8。
UTF-8
UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,常用大部分汉字占用3个字节,少数占用4个字节。
UTF-8 也是我们当前通用的事实标准,新的项目基本会采用 UTF-8 作为编码。
此外还有一些需要注意的,比如 Mysql 的 utf8mb4,Oracle 的 AL32UTF8,原本这两个数据库各自有最古老版本的 UTF-8 实现(Mysql 固定 3 字节,四字节字符报错;Oracle 用两个 3 字节实现 4 字节),但为了兼容新确定的 UTF-8 标准,包括后来越来越多的 4 字节字符,不得不各自给出新的,兼容原来标准字符集的新字符集。
对比
数据结构
数据结构仍然是核心。不同的语言都是对 C 的字符串,或者说裸 bytes 做了基于自己设计哲学的改进和增强。
这里不得不强调一下,Java JDK8 的 String 内部永远是一个 char[] value,每个元素固定 16 bit(UTF-16 code unit),构造阶段就把输入(无论原来是 ASCII、Latin-1 还是中文)全部按 2 byte/char 展开到这个数组里;从 JDK 9开始,HotSpot 引入了一项叫 Compact Strings 的实现,先尝试当 ISO-8859(即单字符一字节)处理,省不下再退到 UTF-16。
Java,真的很不节约。
| 维度 | C | Java | Go | Rust |
|---|---|---|---|---|
| 本质形态 | 以 '\0' 结尾的 char[];编译期不记录长度 | 恒定不可变的 java.lang.String 对象,内部持 char[] value + int hash | 只读字节切片 struct{ *data, len },无容量字段 | 只读字节切片 struct{ ptr, len },另有 String (UTF-8) 与 OsStr / OsString (系统编码) 两套类型 |
是否可变
C 的字符串可变不加修饰,灵活也造成了很多问题,而这种灵活性在很多场景下实际并无用途,只会带来更高的开发成本与更复杂的问题。因此,后续语言都提供了不变的字符串与其他提供可变功能的方式。
| 维度 | C | Java | Go | Rust |
|---|---|---|---|---|
| 是否可变 | 可以,只要数组可写;但容易越界 | 绝对不可变;任何改变都生成新对象 | 不可变;重新赋值只是换指针 | String: 可变 (Mutable), &str:只读切片 |
| 可变构造器 | 无,只能手动 malloc/realloc 扩缓冲区 | StringBuilder(非线程安全) StringBuffer(同步,线程安全) | strings.Builder(底层 []byte 切片,按需 append) | 直接用 String 即可 |
功能封装
这里只讨论官方库。可以看到,这几种语言中,C 非常依赖三方库,本身基本不提供什么能力;Java 将 String 的功能零零散散的封装在了 java.lang.String,java.lang.StringBuilder,java.util.regex等等不同命名的包中,Go 基本在strings中提供,Rust 基本在String中提供。
| 功能维度 | C | Java | Go | Rust |
|---|---|---|---|---|
| 不可变字符串 | 无语言级抽象,只有 char * | java.lang.String 对象,常量池复用 | 原生类型 string(只读字节切片) | 原生类型 &str(只读 UTF-8 切片) |
| 可变构造器 | 手动 malloc/realloc | java.lang.StringBuilder(非线程安全)java.lang.StringBuffer(同步) | strings.Builder | 原生类型 String(底层 Vec<u8>) |
| 查找/替换/大小写转换 | <string.h>:strstr、strtok、toupper 等 | java.lang.String:自带 indexOf、replace、toUpperCase 等 | strings 包:Contains、Replace、ToUpper 等 | str 模块:contains、replace、to_uppercase 等 |
| 正则 | POSIX <regex.h> | java.util.regex.Pattern/Matcher | regexp 包 | regex crate(标准库外,但官方维护) |
| 分段/拼接 | strtok/sprintf | String.join(JDK 8+)、StringJoiner | strings.Split/Join | split_whitespace/split/collect::<Vec<_>> |
| 与数字互转 | atoi/sprintf | Integer.parseInt/String.valueOf | strconv:Atoi、Itoa、ParseFloat | to_string/parse::<i32> |
| Unicode 属性 | 无,需三方库 | java.lang.Character:isDigit、isLetter | unicode包:IsDigit、IsLetter、ToUpper | char 方法:is_numeric、is_alphabetic |
| 字节/符文转换 | 无,手工移位 | getBytes(UTF_8)/new String(bytes, UTF_8) | 强制 []byte(str)/string([]byte) | as_bytes()/from_utf8()/chars() |
| I/O 高效拼接 | 手写缓冲区 | StringBuilder | bytes.Buffer | std::fmt::Write trait + format! |
遍历
Go 特意抽象出了rune 这种数据类型来解释单个字符,弃置了 C 中char的习惯命名。
每种语言都在发展中演进出了自己的安全的面向 UTF-8 的遍历方式。
| 语言 | C | Java | Go | Rust |
|---|---|---|---|---|
| 合法遍历写法 | for 循环 (逐字节) | for (int i = 0; i < s.length(); ++i) 按 char 或 for (cp : s.codePoints()) | for i, r := range s | for ch in s.chars() |
| 默认遍历单位 | char = 1 byte | UTF-16 code unit(char) | Unicode scalar value(rune=int32) | Unicode scalar value(char) |
| 是否可直接遍历字节 | 可以,*p 就是 byte | 不能直接接触 byte;需 getBytes(StandardCharsets.UTF_8) 拿到 byte[] 再遍历 | 可以,强制 []byte(s) 拿到切片再 for _, b := range bytes | 可以,s.bytes() 返回 u8 迭代器,不做任何检查 |
| 编码安全措施(运行时) | 无,任何 byte 序列都能编译通过;出现非 ASCII 时程序员自己保证多字节边界 | 字符串构造时已保证 UTF-16 合法;如果 new String(bytes, UTF_8) 遇到非法序列,JDK 默认用 ? 替换 | range 在解码 UTF-8 时同步检查:遇到非法序列会替换成 utf8.RuneError(U+FFFD)并继续,不会 panic | str 本身必须是合法 UTF-8,构造时已验证;bytes() 只是裸读,不会断字,程序员自己负责后续解码 |
总结
从 string 这个基础数据结构也能看出几种语言不同的设计哲学,在各自设计理念指导下,哪些东西应该聚合。
这里再抛出几个能以这篇文章作为论据的问题:
- Go 为什么被称为 modern C?
- Java 在设计哲学上在意效率吗?
- Java 的面向对象仅仅在官方类库上就导致了什么样的结果?
文档信息
- 本文作者:nyaaar
- 本文链接:https://nyaaarlathotep.github.io/2025/10/01/str/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)