Model changes propagation [Was: Synchronous version of a "sink" call]

Is there a better way of getting the published value synchronously?

class C {
    @Published var value: Int = 123
    
    func foo() {
        let v = getValue($value)
        print(v)
    }
    
    func getValue(_ v: Published<Int>.Publisher) -> Int? {
        var val: Int? = nil
        _ = v.sink {
            val = $0
        }
        return val // 123
    }
}

let c = C()
c.foo()

The lines:

         _ = v.sink {
            val = $0
        }

don't feel right to me: is there a guarantee that in case of ready publishers (like Int = 123) the "sink" call closure is called synchronously? (by that I mean "during duration of the sink call itself). Or is there a more direct way of doing it?

I saw a similar question about a month ago on StackOverflow. Is the answer there also applicable to your use case? combine - How do I get the value of a Published<String> in swift without using debugDescription? - Stack Overflow

1 Like

I could be barking up the wrong tree here..

I have these model classes:

import SwiftUI
import Combine

class BaseModel {
    @Published var someVal: Int = 123
}

class SubModel: ModelWithSubscribers, ObservableObject {
    @Published var someVal: Int = 0
    private var baseModel = BaseModel()
    var cancellables = Set<AnyCancellable>()
    
    init() {
        baseModel.$someVal
            .receive(on: DispatchQueue.main)
            .sink {
                if self.someVal != $0 {
                    self.someVal = $0
                }
            }
            .store(in: &cancellables)
    }
}

where base model change is replicated in the sub model. This is ok-ish (aside from sort of truth duplication) but I'd like to get rid of the boilerplate in the initialiser. With this helper:

protocol ModelWithSubscribers: AnyObject {
    var cancellables: Set<AnyCancellable> { get set }
    func bind<T: Publisher>(_ publisher: T, to binding: Binding<T.Output>) where T.Output: Equatable
}

extension ModelWithSubscribers {
    func bind<T: Publisher>(_ publisher: T, to binding: Binding<T.Output>) where T.Output: Equatable {
        publisher
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { error in
                fatalError("\(error)")
            }, receiveValue: { output in
                if binding.wrappedValue != output {
                    binding.wrappedValue = output
                }
            })
            .store(in: &cancellables)
    }
}

I managed to reduce that boilerplate down to this line:

    bind(baseModel.$someVal, to: .init { self.someVal } set: { self.someVal = $0 })

Is this the best I can do? Ideally I'd like it to look like so:

    bind(baseModel.$someVal, to: $self.someVal)

Found this key-path based solution:

protocol ModelWithSubscribers: AnyObject {
    var cancellables: Set<AnyCancellable> { get set }
    func bind<Root, T: Publisher>(_ publisher: T, to keyPath: ReferenceWritableKeyPath<Root, T.Output>) where T.Failure == Never, T.Output: Equatable, Self == Root
    // 🟨 Same-type requirement makes generic parameters 'Root' and 'Self' equivalent; this is an error in Swift 6
}

extension ModelWithSubscribers {

    func bind<Root, T: Publisher>(_ publisher: T, to keyPath: ReferenceWritableKeyPath<Root, T.Output>) where T.Failure == Never, T.Output: Equatable, Self == Root {
        publisher
            .receive(on: DispatchQueue.main)
            .sink(receiveValue: { output in
                if self[keyPath: keyPath] != output {
                    self[keyPath: keyPath] = output
                }
            })
            .store(in: &cancellables)
    }
}

Usage:

        bind(baseModel.$someVal, to: \.someVal)

Can fix the warning by charging Root to Self:

protocol ModelWithSubscribers: AnyObject {
    var cancellables: Set<AnyCancellable> { get set }
    func bind<T: Publisher>(_ publisher: T, to keyPath: ReferenceWritableKeyPath<Self, T.Output>) where T.Failure == Never, T.Output: Equatable
}

but then I have to make "SubModel" class final as getting this error:

class SubModel: ModelWithSubscribers, ObservableObject { // 🛑 Protocol 'ModelWithSubscribers' requirement 'bind(_:to:)' cannot be satisfied by a non-final class ('SubModel') because it uses 'Self' in a non-parameter, non-result type position

Not sure if making this class final is ok or bad for me - will cross that bridge when get to it.

You could just used the non projected value of the publisher:

    func foo() {
        let v = value
        print(v)
    }

That will be the value of the published property at that instant. Or are you really wanting to get it out of a Published<Int>.Publisher?

Sorry that was red herring: initially I tried to pass a variable like so: "$someValue" - but that didn't give me the wanted binding. I supposed that I could have used a published type instead hence the original question, but looks like that was the wrong approach to the problem; found some working key-path based solution above.

That example with the key paths does have a reference cycle in the sink btw; it captures self which is then stored as a handle in the cancelables. So you might want to find some nice way to break that if that is meaningful to your object graph.

1 Like