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

Kevin Renskers
  • 5,156
  • 4
  • 47
  • 95
  • 2
    `Struct`s 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). – user28434'mstep Apr 06 '20 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. – Kevin Renskers Apr 06 '20 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. – user28434'mstep Apr 06 '20 at 10:46
  • Alright, clear. Thanks! So I guess in theory it should be possible to create a version of .assign that would work with a ReferenceWritableKeyPath? – Kevin Renskers Apr 06 '20 at 11:42
  • 1
    Nope. `ReferenceWritableKeyPath` and `struct` won't work at all, no way. `WritableKeyPath` — maybe, probably, I haven't tried that. – user28434'mstep Apr 06 '20 at 11:44
  • Sorry, yes, I meant WritableKeyPath. I'll have a play around with this at some point. Anyway, thanks for the answers! If you turn it into an actual answer I can accept it. – Kevin Renskers Apr 06 '20 at 11:49
  • @KevinRenskers I think you're missing the point of `ReferenceWritableKeyPath`. It exists precisely because there are contexts in which keypaths on value types like structs won't be useful. You can trivially write a version of `assign` that takes a `WritableKeyPath`. But what then? `assign` will need the `object` parameter (the one labeled `on:`) to hold on to, whose properties will be written to later using the keypath you provided. If you pass a reference type (an object that's an instance of a class), this will work. But if you pass a struct, it'll be copied, and it'll be mutating that copy. – Alexander Apr 04 '21 at 16:51
  • 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 , 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`). – Alexander Apr 04 '21 at 16:55

2 Answers2

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
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
4

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.

Alex Pretzlav
  • 15,505
  • 9
  • 57
  • 55