Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Combine: can't use `.assign` with structs - why?

Tags:

swift

combine

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

like image 946
Kevin Renskers Avatar asked Apr 06 '20 09:04

Kevin Renskers


2 Answers

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
}
like image 60
Asperi Avatar answered Nov 12 '22 23:11

Asperi


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.

like image 4
Alex Pretzlav Avatar answered Nov 12 '22 23:11

Alex Pretzlav