原文链接: swift.gg/2018/08/09/…
作者: Ole Begemann
译者: 东莞大唐和尚
校对: pmst , Firecrest
定稿: CMB
这个系列中其他文章:
- Swift 1 中的字符串
- Swift 3 中的字符串
- Swift 4 中的字符串(本文)
本文节选自我们的新书《高级 Swift 编程》「字符串」这一章。《高级 Swift 编程》新版本已根据 Swift 4 的新特性修订补充,新版现已上市。
所有的现代编程语言都有对 Unicode 编码字符串的支持,但这通常只意味着它们的原生字符串类型可以存储 Unicode 编码的数据——并不意味着所有像获取字符串长度这样简单的操作都会得到「合情合理」的输出结果。
实际上,大多数语言,以及用这些语言编写的大多数字符串操作代码,都表现出对Unicode固有复杂性的某种程度的否定。这可能会导致一些令人不开心的错误
Swift 为了字符串的实现支持 Unicode 做出了巨大的努力。Swift 中的
String
(字符串)是一系列
Character
值(字符)的集合。这里的
Character
指的是人们视为单个字母的可读文本,无论这个字母是由多少个 Unicode 编码字符组成。因此,所有对于
Collection
(集合)的操作(比如
count
或者
prefix(5)
)也同样是按照用户所理解的字母来操作的。
这样的设计在正确性上无可挑剔,但这是有代价的,主要是人们对它不熟悉。如果你习惯了熟练操作其他编程语言里字符串的整数索引,Swift 的设计会让你觉得笨重不堪,让你感觉到奇怪。为什么
str[999]
不能获得字符串第一千个字符?为什么
str[idx+1]
不能获得下一个字符?为什么不能用类似
"a"..."z"
的方式遍历一个范围的
Character
(字符)?
同时,这样的设计对代码性能也有一定的影响:
String
不支持随意获取。换句话说,获得一个任意字符不是 O(1) 的操作——当字符宽度是个变量的时候,字符串只有查看过前面所有字符之后,才会知道第 n 个字符储存在哪里。
在本章中,我们一起来详细讨论一下 Swift 中字符串的设计,以及一些获得功能和性能最优的技巧。不过,首先我们要先来学习一下 Unicode 编码的专业知识。
Unicode:抛弃固定宽度
本来事情很简单。 ASCII编码 的字符串用 0 到 127 之间的一系列整数表示。如果使用 8 比特的二进制数组合表示字符,甚至还多余一个比特!由于每个字符的长度固定,所以 ASCII 编码的字符串是可以随机获取的。
但是,如果不是英语而是其他国家的语言的话,其中的一些字符 ASCII 编码是不够的(其实即使是说英语的英国也有一个"£"符号)。这些语言中的特殊字符大多数都需要超过 7 比特的编码。在 ISO 8859 标准中,就用多出来的那个比特定义了 16 种超出 ASCII 编码范围的编码,比如第一部分(ISO8859-1)包括了几种西欧语言的编码,第五部分包括了对西里尔字母语言的编码。
但这样的做法其实还有局限。如果你想根据 ISO8859 标准,用土耳其语写古希腊语的话,你就不走运了,因为你要么得选择第七部分(拉丁语/希腊语)或者第九部分(土耳其语)。而且,总的来说 8 个比特的编码空间无法涵盖多种语言。例如,第六部分(拉丁语/阿拉伯语)就不包含同样使用阿拉伯字母的乌尔都语和波斯语中的很多字符。同时,越南语虽然使用的也是拉丁字母,但是有很多变音组合,这种情况只有替换掉一些原有 ASCII 编码的字母才可能存储到 8 个比特的空间里。而且,这种方法不适用其他很多东亚语言。
当固定长度编码空间不足以容纳更多字符时,你要做一个选择:要么提高存储空间,要么采用变长编码。起先, Unicode 被定义为 2 字节固定宽度的格式,现在我们称之为 UCS-2 。彼时梦想尚未照进现实,后来人们发现,要实现大部分的功能,不仅 2 字节不够,甚至4个字节都远远不够。
所以到了今天,Unicode 编码的宽度是可变的,这种可变有两个不同的含义:一是说 Unicode 标量可能由若干个代码块组成;一是说字符可能由若干个标量组成。
Unicode 编码的数据可以用多种不同宽度的
代码单元(
code unit
)
来表示,最常见的是 8 比特(
UTF-8
)和 16(
UTF-16
)比特。UTF-8 编码的一大优势是它向后兼容 8 比特的 ACSCII 编码,这也是它取代 ASCII 成为互联网上最受欢迎的编码的一大原因。在 Swift 里面用
UInt16
和
UInt8
的数值代表UTC-16和UTF-8的代码单元(别名分别是
Unicode.UTF16.CodeUnit
和
Unicode.UTF8.CodeUnit
)。
一个
代码点(code point)
指的是 Unicode 编码空间中一个单一的值,可能的范围是
0
到
0x10FFFF
(换算成十进制就是 1114111)。现在已使用的代码点大约只有 137000 个,所以还有很多空间可以存储各种 emoji。如果你使用的是
UTF-32
编码,那么一个代码点就是一个代码块;如果使用的是 UTF-8 编码,一个代码点可能有 1 到 4 个代码块组成。最初的 256 个 Unicode 编码的代码点对应着 Latin-1 中的字母。
Unicode 标量
跟代码点基本一样,但是也有一点不一样。除开
0xD800-0xDFFF
中间的 2048 个代理代码点(
surrogate code points
)之外,他们都是一样的。这 2048 个代理代码点是 UTF-16 中用作表示配对的前缀或尾缀编码。标量在 Swift 中用
\u{xxxx}
表示,xxxx 代表十进制的数字。所以欧元符号在Swift里可以表示为
"€"
或
"\u{20AC}"
。与之对应的 Swift 类型是
Unicode.Scalar
,一个
UInt32
数值的封装。
为了用一个代码单元代表一个 Unicode scalar,你需要一个 21 比特的编码机制(通常会达到 32 比特,比如 UTF-32),但是即便这样你也无法得到一个固定宽度的编码:最终表示字符的时候,Unicode 仍然是一个宽度可变的编码格式。屏幕上显示的一个字符,也就是用户通常认为的一个字符,可能需要多个 scalar 组合而成。Unicode 编码里把这种用户理解的字符称之为 (扩展)字位集 (extended grapheme cluster)。
标量组成字位集的规则决定了如何分词。例如,如果你按了一下键盘上的退格键,你觉得你的文本编辑器就应该删除掉一个字位集,即使那个“字符”是由多个 Unicode scalars 组成,且每个 scalar 在计算机内存上还由数量不等的代码块组成的。Swift中用
Character
类型代表字位集。
Character
类型可以由任意数量的 Scalars 组成,只要它们形成一个用户看到的字符。在下一部分,我们会看到几个这样的例子。
字位集和规范对等(Canonical Equivalence)
组合符号
这里有一个快速了解
String
类型如何处理 Unicode 编码数据的方法:写 “é” 的两种不同方法。Unicode 编码中定义为
U+00E9
,
Latin small letter e with acute
(拉丁字母小写 e 加重音符号),单一值。但是你也可以写一个正常的
小写 e
,再跟上一个
U+0301
,
combining acute accent
(重音符号)。在这两种情况中,显示的都是 é,用户当然会认为这两个 “résumé” 无论使用什么方式打出来的,肯定是相等的,长度也都是 6 个字符。这就是 Unicode 编码规范中所说的
规范对等(Canonically Equivalent)
。
而且,在 Swift 语言里,代码行为和用户预期是一致的:
let single = "Pok\u{00E9}mon"
let double = "Poke\u{0301}mon"
复制代码
它们显示也是完全一致的:
(single, double) // → ("Pokémon", "Pokémon")
复制代码
它们的字符数也是一样的:
single.count // → 7
double.count // → 7
复制代码
因此,比较起来,它们也是相等的:
single == double // → true
复制代码
只有当你通过底层的显示方式查看的时候,才能看到它们的不同之处:
single.utf16.count // → 7
double.utf16.count // → 8
复制代码
这一点和 Foundation 中的
NSString
对比一下:在
NSString
中,两个字符串是不相等的,它们的
length
(很多程序员都用这个方法来确定字符串显示在屏幕上的长度)也是不同的。
import Foundation
let nssingle = single as NSString
nssingle.length // → 7
let nsdouble = double as NSString
nsdouble.length // → 8
nssingle == nsdouble // → false
复制代码
这里,
==
是定义为比较两个
NSObject
:
extension NSObject: Equatable {
static func ==(lhs: NSObject, rhs: NSObject) -> Bool {
return lhs.isEqual(rhs)
}
}
复制代码
在
NSString
中,这个操作会比较两个 UTF-16 代码块。很多其他语言里面的字符串 API 也是这样的。如果你想做的是一个规范比较(cannonical comparison),你必须用
NSString.compare(_:)
。没听说过这个方法?将来遇到一些找不出来的 bug ,以及一些怒气冲冲的国外用户的时候,够你受的。
当然,只比较代码单元有一个很大的优点是:速度快!在 Swift 里,你也可以通过
utf16
视图来实现这一点:
single.utf16.elementsEqual(double.utf16) // → false
复制代码
为什么 Unicode 编码要支持同一字符的多种展现方式呢?因为 Latin-1 中已经有了类似 é 和 ñ 这样的字母,只有灵活的组合方式才能让长度可变的 Unicode 代码点兼容 Latin-1。
虽然使用起来会有一些麻烦,但是它使得两种编码之间的转换变得简单快速。
而且抛弃变音形式也没有什么用,因为这种组合不仅仅只是两个两个的,有时候甚至是多种变音符号组合。例如,约鲁巴语中有一个字符是 ọ́ ,可以用三种不同方式写出来:一个 ó 加一点,一个 ọ 加一个重音,或者一个 o 加一个重音和一点。而且,对最后一种方式来说,两个变音符号的顺序无关紧要!所以,下面几种形式的写法都是相等的:
let chars: [Character] = [
"\u{1ECD}\u{300}"
, // ọ́
"\u{F2}\u{323}", // ọ́
"\u{6F}\u{323}\u{300}", // ọ́
"\u{6F}\u{300}\u{323}" // ọ́
]
let allEqual = chars.dropFirst()
.all(matching: { $0 == chars.first }) // → true
复制代码
all(matching:)
方法用来检测条件是否对序列中的所有元素都为真:
extension Sequence {
func all(matching predicate: (Element) throws -> Bool) rethrows -> Bool {
for element in self {
if try !predicate(element) {
return false
}
}
return true
}
}
复制代码
其实,一些变音符号可以加无穷个。这一点, 网上流传很广 的一个颜文字表现得很好:
let zalgo = "s̼̐͗͜o̠̦̤ͯͥ̒ͫ́ͅo̺̪͖̗̽ͩ̃͟ͅn̢͔͖͇͇͉̫̰ͪ͑"
zalgo.count // → 4
zalgo.utf16.count // → 36
复制代码
上面的例子中,
zalgo.count
返回值是 4(正确的),而
zalgo.utf16.count
返回值是 36。如果你的代码连网上的颜文字都无法正确处理,那它有什么好的?
Unicode 编码的字位分割规则甚至在你处理纯 ASCII 编码的字符的时候也有影响,回车 CR 和 换行 LF 这一个字符对在 Windows 系统上通常表示新开一行,但它们其实只是一个字位:
// CR+LF is a single Character
let crlf = "\r\n"
crlf.count // → 1
复制代码
Emoji
许多其他编程语言处理包含 emoji 的字符串的时候会让人意外。许多 emoji 的 Unicode 标量无法存储在一个 UTF-16 的代码单元里面。有些语言(例如 Java 或者 C#)把字符串当做 UTF-16 代码块的集合,这些语言定义 "😂" 为两个 “字符” 的长度。Swift 处理上述情况更为合理:
let oneEmoji = "😂" // U+1F602
oneEmoji.count // → 1
复制代码
注意,重要的是字符串如何展现给程序的, 不是 字符串在内存中是如何存储的。对于非 ASCII 的字符串,Swift 内部用的是 UTF-16 的编码,这只是内部的实现细节。公共 API 还是基于字位集(grapheme cluster)的。
有些 emoji 由多个标量组成。emoji 中的国旗是由两个对应 ISO 国家代码的
地区标识符号(reginal indicator symbols)
组成的。Swift 里将一个国旗视为一个
Character
:
let flags = "🇧🇷🇳🇿"
flags.count // → 2
复制代码
要检查一个字符串由几个 Unicode 标量组成,需要使用
unicodeScalars
视图。这里,我们将 scalar 的值格式化为十进制的数字,这是代码点的普遍格式:
flags.unicodeScalars.map {
"U+\(String($0.value, radix: 16, uppercase: true))"
}
// → ["U+1F1E7", "U+1F1F7", "U+1F1F3", "U+1F1FF"]
复制代码
肤色是由一个基础的角色符号(例如👧)加上一个肤色修饰符(例如🏽)组成的,Swift 里是这么处理的:
let skinTone = "👧🏽" // 👧 + 🏽
skinTone.count // → 1
复制代码
这次我们用 Foundation API 里面的 ICU string transform 把 Unicode 标量转换成官方的 Unicode 名称:
extension StringTransform {
static let toUnicodeName = StringTransform(rawValue: "Any-Name")
}
extension Unicode.Scalar {
/// The scalar’s Unicode name, e.g. "LATIN CAPITAL LETTER A".
var unicodeName: String {
// Force-unwrapping is safe because this transform always succeeds
let name = String(self).applyingTransform(.toUnicodeName,
reverse: false)!
// The string transform returns the name wrapped in "\\N{...}". Remove those.
let prefixPattern = "\\N{"
let suffixPattern = "}"
let prefixLength = name.hasPrefix(prefixPattern) ? prefixPattern.count : 0
let suffixLength = name.hasSuffix(suffixPattern) ? suffixPattern.count : 0
return String(name.dropFirst(prefixLength).dropLast(suffixLength))
}
}
skinTone.unicodeScalars.map { $0.unicodeName }
// → ["GIRL", "EMOJI MODIFIER FITZPATRICK TYPE-4"]
复制代码
这段代码里面最重要的是对
applyingTransform(.toUnicodeName,...)
的调用。其他的代码只是把转换方法返回的名字清理了一下,移除了括号。这段代码很保守:先是检查了字符串是否符合期望的格式,然后计算了从头到尾的字符数。如果将来转换方法返回的名字格式发生了变化,最好输出原字符串,而不是移除多余字符后的字符串。
注意我们是如何使用标准的集合(
Collection
)方法
dropFirst
和
droplast
进行移除操作的。如果你想对字符串进行操作,但是又不想对字符串进行手动索引,这就是一个很好的例子。这个方法同样也很高效,因为
dropFisrt
和
dropLast
方法返回的是
Substring
值,它们只是原字符串的一部分。在我们最后一步创建一个新的
String
字符串,赋值为这个 substring 之前,它是不占用新的内存的。关于这一点,我们在这一章的后面还有很多东西会涉及到。
Emoji 里面对家庭和夫妻的表示(例如 👨👩👧👦 和 👩❤️👩 )是 Unicode 编码标准面临的又一个挑战。由于性别以及人数的可能组合太多,为每种可能的组合都做一个代码点肯定会有问题。再加上每个人物角色的肤色的问题,这样做几乎不可行。Unicode 编码是这样解决这个问题的,它将这种 emoji 定义为一系列由零宽度连接符( zero-width joiner )联系起来的 emoji 。这样下来,这个家庭 👨👩👧👦 emoji 其实就是 man 👨 + ZWJ + woman 👩 + ZWJ + girl 👧 + ZWJ + boy 👦 。而零宽度连接符的作用就是让操作系统知道这个 emoji 应该只是一个字素。
我们可以验证一下到底是不是这样:
let family1 = "👨👩👧👦"
let family2 = "👨\u{200D}👩\u{200D}👧\u{200D}👦"
family1 == family2 // → true
复制代码
在 Swift 里,这样一个 emoji 也同样被认为是一个字符
Character
:
family1.count // → 1
family2.count // → 1
复制代码
2016年新引入的职业类型 emoji 也是这种情况。例如女性消防队员 👩🚒 就是 woman 👩 + ZWJ + fire engine 🚒 。男性医生就是 man 👨 + ZWJ + staff of aesculapius ⚕ (译者注:阿斯克勒庇厄斯,是古希腊神话中的医神,一条蛇绕着一个柱子指医疗相关职业)。
将这些一系列零宽度连接符连接起来的 emoji 渲染为一个字素是操作系统的工作。2017年,Apple 的操作系统表示支持 Unicode 编码标准下的 RGI 系列(“ recommended for general interchange ”)。如果没有字位可以正确表示这个序列,那文本渲染系统会回退,显示为每个单个的字素。
注意这里又可能会导致一个理解偏差,即用户所认为的字符和 Swift 所认为的字位集之间的偏差。我们上面所有的例子都是担心编程语言会把字符 数多了 ,但这里正好相反。举例来说,上面那个家庭的 emoji 里面涉及到的肤色 emoji 还未被收录到 RGI 集合里面。但尽管大多数操作系统都把这系列 emoji 渲染成多个字素,但 Swift 仍旧只把它们看做一个字符,因为 Unicode 编码的分词规则和渲染无关:
// Family with skin tones is rendered as multiple glyphs
// on most platforms in 2017
let family3 = "👱🏾\u{200D}👩🏽\u{200D}👧🏿\u{200D}👦🏻" // → "👱🏾👩🏽👧🏿👦🏻"
// But Swift still counts it as a single Character
family3.count // → 1
复制代码
Windows 系统已经可以 把这些 emoji 渲染为一个字素了,其他操作系统厂家肯定也会尽快支持。但是,有一点是不变的:无论一个字符串的 API 如何精心设计,都无法完美支持每一个细小的案例,因为文本太复杂了。
过去 Swift 很难跟得上 Unicode 编码标准改变的步伐。Swift 3 渲染肤色和零宽度连接符系列 emoji 是错误的,因为当时的分词算法是根据上一个版本的 Unicode 编码标准。自 Swift 4 起,Swift 开始启用操作系统的 ICU 库。因此,只要用户更新他们的操作系统,你的程序就会采用最新的 Unicode 编码标准。硬币的另一面是,你开发中看到的和用户看到的东西可能是不一样的。
编程语言如果全面考虑 Unicode 编码复杂性的话,在处理文本的时候会引发很多问题。上面这么多例子我们只是谈及其中的一个问题:字符串的长度。如果一个编程语言不是按字素集处理字符串,而这个字符串又包含很多字符序列的话,这时候一个简简单单的反序输出字符串的操作会变得多么复杂。
这不是个新问题,但是 emoji 的流行使得糟糕的文本处理方法造成的问题更容易浮出表面,即使你的用户群大部分是说英语的。而且,错误的级别也大大提升:十年前,弄错一个变音符号的字母可能只会造成 1 个字符数的误差,现在如果弄错了 emoji 的话很可能就是 10 个字符数的误差。例如,一个四人家庭的 emoji 在 UTF-16 编码下是 11 个字符,在 UTF-8 编码下就是 25 个字符了:
family1.count // → 1
family1.utf16.count // → 11
family1.utf8.count // → 25
复制代码
也不是说其他编程语言就完全没有符合 Unicode 编码标准的 API,大部分还是有的。例如,
NSString
就有一个
enumerateSubstrings
的方法可以按照字位集遍历一个字符串。但是缺省设置很重要,而 Swift 的原则就是缺省情况下,就按正确的方式来做。而且如果你需要低一个抽象级别去看,
String
也提供不同的视图,然你可以直接从 Unicode 标量或者代码块的级别操作。下面的内容里我们还会涉及到这一点。
字符串和集合
我们已经看到,
String
是一个
Character
值的集合。在 Swift 语言发展的前三年里,
String
这个类在遵守还是不遵守
Collection
集合协议这个问题上左右摇摆了几次。坚持不要遵守集合协议的人认为,如果遵守的话,程序员会认为所有通用的集合处理算法用在字符串上是绝对安全的,也绝对符合 Unicode 编码标准的,但是显然有一些特例存在。
举一个简单的例子,两个集合相加,得到的新的集合的长度肯定是两个子集合长度的和。但是在字符串中,如果第一个字符串的后缀和第二个字符串的前缀形成了一个字位集,长度就会有变化了:
let flagLetterJ = "🇯"
let flagLetterP = "🇵"
let flag = flagLetterJ + flagLetterP // → "🇯🇵"
flag.count // → 1
flag.count == flagLetterJ.count + flagLetterP.count // → false
复制代码
出于这种考虑,在 Swift 2 和 Swift 3 中,
String
并没有被算作一个集合。这个特性是作为
String
的一个
characters
视图存在的,和其他几个集合视图一样:
unicodeScalars
,
utf8
和
utf16
。选择一个特定的视图,就相当于让程序员转换到另一种“处理集合”的模式,相应的,程序员就必须考虑到这种模式下可能产生的问题。
但是,在实际应用中,这个改变提升了学习成本,降低了可用性;单单为了保证在那些极端个例中的正确性(其实在真实应用中很少遇到,除非你写的是个文本编辑器的应用)做出这样的改变太不值得了。因此,在 Swift 4 中,
String
再次成了一个集合。
characters
视图还在,但是只是为了向后兼容 Swift 3。
双向获取,而非任意获取
然而,
String
并
不是
一个可以任意获取的集合,原因的话,上一部分的几个例子已经展现的很清楚。一个字符到底是第几个字符取决于它前面有多少个 Unicode scalar,这样的情况下,根本不可能实现任意获取。由于这个原因,Swift 里面的字符串遵守双向获取(
BidirectionalCollection
)规则。可以从字符串的两头数,代码会根据相邻字符的组成,跳过正确数量的字节。但是,每次访问只能上移或者下移一个字符。
在写处理字符串的代码的时候,要考虑到这种方式的操作对代码性能的影响。那些依靠任意获取来保证代码性能的算法对 Unicode 编码的字符串并不合适。我们看一个例子,我们要获取一个字符串所有 prefix 的列表。我们只需要得到一个从零到字符串长度的一系列整数,然后根据每个长度的整数在字符串中找到对应长度的 prefix:
extension String {
var allPrefixes1: [Substring] {
return (0...self.count).map(self.prefix)
}
}
let hello = "Hello"
hello.allPrefixes1 // → ["", "H", "He", "Hel", "Hell", "Hello"]
复制代码
尽管这段代码看起来很简单,但是运行性能很低。它先是遍历了字符串一次,计算出字符串的长度,这还 OK。但是每次对
prefix
进行 n+1 的调用都是一次
O(n)
操作,因为
prefix
方法需要从字符串的开头往后找出所需数量的字符。而在一个线性运算里进行另一个线性运算就意味着算法已经成了
O(n2)
——随着字符串长度的增加,算法所需的时间是呈指数级增长的。
如果可能的话,一个高性能的算法应该是遍历字符串一次,然后通过对字符串索引的操作得到想要的子字符串。下面是相同算法的另一个版本:
extension String {
var allPrefixes2: [Substring] {
return [""] + self.indices.map { index in self[...index] }
}
}
hello.allPrefixes2 // → ["", "H", "He", "Hel", "Hell", "Hello"]
复制代码
这段代码只需要遍历字符串一次,得到字符串的索引(
indices
)集合。一旦完成之后,之后再
map
内的操作就只是
O(1)
。整个算法也只是
O(n)
。
范围可替换,不可变
String
还遵从于
RangeReplaceableCollection
(范围可替换)的集合操作。也就是说,你可以先按字符串索引的形式定义出一个范围,然后通过调用
replaceSubrange
(替换子范围)方法,替换掉字符串中的一些字符。这里有一个例子。替换的字符串可以有不同的长度,甚至还可以是空的(这时候就相当于调用
removeSubrange
方法了):
var greeting = "Hello, world!"
if let comma = greeting.index(of: ",") {
greeting[..<comma] // → "Hello"
greeting.replaceSubrange(comma..., with: " again.")
}
greeting // → "Hello again."
复制代码
同样,这里也要注意一个问题,如果替换的字符串和原字符串中相邻的字符形成了新的字位集,那结果可能就会有点出人意料了。
字符串无法提供的一个类集合特性是:
MutableCollection
。该协议给集合除
get
之外,添加了一个通过下标进行单一元素
set
的特性。这并不是说字符串是不可变的——我们上面已经看到了,有好几种变化的方法。你无法完成的是使用下标操作符替换其中的一个字符。许多人直觉认为用下标操作符替换一个字符是即时发生的,就像数组
Array
里面的替换一样。但是,因为字符串里的字符长度是不定的,所以替换一个字符的时间和字符串的长度呈线性关系:替换一个元素的宽度会把其他所有元素在内存中的位置重新洗牌。而且,替换元素索引后面的元素索引在洗牌之后都变了,这也是跟人们的直觉相违背的。出于这些原因,你必须使用
replaceSubrange
进行替换,即使你变化只是一个元素。
字符串索引
大多数编程语言都是用整数作为字符串的下标,例如
str[5]
就会返回
str
的第六个“字符”(无论这个语言定义的“字符”是什么)。Swift 却不允许这样。为什么呢?原因可能你已经听了很多遍了:下标应该是使用固定时间的(无论是直觉上,还是根据集合协议),但是查询第 n 个“字符”的操作必须查询它前面所有的字节。
字符串索引(
String.Index
)
是字符串及其视图使用的索引类型。它是个不透明值(opaque value,内部使用的值,开发者一般不直接使用),本质上存储的是从字符串开头算起的字节偏移量。如果你想计算第 n 个字符的索引,它还是一个
O(n)
的操作,而且你还是必须从字符串的开头开始算起,但是一旦你有了一个正确的索引之后,对这个字符串进行下标操作就只需要
O(1)
次了。关键是,找到现有索引后面的元素的索引的操作也会变得很快,因为你只需要从已有索引字节后面开始算起了——没有必要从字符串开头开始了。这也是为什么有序(向前或是向后)访问字符串里的字符效率很高的原因。
字符串索引操作的依据跟你在其他集合里使用的所有 API 一样。因为我们最常用的集合:数组,使用的是整数索引,我们通常使用简单的算术来操作,所以有一点很容易忘记:
index(after:)
方法返回的是下一个字符的索引:
let s = "abcdef"
let second = s.index(after: s.startIndex)
s[second] // → "b"
复制代码
使用
index(_:offsetBy:)
方法,你可以通过一次操作,自动地访问多个字符,
// Advance 4 more characters
let sixth = s.index(second, offsetBy: 4)
s[sixth] // → "f"
复制代码
如果可能超出字符串末尾,你可以加一个
limitedBy:
参数。如果在访问到目标索引之前到达了字符串的末尾,这个方法会返回一个
nil
值。
let safeIdx = s.index(s.startIndex, offsetBy: 400, limitedBy: s.endIndex)
safeIdx // → nil
复制代码
比起简单的整数索引,这无疑使用了更多的代码。**这是 Swift 故意的。**如果 Swift 允许对字符串进行整数索引,那不小心写出性能烂到爆的代码(比如在一个循环中使用整数的下标操作)的诱惑太大了。
然而,对一个习惯于处理固定宽度字符的人来说,刚开始使用 Swift 处理字符串会有些挑战——没有了整数索引怎么搞?而且确实,一些看起来简单的任务处理起来还得大动干戈,比如提取字符串的前四个字符:
s[..<s.index(s.startIndex, offsetBy: 4)] // → "abcd"
复制代码
不过谢天谢地,你可以使用集合的接口来获取字符串,这意味着许多适用于数组的方法同样也适用于字符串。比如上面那个例子,如果使用
prefix
方法就简单得多了:
s.prefix(4) // → "abcd"
复制代码
(注意,上面的几个方法返回的都是子字符串
Substring
,你可以使用一个
String.init
把它转换为字符串。关于这一部分,我们下一部分会讲更多。)
没有整数索引,循环访问字符串里的字符也很简单,用
for
循环。如果你想按顺序排列,使用
enumerated()
:
for (i, c) in s.enumerated() {
print("\(i): \(c)")
}
复制代码
或者如果你想找到一个特定的字符,你可以使用
index(of:)
:
var hello = "Hello!"
if let idx = hello.index(of: "!") {
hello.insert(contentsOf: ", world", at: idx)
}
hello // → "Hello, world!"
复制代码
insert(contentsOf:at:)
方法可以在指定索引前插入相同类型的另一个集合(比如说字符串里的字符)。并不一定是另一个字符串,你可以很容易地把一个字符的数组插入到一个字符串里。
子字符串
和其他的集合一样,字符串有一个特定的切片类型或者说子序列类型(
SubSequence
):子字符串(
Substring
)。子字符串就像是一个数组切片(
ArraySlice
):它是原字符串的一个视图,起始索引和结束索引不同。子字符串共享原字符串的文本存储空间。这是一个很大的优势,对一个字符串进行切片操作不占用内存空间。在下面的例子中,创建
firstWord
变量不占用内存:
let