Publishers and subscribers
The contract underneath every operator
A Publisher promises to deliver values of one type and to fail with one error type. A Subscriber
promises to accept them and to say how many it can take.
protocol Publisher<Output, Failure> {
associatedtype Output
associatedtype Failure: Error
func receive<S: Subscriber>(subscriber: S)
where S.Input == Output, S.Failure == Failure
}
Two associated types, and both matter. Output is what flows; Failure is how it can stop badly.
A publisher that cannot fail declares Failure == Never, and the compiler then knows the error path
does not exist — which is why sink { } with a single closure compiles for some publishers and not
others.
The part people miss is demand. A subscriber does not passively receive; it requests a number of
values, and the publisher may not send more than that. This is Combine’s backpressure, and it is why
sink — which requests .unlimited — is the wrong tool the moment the producer is faster than the
consumer.
Lifetime
A subscription lives exactly as long as the AnyCancellable you hold:
final class SearchModel {
private var cancellables: Set<AnyCancellable> = []
func start() {
publisher.sink { … }.store(in: &cancellables)
}
}
Drop the reference and the stream stops mid-flight — a silent bug, because nothing errors and nothing logs.
What the three chapters cover
The contract goes through the handshake step by step: what actually happens between subscribe
and the first value, what a Subscription is, and why writing a custom Subscriber — which almost
nobody does — is the fastest way to stop finding the framework mysterious.
Subjects are the bridge from imperative code into a stream. PassthroughSubject and
CurrentValueSubject are how a delegate callback, a button action or a legacy API becomes something
operators can work on, and they are also the most over-used part of Combine.
Demand and backpressure is the concept that distinguishes Combine from every other reactive framework Apple could have shipped, and the one that explains why some pipelines quietly buffer until memory runs out.
A map of the built-in publishers
Worth having in mind before the detail:
| Publisher | Emits |
|---|---|
Just(value) |
One value, then finishes. Failure == Never |
Empty() |
Nothing, then finishes immediately |
Fail(error:) |
Nothing, then fails |
Future { promise in … } |
Exactly one value or one error, from a closure |
Deferred { … } |
Builds its real publisher fresh for each subscriber |
PassthroughSubject |
Whatever you send it, to current subscribers only |
CurrentValueSubject |
Its current value on subscribe, then whatever you send |
URLSession.dataTaskPublisher |
One (Data, URLResponse) or a URLError |
Timer.publish |
Dates, forever, once connected |
NotificationCenter.publisher |
Notifications, forever |
@Published |
Its wrapped value on subscribe, then every change |
Warning
Future runs its closure immediately on creation, not on subscription, and caches the
result. That makes it wrong for anything you meant to be lazy or repeatable — a Future that
performs a network request fires that request whether or not anybody subscribes, and every later
subscriber gets the same cached answer. Wrap it in Deferred when you want per-subscriber
behaviour, which is usually.
Cold and hot
The distinction runs through everything that follows.
Cold publishers do their work per subscriber. URLSession.dataTaskPublisher subscribed to twice
makes two requests; Just delivers to each subscriber separately. Nothing happens until somebody
subscribes, and each subscriber gets its own run.
Hot publishers emit whether anybody is listening or not, and late subscribers miss what already
happened. Subjects, NotificationCenter, and any ConnectablePublisher after connect() behave
this way.
The practical consequence is that a chain built on a cold publisher and subscribed to from three
places does its work three times. share() turns one into a hot publisher with a single upstream
subscription; multicast gives you control over the subject it shares through. Reaching for either
without knowing which kind you started with is how a login request ends up firing twice.