Please help switching from existentials to generics

I'm working on a piece of code the stripped down and simplified version of which is below. It has a Host (with associated HostState) and connected Clients that listen for host changes to update their states and also can change host state. Host and clients are reference objects. Host can have several clients of different types.

The stripped down and simplified code, 150 lines.
import Foundation

protocol HostState {}
protocol ClientState {}

/*
- Host should be parametrized with <HS: HostState>
- Client should be parametrized with <CS: ClientState, HS: HostState>
- Host has a heterogeneous list of clients
- Host & clients are reference types
*/

class Host {    // 😒 (should be more like `class Host<HS: HostState>`
    private var clients: [WeakRef] = [] // 😒 (should be WeakRef<???>)
    
    var _state: HostState! {
        didSet { changed() }
    }
    
    func update(_ cs: ClientState, client: Client) {
        client.update(_state, cs)
    }
    
    func listen(_ client: Client) {
        clean()
        clients.append(WeakRef(client))
        let cs = client.apply(_state)!
        client._state = cs
    }

    func unlisten(_ client: Client) {
        clients.removeAll { holder in
            holder.object === client
        }
        clean()
    }

    private func clean() {
        clients.removeAll { holder in
            holder.object == nil
        }
    }

    private func changed() {
        clean()

        var changes: [(Client, ClientState)] = []

        for client in clients {
            if let client = client.object as! Client? { // 😒 (cast)
                if let cs = client.apply(_state) {
                    changes.append((client, cs))
                }
            }
        }
        if !changes.isEmpty {
            let changes = changes
            changes.forEach { (client, cs) in
                client._state = cs
            }
        }
    }
}

class Client { // 😒 (should more like `class Client<CS: ClientState, HS: HostState>`)
    var cs: ClientState?
    weak var _host: Host?
    
    init(_ host: Host?) {
        self._host = host
        host?.listen(self)
    }
    
    var _state: ClientState! {
        didSet {
            _host?.update(_state, client: self)
        }
    }
    
    func apply(_ hs: HostState) -> ClientState? {
        fatalError("override")          // 😒 (perhaps unavoidable as there are no pure methods like in C++)
    }
    
    func update(_ hs: HostState, _ newCS: ClientState) {
        fatalError("override")          // 😒 (perhaps unavoidable as there are no pure methods like in C++)
    }
}

struct WeakRef {
    weak var object: AnyObject?
    init(_ object: AnyObject) {
        self.object = object
    }
}

struct ExampleHS: HostState {
    var foo: Int
    var bar: Int
    var baz: Int
}

struct ExampleCS: ClientState, Equatable {
    var foo: Int
    var bar: String
    var qux: Int
}

class ExampleHost: Host {
    var state: ExampleHS {              // 😒 (boilerplate)
        get { _state as! ExampleHS }    // 😒
        set { _state = newValue }       // 😒
    }                                   // 😒
}

class ExampleClient: Client {           // 😒 (should be more like Client<ExampleCS, ExampleHS>)
    
    var host: ExampleHost {             // 😒 (boilerplate)
        _host as! ExampleHost           // 😒
    }                                   // 😒
    var state: ExampleCS {              // 😒 (boilerplate)
        get { _state as! ExampleCS }    // 😒
        set { _state = newValue }       // 😒
    }                                   // 😒

    override func apply(_ hs: HostState) -> ClientState? { // 😒 should be `func apply(_ hs: ExampleHS) -> ExampleHS`
        let hs = hs as! ExampleHS           // 😒 (cast)
        let oldCS = self.cs as? ExampleCS   // 😒 (cast)
        let newCS = ExampleCS(foo: hs.foo, bar: String(hs.bar), qux: 0)
        guard oldCS != newCS else { return nil }
        self.cs = newCS
        return newCS
    }
    
    override func update(_ hs: HostState, _ newCS: ClientState) { // 😒 should be `func update(_ hs: ExampleHS, _ newCS: ExampleCS)`
        let hs = hs as! ExampleHS       // 😒 (cast)
        let newCS = cs as! ExampleCS    // 😒 (cast)
        cs = newCS
        host.state.foo = newCS.foo
        print("changed??")
    }
}

func test() {
    var host = ExampleHost()
    var client = ExampleClient(host)
    // ...
    RunLoop.current.run(until: .distantFuture)
}

test()

I don't like this code much (specific pieces marked with :unamused:), for example all those type casts, and the fact that existentials are less efficient than generics (although I don't know the scale of this inefficiency). I tried to make this code generic a few times but every time the result is getting longer and more complex than the current existential version and there are still a few "as!" casts here and there. Refactoring starts with the introduction of associated types, then benign "change T to any T" but then quickly degenerates into "Member cannot be used on value of protocol type; use a generic constraint instead" and likes. Perhaps part of complexity is that "Host" needs to store a heterogeneous list of clients?

Any ideas how to improve this code? I am deliberately showing more or less complete code (albeit simplified) as changing one simple thing (e.g. the type of method parameter from existential to associated type) has a cluster collateral damage effect everywhere.

Please note that certain aspects of this code are removed for brevity, perhaps to the extent some bits might not make much sense (e.g. client calling _host?.update(_state, client: self) while host immediately calls client back with client.update(_state, cs), while in real code the inner code is called on a different queue.

Cheers!

1 Like

Any takers?

I am using the above existential-based implementation for some time now and it works alright, although I wonder if I am missing anything (e.g. on the performance or type safety fronts), or, if there's nothing to worry about and that implementation is totally fine as is and it won't buy me much having it in generic form.

Heterogenous lists pretty much inherently require existentials (or subclasses), since static type information is never enough to fully describe the behavior you want. So you could make the types generic over the HostState, but either the ClientState stays existential, or you make Client itself into a protocol and have that be used existential-ly in Host.

3 Likes

Somehow I completely forgot subclasses option! Will try that.