[譯]Swift 結構體指標

Swants發表於2017-08-30

結構體指標

所有的程式碼都可以在 gist 上獲取。

最近我打算為 Swift 的最新的 keypaths 找一個好的使用場景,這篇文章介紹了我意外獲得的一個使用示例。這是我剛研究出來的,但還沒實際應用在生產程式碼上的成果。也就是說,我只是覺得這個成果非常酷並想把它展示出來。

思考一個簡單的通訊錄應用,這個應用包含一個展示聯絡人的列表檢視和展示聯絡人例項的詳情檢視控制器。如果把 定義成一個類的話,大概是這個樣子:

class Person {
    var name: String
    var addresses: [Address]
    init(name: String, addresses: [Address]) {
        self.name = name
        self.addresses = addresses
    }
}

class Address {
    var street: String
    init(street: String) {
        self.street = street
    }
}複製程式碼

我們的(假設)viewController 有一個通過初始化方法設定的 person 屬性。這個類還有一個 change 方法來修改這個人的屬性。

final class PersonVC {
    var person: Person
    init(person: Person) {
        self.person = person
    }

    func change() {
        person.name = "New Name"
    }
}複製程式碼

讓我們思考下當 Person 初始化為一個物件後遇到的問題:

  • 因為 person 是一個指標,其他部分的程式碼就可能修改它。這是非常實用的,因為這讓訊息傳遞成為了可能。而與此同時,我們需要保證我們可以一直監聽的到這些改變(比如使用 KVO ),否則我們可能會遇到資料不同步的問題。但保證我們能夠實時監聽則是不容易實現的。
  • 當地址發生變化時,收到通知就更難了。觀察巢狀的物件屬性則是最困難的。
  • 如果我們需要給 Person 建立一個獨立的本地 copy,我們就需要實現一些像 NSCopying 這樣的東西, 這需要不少的工作量。甚至當我們決定這麼做時,我們仍然不得不考慮是想要深拷貝(地址也被拷貝)還是淺拷貝(地址陣列是獨立的,但是裡面的地址仍指向相同的物件)?
  • 如果我們把 Person 當成 AddressBook 陣列的元素,我們可能想要知道通訊錄什麼時候做了修改(比如說進行排序)。而想要知道你的物件圖中的東西何時做了改變要麼需要大量的樣板,要麼需要大量的觀察。

如果 PersonAddress 做成結構體的話,我們又會碰到不同的問題:

  • 每個結構體都是獨立的拷貝。這是有用的,因為我們知道它總是一致的,不會在我們手底下改變。然而,當我們在詳情控制器 中對 Person 做了修改時。我們就需要一個方法來將這些改變反饋給列表檢視(或者說通訊錄列表)。而對於物件,這種情況會自動發生(通過在適當的位置修改 Person )。
  • 我們可以觀察通訊錄結構體的根地址,從而知道通訊錄發生的任何變化。然而,我們還是不能很容易得觀察到它內部屬性的變化(比如:觀察第一個人的名字)。

我現在提出的解決方案結合了兩個方案的最大優勢:

  • 我們有可變的共享指標
  • 因為底層資料是結構體,所以我們可以隨時得到我們自己的獨立拷貝
  • 我們可以觀察任何部分:無論在根級別,還是觀察獨立的屬性(例如第一個人的名字)

我接下來會演示這個方案怎麼使用,如何工作,最後再說說方案的侷限性和問題。

讓我們用結構體來建立一個通訊錄。

struct Address {
    var street: String
}
struct Person {
    var name: String
    var addresses: [Address]
}

typealias Addressbook = [Person]複製程式碼

現在我們可以使用我們的 Ref 型別( Reference 的簡稱)。
我們用一個初始化的空陣列來建立一個新的 addressBook。然後新增一個 Person 。接下來就是最酷的地方:通過使用下標我們可以獲得指向第一個人的 指標 ,接著是一個指向他們名字的 指標 。我們可以將指標指向的內容改為 “New Name" 來驗證我們是否更改了原始的通訊錄。

let addressBook = Ref<Addressbook>(initialValue: [])
addressBook.value.append(Person(name: "Test", addresses: []))
let firstPerson: Ref<Person> = addressBook[0]
let nameOfFirstPerson: Ref<String> = firstPerson[\.name]
nameOfFirstPerson.value = "New Name"
addressBook.value // shows [Person(name: "New Name", addresses: [])]複製程式碼

firstPersonnameOfFirstPerson 型別可以被忽略,它們僅僅是為了增加程式碼可讀性。

無論何時我們都可以對 Person 內容進行獨立備份。一旦你做了拷貝,我們就可以使用 myOwnCopy ,並且不必實現 NSCopying 就能保證它的內容不會在我們手底下改變:

var myOwnCopy: Person = firstPerson.value複製程式碼

我們可以監聽任何 Ref 。就像 reactive 庫一樣,我們得到了一個可以控制觀察者生命週期的一次性呼叫:

var disposable: Any?
disposable = addressBook.addObserver { newValue in
    print(newValue) // Prints the entire address book
}

disposable = nil // stop observing複製程式碼

我們也可以監聽 nameOfFirstPerson 。在目前的實現中,無論什麼時候通訊錄中的任何改變都會觸發監聽,但以後的實現會有更多的功能。

nameOfFirstPerson.addObserver { newValue in
    print(newValue) // Prints a string
}複製程式碼

讓我們返回我們的 PersonVC 。我們可以使用 Ref 作為他的實現。 這樣 viewController 就可以收到每一次更改。在響應式程式設計中,訊號通常是隻讀型別的(你只會收到發生了變化的資訊),這時你就需要找到另一種回傳訊號的方法。 在 Ref 方案中,我們可以使用 person.value 進行回寫:

final class PersonVC {
    let person: Ref<Person>
    var disposeBag: Any?
    init(person: Ref<Person>) {
        self.person = person
        disposeBag = person.addObserver { newValue in
            print("update view for new person value: \(newValue)")
        }
    }

    func change() {
        person.value.name = "New Name"
    }
}複製程式碼

這個 PersonVC 不知道 Ref <Person>是從哪裡獲得的:是從一個 person 陣列,一個資料庫或者其他地方。實際上,我們可以通過將我們的陣列包裝在 History 結構體 中來撤銷對我們通訊錄的支援。
這樣我們就不再需要修改 PersonVC

let source: Ref<History<Addressbook>> = Ref(initialValue: History(initialValue: []))
let addressBook: Ref<Addressbook> = source[\.value]
addressBook.value.append(Person(name: "Test", addresses: []))
addressBook[0].value.name = "New Name"
print(addressBook[0].value)
source.value.undo()
print(addressBook[0].value)
source.value.redo()複製程式碼

我們還可以為它新增其他的很多東西:快取,序列化,自動同步(比如只在子執行緒上修改和觀察),但這都是之後的工作。

實現細節

我們來看看這個事情是如何實現的。我們首先從 Ref 類的定義開始。
Ref 包含一個獲取值和一個設定值的方法,以及新增一個觀察者的方法。它有一個需要三個引數的初始化方法:

final class Ref<A> {
    typealias Observer = (A) -> ()

    private let _get: () -> A
    private let _set: (A) -> ()
    private let _addObserver: (@escaping Observer) -> Disposable

    var value: A {
        get {
            return _get()
        }
        set {
            _set(newValue)
        }
    }

    init(get: @escaping () -> A, set: @escaping (A) -> (), addObserver: @escaping (@escaping Observer) -> Disposable) {
        _get = get
        _set = set
        _addObserver = addObserver
    }

    func addObserver(observer: @escaping Observer) -> Disposable {
        return _addObserver(observer)
    }
}複製程式碼

現在我們可以新增一個可以觀察單個結構體值的初始化方法。它建立了一個觀察者和變數對應的字典。這樣無論變數什麼時候被修改了,所有的觀察者都會被通知到。它使用上述定義的初始化方法,並傳遞給 get, set, 和 addObserver:

extension Ref {
    convenience init(initialValue: A) {
        var observers: [Int: Observer] = [:]
        var theValue = initialValue {
            didSet { observers.values.forEach { $0(theValue) } }
        }
        var freshId = (Int.min...).makeIterator()
        let get = { theValue }
        let set = { newValue in theValue = newValue }
        let addObserver = { (newObserver: @escaping Observer) -> Disposable in
            let id = freshId.next()!
            observers[id] = newObserver
            return Disposable {
                observers[id] = nil
            }
        }
        self.init(get: get, set: set, addObserver: addObserver)
    }
}複製程式碼

想一下我們現在已經有 Person 指標,為了拿到 Person name 屬性的指標,我們需要一種方式來對 name 進行讀寫操作。而 WritableKeyPath 恰好可以做到。因此,我們可以在 Ref 中新增一個subscript 來建立可以指向 Person 某一部分的指標:

extension Ref {
    subscript<B>(keyPath: WritableKeyPath<A,B>) -> Ref<B> {
        let parent = self
        return Ref<B>(get: { parent._get()[keyPath: keyPath] }, set: {
            var oldValue = parent.value
            oldValue[keyPath: keyPath] = $0
            parent._set(oldValue)
        }, addObserver: { observer in
            parent.addObserver { observer($0[keyPath: keyPath]) }
        })
    }
}複製程式碼

上面的程式碼有一點難於理解,但如果只是為了使用這個庫,我們不需要真的弄明白它是怎麼實現的。

也許某一天,Swift 中的 keypath 也會支援下標,但至少現在沒有,接下來我們必須為集合新增另外一個下標。除了使用索引而不是 keypath ,它的實現幾乎就跟上面的一樣。

extension Ref where A: MutableCollection {
    subscript(index: A.Index) -> Ref<A.Element> {
        return Ref<A.Element>(get: { self._get()[index] }, set: { newValue in
            var old = self.value
            old[index] = newValue
            self._set(old)
        }, addObserver: { observer in
                self.addObserver { observer($0[index]) }
        })
    }
}複製程式碼

這就是全部實現了。上面程式碼使用了 Swift 大量新特性,但它仍保持在 100 行程式碼以下。如果沒有 Swift 4 最新功能,這也基本不可能實現。它依賴於 keypaths ,通用下標,開放範圍以及以前在 Swift 中提供的許多功能。

討論

就如之前所提到的那樣,這些仍處於研究中而不是生產級的程式碼。一旦我開始在一個真正的應用程式中使用它,我非常感興趣想知道將來會遇到什麼樣問題。 下面就是其中一個讓我感到困惑的程式碼段::

var twoPeople: Ref<Addressbook> = Ref(initialValue:
    [Person(name: "One", addresses: []),
     Person(name: "Two", addresses: [])])
let p0 = twoPeople[0]
twoPeople.value.removeFirst()
print(p0.value) // what does this print?複製程式碼

我很有興趣將它更進一步。我甚至可以想象的到,如果我為他新增佇列支援,你就可以像下面那樣使用:

var source = Ref<Addressbook>(initialValue: [],
    queue: DispatchQueue(label: "private queue"))複製程式碼

我還能想象的到你可以用它和資料庫搭配使用。這個 Var 將會讓你同時支援讀寫操作,並訂閱任何修改的通知:

final class MyDatabase {
   func readPerson(id: Person.Id) -> Var<Person> {
   }
}複製程式碼

我期待著聽到您的評論和反饋,如果你需要更深入的理解它是如何工作的,試著自己去實現它(即便你已經看了程式碼)。順便提一下,我們將會以它為主題開展兩場 Swift Talk。如果你對 Florian 和我從頭開始構建這個專案感興趣,就訂閱它吧。

更新: 感謝 Egor Sobko 指出了一個微妙但卻至關重要的錯誤:我為觀察者傳送的是 initialValue 而不是 theValue,已修改!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章