聊聊 Swift 中的幻象類型
前言
模糊的數據可以說是一般應用程序中最常見的錯誤和問題的來源之一。雖然 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來引用一個文本文檔是相當乏味的,所以讓我們也使用類型別名為每種格式定義速記。這將給我們提供漂亮的、有語義的名字,而不需要任何重復的代碼:
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的許多不同地方都可以找到這種模式。
例如,Foundation的Measurement API使用幻象類型來確保在傳遞各種測量值時的類型安全——例如度數、長度和重量:
let meters = Measurement<UnitLength>(value: 5, unit: .meters)
let degrees = Measurement<UnitAngle>(value: 90, unit: .degrees)
通過使用幻影類型,上述兩個測量值不能被混合,因為每個值是哪種單位,都被編碼到該值的類型中。這可以防止我們不小心將一個長度傳遞給一個接受角度的函數,反之亦然——就像我們之前防止文檔格式被混淆一樣。
結論
使用幻象類型是一種非常強大的技術,它可以讓我們利用類型系統來驗證一個特定值的不同變體。雖然使用幻象類型通常會使API更加冗長,而且確實伴隨著泛型的復雜性——當處理不同的格式和變體時,它可以讓我們減少對運行時檢查的依賴,而讓編譯器來執行這些檢查。
就像一般的泛型一樣,我認為在部署幻象類型之前,首先要仔細評估當前的情況,這很重要。就像我們最初的Document模型并不是手頭任務的正確選擇,盡管它的結構很好,但如果部署在錯誤的情況下,幻象類型會使簡單的設置變得更加復雜。像往常一樣,它歸結為為工作選擇正確的工具。