专栏名称: SwiftGG翻译组
iOS 开发
目录
相关文章推荐
51好读  ›  专栏  ›  SwiftGG翻译组

Swift 4 中的字符串

SwiftGG翻译组  · 掘金  · ios  · 2018-08-09 03:39

正文

阅读 12

Swift 4 中的字符串

原文链接: swift.gg/2018/08/09/…
作者: Ole Begemann
译者: 东莞大唐和尚
校对: pmst , Firecrest
定稿: CMB

这个系列中其他文章:

  1. Swift 1 中的字符串
  2. Swift 3 中的字符串
  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






请到「今天看啥」查看全文