Last active
April 17, 2022 17:51
-
-
Save rnapier/90072db9e741a181eb9eb0481dde08c5 to your computer and use it in GitHub Desktop.
New ideas on Observer pattern in Swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
// Lots of ideas here that I'd love thoughts about. Some of this creates new highly generic types | |
// (UniqueIdentifier, IdentifiedSet) that may have broader purpose. Some of it defines a new Observer pattern. | |
// I've been playing around with this UniqueIdentifier type. Its purpose is to let you store things | |
// in dictionaries (or possibly sets) that you couldn't otherwise. | |
// It's just a self-referencial ObjectIdentifier. I used to use NSUUID for this purpose, but wondering | |
// if this is better. | |
final class UniqueIdentifier: Hashable, Equatable { | |
private lazy var _identifier: ObjectIdentifier = ObjectIdentifier(self) | |
var hashValue: Int { return _identifier.hashValue } | |
} | |
func == (lhs: UniqueIdentifier, rhs: UniqueIdentifier) -> Bool { | |
return lhs._identifier == rhs._identifier | |
} | |
// | |
// | |
// | |
// I'm using it in this IdentifiedSet experiment. An IdentifiedSet is a set of anything (not just | |
// Hashables). When items are inserted, an identifier is returned that can be used to remove it. | |
// NOTE: @connerk points out that this isn't very "set-like." A better name may be "Bag." https://en.wikipedia.org/wiki/Multiset | |
struct IdentifiedSet<Element> { | |
private var _elements: [UniqueIdentifier: Element] = [:] | |
// Adds an item, returning an identifier | |
mutating func insert(_ element: Element) -> UniqueIdentifier { | |
let identifier = UniqueIdentifier() | |
_elements[identifier] = element | |
return identifier | |
} | |
// Removes an item, based on its identifier | |
mutating func remove(_ identifier: UniqueIdentifier) { | |
_elements[identifier] = nil | |
} | |
} | |
// It's not hard to make IdentifiedSet a Collection; see below. Just didn't want to mix it in here because I don't need it. | |
extension IdentifiedSet: Sequence { | |
func makeIterator() -> AnyIterator<Element> { | |
return AnyIterator(_elements.values.makeIterator()) | |
} | |
} | |
// | |
// | |
// | |
// What good is this? Here's an very simple observer pattern using it. | |
// First, we define a NotificationHandler | |
protocol NotificationHandler { | |
associatedtype Notification | |
func notify(_ notification: Notification) | |
} | |
// And we wrap IdentifiedSet in a simple Observers type | |
struct Observers<Handler: NotificationHandler> { | |
private var _observers = IdentifiedSet<Handler>() | |
// "add" matches other common Observer patterns better than "insert" | |
// The use of a set is an implementation detail | |
mutating func add(_ observer: Handler) -> UniqueIdentifier { | |
return _observers.insert(observer) | |
} | |
// remove is very often called with an Optional, so it's nice to accept it | |
mutating func remove(_ identifier: UniqueIdentifier?) { | |
if let identifier = identifier { | |
_observers.remove(identifier) | |
} | |
} | |
func notify(_ notification: Handler.Notification) { | |
for observer in _observers { | |
observer.notify(notification) | |
} | |
} | |
} | |
// | |
// | |
// | |
// Here's a simple observer. It's a struct of closures. | |
struct LoginHandler { | |
var didLogin: () -> Void | |
var didLogout: () -> Void | |
} | |
// And it's a NotificationHandler. In practice I expect this often to be a switch on an enum. | |
extension LoginHandler: NotificationHandler { | |
func notify(_ isLoggedIn: Bool) { | |
if isLoggedIn { | |
didLogin() | |
} else { | |
didLogout() | |
} | |
} | |
} | |
// And we have a class that is observable | |
final class LoginManager { | |
static let sharedManager = LoginManager() | |
var isLoggedIn = false { | |
didSet { | |
observers.notify(isLoggedIn) // I like how simple, yet explicit, this winds up being. | |
} | |
} | |
// Adding and removing observers is very easy. Just obj.observers.add(...), obj.observers.remove(...). | |
// No wrapper functions are needed. | |
// It's also very scalable. You could have obj.somethingObservers and obj.somethingElseObservers trivially. | |
// The one danger I see is that it's possible for a caller to completely replace the observers collection. | |
// That danger, if it really is one, could be fixed by making Observers a class and 'let' here. | |
// Of course it's also possible for a caller to notify the observers, which would be odd, but not really a "problem." | |
var observers = Observers<LoginHandler>() | |
func logout() { | |
isLoggedIn = false | |
} | |
func login() { | |
isLoggedIn = true | |
} | |
} | |
// And we have an observer | |
class LoginViewController { | |
weak var messageLabel: UILabel! = nil // Just for demonstration | |
var loginManager: LoginManager { return LoginManager.sharedManager } | |
var loginObserver: UniqueIdentifier? = nil | |
// Start observing | |
func viewDidAppear(_ animated: Bool) { | |
let messageLabel = self.messageLabel! // no self, no headaches | |
loginObserver = loginManager.observers.add( | |
LoginHandler( | |
didLogin: { messageLabel.text = "We are logged in" }, | |
didLogout: { messageLabel.text = "We are NOT logged in" })) | |
} | |
// Stop observing | |
func viewDidDisappear(_ animated: Bool) { | |
loginManager.observers.remove(loginObserver) // This is that Optional case I was talking about | |
} | |
@IBAction func login(_ sender: AnyObject) { | |
loginManager.login() | |
} | |
} | |
// In practice, extensions can make this even nicer. For instance, if you don't want to implement | |
// separate didLogin/didLogout methods, create a new init() | |
extension LoginHandler { | |
init(didChangeLoginState: (Bool) -> Void) { | |
didLogin = { didChangeLoginState(true) } | |
didLogout = { didChangeLoginState(false) } | |
} | |
} | |
// Then you can have code like: | |
//override func viewDidAppear(_ animated: Bool) { | |
// loginObserver = loginManager.observers.add(LoginHandler(didChangeLoginState: updateMessage)) | |
//} | |
// | |
//func updateMessage(loggedIn: Bool) { | |
// messageLabel.text = "We are\(loggedIn ? "" : "NOT") logged in" | |
//} | |
// The power of this approach is that you can offer many equivalent ways of observing, and the caller | |
// can easily create new ways of observing by creating new init's on the handler. You could easily | |
// offer a delegate interface with init(delegate: DelegateProtocol), or provide default {} handlers to | |
// allow optional protocols. | |
// | |
// Other stuff | |
// | |
// See also https://gist.github.com/rnapier/e973e0821c5bb0aa863c for an older and I think less powerful version of this approach. | |
// I think this is the correct way to make IdentifiedSet into a Collection. Is there a better approach these days? | |
struct IdentifiedSetIndex<Element> { | |
private var _index: DictionaryIndex<UniqueIdentifier, Element> | |
} | |
extension IdentifiedSetIndex: Comparable {} | |
func < <Element>(lhs: IdentifiedSetIndex<Element>, rhs: IdentifiedSetIndex<Element>) -> Bool { | |
return lhs._index < rhs._index | |
} | |
func == <Element>(lhs: IdentifiedSetIndex<Element>, rhs: IdentifiedSetIndex<Element>) -> Bool { | |
return lhs._index == rhs._index | |
} | |
extension IdentifiedSet: Collection { | |
typealias Index = IdentifiedSetIndex<Element> | |
var startIndex: Index { return Index(_index: _elements.startIndex) } | |
var endIndex: Index { return Index(_index: _elements.endIndex) } | |
subscript (position: Index) -> Element { | |
return _elements.values[position._index] | |
} | |
func index(after i: Index) -> Index { | |
return IdentifiedSetIndex(_index: _elements.index(after: i._index)) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Agreed... IdentifiedSet is a bit confusing. Just a dictionary where the keys are determined for you, right? I had to lookup Bag/ Multiset, but it sounds like the correct terminology.
I like where this ends up, but stumbling on the intermediary nature of LoginHandler. Could this simply be a closure, or self assuming I conform to a protocol?
I'm gonna abdicate on collection conformance, as it's hurting my brain.