原文连接:swift.gg/2018/08/09/…
做者:Ole Begemann
译者:东莞大唐和尚
校对:pmst,Firecrest
定稿:CMB
html
这个系列中其余文章:git
本文节选自咱们的新书《高级 Swift 编程》「字符串」这一章。《高级 Swift 编程》新版本已根据 Swift 4 的新特性修订补充,新版现已上市。程序员
全部的现代编程语言都有对 Unicode 编码字符串的支持,但这一般只意味着它们的原生字符串类型能够存储 Unicode 编码的数据——并不意味着全部像获取字符串长度这样简单的操做都会获得「合情合理」的输出结果。github
实际上,大多数语言,以及用这些语言编写的大多数字符串操做代码,都表现出对Unicode固有复杂性的某种程度的否认。这可能会致使一些使人不开心的错误正则表达式
Swift 为了字符串的实现支持 Unicode 作出了巨大的努力。Swift 中的 String
(字符串)是一系列 Character
值(字符)的集合。这里的 Character
指的是人们视为单个字母的可读文本,不管这个字母是由多少个 Unicode 编码字符组成。所以,全部对于 Collection
(集合)的操做(好比 count
或者 prefix(5)
)也一样是按照用户所理解的字母来操做的。算法
这样的设计在正确性上无可挑剔,但这是有代价的,主要是人们对它不熟悉。若是你习惯了熟练操做其余编程语言里字符串的整数索引,Swift 的设计会让你以为笨重不堪,让你感受到奇怪。为何 str[999]
不能得到字符串第一千个字符?为何 str[idx+1]
不能得到下一个字符?为何不能用相似 "a"..."z"
的方式遍历一个范围的 Character
(字符)?express
同时,这样的设计对代码性能也有必定的影响:String
不支持随意获取。换句话说,得到一个任意字符不是 O(1) 的操做——当字符宽度是个变量的时候,字符串只有查看过前面全部字符以后,才会知道第 n 个字符储存在哪里。编程
在本章中,咱们一块儿来详细讨论一下 Swift 中字符串的设计,以及一些得到功能和性能最优的技巧。不过,首先咱们要先来学习一下 Unicode 编码的专业知识。swift
原本事情很简单。ASCII编码 的字符串用 0 到 127 之间的一系列整数表示。若是使用 8 比特的二进制数组合表示字符,甚至还多余一个比特!因为每一个字符的长度固定,因此 ASCII 编码的字符串是能够随机获取的。windows
可是,若是不是英语而是其余国家的语言的话,其中的一些字符 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 组成,只要它们造成一个用户看到的字符。在下一部分,咱们会看到几个这样的例子。
这里有一个快速了解 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 的 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 sentence = "The quick brown fox jumped over the lazy dog."
let firstSpace = sentence.index(of: " ") ?? sentence.endIndex
let firstWord = sentence[..<firstSpace] // → "The"
type(of: firstWord) // → Substring.Type
复制代码
切片操做不占用内存意义重大,特别是在一个循环中,好比你要经过循环访问整个字符串(可能会很长)来提取其中的字符。好比在文本中找到一个单词使用的次数,好比解析一个 CSV 文件。这里有一个很是有用的字符串处理操做:split。split
是 Collection
集合中定义的一个方法,它会返回一个子序列的数组(即 [Substring]
)。它最多见的变种就像是这样:
extension Collection where Element: Equatable {
public func split(separator: Element, maxSplits: Int = Int.max, omittingEmptySubsequences: Bool = true) -> [SubSequence]
}
复制代码
你能够这样使用:
let poem = """
Over the wintry
forest, winds howl in rage
with no leaves to blow.
"""
let lines = poem.split(separator: "\n")
// → ["Over the wintry", "forest, winds howl in rage", "with no leaves to blow."]
type(of: lines) // → Array<Substring>.Type
复制代码
这个跟 String
继承自 NSString
的 components(separatedBy:)
方法的功能相似,你还能够用一些额外设置好比是否抛弃空的组件。并且在这个操做中,全部输入字符串都没有建立新的复制。由于还有其余split
方法的变种能够完成操做,除了比较字符之外,split
还能够完成更多的事情。下面这个例子是文本换行算法的一个原始的实现,最后的代码计算了行的长度:
extension String {
func wrapped(after: Int = 70) -> String {
var i = 0
let lines = self.split(omittingEmptySubsequences: false) {
character in
switch character {
case "\n", " " where i >= after:
i = 0
return true
default:
i += 1
return false
}
}
return lines.joined(separator: "\n")
}
}
sentence.wrapped(after: 15)
// → "The quick brown\nfox jumped over\nthe lazy dog."
复制代码
或者,考虑写另一个版本,能够拿到一个包含多个分隔符的序列:
extension Collection where Element: Equatable {
func split<S: Sequence>(separators: S) -> [SubSequence]
where Element == S.Element
{
return split { separators.contains($0) }
}
}
复制代码
这样的话,你还能够这么写:
"Hello, world!".split(separators: ",! ") // → ["Hello", "world"]
复制代码
StringProtocol
Substring
和 String
几乎有着相同的接口,由于两种类型都遵照一个共同的字符串协议(StringProtocol
)。由于几乎全部的字符串API 都是在 StringProtocol
中定义的,因此操做 Substring
跟操做 String
没有什么大的区别。可是,在有些状况下,你还必须把子字符串转换为字符串的类型;就像全部的切片(slice)同样,子字符串只是为了短期内的存储,为了防止一次操做定义太多个复制。若是操做结束以后,你还想保留结果,将数据传到另外一个子系统里,你应该建立一个新的字符串。你能够用一个 Substring
的值初始化一个 String
,就像咱们在这个例子中作的:
func lastWord(in input: String) -> String? {
// Process the input, working on substrings
let words = input.split(separators: [",", " "])
guard let lastWord = words.last else { return nil }
// Convert to String for return
return String(lastWord)
}
lastWord(in: "one, two, three, four, five") // → "five"
复制代码
不建议子字符串长期存储背后的缘由是子字符串一直关联着原字符串。即便一个超长字符串的子字符串只有一个字符,只要子字符串还在使用,那原先的字符串就还会在内存里,即便原字符串的生命周期已经结束。所以,长期存储子字符串可能致使内存泄漏,由于有时候原字符串已经没法访问了,可是还在占用内存。
操做过程当中使用子字符串,操做结束的时候才建立新的字符串,经过这种方式,咱们把占用内存的动做推迟到了最后一刻,并且保证了咱们只会建立必要的字符串。在上面的例子当中,咱们把整个字符串(可能会很长)分红了一个个的子字符串,可是在最后只是建立了一个很短的字符串。(例子中的算法可能效率不是那么高,暂时忽略一下;从后先前找到第一个分隔符多是个更好的方法。)
遇到只接受 Substring
类型的方法,可是你想传递一个 String
的类型,这种状况不多见(大部分的方法都接受 String
类型或者接受全部符合字符串协议的类型),可是若是你确实须要传递一个 String
的类型,最便捷的方法是使用范围操做符:...
(range operator),不限定范围:
// 子字符串和原字符串的起始和结束的索引彻底一致
let substring = sentence[...]
复制代码
Substring
类型是 Swift 4 中的新特性。在 Swift 3 中,String.CharacterView
是本身独有的切片类型(slice type)。这么作的优点是用户只须要了解一种类型,但这也意味这若是存储一个子字符串,整个原字符串也会占据内存,即便它正常状况下应该已经被释放了。Swift 4 损失了一点便捷,换来的是的方便的切片操做和可预测的内存使用。
要求 Substring
到 String
的转换必须明确写出,Swift 团队认为这没那么烦人。若是实际应用中你们都以为问题很大,他们也会考虑直接在编译器中写一个 Substring
和 String
之间的模糊子类型关系(implicit subtype relationship),就像 Int
是 Optional<Int>
的子类型同样。这样你就能够随意传递 Substring
类型,编译器会帮你完成类型转换。
你可能会倾向于充分利用字符串协议,把你全部的 API 写成接受全部遵照字符串协议的实例,而不是仅仅接受 String
字符串。但 Swift 团队的建议是,别这样:
总的来讲,咱们建议继续使用字符串变量。 使用字符串变量,大多数的 API 都会比把它们写成通用类型(这个操做自己就有一些代价)更加简洁清晰,用户在必要的时候进行一些转换并不须要花费很大的精力。
一些 API 极有可能和子字符串一块儿使用,同时没法泛化到适用于整个序列 Sequence
或集合 Collection
的级别,这些 API 能够不受这条规则的限制。一个例子就是标准库中的 joined
方法。Swift 4 中,针对遵照字符串协议的元素组成的序列(Sequence
)添加了一个重载(overload
):
extension Sequence where Element: StringProtocol {
/// 两个元素中间加上一个特定分隔符后
/// 合并序列中全部元素,返回一个新的字符串
/// Returns a new string by concatenating the elements of the sequence,
/// adding the given separator between each element.
public func joined(separator: String = "") -> String
}
复制代码
这样,你就能够直接对一个子字符串的数组调用 joined
方法了,不必遍历一次数组而且把每一个子字符串转换为新的字符串。这样,一切都很方便快速。
数值类型初始器(number type initializer)能够将字符串转换为一个数字。在 Swift 4 中,它也接受遵照字符串协议的值。若是你要处理一个子字符串的数组的话,这个方法很顺手:
let commaSeparatedNumbers = "1,2,3,4,5"
let numbers = commaSeparatedNumbers
.split(separator: ",").flatMap { Int($0) }
// → [1, 2, 3, 4, 5]
复制代码
因为子字符串的生命周期很短,因此不建议方法的返回值是子字符串,除非是序列 Sequence
或集合 Collection
的一些返回切片的 API。若是你写了一个相似的方法,只对字符串有意义,那让它的返回值是子字符串,好让读者明白这个方法并不会产生复制,不会占用内存。建立新字符串的方法须要占用内存,好比 uppercased()
,这类的方法应该返回 String
字符串类型的值。
若是你想为字符串类型扩展新的功能, 好的办法是将扩展放在字符串协议 StringProtocol
上,保证 API 在字符串和子字符串层面的一致性。字符权协议的设计初衷就是替换原先在字符串基础上作的扩展功能。若是你想把现有的扩展从字符串转移到字符串协议上,你要作的惟一改变就是,把传递 Self
给只接受具体 String
值的 API替换为 String(Self)
。
须要记住的一点是,从 Swift 4 开始,若是你有一些自定义的字符串类型,不建议遵照字符串协议StringProtocol
。官方文档明确警告:
不要作任何新的遵照字符串协议
StringProtocol
的声明。只有标准库里的String
和Substring
是有效的遵照类型。
容许开发者写本身的字符串类型(好比有特殊的存储优化或性能优化)是终极目标,可是现阶段协议的设计尚未最终肯定,因此如今就启用它可能会致使你的代码在 Swift 5里没法正常运行。
… <SNIP> <内容有删减>…
Swift 语言里的字符串跟其余全部的主流编程语言里的字符串差别很大。当你习惯于把字符串当作代码块的数组后,你得花点时间转化思惟,习惯 Swift 的处理方法:它把遵照 Unicode 编码标准放在简洁前面。
总的来说,咱们认为 Swift 的选择是正确的。Unicode 编码文本比其余编程语言所认为的要复杂得多。长远来看,处理你可能写出来的 bug 的时间确定比学习新的索引方式(忘记整数索引)所需的时间多。
咱们已经习惯于任意获取“字符”,以致于咱们都忘了其实这个特性在真正的字符串处理的代码里不多用到。咱们但愿经过这一章里的例子能够说服你们,对于大多数常规的操做,简单的按序遍历也彻底 OK。强迫你清楚地写出你想在哪一个层面(字位集,Unicode scalar,UTF-16 代码块,UTF-8 代码块)处理字符串是另外一项安全措施;读你代码的人会对你心存感激的。
2016年7月,Chris Lattner 谈到了 Swift 语言字符串处理的目标,他最后是这么说的:
咱们的目标是在字符串处理上超越 Perl。
固然 Swift 4 尚未实现这个目标——不少想要的特性还没实现,包括把 Foundation 库中的诸多字符串 API 转移到标准库,正则表达式的天然语言支持,字符串格式化和解析 API,更强大的字符串插入功能。好消息是 Swift 团队已经表示 会在未来解决全部这些问题。
若是喜欢本文的话,请考虑购买全书。谢谢!
全书中第一张是本文的两本。讨论了其余的一些问题,包括如何使用以及何时使用字符串的代码块视图,如何和 Foundation里的处理字符串的 API(例如 NSRegularExpression
或者 NSAttributedString
) 配合处理。贴别是后面这个问题很难,并且很容易犯错。除此以外还讨论了其余标准库里面机遇字符串的 API,例如文本输出流(TextOutputStream
)或自定义字符串转换(CustomStringConvertible
)。