很多朋友都在抱怨 Swift 中内置的 String
非常不友好,尤其是在进行 substring
操作的时候,其 API 使用起来十分复杂啰嗦,甚至一度有人会认为这种设计很糟糕。其实苹果之所以这么做肯定是有一定的道理的。本文我们来一起探讨一下 Swift 的字符串为什么这么“难用”。
字符串的编码
我们人类可以非常轻松地辨认出一个一个的文字,但是这件事放到计算机上时就变得比较复杂了,究其原因呢,我们还要说到内存中存储数据的基本单位:字节(Byte)。我们都知道一个字节由 8 个位(Bit)组成,因此它可以表示从 0 ~ 255 的数值,如果是有符号数据类型的话则是 -128 ~ 127。当然了,我们都知道有 ASCII 编码这种东西,用这 256 个数字表示西文字母和简单的标点符号简直太容易了。
但是,其他语言呢?
先不说别的语种,就说咱们中文,博大精深,那么多的文字区区 256 个坑怎么能填满?于是世界上就出现了许许多多应对这种多语种文字的编码方案。
BIG-5
为了存储大量的汉字,BIG-5 这种编码方式诞生了,它采用两个字节来存储文字,两个字节能存下多少文字呢?65536 个。啊好多哇...多啥啊,世界上的文字远不止这些呢,况且现在还有 Emoji 这种东西。
Unicode
既然我们说两个字节也不够用,那咱们就上四个字节吧,四个字节能放下多少字呢?4294967295 个,这对于人类的文字来说就是个极大的数字了,全宇宙的文字都能放下。但是问题又来了,四个字节,是什么概念呢?
我们简单来做个计算:一篇常规的英文小说有大约 100,000 个词,一个词按 6 个字母算,那么 ASCII 编码下这篇小说大约有 585 KB 这么大,但是如果每个字都占四个字节的话,这篇小说将达到 2 MB 这么大,那时的存储介质伤不起啊!所以编码一定不能每个字都占四个字节。
为了解决这一问题,又出现了多种编码方式,如 UTF-8、UTF-16、UTF-32,它们都是 Unicode 的一种实现方式。
UTF-8
我们以 UTF-8 来举例。UTF-8 将编码分为了六个区:
- 编码为 0xxxxxxx
对应 Unicode 0x0000 - 0x007F
- 编码为 110xxxxx 10xxxxxx
对应 Unicode 0x0080 - 0x07FF
- 编码为 1110xxxx 10xxxxxxx 10xxxxxx
对应 Unicode 0x0800 - 0xFFFF
- 编码为 11110xxx 10xxxxxxx 10xxxxxx 10xxxxxx
对应 Unicode 0x00010000 - 0x0001FFFF
- 编码为 111110xx 10xxxxxxx 10xxxxxxx 10xxxxxxx 10xxxxxxx
对应 Unicode 0x00200000 - 0x03FFFFFF
- 编码为 111110x 10xxxxxxx 10xxxxxxx 10xxxxxxx 10xxxxxxx 10xxxxxxx
对应 Unicode 0x04000000 - 0x7FFFFFFF
用这种方式,我们可以用可变数量的字节覆盖所有的 Unicode 编码,从而节省存储空间。
其他语言中处理字符串的方式
C/C++
C 和 C++ 中习惯将字符串看作 char
数组,对于 Unicode 字符,使用 wchar_t
来存储,但是 UTF-8 与 Unicode 之间的转换比较麻烦,通常需要使用库函数或者自己用算法实现。
Java
Java 中的 char
类型占两个字节,无法覆盖 Unicode 中的所有字符,所以 String
中会出现两个 char
拼接成一个字符的情况。
Objective-C (NSString)
NSString 采用 unichar
储存字符,每个字符占两个字节,非常类似于 Java。
问题来了
这些存储方式有什么问题吗?当然有问题,例如我们在 C++ 中给一个 char[]
变量赋值 "😂"
,那么这个数组中将会有 5 个元素,包括 4 个 UTF-8 字节和一个 \0
结束符。那么我们就无法将 😂
作为一个整体来处理,因为通过 subscript 运算符只能拿出一个字节。当我们需要删掉一个字符串末尾的一个 Emoji 的时候,就需要一次删除 4 个字节,然而我们怎么知道最后一个字符串占几个字节呢?很麻烦是不是?
Swift 如何拯救世界
Swift 中以一种抽象的方式来存储字符串,你根本不需要考虑它是什么编码,因为在 String
类型中,有几个字符,它的长度就是几。但是我们不能直接询问 String
其包含几个字符,我们需要通过 characters
属性来访问其 CharacterView
。
什么意思呢,我们假设 Swift 使用 UTF-8 来存储文字,那么一个字符串 "😂"
将包含 4 个字节(不需要有结束符,因为内部存储字符长度了)。但是如果我们用 CharacterView
来访问字符串,那么它只包含一个元素,😂
的 4 个字节被看作一个整体,这样就非常方便我们的处理了。
例如我们要去掉 "A字😄"
末尾的一个字符,我们不需要知道它是什么,它占几个字节,只需要:
var str = "A字😄"
str.remove(at: str.index(before: str.endIndex))
print(str)
这样,Swift 就会自己索引最后一个字符占几个字节,然后将其一并移除。至于为什么要使用 String.index(before:)
方法,这是为了索引所需字符所在内存的真实位置,毕竟计算机内部还是以 UTF-8 的方式进行存储的,并不能根据下标直接算出其真实偏移量,而需要一点点从头扫描,这个“头”就是你用参数传进去的基地址,Swift 提供了向后扫描和向前扫描的两个方法。
到这里,你应该知道了,String.index(before:)
并不是一个廉价的方法,它需要有扫描的过程,因此适当的使用方式才能做到高效。我们看下面两段代码:
for i in 0..
var index = str.startIndex
repeat {
print(str[index])
index = str.index(after: index)
} while (index != str.endIndex)
孰好孰坏大家应该能分辨出来了吧,上面的方法每次调用 String.index(_:, offsetBy:)
都会从头进行一遍扫描,而下面的方法中调用 String.index(after:)
则是在上一位置的基础上继续扫描的。这点我们以后开发中都需要注意,直接关乎运行效率。
当然,Swift 也提供了 UTF-8 字节的访问:
var str = "A字😄"
print(str.utf8.map{ String($0.toUIntMax(), radix: 2) })
上面代码将输出:
["1000001", "11100101", "10101101", "10010111", "11110000", "10011111", "10011000", "10000100"]
总结一下
由此我们能够得出,Swift 中通过多种 “View” 来区分处理字符串的方式,UTF8View
让你以 UTF-8 编码的方式来处理字符串,而 CharacterView
则让你以字符为单位处理字符串而不必考虑编码的问题。
NSString 怎么办?
最后在谈谈我对 NSString
的看法,Swift 开发中,我有时仍然会使用 NSString
。因为很多系统类库内部仍然是在使用 NSString
,只不过 API 在导出时转换成了 Swift String
罢了,但是对于一些操作,尤其是与 NSRange
有关的,还是转换成 NSString
来的方便一些。如果你实在不愿意,使用 String.utf16
访问 UTF16View
,它和 NSString
的存储方式是一致的:
var str = "A字😄"
Array(str.utf16).index(of: "😄".utf16.first!)
最后再总结
Swift 使用了一种编码无关的方式来让开发者操作字符串,无疑又解决了一个世界难题,当然带给我们的都是好处,并不是啰嗦,更不能说是糟糕的设计。
- EOF -