KeyPath在Swift中的妙用

東方宜玲發表於2019-03-03

原文連結: The power of key paths in Swift

自從swift剛開始就被設計為是編譯時安全和靜態型別後,它就缺少了那種我麼經常在執行時語言中的動態特性,比如Object-C, Ruby和JavaScript。舉個例子,在Object-C中,我們可以很輕易的動態去獲取一個物件的任意屬性和方法 – 甚至可以在執行時交換他們的實現。

雖然缺乏動態性正是Swift如此強大的一個重要原因 – 它幫助我們編寫更加可以預測的程式碼以及更大的保證了程式碼編寫的準確性, 但是有的時候,能夠編寫具有動態特性的程式碼是非常有用的。

值得慶幸的是,Swift不斷獲取越來越多的更具動態性的功能,同時還一直把它的關注點放在程式碼的型別安全上。其中的一個特性就是KeyPath。這周,就讓我們來看看KeyPath是如何在Swift中工作的,並且有哪些非常酷非常有用的事情可以讓我們去做。

基礎知識

Key paths實質上可以讓我們將任意一個屬性當做一個獨立的值。因此,它們可以傳遞,可以在表示式中使用,啟用一段程式碼去獲取或者設定一個屬性,而不用確切地知道它們使用哪個屬性。

Key paths主要有三種變體:

  • KeyPath: 提供對屬性的只讀訪問
  • WritableKeyPath: 提供對具有價值語義的可變屬性提供可讀可寫的訪問
  • ReferenceWritableKeyPath: 只能對引用型別使用(比如一個類的例項), 對任意可變屬性提供可讀可寫的訪問

這裡有一些額外的keypath型別,它們可以減少內部重複的程式碼,以及可以幫助我們做型別的擦除,但是我們在這篇文章中,會專注於上面的三種主要的型別。

讓我們深入瞭解如何使用key paths吧,以及使它們變得有趣且非常強大的原因。

功能速記

我們這樣說吧,我們正在構建一個可以讓我們閱讀從網路上獲取到文章的app,以及我們已經有一個Article的模型用來表達這篇文章,就像下面這樣:

struct Article {
    let id: UUID
    let source: URL
    let title: String
    let body: String
}
複製程式碼

無論什麼時候,我們使用這個模型陣列,通常需要從每個模型中提取單個資料以組成新的陣列 – 就像下面這兩個從文章陣列中獲取所有的IDs和sources的列子一樣:

let articleIDs = articles.map { $0.id }
let articleSources = articles.map { $0.source }
複製程式碼

雖然上面的實現完全沒有問題,但是我們只是想要從每個元素中提取單一的值,我們不是真的需要閉包的所有功能 – 所以使用keypath就可能非常合適。讓我們看看它是如何工作的吧。

我們會通過在Sequence協議中重寫map方法來處理key path,而不是通過閉包。既然我們只對這個使用例子的只讀訪問有興趣,那麼我們將會使用標準的KeyPath型別,並且為了實際的資料提取,我們將會使用給定的鍵值路徑作為下標引數,如下所示:

extension Sequence {
	func map<T>(_ keyPath: KeyPath<Element, T>) -> [T] {
	  return map { $0[keyPath: keyPath] }
	}
}
複製程式碼

隨著上述準備就緒,我們能夠使用友好和簡單的語法來從任意的序列元素中提取出單一的值,使將之前的列子轉化成下面的樣子成為可能:

let articleIDs = articles.map(.id)
let articleSources = articles.map(.source)
複製程式碼

這是非常酷的,但是鍵值路徑真正開始閃光的時候,是當它們被用來組成更加靈活的表示式 – 比如當給序列的值排序的時候。

標準庫可以給任意的包含可排序的元素的序列進行自動排序,但是對於其他不可排序的元素,我們必須提供自己的排序閉包。然而,使用關鍵路徑,我們可以很簡單的給任意的可比較的元素新增排序的支援。就像之前一樣,我們給序列新增一個擴充套件,來將給定的關鍵路徑在排序表達閉包中進行轉化:

extension Sequence {
    func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>) -> [Element] {
        return sorted { a, b in
            return a[keyPath: keyPath] < b[keyPath: keyPath]
        }
    }
}
複製程式碼

使用上述方法,我們可以非常快速的排序任意的序列,只用簡單的提供一個我們期望被排序的關鍵路徑。如果我們構建的app是用來處理任意的可排序的列表 – 舉個例子,一個包含了播放列表的音樂app – 這將是非常有用的,我們現在可以隨意排序基於可比較屬性的列表(甚至是巢狀的屬性)。

playlist.songs.sorted(by: .name)
playlist.songs.sorted(by: .dateAdded)
playlist.songs.sorted(by: .ratings.worldWide)
複製程式碼

完成上述的事情看起來有點簡單,就像新增了一個語法糖。但是既可以寫出更加靈活的程式碼去處理序列,讓他們更易讀,也可以減少重複的程式碼。因此我們現在能夠為任意屬性重用相同的排序程式碼。

不需要例項

雖然適量的語法糖很好,但是關鍵路徑的真正的威力來自於,它可以讓我們引用屬性而不必與任意的例項相關聯。延續使用之前的音樂主題,假設我們正在開發一個展示歌曲列表的App – 並且在UI中為這個列表配置UITableViewCell,我們使用如下的配置型別:

struct SongCellConfigurator {
    func configure(_ cell: UITableViewCell, for song: Song) {
        cell.textLabel?.text = song.name
        cell.detailTextLabel?.text = song.artistName
        cell.imageView?.image = song.albumArtwork
    }
}
複製程式碼

再次宣告,上面的程式碼沒有一點問題,但是我們期望以這樣的方式渲染其他的模型的概率非常的高(非常多的tableView的cells嘗試著去渲染標題,副標題以及圖片而不用去管他們代表的是什麼模型)- 因此讓我們看看,我們能否用關鍵路徑的威力去建立一個共享的配置實現,讓他可以被任意的模型使用。

讓我們建立一個名叫CellConfigurator的泛型,然後因為我們想要用不同的模型去渲染不同的資料,所以我們將會給它提供一組基於關鍵路徑的屬性 – 我們先渲染其中的一個資料:

struct CellConfigurator<Model> {
    let titleKeyPath: KeyPath<Model, String>
    let subtitleKeyPath: KeyPath<Model, String>
    let imageKeyPath: KeyPath<Model, UIImage?>

    func configure(_ cell: UITableViewCell, for model: Model) {
        cell.textLabel?.text = model[keyPath: titleKeyPath]
        cell.detailTextLabel?.text = model[keyPath: subtitleKeyPath]
        cell.imageView?.image = model[keyPath: imageKeyPath]
    }
}
複製程式碼

上面的實現優雅的地方在於,我們現在可以為每個模型定製我們的CellConfigurator,使用相同的輕量的關鍵路徑語法,如下所示:

let songCellConfigurator = CellConfigurator<Song>(
    titleKeyPath: .name,
    subtitleKeyPath: .artistName,
    imageKeyPath: .albumArtwork
)

let playlistCellConfigurator = CellConfigurator<Playlist>(
    titleKeyPath: .title,
    subtitleKeyPath: .authorName,
    imageKeyPath: .artwork
)
複製程式碼

就像標準庫中的map和sorted等函式的操作一樣,我們曾經可能會使用閉包去實現CellConfigurator。然而,通過關鍵路徑,我們能夠使用一個非常好的語法去實現它 – 並且我們也不需要任何的訂製化的操作去不得不通過模型例項去處理 – 使它們變得更加的簡單,更加的具有說服力。

轉化為函式

目前為止,我們僅僅使用關鍵路徑來讀取值 – 現在讓我們看看我們如何使用它們來動態的寫值。在很多不同的程式碼中,我們常常可以見到一些像下面的程式碼一樣的列子 – 我們通過這段程式碼來載入一系列的事項,然後在ListViewController中去渲染它們,然後當載入操作完成後,我們會簡單的將載入的事項賦值給檢視控制器中的屬性。

class ListViewController {
    private var items = [Item]() { didSet { render() } }

    func loadItems() {
        loader.load { [weak self] items in
            self?.items = items
        }
    }
}
複製程式碼

讓我們看看,通過關鍵路徑賦值能否讓上面的語法簡單一點,並且能夠移除我們經常使用的weak self的語法(如果我們忘記對self的引用前加上weak關鍵字的話,那麼就會產生迴圈引用)。

既然所有上面我們做的事情都是獲取傳遞給我們閉包的值,並將它賦值給檢視控制器中的屬性 – 那麼如果我們真的能夠將屬性的setter作為函式傳遞,會不會很酷呢?這樣我們就可以直接將函式作為完成閉包傳遞給我們的載入方法,然後所有的事情都會正常執行。

為了實現這一目標,首先我們先定義一個函式,讓任意的可寫的轉化為一個閉包,然後為關鍵路徑設定屬性值。為此,我們將會使用ReferenceWritableKeyPath型別,因為我們只想把它限制為引用型別(否則的話,我們只會改變本地屬性的值)。給定一個物件,以及給這個物件設定關鍵路徑,我們將會自動將捕獲的物件作為弱引用型別,一旦我們的函式被呼叫,我們就會給匹配關鍵路徑的屬性賦值。就像這樣:

func setter<Object: AnyObject, Value>(
    for object: Object,
    keyPath: ReferenceWritableKeyPath<Object, Value>
) -> (Value) -> Void {
    return { [weak object] value in
        object?[keyPath: keyPath] = value
    }
}
複製程式碼

使用上面的程式碼,我們可以簡化之前的程式碼,將弱引用的self去除,然後用看起來非常簡潔的語法結尾:

class ListViewController {
    private var items = [Item]() { didSet { render() } }

    func loadItems() {
        loader.load(then: setter(for: self, keyPath: .items))
    }
}
複製程式碼

非常酷有沒有!或許它還能變得更加的酷,當上面的程式碼跟更加先進的函數語言程式設計思想結合在一起的時候,如組合函式 – 因此我們現在可以將多個setter函式和其他的函式連結在一起使用。在接下來的文章中,我們將介紹函數語言程式設計和組合函式。

總結

首先,看起來如何以及何時去使用swift關鍵路徑這樣的功能有點困難,並且很容易將它們看做是簡單的語法糖。能夠使用更加動態的方法去引用屬性是一件非常強大的事情,即使閉包通常可以做很多類似的事情,但是輕量的語法以及關鍵路徑的宣告,都使他們能夠成為處理非常多種類的資料的好的匹配。

謝謝閱讀!

相關文章