I'm seeing some struct vs class behavior that I don't really don't understand, when trying to assign a value using Combine.
Code:
import Foundation
import Combine
struct Passengers {
var women = 0
var men = 0
}
class Controller {
@Published var passengers = Passengers()
var cancellables = Set<AnyCancellable>()
let minusButtonTapPublisher: AnyPublisher<Void, Never>
init() {
// Of course the real code has a real publisher for button taps :)
minusButtonTapPublisher = Empty<Void, Never>().eraseToAnyPublisher()
// Works fine:
minusButtonTapPublisher
.map { self.passengers.women - 1 }
.sink { [weak self] value in
self?.passengers.women = value
}.store(in: &cancellables)
// Doesn't work:
minusButtonTapPublisher
.map { self.passengers.women - 1 }
.assign(to: \.women, on: passengers)
.store(in: &cancellables)
}
}
The error I get is Key path value type 'ReferenceWritableKeyPath<Passengers, Int>' cannot be converted to contextual type 'WritableKeyPath<Passengers, Int>'
.
The version using sink
instead of assign
works fine, and when I turn Passengers
into a class, the assign
version also works fine. My question is: why does it only work with a class? The two versions (sink and assign) really do the same thing in the end, right? They both update the women
property on passengers
.
(When I do change Passengers
to a class, then the sink
version no longer works though.)
Actually it is explicitly documented - Assigns each element from a Publisher to a property on an object. This is a feature, design, of Assign
subscriber - to work only with reference types.
extension Publisher where Self.Failure == Never {
/// Assigns each element from a Publisher to a property on an object.
///
/// - Parameters:
/// - keyPath: The key path of the property to assign.
/// - object: The object on which to assign the value.
/// - Returns: A cancellable instance; used when you end assignment of the received value. Deallocation of the result will tear down the subscription stream.
public func assign<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable
}
The answer from Asperi is correct in so far as it explains the framework's design. The conceptual reason is that since passengers
is a value type, passing it to assign(to:on:)
would cause the copy of passengers passed to assign
to be modified, which wouldn't update the value in your class instance. That's why the API prevents that. What you want to do is update the passengers.women
property of self
, which is what your closure example does:
minusButtonTapPublisher
.map { self.passengers.women - 1 }
// WARNING: Leaks memory!
.assign(to: \.passengers.women, on: self)
.store(in: &cancellables)
}
Unfortunately this version will create a retain cycle because assign(to:on:)
holds a strong reference to the object passed, and the cancellables
collection holds a strong reference back. See How to prevent strong reference cycles when using Apple's new Combine framework (.assign is causing problems) for further discussion, but tl;dr: use the weak self block based version if the object being assigned to is also the owner of the cancellable.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With