Swift|Swift 中的字符串设计

Swift中字符串设计得很糟糕? 不存在的.
作为吃大白米饭直立行走的哺乳动物, 我们对形形色色的文字已经见怪不怪. 但对于吃进去的是电, 只记得一堆01串的计算机来说, 理解并且能够表示文字可不是件容易的事. 要让计算机理解文字, 最简单的方式就是将文字转变成它能够直接处理的数字, 对于这种从文字到数字的映射, 我们称其为编码.
从 ASCII 讲起 ASCII 是一套基于拉丁字母的编码系统, 它定义了127个常用字符, 包括26个基本拉丁字母, 阿拉伯数字和英式标点符号, 以及不可见的控制字符, 如空格, 换行等. 在计算机中, 只需要 7Bit 就可以表示下任意的 ASCII 字符, 相对 1Byte 而言, 还留出了一位用于表示其它字符, 为了利用好这一 Bit, 人们在之后发明出了众多 8Bit 的编码方式.
但是这些编码方式之间互不兼容, 同样的编码在不同的编码系统中代表着完全不同的两个字符. 并且对于一些语言来说, 8Bit 所能表示的字符量实在太少了. 于是又出现了各个针对不同语言的编码方式, 广为人知的 GB 2312, BIG-5 就是针对中文而产生的中文字符集.
这样虽然能够表示所有的字符, 但是各玩各的总不是互联网的玩法, 计算机也无法支持多语言环境. 于是, 为了解决传统编码方式的局限, Unicode 标准产生了, 它对世界上大部分的文字系统进行了整理, 编码, 提供了一套通用的解决方案.
Unicode 使用 21Bit 来表示字符, 221 = 2,097,152, 这个数量已经大到可以表示整个人类历史上的所有字符了. 但截至目前, 也只有十万个出头的编码被使用. Unicode 为世界上几乎所有的字符都定义了一个数字, 这个数字叫做码点, 用 U+xxxx 的形式书写, xxxx 代表4到6个十六进制数.
'U+0041' <=> 'A' 'U+1F60A' <=> ':blush:'

需要注意的是, Unicode 只是定义了从字符到数字的映射, 却没有定义具体如何存储这些信息. 倘若使用 2Byte 来存储字符, 不足以表示所有字符信息, 使用 4Byte 则又会造成空间的浪费. 为了解决这一问题, 又出现了多种针对 Unicode 的实现方式.
UTF-8, UTF-16 和 UTF-32 UTF-8, UTF-16UTF-32 是针对 Unicode 的三种编码方式,下面将一一介绍. 不过首先, 还是得简单了解一下 Unicode 的组成.
前面说过 Unicode 使用 21Bit 来表示字符, 在这其中又分为基本多文种平面(Basic Multilingual Plane,简称BMP) 和补充平面, 每个平面拥有 216 = 65,536 个可表示字符, 又称作码点. 它们的表示范围见下表, 摘自 wiki 百科.
平面 始末字符值 中文名称 英文名称
0号平面 U+0000 - U+FFFF 基本多文种平面 Basic Multilingual Plane,简称BMP
1号平面 U+10000 - U+1FFFF 多文种补充平面 Supplementary Multilingual Plane,简称SMP
2号平面 U+20000 - U+2FFFF 表意文字补充平面 Supplementary Ideographic Plane,简称SIP
3号平面 U+30000 - U+3FFFF 表意文字第三平面(未正式使用[1]) Tertiary Ideographic Plane,简称TIP
4号平面至13号平面 U+40000 - U+DFFFF (尚未使用)
14号平面 U+E0000 - U+EFFFF 特别用途补充平面 Supplementary Special-purpose Plane,简称SSP
15号平面 U+F0000 - U+FFFFF 保留作为私人使用区(A区)[2] Private Use Area-A,简称PUA-A
16号平面 U+100000 - U+10FFFF 保留作为私人使用区(B区)[2] Private Use Area-B,简称PUA-B
UTF-16
实际情况来讲, 65,536个字符已经足够覆盖到我们日常所接触到的符号了, 这也是 UTF-16 使用 2Byte 作为字符存储单元的原因, 也叫做码元. 所有基本多语种平面内的字符都可以用一个 UTF-16 码元表示, 而对于罕见的补充平面内字符, 一个码元是不够用来表示的.
UTF-16 是变长编码, 在最开始没有补充平面的时候每个字符都由一个码元表示, 等到 Unicode 标准提出了补充平面的概念后, 它为这些超出一个码元表示能力的字符提供了一个映射关系, 使之映射到2个码元, 并且计算机能够分别字节归属单码元还是双码元, 不会存在理解上的误差.
在基本多文种平面中, 预留了 0xD800-0xDFFF 之间的码位未被使用, 而补充平面内的码位都在 0x10000-0x10FFFF 这个范围内, 如果用它们的码位值减去 0x10000, 就刚好能得到一个位于 0x0000-0xFFFFF 范围的数字, 这些数字可以被一个 20Bit 的数字表示. 该数字的前10位加上 0xD800,就得到 UTF-16 双码元编码中的第一个码元, 该数字的后10位加上 0xDC00,就得到 UTF-16 双码元编码中的后一个码元.
/// 对于小于0xFFFF的即基本平面的字符,为两个字节 U+8D9E = 0x8D9E///对应的二进制格式为:10001101 10011110/// 对出于辅助平面的字符 /// 对于U+1D306 (0x1D306-0x10000) / 0x400 + 0xD800 = 0xd834 (0x1D306 - 0x10000) % 0x400 + 0xDC00 = 0xdf06 // 最终两个码元都落在了保留区 0xD800-0xDFFF 内, 计算机读到这个码元的内容就知道是双码元字符的一部分了

UTF-8
UTF-8 也是可变长编码, 使用1-4个 Byte 来表示一个字符, 一个码元只有 1Byte. 它定义了一个巧妙的编码规则, 完全兼容了 ASCII. 对于 U+0000-U+007F 之间的 ASCII 码, 用 UTF-8 表示就是 0x0xxxxxxx, 跟 ASCII 完全一样的一个码元.
对于大于 U+007F 的非 ASCII 码, 则采用填格子的方式进行编码, 参照下表.
Unicode 字符:UTF-8 码: U+00000000 - U+0000007F:0xxxxxxx///表示ASCII U+00000080 - U+000007FF:110xxxxx 10xxxxxx U+00000800 - U+0000FFFF:1110xxxx 10xxxxxx 10xxxxxx U+00010000 - U+001FFFFF:11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

以':blush:'为例, Unicode 码为 U+1F60A, 查表位于 U+00010000-U+001FFFFF 区间.
转换成二进制 11111011000001010, 依次填入所对应的格子里, 得到 11110111 10110110 10000010 10100000, 就是该字符所对应的 UTF-8 编码.
UTF-32
UTF-32 更为简单粗暴, 一个码元 4Bit, 完全能够覆盖到所有 Unicode 字符. 但由于空间上的低效, 一般不被使用.
Objective-C OC 中的 NSString 对象实际上代表着使用 UTF-16 编码的码元数组, 而返回字符串长度的 length: 方法则会直接返回码元的个数. 那么问题来了, 并不是所有的 Unicode 字符都可以使用1个码元表示, 因此 NSString 在这方面存在一些缺陷.
并且 Unicode 中还存在着组合字符这一现象, é 可以使用 U+00E9 来表示, 也可以使用 U+0065 (代表字母 e) 后跟一个尖音符号 U+0301 来表示, 二者在输出的时候表现一模一样, 但是在获取长度方面一样有缺陷.
NSString *string = @":grinning:"; NSLog(@"%@", @(string.length)); // 2NSString *eString1 = @"\u00e9"; NSLog(@"%@ length is %@", eString1, @(eString1.length)); // é length is 1NSString *eString2 = @"e\u0301"; NSLog(@"%@ length is %@", eString2, @(eString2.length)); // é length is 2

因为这个缺陷, 在随机访问, 遍历, 比较等对字符串的操作一样会遇到跟人类直接感官不一致的问题.
Swift 设计原则 为了解决这个问题, Swift 采用了一种抽象的方式来存储字符串, 开发者根本不需要关心其到底是什么编码. 开发者对 String 的访问和操作都是以 Character 为单位的, 而每一个 Character 实例都代表一个 可扩展字形群集(extended grapheme cluster, 该翻译来源于极客学院 Swift 教程).
简单来说, 就是 Character 就代表着一个人类肉眼所见的一个字符, 不管该字符是否由多个 Unicode 字符组成. :umbrella: (U+2614 U+FE0F), é(U+0065 U+0301) 通通代表着一个 Character. 这样就避免了使用 count方法, 遍历, 比较的时候产生与人类认知不相符的结论.
var sunString = "今天天气真好:sunny:" print("\(sunString) last character is \(sunString.last!)")// 今天天气真好:sunny: last character is :sunny:var rainString = "又特么下雨了\u{2614}\u{fe0f}" print("\(rainString) count is \(rainString.count)")// 又特么下雨了:umbrella: count is 7

那么问题来了, 为什么 String 不提供直接使用整数下标进行访问的接口, 而要使用 index(after:), index(before:) 这样复杂的接口来生成访问下标的接口呢?
原因有两个:
  1. 使用下标访问字符串并不是一个必要的操作.
  2. 使用整数作为下标不是一个正确的行为, 会让开发者认为自己在操作一个线性的集合, 所有的访问操作都是常数级的复杂度. 但 Character 实际存储并不一定是固定内存, 使用整数下标访问实际上伴随着遍历的过程, 线性复杂度. 而 index(after:), index(before:) 这样设计接口不仅能让开发者直观感受到访问的开销, 并且在遍历的时候连续调用 index(after:) 在上一位置接着扫描, 提高了运行效率.
不过还是有些场景是编码敏感的, 为此 Swift 同样提供了访问各种编码视图的接口.
let string = "今天天气真不错:grinning:"print("\(string.utf8.count)")// 25 print("\(string.utf16.count)")// 9 print("\(string.unicodeScalars.count)")// 8

Swift4 Swift4 作为一个改进版本, 对字符串同样进行了一些提高易用性的修改(别想了, 不会让你用整数访问下标的).
  1. 多行字符串. Swift4 提供了支持多行的字符串字面量表达方式.
    print( """ :oncoming_automobile:Test Drive -------------- Quickly try out any Swift pod or framework in a playground. Usage: - Simply pass a list of pod names or URLs that you want to test drive. - You can also specify a platform (iOS, macOS or tvOS) using the '-p' option - To use a specific version or branch, use the '-v' argument (or '-m' for master) Examples: - testdrive Unbox Wrap Files - testdrive https://github.com/johnsundell/unbox.git Wrap Files - testdrive Unbox -p tvOS - testdrive Unbox -v 2.3.0 - testdrive Unbox -v swift3 """ )

    ?
  2. 在之前的版本中, String 一直遵循着 collection 协议, 使得开发者可以像操作集合一样操作 String. 在 Swift4 中, String 直接被作为了 collection 类型, 意味着开发者能够简单的把它当做 a collection of characters.
  3. 新的生成 SubString 的 API.
    let substring = string[index...]

结语 在C语言中, 字符串就是一串非0序列的 Byte 数组, 获取长度都需要整个遍历字符串, 对于 Unicode 字符则提供了额外的 wchar_t 类型.
Java, Objective-c 这些语言则将字符串看成一堆 2Byte 码元的集合, 这跟当时的环境有关系. Unicode 在1991年被提出, 当时总计7,161个字符, 直至1999年也才收录49,259个字符. 如今流行的一大批编程语言都是在这个时间点之前诞生. 等到了 2Byte 满足不了字符展示需求的时候, 这些语言要再改变字符串的设计为时已晚.
【Swift|Swift 中的字符串设计】幸运的是 Swift 开发者们可以享受到 Swift 对字符串设计所带来的便利性, 开发者不用再去关心各种 Unicode, 组合字符, 编码格式. 字符就只是字符. 新上手的开发者可能会对不能使用整形下标访问字符串感到不习惯, 不过无所谓了, they will get used to it 不是苹果的一贯作风嘛. ┑( ̄Д  ̄)┍
参考资料
  • 字符编码笔记:ASCII,Unicode和UTF-8
  • 细说:Unicode, UTF-8, UTF-16, UTF-32, UCS-2, UCS-4
  • Unicode和UTF-8、UTF-16、UTF-32
  • Unicode
  • Exploring the new String API in Swift 4
  • Why is Swift's String API So Hard?
  • Swift 中 “难用” 的字符串
  • NSString 与 Unicode

    推荐阅读