在Swift中扩展协议的教程

106 阅读5分钟

Swift协议的核心优势之一是,它使我们能够定义多个类型都能符合的共享接口,这反过来又使我们能够以非常统一的方式与这些类型进行互动,而不一定知道我们当前处理的是什么底层类型。

例如,为了清楚地定义一个API,使我们能够将一个给定的实例持久化到磁盘上,我们可能会选择使用一个类似这样的协议:

protocol DiskWritable {
    func writeToDisk(at url: URL) throws
}

这样定义常用API的一个好处是,它可以帮助我们保持代码的一致性,因为我们现在可以让任何应该可以写入磁盘的类型符合上述协议,这就要求我们为所有这些类型实现完全相同的方法。

Swift协议的另一大优势是它们是可扩展的,这使得我们有可能为我们自己的协议以及那些外部定义的协议--例如在标准库中,或者在我们导入的任何框架中--定义各种便利的API。

在编写这些方便的API时,我们可能还想把我们目前正在扩展的协议与另一个协议所提供的一些功能混合起来。例如,假设我们想为同样符合Encodable 协议的类型提供DiskWritable 协议的writeToDisk 方法的默认实现--因为一个可编码的类型可以被转化为Data ,然后我们可以自动写入磁盘。

实现这一目标的一个方法是使我们的DiskWritable 协议继承Encodable ,这又会要求所有符合要求的类型都实现这两个协议的要求。然后我们可以简单地扩展DiskWritable ,以增加我们想要提供的writeToDisk 的默认实现:

protocol DiskWritable: Encodable {
    func writeToDisk(at url: URL) throws
}

extension DiskWritable {
    func writeToDisk(at url: URL) throws {
        let encoder = JSONEncoder()
        let data = try encoder.encode(self)
        try data.write(to: url)
    }
}

虽然功能强大,但上述方法确实有一个相当大的缺点,因为我们现在已经将我们的DiskWritable 协议与Encodable 完全耦合在一起--这意味着我们不能再单独使用该协议,而不要求任何符合要求的类型也完全实现Encodable ,这可能会成为问题。

另一个更灵活的方法是让DiskWritable 保持一个完全独立的协议,而写一个类型约束的扩展,只将我们默认的writeToDisk 实现添加到符合Encodable 的类型中--像这样:

extension DiskWritable where Self: Encodable {
    func writeToDisk(at url: URL) throws {
        let encoder = JSONEncoder()
        let data = try encoder.encode(self)
        try data.write(to: url)
    }
}

这里的权衡是,上述方法确实需要每个想要利用我们默认的writeToDisk 实现的类型明确地符合DiskWritableEncodable ,这可能不是一个大问题,但它可能会使发现默认的实现变得有点困难--因为它不再自动用于所有符合DiskWritable 的类型。

不过,解决这个可发现性问题的一个方法是创建一个方便的类型别名(使用 Swift 的协议组合操作符& ),给我们一个指示,即DiskWritableEncodable 可以被组合来释放新功能:

typealias DiskWritableByEncoding = DiskWritable & Encodable

当一个类型符合这两个协议时(要么使用上述类型别名,要么完全分开),它现在就可以访问我们默认的writeToDisk 实现(同时还可以选择提供自己的、自定义的实现):

struct TodoList: DiskWritableByEncoding {
    var name: String
    var items: [Item]
    ...
}

let list = TodoList(...)
try list.writeToDisk(at: fileURL)

像这样组合协议可以是一个非常强大的技术,因为我们不仅仅局限于添加协议要求的默认实现--我们还可以为任何协议组合添加全新的API,只需在我们的一个扩展中添加新方法或计算属性。

例如,在这里我们添加了我们的writeToDisk 方法的第二个重载,这使得我们可以传递一个自定义的JSONEncoder ,在序列化当前实例时使用:

extension DiskWritable where Self: Encodable {
    func writeToDisk(at url: URL, encoder: JSONEncoder) throws {
        let data = try encoder.encode(self)
        try data.write(to: url)
    }

    func writeToDisk(at url: URL) throws {
        try writeToDisk(at: url, encoder: JSONEncoder())
    }
}

不过我们必须小心,不要过度使用上述模式,因为如果一个给定的类型最终能够访问同一个方法的多个默认实现,这样做可能会带来冲突。

为了说明这一点,我们假设我们的代码库也包含一个DataConvertible 协议,我们想用一个类似的、默认的writeToDisk 的实现来扩展它--像这样:

protocol DataConvertible {
    func convertToData() throws -> Data
}

extension DiskWritable where Self: DataConvertible {
    func writeToDisk(at url: URL) throws {
        let data = try convertToData()
        try data.write(to: url)
    }
}

虽然我们现在创建的这两个DiskWritable 扩展在孤立的情况下是完全有意义的,但是如果某个符合DiskWritable 的类型也想同时符合EncodableDataConvertible ,我们就会出现冲突(这是很有可能的,因为这两个协议都是关于将一个实例转化为Data )。

由于编译器无法在这种情况下选择使用哪种默认实现,所以我们必须为每一种有冲突的类型手动实现我们的writeToDisk 方法。也许这不是一个大问题,但它可能会导致我们难以判断哪种类型将使用哪种方法实现,这反过来又会使我们的代码感觉相当不可预测,更难以调试和维护。

因此,让我们也来探讨一下解决上述一系列问题的最后一种替代方法--那就是在一个专门的类型中实现我们的写盘便利API,而不是使用协议扩展。例如,我们可以这样定义一个EncodingDiskWriter ,它只要求与之配合使用的类型符合Encodable ,因为写入器本身符合DiskWritable

struct EncodingDiskWriter<Value: Encodable>: DiskWritable {
    var value: Value
    var encoder = JSONEncoder()

    func writeToDisk(at url: URL) throws {
        let data = try encoder.encode(value)
        try data.write(to: url)
    }
}

因此,即使下面的Document 类型不符合DiskWritable ,我们仍然可以使用我们新的EncodingDiskWriter ,轻松地将其数据写入磁盘:

struct Document: Identifiable, Codable {
    let id: UUID
    var name: String
    ...
}

class EditorViewController: UIViewController {
    private var document: Document
    private var fileURL: URL
    ...

    private func save() throws {
        let writer = EncodingDiskWriter(value: document)
        try writer.writeToDisk(at: fileURL)
    }
}

因此,尽管协议扩展为我们提供了一套非常强大的工具,但重要的是要记住,还有其他的选择,可能更适合我们试图建立的东西。

谢谢你的阅读!