标准库中的条件一致性

Swift 4.1 编译器带来了 泛型路线图 中的下一阶段改进:条件一致性

这篇文章将探讨这项备受期待的功能如何在 Swift 标准库中被采用,以及它如何影响您和您的代码。

Equatable 容器

条件一致性最显著的好处是,像 ArrayOptional 这样存储其他类型的类型,能够遵循 Equatable 协议。这个协议保证了您可以在类型的两个实例之间使用 ==。让我们看看为什么遵循这个协议如此有用。

您一直可以在两个包含任何可 Equatable 元素的数组中使用 ==

[1,2,3] == [1,2,3]     // true
[1,2,3] == [4,5]       // false

或者两个包装了可 Equatable 类型的 Optional

// The failable initializer from a String returns an Int?
Int("1") == Int("1")                        // true
Int("1") == Int("2")                        // false
Int("1") == Int("swift")                    // false, Int("swift") is nil

这通过 == 运算符的重载来实现,例如 Array 的这个重载

extension Array where Element: Equatable {
    public static func ==(lhs: [Element], rhs: [Element]) -> Bool {
        return lhs.elementsEqual(rhs)
    }
}

但这仅仅因为它们实现了 == 并不意味着 ArrayOptional 遵循了 Equatable。由于这些类型可以存储不可 Equatable 的类型,我们需要能够表达它们仅在存储可 Equatable 类型时才是 Equatable 的。

这意味着这些 == 运算符有一个很大的局限性:它们不能在两层深度上使用。如果您在 Swift 4.0 中尝试这样的操作

// convert a [String] to [Int?]
let a = ["1","2","x"].map(Int.init)

a == [1,2,nil]    // expecting 'true'

您会收到一个编译器错误

二元运算符“==”不能应用于两个“[Int?]”操作数。

这是因为如上所示,Array== 实现要求数组的元素是 Equatable 的,而 Optional 不是 Equatable 的。

有了条件一致性,我们现在可以修复这个问题。它允许我们编写这些类型遵循 Equatable——使用已经定义的 == 运算符——如果它们基于的类型是 Equatable 的

extension Array: Equatable where Element: Equatable {
    // implementation of == for Array
}

extension Optional: Equatable where Wrapped: Equatable {
    // implementation of == for Optional
}

Equatable 一致性带来了 == 之外的其他好处。拥有 Equatable 元素为集合提供了其他辅助函数,用于搜索等任务

a.contains(nil)                 // true
[[1,2],[3,4],[]].index(of: [])  // 2

使用条件一致性,Swift 4.1 的 OptionalArrayDictionary 现在在它们的值或元素遵循这些协议时,会遵循 EquatableHashable

这种方法也适用于 Codable。如果您尝试编码一个不可 Codable 类型的数组,您现在会收到一个编译时错误,而不是您过去常遇到的运行时陷阱。

集合协议

条件一致性对于逐步构建类型的功能,避免代码重复也很有好处。为了探索通过使用条件一致性在 Swift 标准库中实现的一些更改,我们将使用一个示例,为 Collection 添加一个新功能:惰性分割。我们将创建一个新类型,用于提供从集合中分割出来的切片,然后看看当基本集合是双向的时,如何使用条件一致性来添加双向功能。

迫切分割 vs 惰性分割

Swift 中的 Sequence 协议有一个 split 方法,它可以将一个序列分割成一个子序列的 Array

let numbers = "15,x,25,2"
let splits = numbers.split(separator: ",")
// splits == ["15","x","25","2"]
var sum = 0
for i in splits {
    sum += Int(i) ?? 0
}
// sum == 42

我们将此 split 方法描述为“迫切的”,因为它在您调用它时立即迫切地将序列分割成子序列并将它们放入数组中。

但是假设您只需要前几个子序列呢?假设您有一个巨大的文本文件,并且您只想抓取它的初始行以显示为预览。您不会想处理整个文件,只是为了使用开头的一些行。

这种问题也适用于像 mapfilter 这样的操作,它们在 Swift 中默认也是迫切的。为了避免这种情况,标准库具有“惰性”序列和集合。您可以通过 lazy 属性访问它们。这些惰性序列和集合具有像 map 这样的操作的实现,这些操作不会立即运行。相反,它们在访问元素时动态地执行映射或过滤。例如

// a huge collection
let giant = 0..<Int.max
// lazily map it: no work is done yet
let mapped = giant.lazy.map { $0 * 2 }
// sum the first few elements
let sum = mapped.prefix(10).reduce(0, +)
// sum == 90

当创建 mapped 集合时,不会发生任何映射。事实上,您可能会注意到,如果您对 giant 的每个元素执行映射操作,它会陷入陷阱:当加倍的值不再适合 Int 时,它会在中途溢出。但是使用惰性 map,映射仅在您访问元素时发生。因此,在这个例子中,只有前十个值被计算出来,当 reduce 操作将它们加起来时。

一个惰性分割包装器

标准库没有惰性分割操作。下面是一个关于如何工作的草图。如果您有兴趣为 Swift 做出贡献,这将是一个很好的首个问题演化提案

首先,我们创建一个简单的泛型包装结构体,它可以容纳任何基本集合,以及一个闭包来识别要分割的元素

struct LazySplitCollection<Base: Collection> {
    let base: Base
    let isSeparator: (Base.Element) -> Bool
}

(为了使这篇文章的代码简单,我们将忽略访问控制等内容)

接下来,我们遵循 Collection 协议。要成为集合,您只需要提供四件事:startIndexendIndex,一个 subscript,它为给定的索引提供元素,以及一个 index(after:) 方法,用于将索引向前移动一位。

这个集合的元素是基本集合的子序列(来自 "one,two,three" 的子字符串 "one")。集合的子序列使用与其父集合相同的索引类型,因此我们也可以重用基本集合的索引作为我们的索引。索引将是基本集合中下一个子序列的开始,或结尾。

extension LazySplitCollection: Collection {
    typealias Element = Base.SubSequence
    typealias Index = Base.Index

    var startIndex: Index { return base.startIndex }
    var endIndex: Index { return base.endIndex }

    subscript(i: Index) -> Element {
        let separator = base[i...].index(where: isSeparator)
        return base[i..<(separator ?? endIndex)]
    }

    func index(after i: Index) -> Index {
        let separator = base[i...].index(where: isSeparator)
        return separator.map(base.index(after:)) ?? endIndex
    }
}

查找下一个分隔符并返回其间序列的工作在 subscriptindex(after:) 方法中完成。在这两者中,我们从给定索引开始搜索基本集合中的下一个分隔符。如果没有找到,index(where:) 返回 nil 表示未找到,因此我们使用 ?? endIndex 在这种情况下替换结束索引。唯一棘手的部分是在 index(after:) 实现中跳过分隔符,我们使用 optional map 来完成。

扩展 lazy

现在我们有了这个包装器,我们想扩展所有惰性集合类型,以便在惰性分割方法中使用它。所有惰性集合都遵循 LazyCollectionProtocol,所以这就是我们用我们的方法扩展的内容

extension LazyCollectionProtocol {
    func split(
        whereSeparator matches: @escaping (Element) -> Bool
    ) -> LazySplitCollection<Elements> {
        return LazySplitCollection(base: elements, isSeparator: matches)
    }
}

对于像这样的方法,按照惯例,当元素是 Equatable 时,还会提供一个接受值而不是闭包的版本

extension LazyCollectionProtocol where Element: Equatable {
    func split(separator: Element) -> LazySplitCollection<Elements> {
        return LazySplitCollection(base: elements) { $0 == separator }
    }
}

有了这个,我们就将惰性分割方法添加到惰性子系统中

let one = "one,two,three".lazy.split(separator: ",").first
// one == "one"

我们还想用 LazyCollectionProtocol 标记我们的惰性包装器,以便对其进行的任何进一步操作也是惰性的,正如用户所期望的那样

extension LazySplitCollection: LazyCollectionProtocol { }

条件性双向

所以现在我们可以有效地从分隔集合中分割前几个元素。那么读取最后几个元素呢?BidirectionalCollection 添加了一个 index(before:) 方法,用于从末尾向后移动索引。这允许双向集合支持像 last 属性这样的功能。

如果我们分割的集合是双向的,我们也应该能够使我们的分割包装器也是双向的。在 Swift 4.0 中,执行此操作的方法非常笨拙。您必须添加一个全新的类型 LazySplitBidirectionalCollection,它需要 Base: BidirectionalCollection 并实现 BidirectionalCollection。然后,您重载 split 方法以在 where Base: BidirectionalCollection 的情况下返回它。

现在,有了条件一致性,我们有了一个更简单的解决方案:只需在 LazySplitCollection 的基本集合是双向的时候,使其遵循 BidirectionalCollection 即可。

extension LazySplitCollection: BidirectionalCollection
where Base: BidirectionalCollection {
    func index(before i: Index) -> Index {
        let reversed = base[..<base.index(before: i)].reversed()
        let separator = reversed.index(where: isSeparator)
        return separator?.base ?? startIndex
    }
}

在这里,我们使用了 reversed(),另一个惰性包装器,它可以反转任何双向集合的顺序。这允许我们向后搜索下一个分隔符,然后使用反转集合索引的 .base 属性返回到底层集合中的索引。

通过这个新方法,我们让我们的惰性集合可以访问任何双向集合的功能,例如 .last 属性或 reversed() 方法

let backwards = "one,two,three"
                .lazy.split(separator: ",")
                .reversed().joined(separator: ",")
// backwards == "three,two,one"

当您必须组合多个不同的独立一致性时,这种增量条件一致性真的会发光。假设我们想在基本集合是可变的时,使我们的惰性分割器遵循 MutableCollection。这两种一致性是独立的——可变集合不必是双向的,反之亦然——因此我们需要为这两种可能的组合的每一种创建一个专门的类型。

但是使用条件一致性,您只需添加第二个条件一致性即可。

此功能正是标准库的 Slice 类型所需要的。此类型为任何集合类型提供默认的切片功能。如果您尝试切片我们的惰性分割器,您可以看到它的使用

// dropFirst() creates a slice without the first element of a collection
let slice = "a,b,c".lazy.split(separator: ",").dropFirst()
print(type(of: slice))
// prints: Slice<LazySplitCollection<String>>

在 Swift 4 中,需要有十几个不同的实现,最坏的情况是 MutableRangeReplaceableRandomAccessSlice。现在,有了条件一致性,它可以只是一个具有 4 种不同条件一致性的 Slice 类型。仅此更改就使标准库的二进制大小减少了 5%。

进一步的实验

如果您熟悉迫切的 split,您会注意到我们的实现缺少一些功能,例如合并空子序列。您还可以进行一些性能优化,例如为包装器提供一个自定义索引,该索引缓存下一个分隔符的位置。

如果您想从头开始尝试编写自己的惰性包装器,您还可以考虑一个“分块”包装器,它一次提供长度为 n 的切片。这种情况很有趣,因为如果基本集合是随机访问的,您可以使其成为 BidirectionalCollection,但如果基本集合是双向的则不行,因为您需要能够在恒定时间内计算最后一个元素的长度。

条件一致性今天已在 Swift 4.1 开发工具链上提供,因此您可以下载最新的快照并试用一下!