Future
is behaving correctly.
In your first example, everything happens synchronously, so there is no opportunity for Combine to cancel the subscription before the Future
delivers its value to the Sink
.
In your second example, you're not saving the AnyCancellable
returned by the sink
modifier, so Combine cancels your subscription before your async closure runs.
Gory details follow...
First, consider initializing a Future
like this:
let future = Future<String, Never> { promise in
promise(.success("world"))
}
This calls promise
synchronously, before Future.init
returns. So by the time the Future
gets assigned to the future
variable, it is in a completed/success state. We can prove it in the debugger (with a breakpoint after future
is initialized):
(lldb) expr -- dump(future)
▿ Combine.Future<Swift.String, Swift.Never> #0
(Future<String, Never>) $R0 = 0x0000600003880910 {}
▿ lock: 0x00007ff03d4008b0
- pointerValue: 140669796485296
▿ downstreams: Combine.SubscriberList
- items: 0 elements
- tickets: 0 elements
- nextTicket: 0
▿ result: Optional(Swift.Result<Swift.String, Swift.Never>.success("world"))
▿ some: Swift.Result<Swift.String, Swift.Never>.success
- success: "world"
Note that future
has a private result
property containing the successful result.
Next, apply the sink
modifier to future
with a breakpoint on the print
:
_ = future.sink {
print("hello \($0)") // put a breakpoint here
}
When you run it and stop at the breakpoint, you'll see a stack trace like this:
#0 closure #2 in AppDelegate.application(_:didFinishLaunchingWithOptions:) at /Users/rmayoff/TestProjects/combineTest/combineTest/AppDelegate.swift:18
#1 thunk for @escaping @callee_guaranteed (@guaranteed String) -> () ()
#2 Subscribers.Sink.receive(_:) ()
#3 protocol witness for Subscriber.receive(_:) in conformance Subscribers.Sink<A, B> ()
#4 AnySubscriberBox.receive(_:) ()
#5 Future.Conduit.request(_:) ()
#6 protocol witness for Subscription.request(_:) in conformance Future<A, B>.Conduit ()
#7 Subscribers.Sink.receive(subscription:) ()
#8 protocol witness for Subscriber.receive(subscription:) in conformance Subscribers.Sink<A, B> ()
#9 Future.receive<A>(subscriber:) ()
#10 protocol witness for Publisher.receive<A>(subscriber:) in conformance Future<A, B> ()
#11 Publisher.subscribe<A>(_:) ()
#12 Publisher<>.sink(receiveValue:) ()
#13 AppDelegate.application(_:didFinishLaunchingWithOptions:) at /Users/rmayoff/TestProjects/combineTest/combineTest/AppDelegate.swift:17
The interesting thing to note here is that the sink
modifier call is still on the stack (at frame #12). The sink
modifier creates a Sink
and subscribes it to future
(frame #9), which results in future
creating a subscription (of type Future.Conduit
) that it passes to the Sink
(frame #7), which the Sink
then makes a demand on (frame #5), which gets passed to future
. Since future
is already complete, it can and does respond to the demand immediately by delivering its value. I believe the call into future
with the demand has been eliminated by tail-call-optimization, else it would be at frame #3.
So, in your first example, everything happens synchronously. There is no chance for the subscription to be cancelled before the Future
has delivered its value to the Sink
.
Now consider initializing a Future
like this:
let future = Future<String, Never> { promise in
DispatchQueue.main.async {
promise(.success("world"))
}
}
Here, assuming this runs on the main thread, we can't possibly call the promise
until some time after Future.init
returns. So future
is initialized to an incomplete promise. We can check this in the debugger:
(lldb) expr -- dump(future)
▿ Combine.Future<Swift.String, Swift.Never> #0
▿ lock: 0x00007fe88f501b70
- pointerValue: 140636813532016
▿ downstreams: Combine.SubscriberList
- items: 0 elements
- tickets: 0 elements
- nextTicket: 0
- result: nil
(Future<String, Never>) $R0 = 0x0000600001f37f70 {}
Here we can see that future
's result
property is nil because it hasn't been completed yet.
Next, apply the sink
modifier to this incomplete Future
:
_ = future.sink {
print("hello \($0)")
}
The print
statement never runs. Why not? Well, this is all still running on the main thread. So the closure that completes future
(by calling promise
) still can't run yet. But the sink
modifier returns an AnyCancellable
which we're not storing persistently. This means Swift destroys the AnyCancellable
by the time the enclosing scope is exited, which still happens on the main thread. So the AnyCancellable
is destroyed before the promise
-calling closure can run. When an AnyCancellable
is destroyed, it calls its cancel
method, which in this case cancels the subscription. (Other types that conform to Cancellable
do not necessarily call cancel
on destruction, but AnyCancellable
always does.)
Eventually, control of the main thread returns to the run loop, which executes any blocks added to the main queue, including the closure that calls promise
. But by that time, the subscription has been cancelled, so our print
statement never runs.
How can we fix this? We have to store the AnyCancellable
persistently to keep the subscription alive. Usually we'd use an instance property, like this:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let future = Future<String, Never> { promise in
DispatchQueue.main.async { [weak self] in
promise(.success("world"))
self?.scrip = nil
}
}
scrip = future.sink {
print("hello \($0)")
}
return true
}
var scrip: AnyCancellable?