聊聊Swift 中 key paths 的能力
前言
自從 swift 剛開始就被設(shè)計(jì)為是編譯時(shí)安全和靜態(tài)類型后,它就缺少了那種我么經(jīng)常在運(yùn)行時(shí)語言中的動態(tài)特性,比如 Object-C, Ruby 和 JavaScript。舉個(gè)例子,在 Object-C 中,我們可以很輕易的動態(tài)去獲取一個(gè)對象的任意屬性和方法 - 甚至可以在運(yùn)行時(shí)交換他們的實(shí)現(xiàn)。
雖然缺乏動態(tài)性正是 Swift 如此強(qiáng)大的一個(gè)重要原因 - 它幫助我們編寫更加可以預(yù)測的代碼以及更大的保證了代碼編寫的準(zhǔn)確性�, 但是有的時(shí)候,能夠編寫具有動態(tài)特性的代碼是非常有用的。
值得慶幸的是,Swift 不斷獲取越來越多的更具動態(tài)性的功能,同時(shí)還一直把它的關(guān)注點(diǎn)放在代碼的類型安全上。其中的一個(gè)特性就是 KeyPath。這周,就讓我們來看看 KeyPath 是如何在 Swift 中工作的,并且有哪些非常酷非常有用的事情可以讓我們?nèi)プ觥?/p>
基礎(chǔ)
key paths 基本上讓我們將任何實(shí)例屬性引用為單獨(dú)的值。因此,它們可以通過表達(dá)式傳遞,并使一段代碼能夠獲取或設(shè)置一個(gè)屬性而無需實(shí)際了解該屬性。
Key paths 有三種主要變種:
- KeyPath:提供對屬性的只讀訪問權(quán)限。
- WritableKeyPath: 提供對具有值語義的可變屬性的讀寫訪問權(quán)限(因此所討論的實(shí)例也需要是可變的,以便允許的寫入)。
- ReferenceWritableKeyPath: 只能與引用類型(例如類的實(shí)例)一起使用,并為任何可變屬性提供讀寫訪問權(quán)限。
還有一些額外的 key paths 類型,即可以減少內(nèi)部代碼復(fù)制并幫助類型擦除,但我們將專注于本文中的主要類型。
讓我們深入查看如何使用 key paths,是什么讓他們有趣和潛在的強(qiáng)大。
功能表達(dá)
假設(shè)我們正在構(gòu)建一個(gè)應(yīng)用程序,讓用戶讀取來自 Web 的文章,并且我們有一個(gè)用來代表一個(gè)這樣的文章的 Article 模型,看起來像這樣:
- struct Article {
- let id: UUID
- let source: URL
- let title: String
- let body: String
- }
每當(dāng)我們使用這些模型的數(shù)組時(shí),希望從每個(gè)型號中提取一個(gè)數(shù)據(jù)來形成一個(gè)新數(shù)組 —— 例如在以下兩個(gè)示例中,我們正在收集所有 ID 和所有文章的來源:
- let articleIDs = articles.map { $0.id }
- let articleSources = articles.map { $0.source }
雖然上面完全有效,因?yàn)槲覀儍H僅對從每個(gè)實(shí)例提取單個(gè)值有興趣,但我們真的不需要閉包的全部能力,因此使用 key paths 可能非常適合。
我們將首先擴(kuò)展 Sequence 來添加 map 的重載,該 map 采用 key paths 而不是閉包。由于我們只對此用例的只讀屬性訪問感興趣,因此我們將使用標(biāo)準(zhǔn)的 KeyPath,并且實(shí)際執(zhí)行數(shù)據(jù)提取,我們將使用與給定鍵路徑的子項(xiàng)作為參數(shù)使用,如下所示:
- extension Sequence {
- func map<T>(_ keyPath: KeyPath<Element, T>) -> [T] {
- return map { $0[keyPath: keyPath] }
- }
- }
- 注意:如果您使用的 Swift 5.2 或更高版本,則不再需要上述擴(kuò)展,因?yàn)楝F(xiàn)在可以將 key paths 自動轉(zhuǎn)換為函數(shù)。
通過以上擴(kuò)展,我們現(xiàn)在能夠使用一個(gè)非常好的和簡單的語法來從任何序列中的每個(gè)元素中提取單個(gè)值,使得可以從之前轉(zhuǎn)換我們的示例:
- let articleIDs = articles.map(\.id)
- let articleSources = articles.map(\.source)
這是非常酷的,但是,當(dāng) key paths 真正開始發(fā)光時(shí),它們用于形成稍微復(fù)雜的表達(dá)式,例如在排序一系列值時(shí)。
標(biāo)準(zhǔn)庫能夠自動對包含 Sortable 元素的任何序列進(jìn)行排序,但對于所有其他類型,我們必須提供自己的排序閉包。但是,使用 key paths,我們可以通過基于 Comparable 的 key patsh 輕松添加用于對任何序列進(jìn)行排序的支持。就像之前一樣,我們將在序列 Sequence 協(xié)議中添加一個(gè)擴(kuò)展,將給定 key paths 轉(zhuǎn)換為排序表達(dá)式閉包:
- extension Sequence {
- func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>) -> [Element] {
- return sorted { a, b in
- return a[keyPath: keyPath] < b[keyPath: keyPath]
- }
- }
- }
使用上面的擴(kuò)展,我們現(xiàn)在能夠快速且輕松地對任何序列進(jìn)行排序,只需給出我們想要排序的 key paths。如果我們正在構(gòu)建任何形式的可排序列表的應(yīng)用程序 —— 例如包含播放列表的音樂應(yīng)用程序 —— 這非常方便,因?yàn)槲覀儸F(xiàn)在自由地對我們的列表進(jìn)行排序,甚至是嵌套的):
- playlist.songs.sorted(by: \.name)
- playlist.songs.sorted(by: \.dateAdded)
- playlist.songs.sorted(by: \.ratings.worldWide)
這樣做的似乎只是簡單地添加了一個(gè)語法糖,但可以制作一些更復(fù)雜的代碼處理的序列同時(shí)更容易閱讀,并且還可以幫助減少代碼復(fù)制,因?yàn)槲覀儸F(xiàn)在能夠?yàn)槿魏螌傩灾赜孟嗤呐判虼a。
不需要實(shí)例
雖然適量的語法糖很好,但是關(guān)鍵路徑的真正的威力來自于,它可以讓我們引用屬性而不必與任意的實(shí)例相關(guān)聯(lián)。延續(xù)使用之前的音樂主題,假設(shè)我們正在開發(fā)一個(gè)展示歌曲列表的 App - 并且在 UI 中為這個(gè)列表配置 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
- }
- }
再次聲明,上面的代碼沒有一點(diǎn)問題,但是我們期望以這樣的方式渲染其他的模型的概率非常的高(非常多的 tableView 的 cells 嘗試著去渲染標(biāo)題,副標(biāo)題以及圖片而不用去管他們代表的是什么模型)- 因此讓我們看看,我們能否用關(guān)鍵路徑的威力去創(chuàng)建一個(gè)共享的配置實(shí)現(xiàn),讓他可以被任意的模型使用。
讓我們創(chuàng)建一個(gè)名叫 CellConfigurator 的泛型,然后因?yàn)槲覀兿胍貌煌哪P腿ヤ秩静煌臄?shù)據(jù),所以我們將會給它提供一組基于關(guān)鍵路徑的屬性 - 我們先渲染其中的一個(gè)數(shù)據(jù):
- 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]
- }
- }
上面的實(shí)現(xiàn)優(yōu)雅的地方在于,我們現(xiàn)在可以為每個(gè)模型定制我們的 CellConfigurator,使用相同的輕量的關(guān)鍵路徑語法,如下所示:
- let songCellConfigurator = CellConfigurator<Song>(
- titleKeyPath: \.name,
- subtitleKeyPath: \.artistName,
- imageKeyPath: \.albumArtwork
- )
- let playlistCellConfigurator = CellConfigurator<Playlist>(
- titleKeyPath: \.title,
- subtitleKeyPath: \.authorName,
- imageKeyPath: \.artwork
- )
就像標(biāo)準(zhǔn)庫中的 map 和 sorted 等函數(shù)的操作一樣,我們曾經(jīng)可能會使用閉包去實(shí)現(xiàn) CellConfigurator。然而,通過關(guān)鍵路徑,我們能夠使用一個(gè)非常好的語法去實(shí)現(xiàn)它 - 并且我們也不需要任何的訂制化的操作去不得不通過模型實(shí)例去處理 - 使它們變得更加的簡單,更加的具有說服力。
轉(zhuǎn)化為函數(shù)
目前為止,我們僅僅使用關(guān)鍵路徑來讀取值 - 現(xiàn)在讓我們看看我們?nèi)绾问褂盟鼈儊韯討B(tài)的寫值。在很多不同的代碼中,我們常常可以見到一些像下面的代碼一樣的列子 - 我們通過這段代碼來加載一系列的事項(xiàng),然后在 ListViewController 中去渲染它們,然后當(dāng)加載操作完成后,我們會簡單的將加載的事項(xiàng)賦值給視圖控制器中的屬性。
- class ListViewController {
- private var items = [Item]() { didSet { render() } }
- func loadItems() {
- loader.load { [weak self] items in
- self?.items = items
- }
- }
- }
讓我們看看,通過關(guān)鍵路徑賦值能否讓上面的語法簡單一點(diǎn),并且能夠移除我們經(jīng)常使用的 weak self 的語法(如果我們忘記對 self 的引用前加上 weak 關(guān)鍵字的話,那么就會產(chǎn)生循環(huán)引用)。
既然所有上面我們做的事情都是獲取傳遞給我們閉包的值,并將它賦值給視圖控制器中的屬性 - 那么如果我們真的能夠?qū)傩缘?setter 作為函數(shù)傳遞,會不會很酷呢?這樣我們就可以直接將函數(shù)作為完成閉包傳遞給我們的加載方法,然后所有的事情都會正常執(zhí)行。
為了實(shí)現(xiàn)這一目標(biāo),首先我們先定義一個(gè)函數(shù),讓任意的可寫的轉(zhuǎn)化為一個(gè)閉包,然后為關(guān)鍵路徑設(shè)置屬性值。為此,我們將會使用 ReferenceWritableKeyPath 類型,因?yàn)槲覀冎幌氚阉拗茷橐妙愋?否則的話,我們只會改變本地屬性的值)。給定一個(gè)對象,以及給這個(gè)對象設(shè)置關(guān)鍵路徑,我們將會自動將捕獲的對象作為弱引用類型,一旦我們的函數(shù)被調(diào)用,我們就會給匹配關(guān)鍵路徑的屬性賦值。就像這樣:
- func setter<Object: AnyObject, Value>(
- for object: Object,
- keyPath: ReferenceWritableKeyPath<Object, Value>
- ) -> (Value) -> Void {
- return { [weak object] value in
- object?[keyPath: keyPath] = value
- }
- }
使用上面的代碼,我們可以簡化之前的代碼,將弱引用的 self 去除,然后用看起來非常簡潔的語法結(jié)尾:
- class ListViewController {
- private var items = [Item]() { didSet { render() } }
- func loadItems() {
- loader.load(then: setter(for: self, keyPath: \.items))
- }
- }
非常酷有沒有!或許它還能變得更加的酷,當(dāng)上面的代碼跟更加先進(jìn)的函數(shù)式編程思想結(jié)合在一起的時(shí)候,如組合函數(shù) - 因此我們現(xiàn)在可以將多個(gè) setter 函數(shù)和其他的函數(shù)鏈接在一起使用。在接下來的文章中,我們將介紹函數(shù)式編程和組合函數(shù)。
總結(jié)
首先,看起來如何以及何時(shí)去使用 swift 關(guān)鍵路徑這樣的功能有點(diǎn)困難,并且很容易將它們看做是簡單的語法糖。能夠使用更加動態(tài)的方法去引用屬性是一件非常強(qiáng)大的事情,即使閉包通常可以做很多類似的事情,但是輕量的語法以及關(guān)鍵路徑的聲明,都使他們能夠成為處理非常多種類的數(shù)據(jù)的好的匹配。