Swift 中 “难用” 的字符串

2,637 阅读7分钟

很多朋友都在抱怨 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-8UTF-16UTF-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 -