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

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.

Related

In Swift, how to assign values when generic parameters are different

I am trying to subscribe to multiple publishers. The Output type of publishers may not be determined.
static func listen<T>(publisher: Published<T>.Publisher){
publisher.sink { _Arg in
// do something
}.store(in: &cancellables)
}
listen(publisher: env.$showMenuIcon)
listen(publisher: env.$dateFormatLunar)
listen(publisher: env.$dateFormatAd)
listen(publisher: env.$showWeek)
listen(publisher: env.$showWeather)
// in env class
#Published var timeItem = true
#Published var dateFormatAd = "yyyy-MM-dd"
Each of my publishers may have different generic parameter types, and I can only call listen by copying multiple lines of code like this. Is there any way to modify the Listen method to accept an array type? Or is there another way I can simplify my code?
First of all, I would advise you against your idea of a generic function for subscribing to publishers of different types. Why? Imagine that this is possible (and it is possible in principle, an example is below). How do you want to distinguish between different data types in your sink block? In my opinion, the only way to bring different data types under one roof and then still have the possibility to distinguish them from each other is to create your own data type, that is not generic. E.g. something like this:
struct Result {
let type: Type // E.g. an enum of possible types.
let value: Any
}
Then you have to look in your sink block each time for the data type of your value and casts it accordingly. In my opinion, this makes your logic very complicated. I am not a fan of universal functions. They are very often the big sources of errors.
An example/idea for you on how to realise your wish:
class MyClass {
// Common data type
typealias Value = Any
#Published var numberPublisher: Value?
#Published var stringPublisher: Value?
#Published var booleanPublisher: Value?
var subscription: AnyCancellable?
init() {
// Start listening of publishers.
listen(publishers: [
$numberPublisher,
$stringPublisher,
$booleanPublisher
])
}
func listen(publishers: [Published<Value?>.Publisher]) {
let mergedPublishers = Publishers.MergeMany(publishers)
subscription = mergedPublishers
// Skipping initial nil values and just not seeing them.
.dropFirst(publishers.count)
.sink(receiveCompletion: { completion in
print("Completion \(completion)")
}, receiveValue: { value in
print("Value \(String(describing: value))")
})
}
}
As I wrote above, I created a common data type for all my publishers. Even if your function is generic, you can not pass parameters of different types to it at the same time.
I tested my code in the Playground:
let myClass = MyClass()
myClass.numberPublisher = 123
myClass.stringPublisher = "ABC"
myClass.booleanPublisher = false
// Console output
Value Optional(123)
Value Optional("ABC")
Value Optional(false)
In your place I would subscribe to each publisher separately and directly, without any functions in between (the way you're already doing it).
Hopefully I could help you.

Swift Reference Cycle When Capturing A Computed String Property Declared From Inside An Instance Method

I'm implementing an in-game store using Swift and SpriteKit. There is a class called Store which has a method setupItems() inside of which we declare and instantiate instances of a class StoreItem and also add each store item instance as a child of Store. Each StoreItem has an optional closure property called updateInventory which is set inside of setupItems() as well. This is an optional closure because some items don't have a limited inventory.
Store also has an unowned instance property storeDelegate which is responsible for determining how funds are deducted and how storeItems are applied once purchased. storeDelegate is unowned as it has an equal or greater lifetime than Store.
Now, here is where things get interesting - the closure updateInventory references a computed string variable called itemText which makes a calculation based on a property of storeDelegate. If itemText is declared and instantiated as a variable inside of setupItems() we have a reference cycle and store is not deallocated. If instead, itemText is declared and instantiated as an instance property of Store (right under the property unowned storeDelegate that it references) then there is no reference cycle and everything deallocates when it should.
This seems to imply that referencing storeDelegate from a computed variable inside an instance method of a class doesn't respect the unowned qualifier. Code examples follow:
Current Scenario
protocol StoreDelegate: AnyObject {
func subtractFunds(byValue value: Int)
func addInventoryItem(item: InventoryItem) throws
var player: Player! { get }
}
class Store: SKSpriteNode, StoreItemDelegate {
unowned var storeDelegate: StoreDelegate
init(storeDelegate: StoreDelegate) {
self.storeDelegate = storeDelegate
setupItems()
...
}
func setupItems() {
var itemText: String {
return "item info goes here \(storeDelegate.player.health)"
}
let storeItem = StoreItem(name: "coolItem")
storeItem.updateInventory = {
[unowned storeItem, unowned self] in
// some logic to check if the update is valid
guard self.storeDelegate.player.canUpdate() else {
return
}
storeItem.label.text = itemText
}
}
The above leads to a reference cycle, interestingly if we move
var itemText: String {
return "item info goes here \(storeDelegate.player.health)"
}
outside of updateItems and make it an instance variable of Store right below unowned var storeDelegate: StoreDelegate, then there is no reference cycle.
I have no idea why this would be the case and don't see mention of it in the docs. Any suggestions would be appreciated and let me know if you'd like any additional details.
storeItem.updateInventory now keeps strong reference to itemText.
I think the issue is that itemText holds a strong reference to self implicitly in order to access storeDelegate, instead of keeping a reference to storeDelegate. Anothe option is that even though self is keeping the delegate as unowned, once you pass ot to itemText for keeping, it is managed (ie, strong reference again).
Either way, you can guarantee not keeping strong reference changing itemText to a function and pass the delegate directly:
func setupItems() {
func itemText(with delegate: StoreDelegate) -> String
return "item info goes here \(storeDelegate.player.health)"
}
let storeItem...
storeItem.updateInventory = {
// ...
storeItem.label.text = itemText(with self.storeDelegate)
}
}

How to chain together two Combine publishers in Swift and keep the cancellable object

Does anyone know how to chain together two publishers with Swift + Combine and keep the cancellable so that I can cancel the pipeline later?
I have a method that accepts input from a publisher and outputs to a publisher:
static func connect(inputPublisher: Published<String>.Publisher, outputPublisher: inout Published<String>.Publisher) -> AnyCancellable {
inputPublisher.assign(to: &outputPublisher)
// Unfortunately assign consumes cancellable
}
Note that this method cannot access the wrapped properties directly. The publishers must be passed as arguments.
If I understand correctly, you want to link two publishers but with the option to break that link at some point in the future.
I would try using sink on the inputPublisher, since that function gives me a cancellable, and then a PassthroughSubject, since I wasn't able to figure out how to pass the value from sink directly to outputPublisher.
It would look something like this:
static func connect(inputPublisher: Published<String>.Publisher, outputPublisher: inout Published<String>.Publisher) -> AnyCancellable {
let passthrough = PassthroughSubject<String, Never>()
passthrough.assign(to: &outputPublisher)
let cancellable = inputPublisher.sink { string in
passthrough.send(string)
}
return cancellable
}
Disclaimer: I wrote this on a Playground and it compiles, but I didn't actually run it.
You cannot do that with assign(to:), since it binds the lifetime of the subscription to the lifetime of the Published upstream (inputPublisher in your case) and hence you cannot manually cancel the subscription.
Instead, you can use assign(to:on:), which takes an object and a key path and whenever the upstream emits, it assigns the value to the property represented by the key path on the object.
inputPublisher.assign(to:on:)
class PublishedModel {
#Published var value: String
init(value: String) {
self.value = value
}
}
let outputModel = PublishedModel(value: "")
// this returns an `AnyCancellable` that you can cancel
PublishedModel(value: "initial").$value.assign(to: \.value, on: outputModel)
You can also create a convenience method for this
func assign<Root, Value>(published: Published<Value>.Publisher, to keyPath: ReferenceWritableKeyPath<Root, Value>, on object: Root) -> AnyCancellable {
published.assign(to: keyPath, on: object)
}
let outputModel = PublishedModel(value: "")
let inputModel = PublishedModel(value: "initial")
assign(published: inputModel.$value, to: \.value, on: outputModel)

Can Combine be used in struct (instead of class)?

When using Combine as below
var cancellables: [AnyCancellable] = []
func loadItems(tuple : (name : String, imageURL : URL)) {
URLSession.shared.dataTaskPublisher(for: tuple.imageURL)
.sink(
receiveCompletion: {
completion in
switch completion {
case .finished:
break
case .failure( _):
return
}},
receiveValue: { data, _ in DispatchQueue.main.async { [weak self] in self?.displayFlag(data: data, title: tuple.name) } })
.store(in: &cancellables)
}
We don't need to call cancel in the deinit as below
deinit {
cancellables.forEach {
$0.cancel()
}
}
Given that in https://developer.apple.com/documentation/combine/anycancellable, it is stated:
An AnyCancellable instance automatically calls cancel() when deinitialized.
Given we don't need to release during deinit, can the Combine be used in struct instead of class?
To answer your question directly, AnyCancellable does not rely on being stored in a class in order to cancel itself. Like any ref-counted object, it can be stored in a struct just fine, and it will be properly de-initialized and thus cancelled when there are no more references to it.
That said, you are correct to be suspicious here. You probably don't want to store an AnyCancellable in a struct the way you are doing it here. For starters, you would have to mark your loadItems function as mutating to even get it to compile, because storing the AnyCancellable means mutating the cancellables array.
Typically, if you're storing an AnyCancellable then you are associating that operation with something that has true identity, and thus is better represented as a class. You are basically saying "cancel this operation when this instance goes away". For example, if you're downloading an image to display in a UIViewController, you probably want to cancel that download if the UIViewController goes away because the user dismissed it; that is to say, the download operation is associated with a particular instance of UIViewController.
Since structs have value semantics, it is almost conceptually incoherent to have an AnyCancellable associated with an "instance" of a struct. Structs don't have instances, they just have values. When you pass a struct as an argument to a function, it creates a copy. That means if the function called loadItems then only the function's own copy of the struct value would store the AnyCancellable, and the operation would be immediately cancelled when the function returns because your original copy of the value is not storing the AnyCancellable.
You don't need deinit and don't need to call
cancellables.forEach {
$0.cancel()
}
I agree it's quite confusing that AnyCancellable have method cancel, that actually you don't need to call.
Publishers are automatically cancelled, when cancellables got disposed.
That is why you receive nothing if forget to store them somewhere.

Is there an alternative to Combine's #Published that signals a value change after it has taken place instead of before?

I would like to use Combine's #Published attribute to respond to changes in a property, but it seems that it signals before the change to the property has taken place, like a willSet observer. The following code:
import Combine
class A {
#Published var foo = false
}
let a = A()
let fooSink = a.$foo.dropFirst().sink { _ in // `dropFirst()` is to ignore the initial value
print("foo is now \(a.foo)")
}
a.foo = true
outputs:
foo is now false
I'd like the sink to run after the property has changed like a didSet observer so that foo would be true at that point. Is there an alternative publisher that signals then, or a way of making #Published work like that?
There is a thread on the Swift forums for this issue. Reasons of why they made the decision to fire signals on "willSet" and not "didSet" explained by Tony_Parker
We (and SwiftUI) chose willChange because it has some advantages over
didChange:
It enables snapshotting the state of the object (since you
have access to both the old and new value, via the current value of
the property and the value you receive). This is important for
SwiftUI's performance, but has other applications.
"will" notifications are easier to coalesce at a low level, because you can
skip further notifications until some other event (e.g., a run loop
spin). Combine makes this coalescing straightforward with operators
like removeDuplicates, although I do think we need a few more grouping
operators to help with things like run loop integration.
It's easier to make the mistake of getting a half-modified object with did,
because one change is finished but another may not be done yet.
I do not intuitively understand that I'm getting willSend event instead of didSet, when I receive a value. It does not seem like a convenient solution for me. For example, what do you do, when in ViewController you receiving a "new items event" from ViewModel, and should reload your table/collection? In table view's numberOfRowsInSection and cellForRowAt methods you can't access new items with self.viewModel.item[x] because it's not set yet. In this case, you have to create a redundant state variable just for the caching of the new values within receiveValue: block.
Maybe it's good for SwiftUI inner mechanisms, but IMHO, not so obvious and convenient for other usecases.
User clayellis in the thread above proposed solution which I'm using:
Publisher+didSet.swift
extension Published.Publisher {
var didSet: AnyPublisher<Value, Never> {
self.receive(on: RunLoop.main).eraseToAnyPublisher()
}
}
Now I can use it like this and get didSet value:
self.viewModel.$items.didSet.sink { [weak self] (models) in
self?.updateData()
}.store(in: &self.subscriptions)
I'm not sure if it is stable for future Combine updates, though.
UPD: Worth to mention that it can possibly cause bugs (races) if you set value from a different thread than the main.
Original topic link: https://forums.swift.org/t/is-this-a-bug-in-published/31292/37?page=2
You can write your own custom property wrapper:
import Combine
#propertyWrapper
class DidSet<Value> {
private var val: Value
private let subject: CurrentValueSubject<Value, Never>
init(wrappedValue value: Value) {
val = value
subject = CurrentValueSubject(value)
wrappedValue = value
}
var wrappedValue: Value {
set {
val = newValue
subject.send(val)
}
get { val }
}
public var projectedValue: CurrentValueSubject<Value, Never> {
get { subject }
}
}
Further to Eluss's good explanation, I'll add some code that works. You need to create your own PassthroughSubject to make a publisher, and use the property observer didSet to send changes after the change has taken place.
import Combine
class A {
public var fooDidChange = PassthroughSubject<Void, Never>()
var foo = false { didSet { fooDidChange.send() } }
}
let a = A()
let fooSink = a.fooDidChange.sink { _ in
print("foo is now \(a.foo)")
}
a.foo = true
Before the introduction of ObservableObject SwiftUI used to work the way that you specify - it would notify you after the change has been made. The change to willChange was made intentionally and is probably caused by some optimizations, so using ObservableObjsect with #Published will always notify you before the changed by design. Of course you could decide not to use the #Published property wrapper and implement the notifications yourself in a didChange callback and send them via objectWillChange property, but this would be against the convention and might cause issues with updating views. (https://developer.apple.com/documentation/combine/observableobject/3362556-objectwillchange) and it's done automatically when used with #Published.
If you need the sink for something else than ui updates, then I would implement another publisher and not go agains the ObservableObject convention.
Another alternative is to just use a CurrentValueSubject instead of a member variable with the #Published attribute. So for example, the following:
#Published public var foo: Int = 10
would become:
public let foo: CurrentValueSubject<Int, Never> = CurrentValueSubject(10)
This obviously has some disadvantages, not least of which is that you need to access the value as object.foo.value instead of just object.foo. It does give you the behavior you're looking for, however.