2

I'm looking to make an observable property without relying on a reactive 3rd party lib / framework.

I read this and came up with a similar solution to their Observable Properties answer...

https://blog.scottlogic.com/2015/02/11/swift-kvo-alternatives.html

Theirs

class Observable<T> {

  let didChange = Event<(T, T)>()
  private var value: T

  init(_ initialValue: T) {
    value = initialValue
  }

  func set(newValue: T) {
    let oldValue = value
    value = newValue
    didChange.raise(oldValue, newValue)
  }

  func get() -> T {
    return value
  }
}

Mine

public class Observable<V> {

    public var value: V { didSet { for observer in observers { observer(value) } }}
    private var observers = [(V) -> Void]()

    public init(_ initital: V) {
        value = initital
    }

    public func observe(with closure: @escaping (V) -> Void) {
        observers.append(closure)
    }
}

the only difference is I want to capture an array of closures instead of using Event and addHander... the reason being I want to provide the syntax of passing the value through rather than have the consumers of my code make a function every time and again to not rely on any 3rd party code.

I'm not sure how these closures could automatically be removed from the array once their owners are deallocated. I'm guessing they can't which is why addHandler is used, I'm just hoping someone out there more knowledgable than me can shed some light on the issue.

Thanks for your time.

Magoo
  • 2,552
  • 1
  • 23
  • 43
  • FYI https://stackoverflow.com/questions/30633855/weak-reference-to-closure-in-swift – hackape Apr 16 '19 at 11:55
  • So the key difference is your impl accepts a closure, while the event version takes a function? – hackape Apr 16 '19 at 13:27
  • Why don't you use notification center which does the exact same thing? By the way, the trick used by notification center is to return an object which will remove the observer when deallocated. That means that the observer will exist while you hold that object strongly. – Sulthan Apr 16 '19 at 19:02
  • @hackape they also use some 3rd party framework `Event` for the KVO – Magoo Apr 17 '19 at 02:07
  • @Sulthan it's a good idea... let me update – Magoo Apr 17 '19 at 02:08
  • @hackape re: https://stackoverflow.com/questions/30633855/weak-reference-to-closure-in-swift the short answer is no for sure, but i'm wondering how the same effect could be made.. I think the notification way is the answer – Magoo Apr 17 '19 at 02:09
  • @Sulthan can you explain a little more? not sure how to do the thing you are suggesting – Magoo Apr 18 '19 at 05:47

3 Answers3

1

So I come up with this solution:

class Wrapper<V> {
    var observer: (V) -> Void
    public init(_ b: @escaping (V) -> Void) {
        observer = b
    }
}

class Observable<V> {
    public var value: V { didSet {
        let enumerator = observers.objectEnumerator()
        while let wrapper = enumerator?.nextObject() {
            (wrapper as! Wrapper<V>).observer(value)
        }
    }}
    private var observers = NSMapTable<AnyObject, Wrapper<V>>(keyOptions: [.weakMemory], valueOptions: [.strongMemory])

    public init(_ initital: V) {
        value = initital
    }

    public func observe(_ subscriber: AnyObject, with closure: @escaping (V) -> Void) {
        let wrapper = Wrapper(closure)
        observers.setObject(wrapper, forKey: subscriber)
    }
}

The final API require subscriber to identify themselves at calling:

Observable.observe(self /* <-- extra param */) { /* closure */ }

Although we cannot weak ref a closure, but with NSMapTable, we can weak ref the subscriber object, then use it as a weak key to track observer closure. This allow deallocation of subscriber thus automatically cleanup outdated observers.

Finally, here's the code for a demo. Expand the snippet and copy-paste to swift playground and see it live.

import Foundation

func setTimeout(_ delay: TimeInterval, block:@escaping ()->Void) -> Timer {
    return Timer.scheduledTimer(timeInterval: delay, target: BlockOperation(block: block), selector: #selector(Operation.main), userInfo: nil, repeats: false)
}

class Wrapper<V> {
    var observer: (V) -> Void
    public init(_ b: @escaping (V) -> Void) {
        observer = b
    }
}

class Observable<V> {
    public var value: V { didSet {
        let enumerator = observers.objectEnumerator()
        while let wrapper = enumerator?.nextObject() {
            (wrapper as! Wrapper<V>).observer(value)
        }
    }}
    private var observers = NSMapTable<AnyObject, Wrapper<V>>(keyOptions: [.weakMemory], valueOptions: [.strongMemory])

    public init(_ initital: V) {
        value = initital
    }
    
    public func observe(_ subscriber: AnyObject, with closure: @escaping (V) -> Void) {
        let wrapper = Wrapper(closure)
        observers.setObject(wrapper, forKey: subscriber)
    }
}

class Consumer {
    private var id: String

    public init(_ id: String, _ observable: Observable<Int>) {
        self.id = id
        observable.observe(self) { val in
            print("[\(id)]", "ok, i see value changed to", val)
        }
    }
    
    deinit {
        print("[\(id)]", "I'm out")
    }
}

func demo() -> Any {
    let observable = Observable(1)
    var list = [AnyObject]()

    list.append(Consumer("Alice", observable))
    list.append(Consumer("Bob", observable))
    
    observable.value += 1

    // pop Bob, so he goes deinit
    list.popLast()
    
    // deferred
    setTimeout(1.0) {
        observable.value += 1
        observable.value += 1
    }

    return [observable, list]
}

// need to hold ref to see the effect
let refHolder = demo()

Edit:

As OP @Magoo commented below, the Wrapper object is not properly deallocated. Even though the subscriber object is successfully deallocated, and corresponding key is removed from NSMapTable, the Wrapper remain active as an entry held in NSMapTable.

Did a bit of test and found this is indeed the case, unexpectedly. Some further research reveal an unfortunate fact: it's a caveat in NSMapTable's implementation.

This post explain the reason behind thoroughly. Quote directly from Apple doc:

However, weak-to-strong NSMapTables are not currently recommended, as the strong values for weak keys which get zero’d out do not get cleared away (and released) until/unless the map table resizes itself.

Hmm, so basically Apple just think it's ok to keep them alive in memory until a resize takes place. Reasonable from GC strategy POV.

Conclusion: no chance it'd be handled if NSMapTables implementation remains the same.

However it shouldn't be a problem for most case. This Observer impl works as intended. And as long as the Wrapper don't do anything fishy and closure don't hold strong ref, only negative impact is just some extra memory footprint.

I do have a fix though, you can use weak -> weak map, so Wrapper as a weak value get dealloc too. But that'll require .observe() returns the Wrapper then Consumer get hold to a ref to it. I'm not keen on this idea, API not ergonomic to end user. I'd rather live with some memory overhead in favor of better API.

Edit 2:

I don't like the aforementioned fix cus the resulting API is not friendly. I saw no way around but @Magoo managed to NAIL IT! Using objc_setAssociatedObject API, which I never heard about before. Make sure to checkout his answer for detail, it's awesome.

hackape
  • 18,643
  • 2
  • 29
  • 57
  • 1
    For the record I don't think that API is bad at all, but in my implementation I changed the wording to `public func addObserver(_ object: AnyObject, closure: @escaping (V) -> Void) {` – Magoo Apr 18 '19 at 02:29
  • So, doing some tests reveals the `Wrapper` object is still hanging around... :( you can hold a weak ref to one and fire it's closure – Magoo Apr 18 '19 at 05:37
  • Edited your code snippet to my example, should be easy to remake in a viewController project – Magoo Apr 18 '19 at 05:44
  • @Magoo I'll look into it later – hackape Apr 18 '19 at 06:08
  • @Magoo see my update. I do have a fix though, you can use `weak -> weak` map, so `Wrapper` as a weak value get dealloc too. But that'll require `Consumer` to get hold to a ref to the `Wrapper` object. I'm not keen on this idea, not ergonomic to end user. – hackape Apr 18 '19 at 09:54
  • I agree with your assessment too... and I still like this implementation... I have an idea about tidying up inside... I'll share with you soon – Magoo Apr 19 '19 at 07:58
  • `setAssociatedObject` :) – Magoo Apr 19 '19 at 12:01
  • @Magoo Wow, I don't even know there exists such method. Good to know! Why don't they port it to swift, very useful tool. – hackape Apr 19 '19 at 12:38
  • @Magoo however I don't think it's good idea to append this information to my answer. It's already long enough, plus your finding is actually new, making it more than an "edit". Why don't you write a separate answer then I link to it? – hackape Apr 19 '19 at 12:41
  • Yeah me either until today! Super useful. Absolutely I will re-edit and add another answer... I just wanted to show you first. Thanks for all your effort with this question. – Magoo Apr 19 '19 at 13:46
  • @Magoo no pb man. I also learn new stuff, really worth it. Thank you for the good question – hackape Apr 19 '19 at 14:08
  • added my answer if you'd like to link to it – Magoo Apr 22 '19 at 03:58
  • @Magoo linked, awesome solution! – hackape Apr 22 '19 at 05:12
  • 1
    Cheers mate, really happy with how it turned out. Thanks again. – Magoo Apr 22 '19 at 05:13
1

OK so @hackape answer with objc_setAssociatedObject

public class Observable<V> {

    private class ClosureWrapper<V> {
        var closure: (V) -> Void
        public init(_ closure: @escaping (V) -> Void) {
            self.closure = closure
        }
    }

    private var observers = NSMapTable<AnyObject, ClosureWrapper<V>>(keyOptions: [.weakMemory], valueOptions: [.weakMemory])
    public var value: V { didSet { notify() } }

    public init(_ initital: V) {
        value = initital
    }

    public func addObserver(_ object: AnyObject, skipFirst: Bool = true, closure: @escaping (V) -> Void) {
        let wrapper = ClosureWrapper(closure)
        let reference = "observer\(UUID().uuidString)".replacingOccurrences(of: "-", with: "")
        observers.setObject(wrapper, forKey: object)

        // Giving the closure back to the object that is observing
        // allows ClosureWrapper to die at the same time as observing object
        objc_setAssociatedObject(object, reference, wrapper, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        if !skipFirst { closure(value) }
    }

    private func notify() {
        let enumerator = observers.objectEnumerator()
        while let wrapper = enumerator?.nextObject() { (wrapper as? ClosureWrapper<V>)?.closure(value) }
    }
}

This guy also remade the NSMapTable in Swift using a really similar method https://codereview.stackexchange.com/questions/85709/generic-nsmaptable-replacement-written-in-swift

Magoo
  • 2,552
  • 1
  • 23
  • 43
0

The easiest, and likely safest, solution would be use the exact implementation you have, but make sure all callers use [weak self] and guard that self still exists before performing any actions/side effects.

This way when the array of closures is executed, any that had their creator already dealloc will simply immediately return when called.

// called from outside class
observer.observe { [weak self] in 
    guard strongSelf = self else { return }

    // do work using `strongSelf`
}

If the observer is going to be used by a lot of instances that are constantly deallocating, I'd recommend adding a remove observer function. To do this you'd probably want to return a string on the observe call and then use it for removing the closure. Something along the lines of this:

public typealias ObserverIdentifier = String
public class Observable<V> {
    public var value: V { didSet { for observer in observers.values { observer(value) } }}
    private var observers = [ObserverIdentifier  : (V) -> Void]()

    public init(_ initital: V) {
        value = initital
    }

    @discardableResult public func observe(with closure: @escaping (V) -> Void) -> ObserverIdentifier {
        let identifier = UUID().uuidString
        observers[identifier] = closure
        return identifier
    }

    public func remove(identifier: ObserverIdentifier) {
        observers.removeValue(forKey: identifier)
    }
}

Because you are using [weak self], removing the observer on deallocation is simply a nice thing to do to avoid some additional no-ops, but still completely safe if not removed.

Casey
  • 6,531
  • 24
  • 43
  • Using a dictionary instead of an array is the first step. I am not sure you should use `discardableResult` because that means the observer cannot be ever removed. I would prefer the solution used by `NotificationCenter` which would remove the observer automatically unless you hold to the return value. – Sulthan Apr 16 '19 at 19:09
  • Casey I'm worried about the consumers of my API... yeah they should be using weak self but I want to throw the closure away when the owner dies... I want the burden to be with me not my consumers. @Sulthan I was thinking that too... I'm going to update my question with a notification example – Magoo Apr 17 '19 at 02:06
  • If the code is for consumers, then definitely don't have the `@discardableResult`! Internally I find it very useful and perfectly safe to use. – Casey Apr 17 '19 at 02:18