10

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.)

8
  • 2
    Structs are immutable, and are passed by value, not by reference. Therefore they can't be reference writable. When you change var property in struct entire struct is replaced (in the parent's var property). Commented Apr 6, 2020 at 10:26
  • But if structs are immutable, then why does the .sink version work? That is mutating the women property just fine. After all that is a var (and so is passengers). So if the sink version can do it, why not the assign version? I feel like I am missing a fundamental piece of understanding here. Commented Apr 6, 2020 at 10:30
  • When you mutate women property entire passengers var of the Controller instance gets recreated (struct mutation doesn't change it rather create new one with some data changed and rest copied), you're essentially setting new value to controller's property. That's allowed. ReferenceWritableKeyPath would try to mutate just women property via reference. And you can't do anything via reference with structure. Commented Apr 6, 2020 at 10:46
  • 1
    Nope. ReferenceWritableKeyPath and struct won't work at all, no way. WritableKeyPath — maybe, probably, I haven't tried that. Commented Apr 6, 2020 at 11:44
  • 1
    @KevinRenskers sink works because what's being captured is self, not self.passengers. Setting aside the concerns about the retain cycle assign can cause if you do assign(to <anything>, on: self).store(in: &self.cancellables), sink { self.passengers.women = someNewValue works like assign(to: \passengers.women, on: self). It captures self (a reference type, of type Controller). contrast this with assign(to: \.women, on: self.passengers), which captures a value type (passesngers, of type Passengers). Commented Apr 4, 2021 at 16:55

2 Answers 2

6

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 } 
Sign up to request clarification or add additional context in comments.

Comments

5

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.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.