字符串进阶:Unicode 与标量
本教程共 93 篇 · 第 16 篇 · 更新于 2026-08-08 · 约 8 分钟阅读
本节目标:读完你能理解 Swift 字符串为何用扩展字形簇表示字符,并清楚”字符计数”和”下标访问”的两个常见陷阱。
前面用字符串时一切顺手,是因为大多场景碰不到 Unicode 的复杂。但一旦涉及 emoji、带音调字母、多语言混排,就会踩到几个反直觉的坑。这一章讲清楚底层原理,让你不再被”字符数”和”下标”搞懵。
1-1 Unicode 是什么
Unicode 是一套国际标准,给世界上几乎所有文字系统的字符都编了号。它的目标是:无论哪种语言,都能用统一的方式表示、存储、传输。Swift 的 String 和 Character 完全兼容 Unicode。
这意味着你写的字符串里,可以同时有英文、中文、阿拉伯文、emoji,Swift 一视同仁地对待它们。
1-2 Unicode 标量值
在底层,Swift 的 String 由”Unicode 标量值”构成。一个标量值是一个 21 位的数字,对应一个字符或修饰符。比如 U+0061 是拉丁小写字母 a(a),U+1F425 是正面朝下的小鸡 emoji(🐥)。
不是所有 21 位标量都对应可见字符——有些保留给未来或编码内部使用。但凡已分配的,通常还有个名字,比如上面那两个。
1-3 扩展字形簇:一个字符可能是多个标量
这是最大的反直觉点。Swift 里的一个 Character,实际表示的是一个”扩展字形簇”——由一个或多个 Unicode 标量组合而成,渲染出来像一个可读字符。
比如字母 é,有两种表示法:
let eAcute: Character = "\u{E9}" // é,单个标量
let combinedEAcute: Character = "\u{65}\u{301}" // e 后跟重音修饰,两个标量
第一种是预成的 é(一个标量),第二种是普通 e 加上”组合重音”标量(两个标量)。但它们渲染出来都是 é,在 Swift 里都是”一个 Character”。
这就是为什么不能简单认为”一个字符 = 一个标量”。一个字符背后可能是好几个标量拼的。
1-4 字符计数陷阱
因为字符可能由多个标量组成,count 属性的结果常出乎意料。看这个经典例子:
var word = "cafe"
print(word.count) // 4
word += "\u{301}" // 加上组合重音
print(word.count) // 还是 4!
你往 “cafe” 后面加了个重音修饰符,最后一个字符从 e 变成了 é,但字符数仍然是 4——因为只是最后一个字形簇多了一个修饰标量,并没有新增”字符”。
初学者往往以为”加一个字符 count 就该加一”,在 Unicode 面前这不成立。所以别把 count 当成”用户眼里几个字”的精确代理,它只是”几个扩展字形簇”。
1-5 内存占用也不均匀
扩展字形簇可由不同数量的标量组成,于是不同字符占的内存不一样。字符串内部没有”每个字符等宽”的保证。
后果是:Swift 不能像数组那样用整数下标直接跳到第 N 个字符,因为”第 N 个字符从哪开始”必须从头顺着数标量边界才能确定。所以要访问字符串里某个位置,得用专门的索引类型,而不是直接写 string[3]。
Warning
greeting[3]这种整数下标在 Swift 字符串上是非法的,会编译报错。字符串索引要用String.Index,这点务必记住,后面会专门讲访问与修改字符串。
1-6 不同表示却判相等
由于比较基于”规范等价”,两种不同的标量组合只要意思和样子一样,就判为相等:
let e1 = "caf\u{E9}" // 用预成 é
let e2 = "caf\u{65}\u{301}" // 用 e + 重音
if e1 == e2 {
print("这两个字符串相等")
}
虽然底层标量不同,但表达同一个 é,Swift 认为它们相等。这比”逐字节比较”更符合人的直觉,也让国际化文本处理更稳。
反过来,长得像但不是一回事的字符不会相等,比如拉丁大写 A(U+0041)和西里尔大写 А(U+0410),视觉相似但语言含义不同,比较结果为不等。
1-7 三种 Unicode 编码视图
当字符串要写进文件或网络时,会被编码成字节序列。Swift 提供三种视图访问底层表示:
utf8 属性:以 8 位码元(字节)序列看字符串。
utf16 属性:以 16 位码元序列看。
unicodeScalars 属性:以 21 位标量序列看(相当于 UTF-32)。
let dog = "Dog‼🐶"
for codeUnit in dog.utf8 {
print(codeUnit, terminator: " ")
}
// 打印一串字节:68 111 103 ...
普通业务代码很少直接碰这些视图,但知道它们存在,能理解”为什么字符串长度和字节数不一样”——NSString 的 length 是按 UTF-16 码元算的,和 Swift 的 count(按字形簇算)常常对不上。
1-8 实践建议
面对 Unicode,记住几条实用经验:
一、永远用 == 比字符串,别自己比长度或字节。
二、不要用整数下标访问字符串字符,用 indices 或 index(_:offsetBy:)。
三、用户”看到的字数”和 count 可能不一致,做字数限制等业务逻辑时要考虑这一点。
四、涉及表情、重音、组合字符时,多测几组输入。
1-9 面对 Unicode 的使用纪律
Unicode 这一章反直觉,但理解它能避免很多隐蔽 bug。核心是:Swift 的 Character 是”扩展字形簇”,可能由多个 Unicode 标量组合而成。所以”一个字符”不等于”一个标量”,加个重音修饰符不会增加字符数,只让最后一个字形簇多了一个修饰。
由此引出两个陷阱:字符计数 count 不等于你直觉里的”用户看到的字数”;字符串不能用整数下标直接访问(各字符内存不均等),得用 String.Index。这两点在处理 emoji、重音、多语言混排时尤其要命,踩过一次就记住了。
比较字符串用 ==,它基于”规范等价”——不同标量组合只要意思和样子一样就判相等。这比逐字节比较符合人的直觉。但 NSString 的 length 按 UTF-16 码元算,和 Swift 的 count 常对不上,混用苹果框架时要留意。
实践上记住:永远用 == 比字符串;不用整数下标访问字符;做字数限制等业务逻辑时考虑”字符数 ≠ 用户认知字数”。涉及表情、重音、组合字符时,多测几组输入。理解 Unicode,处理国际化文本时才不会翻车。把这套纪律变成肌肉记忆,你的字符串代码就能在任何语言环境下稳稳当当。
1-10 小结
Swift 字符串底层是 Unicode 标量,但一个 Character 实际是”扩展字形簇”(一个或多个标量组合成的一个可读字符)。这导致两个陷阱:字符计数不等于你直觉里的字数(加修饰符不增加字符数),以及字符串不能用整数下标直接访问(各字符内存不均等)。比较基于规范等价,不同表示可判相等。理解这些,处理国际化文本时才不会翻车。下一章进入集合类型,先讲最常用的数组。