持续创造,加快生长!这是我参与「日新方案 6 月更文挑战」的第1天,点击查看活动详情

前言

含糊的数据能够说是一般应用程序中最常见的过错和问题的来历之一。虽然 Swift 经过其强壮的类型体系和完善的编译器协助咱们避免了许多含糊不清的来历——但只需咱们无法在编译时确保某个数据总是契合咱们的要求,就总是有危险,咱们终究会处于含糊不清或不行猜测的状态。

本周,让咱们来看看一种技能,它能够让咱们利用 Swift 的类型体系在编译时执行更多种类的数据验证——消除更多潜在的歧义来历,并协助咱们在整个代码库中保持类型安全——经过运用幻象类型(phantom types)。

界说良好,但依然含糊不清

举个比方,假定咱们正在开发一个文本修改器,虽然它开始只支撑纯文本文件——跟着时间的推移,咱们还增加了对修改HTML文档的支撑,以及PDF预览。

为了能够尽或许多地重复运用咱们原来的文档处理代码,咱们持续运用与开始时相同的Document模型——仅仅现在它获得了一个Format特点,告诉咱们正在处理什么样的文档:

struct Document {
    enum Format {
        case text
        case html
        case pdf
    }
    var format: Format
    var data: Data
    var modificationDate: Date
    var author: Author
}

能够避免代码重复当然是件好事,并且枚举是当咱们在处理一个模型的不同格局或变体时一般状况下建模 的好办法,但是上述那种设置实践上终究会形成相当多的含糊性。

例如,咱们或许有一些API,只要在调用给定格局的文档时才有含义——比方这个翻开文本修改器的函数,它假定任何传入它的Document都是文本文档:

func openTextEditor(for document: Document) {
    let text = String(decoding: document.data, as: UTF8.self)
    let editor = TextEditor(text: text)
    ...
}

虽然假如咱们不小心将一个HTML文档传递给上述函数并不是世界末日(HTML毕竟仅仅文本),但试图以这种方式翻开一个PDF,很或许会导致呈现出彻底无法了解的东西,咱们的文本修改功用将无法作业,咱们的应用程序乃至或许终究溃散。

咱们在编写任何其他特定格局的代码时都会不断遇到相同的问题,例如,假如咱们想经过完成一个解析器和一个专门的修改器来改进修改HTML文档的用户体会:

func openHTMLEditor(for document: Document) {
    // 就像咱们上面用于文本修改的函数相同,
    // 这个函数假定它总是被传递给HTML文档。
    let parser = HTMLParser()
    let html = parser.parse(document.data)
    let editor = HTMLEditor(html: html)
    ...
}

一个关于怎么处理上述问题的初步想法或许是编写一个包装函数,切换到所传递文档的格局,然后为每种状况翻开正确的修改器。但是,虽然这对文本和HTML文档很有效,但由于PDF文档在咱们的应用程序中是不行修改的——当遇到PDF时,咱们将被迫抛出一个过错,触发一个断言,或以其他方式失利:

func openEditor(for document: Document) {
    switch document.format {
    case .text:
        openTextEditor(for: document)
    case .html:
        openHTMLEditor(for: document)
    case .pdf:
        assertionFailure("Cannot edit PDF documents")
    }
}

上述状况不是很好,由于它要求咱们作为开发者始终盯梢咱们在任何给定的代码路径中所处理的文件类型,而咱们或许犯的任何过错只能在运行时被发现——编译器根本没有满足的信息能够在编译时进行这种查看。

因此,虽然咱们的 “Document “模型乍一看或许非常高雅和完善,但事实证明,它并不彻底是手头状况的正确处理方案。

看起来咱们需要一个协议!

处理上述问题的一个办法是把Document变成一个协议,而不是作为一个具体的类型,把它的一切特点(除了format)都作为要求:

protocol Document {
    var data: Data { get }
    var modificationDate: Date { get }
    var author: Author { get }
}

有了上述变化,咱们现在能够为咱们的三种文档格局中的每一种完成专门的类型,并让这些类型都契合咱们新的文档协议——比方这样:

struct TextDocument: Document {
    var data: Data
    var modificationDate: Date
    var author: Author
}

上述办法的优点是,它使咱们既能完成能够对任何Document进行操作的通用功用,又能完成只承受某种具体类型的特定API:

// 这个函数能够保存任何文件,
// 所以它承受任何契合咱们的新文档协议。
func save(_ document: Document) {
    ...
}
// 咱们现在只能向咱们的函数传递文本文件,
// 即翻开一个文本修改器。
func openTextEditor(for document: TextDocument) {
    ...
}

咱们在上面所做的基本上是将以前在运行时进行的查看转为在编译时进行验证——由于编译器现在能够查看咱们是否总是向咱们的每个API传递正确格局的文件,这是一个很大的进步。

但是,经过执行上述改动,咱们也失去了咱们开始完成的优点——代码重用。由于咱们现在运用一个协议来表明一切的文档格局,咱们将需要为咱们的三种文档类型中的每一种编写彻底重复的模型完成,以及为咱们将来或许增加的任何其他格局供给支撑。

引进幻象类型

假如咱们能找到一种办法,既能为一切格局重用相同的Document模型,又能在编译时验证咱们特定格局的代码,岂不妙哉?事实证明,咱们之前的一行代码实践上能够给咱们一个完成这一目标的提示:

let text = String(decoding: document.data, as: UTF8.self)

当把Data转换为String时,就像咱们上面做的那样,咱们经过传递对该类型本身的引证来传递咱们希望字符串被解码的编码——在本例中是UTF8。这真的很风趣。假如咱们再深入一点,就会发现 Swift 规范库将咱们上面提到的UTF8类型界说为另一个相似命名空间的枚举中的一个无大小写枚举,称为Unicode

enum Unicode {
    enum UTF8 {}
    ...
}
typealias UTF8 = Unicode.UTF8

请注意,假如你看一下UTF8类型的实践完成,它的确包含一个私有case,仅仅为了向后兼容 Swift 3 而存在。

咱们在这里看到的是一种被称为幻象类型的技能——当类型被用作标记,而不是被实例化来表明值或目标时。事实上,由于上述枚举都没有任何揭露的状况,它们乃至不能被实例化!

让咱们看看是否能够用相同的技能来处理咱们的Document困境。咱们首先将Document还原成一个结构体,仅仅这次咱们将删去它的format特点(以及相关的枚举),而将它变成一个掩盖任何Format类型的泛型——比方这样:

struct Document<Format> {
    var data: Data
    var modificationDate: Date
    var author: Author
}

受规范库的Unicode枚举及其各种编码的启示,咱们将界说一个相似的枚举——DocumentFormat——作为三个无大小写的枚举的命名空间,每种格局都有一个:

enum DocumentFormat {
    enum Text {}
    enum HTML {}
    enum PDF {}
}

请注意,这里不涉及任何协议——任何类型都能够被用作格局,由于就像String和它的各种编码相同,咱们将只运用文档的Format类型作为编译时的标记。这将使咱们能够像这样写出咱们特定格局的API:

func openTextEditor(for document: Document<DocumentFormat.Text>) {
    ...
}
func openHTMLEditor(for document: Document<DocumentFormat.HTML>) {
    ...
}
func openPreview(for document: Document<DocumentFormat.PDF>) {
    ...
}

当然,咱们依然能够编写不需要任何特定格局的通用代码。例如,这里咱们能够把之前的saveAPI变成一个彻底通用的函数:

func save<F>(_ document: Document<F>) {
    ...
}

但是,总是输入Document<DocumentFormat.Text>来引证一个文本文档是相当乏味的,所以让咱们也运用类型别名为每种格局界说速记。这将给咱们供给美丽的、有语义的姓名,而不需要任何重复的代码:

typealias TextDocument = Document<DocumentFormat.Text>
typealias HTMLDocument = Document<DocumentFormat.HTML>
typealias PDFDocument = Document<DocumentFormat.PDF>

在涉及到特定格局的扩展时,幻象类型也的确大放异彩,现在能够直接运用 Swift 强壮的泛型体系和泛型型束缚来完成。例如,咱们能够用一个生成NSAttributedString的办法来扩展一切文本文档:

extension Document where Format == DocumentFormat.Text {
    func makeAttributedString(withFont font: UIFont) -> NSAttributedString {
        let string = String(decoding: data, as: UTF8.self)
        return NSAttributedString(string: string, attributes: [
            .font: font
        ])
    }
}

由于咱们的幻象类型在最终仅仅一般的类型——咱们也能够让它们遵守协议,并运用这些协议作为泛型束缚。例如,咱们能够让咱们的一些DocumentFormat类型遵守Printable协议,然后咱们能够在打印代码中运用这些协议作为束缚条件。这里有大量的或许性。

一个规范的模式

起初,幻象类型在 Swift 中或许看起来有点 “方枘圆凿”。但是,虽然 Swift 并没有像更多的纯函数式语言(如Haskell)那样为幻象类型供给一流的支撑,但在规范库和苹果平台SDK的许多不同地方都能够找到这种模式。

例如,FoundationMeasurement API运用幻象类型来确保在传递各种测量值时的类型安全——例如度数、长度和分量:

let meters = Measurement<UnitLength>(value: 5, unit: .meters)
let degrees = Measurement<UnitAngle>(value: 90, unit: .degrees)

经过运用幻影类型,上述两个测量值不能被混合,由于每个值是哪种单位,都被编码到该值的类型中。这能够避免咱们不小心将一个长度传递给一个承受视点的函数,反之亦然——就像咱们之前避免文档格局被混杂相同。

定论

运用幻象类型是一种非常强壮的技能,它能够让咱们利用类型体系来验证一个特定值的不同变体。虽然运用幻象类型通常会使API更加冗长,并且的确伴跟着泛型的杂乱性——当处理不同的格局和变体时,它能够让咱们削减对运行时查看的依靠,而让编译器来执行这些查看。

就像一般的泛型相同,我以为在布置幻象类型之前,首先要细心评价当前的状况,这很重要。就像咱们开始的Document模型并不是手头任务的正确挑选,虽然它的结构很好,但假如布置在过错的状况下,幻象类型会使简单的设置变得更加杂乱。像平常相同,它归结为为作业挑选正确的工具。