Swift 进阶【七】字符串

字符串索引


大部分编程语言使用整数值对字符串进行下标操作,比如 str[5] 将会返回 str 中的第六个“字符” (这里的“字符”的概念由所操作的编程语言进行定义)。Swift 不允许这么做。为什么?答案可能现在你已经很耳熟了:因为整数的下标访问无法在常数时间内完成 (对于 Collection 协议来说这也是个直观要求),而且查找第 n 个 Character 的操作也必须要对它之前的所有字节进行检查。

String.IndexString 和它的视图所使用的索引类型,它本质上是一个存储了从字符串开头的字节偏移量的不透明值。如果你想计算第 n 个字符所对应的索引,你依然从字符串的开头或结尾开始,并花费 O(n) 的时间。但是一旦你拥有了有效的索引,就可以通过索引下标以 O(1) 的时间对字符串进行访问了。至关重要的是,通过一个已有索引来寻找下一个索引也是很快的,因为你可以从这个已有索引的字节偏移量开始进行查找,而不需要从头开始。正是由于这个原因,按顺序 (前向或者后向) 对字符串中的字符进行迭代是一个高效操作。

对字符串索引的操作的 API 与你在遇到其他任何集合时使用的索引操作是一样的。我们之所以经常容易忽略索引操作的等效性,是因为到现在为止我们最经常使用的数组的索引是整数类型,于是我们往往通过简单的算数,而非正式的索引操作 API,来对数组索引进行操作。index(after:) 方法将返回下一个字符的索引:

1
2
3
let s = "abcdef"
let second = s.index(after: s.startIndex)
s[second] // b

如果需要一次性地自动对多个字符进行迭代:

1
2
3
// 步进 4 个字符
let sixth = s.index(second, offsetBy: 4)
s[sixth] // f

如果存在超过字符串末尾的风险,你可以加上 limitedBy: 参数。如果这个方法在达到目标索引之前就先触发了限制条件的话,它将返回 nil

1
2
let safeIdx = s.index(s.startIndex, offsetBy: 400, limitedBy: s.endIndex)
safeIdx // nil

毫无疑问,这比简单的整数索引需要更多的代码,但是再一次,Swift 就是这样设计的。如果 Swift 允许使用整数下标索引来访问字符串,会大大增加意外地写出性能相当糟糕的代码的可能性(比如,在一个循环中使用了整数下标)。

子字符串


和所有集合类型一样,String 有一个特定的 SubSequence 类型,它就是 SubstringSubstringArraySlice 很相似:它是一个以不同起始和结束索引的对原字符串的切片。子字符串和原字符串共享文本存储,这带来的巨大的好处,它让对字符串切片成为了非常高效的操作。在下面的例子中,创建 firstWord 并不会导致昂贵的复制操作或者内存申请:

1
2
3
4
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

在你对一个(可能会很长的)字符串进行迭代并提取它的各个部分的循环中,切片的高效特性就非常重要了。这类任务可能包括在文本中寻找某个单词出现的所有位置,或者解析一个 CSV 文件等。在这里,字符串分割是一个很有用的操作。Colleciton 定义了一个 split 方法,它会返回一个子序列的数组(也就是 [Substring] )。最常用的一种形式是:

1
2
3
4
extension Collection where Element: Equatable {
public func split(separator: Element, maxSplits: Int = Int.max,
omittingEmptySubsequences: Bool = true) -> [SubSequence]
}

你可以这样来使用:

1
2
3
4
5
6
7
8
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>

StringProtocol


SubstringString 的接口几乎完全一样。这是通过一个叫做 StringProtocol 的通用协议来达到的,StringSubstring 都遵守这个协议。因为几乎所有的字符串 API 都被定义在 StringProtocol 上,对于 Substring,你完全可以假装将它看作就是一个 String,并完成各项操作。不过,在某些时候,你还是需要将子字符串转回 String 实例;和所有的切片一样,子字符串也只能用于短期的存储,这可以避免在操作过程中发生昂贵的复制。当这个操作结束,你想将结果保存起来,或是传递给下一个子系统,这时你应该通过初始化方法从 Substring 创建一个新的 String,如下例所示:

1
2
3
4
5
6
7
8
func lastWord(in input: String) -> String? {
// 处理输入,操作子字符串
let words = input.split(separators: [",", " "])
guard let lastWord = words.last else { return nil }
// 转换为字符串并返回
return String(lastWord)
}
lastWord(in: "one, two, three, four, five") // Optional("five")

不鼓励长期存储子字符串的根本原因在于,子字符串会一直持有整个原始字符串。如果有一个巨大的字符串,它的一个只表示单个字符的子字符串将会在内存中持有整个字符串。即使当原字符串的生命周期本应该结束时,只要子字符串还存在,这部分内存就无法释放。长期存储子字符串实际上会造成内存泄漏,由于原字符串还必须被持有在内存中,但是它们却不能再被访问。

如果你想要扩展 String 为其添加新的功能,将这个扩展放在 StringProtocol 会是一个好主意,这可以保持 StringSubstring API 的统一性。StringProtocol 设计之初就是为了在你想要对 String 扩展时来使用的。如果你想要将已有的扩展从 String 移动到 StringProtocol 的话,唯一需要做的改动是将传入其他 API 的 self 通过 String(self) 换为具体的 String 类型实例。

不过需要记住,在 Swift 4 中,StringProtocol 还并不是一个你想要构建自己的字符串类型时所应该实现的目标协议。文档中明确警告了这一点:

不要声明任意新的遵守 StringProtocol 协议的类型。只有标准库中的 StringSubstring 类型是有效的适配类型。

最终的目标是允许开发者创建他们自己的字符串类型 (比如带有特定的存储或者性能优化),但是协议的设计还没有结束,所以现在就遵守这个协议的话,可能会让你的代码在 Swift 5 中无法通过编译。

CustomStringConvertible


符合 CustomStringConvertible 协议的类型可以在将实例转换为字符串时提供自己的表示形式。String(describing:) 初始值设定项是将任何类型的实例转换为字符串的首选方法。如果传递的实例符合CustomStringConvertible ,则 String(describing: 初始值设定项和 print(_:) 函数将使用实例的自定义描述属性。

不鼓励直接访问类型的 description 属性或使用 CustomStringConvertible 作为通用约束。

遵守 CustomStringConvertible 协议

通过定义 description 属性将 CustomStringConvertible 协议添加到自定义类型中。

例如,这个自定义的 Point 结构体使用标准库提供的默认表示形式:

1
2
3
4
5
6
7
struct Point {
let x: Int, y: Int
}

let p = Point(x: 21, y: 30)
print(p)
// Prints "Point(x: 21, y: 30)"

在实现 description 属性并遵守 CustomStringConvertible 协议之后,Point 类型提供了它自己的自定义表示。

1
2
3
4
5
6
7
8
extension Point: CustomStringConvertible {
var description: String {
return "(\(x), \(y))"
}
}

print(p)
// Prints "(21, 30)"

CustomDebugStringConvertible


Swift 为任意类型(any type)提供了默认的调试文本表示。 String(reflecting:) 的初始化和 debugPrint(_:) 函数使用该默认表示形式。要自定义该表示,请使您的类型符合 CustomDebugStringConvertible 协议。

由于 String(reflecting:) 的初始化适用于任何类型的实例,如果传递的值符合 CustomDebugStringConvertible ,则返回实例的 debugDescription ,不鼓励直接访问类型的 debugDescription 属性,或者使用 CustomDebugStringConvertible 作为通用约束。

Note

调用 dump(_:_:_:_:) 函数并在调试器中打印时使用 String(reflecting:)Mirror(reflecting:) 来收集有关实例的信息,如果你为你自定义的类型实现了 CustomDebugStringConvertible 协议,则可能需要考虑通过实现 CustomReflectable 协议来提供自定义镜像。

遵守 CustomDebugStringConvertible 协议

通过定义 debugDescription 属性将 CustomDebugStringConvertible 协议添加到自定义类型中。

例如,这个自定义的 Point 结构体使用标准库提供的默认表示形式:

1
2
3
4
5
6
7
8
9
10
struct Point {
let x: Int, y: Int
}

let p = Point(x: 21, y: 30)
print(String(reflecting: p))
// Prints "p: Point = {
// x = 21
// y = 30
// }"

在实现 debugDescription 属性并遵守 CustomDebugStringConvertible 协议之后,Point 类型提供了它自己的自定义表示。

1
2
3
4
5
6
7
8
extension Point: CustomDebugStringConvertible {
var debugDescription: String {
return "Point(x: \(x), y: \(y))"
}
}

print(String(reflecting: p))
// Prints "Point(x: 21, y: 30)"