The declarative syntax of Swift Combine looks odd to me and it appears that there is a lot going on that is not visible.
For example the following code sample builds and runs in an Xcode playground:
[1, 2, 3]
.publisher
.map({ (val) in
return val * 3
})
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
print("Something went wrong: \(error)")
case .finished:
print("Received Completion")
}
}, receiveValue: { value in
print("Received value \(value)")
})
I see what I assume is an array literal instance being created with [1, 2, 3]. I guess it is an array literal but I'm not accustomed to seeing it "declared" without also assigning it to a variable name or constant or using _=.
I've put in an intentional new line after and then .publisher. Is Xcode ignoring the whitespace and newlines?
Because of this style, or my newness to visually parsing this style, I mistakenly thought ", receiveValue:" was a variadic parameter or some new syntax, but later realized it is actually an argument to .sink(...).
To start, reading/understanding this code would be much easier if it was formatted properly. So let's start with that:
[1, 2, 3]
.publisher
.map({ (val) in
return val * 3
})
.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
print("Something went wrong: \(error)")
case .finished:
print("Received Completion")
}
},
receiveValue: { value in
print("Received value \(value)")
}
)
map
expressionWe can further clean up the map, by:
Using an implicit return
map({ (val) in
return val * 3
})
Using an implicit return
map({ (val) in
val * 3
})
Remove unecessary brackets around param declaration
map({ val in
val * 3
})
Remove unecessary new-lines. Sometimes they're useful for visually seperating things, but this is a simple enough closure that it just adds uneeded noise
map({ val in val * 3 })
Use an implicit param, instead of a val
, which is non-descriptive anyway
map({ $0 * 3 })
Use trailing closure syntax
map { $0 * 3 }
with numbered lines, so I can refer back easily.
/* 1 */[1, 2, 3]
/* 2 */ .publisher
/* 3 */ .map { $0 * 3 }
/* 4 */ .sink(
/* 5 */ receiveCompletion: { completion in
/* 6 */ switch completion {
/* 7 */ case .failure(let error):
/* 8 */ print("Something went wrong: \(error)")
/* 9 */ case .finished:
/* 10 */ print("Received Completion")
/* 11 */ }
/* 12 */ },
/* 13 */ receiveValue: { value in
/* 14 */ print("Received value \(value)")
/* 15 */ }
/* 16 */ )
[1, 2, 3]
Line 1 is an array literal. It's an expression, just like 1
, "hi"
, true
, someVariable
or 1 + 1
. An array like this doesn't need to be assigned to anything for it to be used.
Interestingly, that doesn't mean necessarily that it's an array. Instead, Swift has the ExpressibleByArrayLiteralProtocol
. Any conforming type can be initialized from an array literal. For example, Set
conforms, so you could write: let s: Set = [1, 2, 3]
, and you would get a Set
containing 1
, 2
and 3
. In the absence of other type information (like the Set
type annotation above, for example), Swift uses Array
as the preferred array literal type.
.publisher
Line 2 is calling the publisher
property of the array literal. This returns a Sequence<Array<Int>, Never>
. That isn't a regular Swift.Sequence
, which is a non-generic protocol, but rather, it's found in the Publishers
namespace (a case-less enum) of the Combine
module. So its fully qualified type is Combine.Publishers.Sequence<Array<Int>, Never>
.
It's a Publisher
whose Output
is Int
, and whose Failure
type is Never
(i.e. an error isn't possible, since there is no way to create an instance of the Never
type).
map
Line 3 is calling the map
instance function (a.k.a. method) of the Combine.Publishers.Sequence<Array<Int>, Never>
value above. Everytime an element passed through this chain, it'll be transformed by the closure given to map
.
1
will go in, 3
will come out.2
will go in, and 6
will come out.3
would go in, and 6
would come out.The result of this expression so far is another Combine.Publishers.Sequence<Array<Int>, Never>
sink(receiveCompletion:receiveValue:)
Line 4 is a call to Combine.Publishers.Sequence<Array<Int>, Never>.sink(receiveCompletion:receiveValue:)
. With two closure arguments.
{ completion in ... }
closure is provided as an argument to the parameter labelled receiveCompletion:
{ value in ... }
closure is provided as an argument to the parameter labelled receiveValue:
Sink is creating a new subscriber to the Subscription<Array<Int>, Never>
value that we had above. When elements come through, the receiveValue
closure will be called, and passed as an argument to its value
parameter.
Eventually the publisher will complete, calling the receiveCompletion:
closure. The argument to the completion
param will be a value of type Subscribers.Completion
, which is an enum with either a .failure(Failure)
case, or a .finished
case. Since the Failure
type is Never
, it's actually impossible to create a value of .failure(Never)
here. So the completion will always be .finished
, which would cause the print("Received Completion")
to be called. The statement print("Something went wrong: \(error)")
is dead code, which can never be reached.
There's no single syntactic element that makes this code qualify as "declarative". A declarative style is a distinction from an "imperative" style. In an imperative style, your program consists of a series of imperatives, or steps to be completed, usually with a very rigid ordering.
In a declarative style, your program consists of a series of declarations. The details of what's necessary to fulfill those declarations is abstracted away, such as into libraries like Combine
and SwiftUI
. For example, in this case, you're declaring that print("Received value \(value)")
of triple the number is to be printed whenever a number comes in from the [1, 2, 3].publisher
. The publisher is a basic example, but you could imagine a publisher that's emitting values from a text field, where events are coming in an unknown times.
My favourite example for disguising imperative and declarative styles is using a function like Array.map(_:)
.
You could write:
var input: [InputType] = ...
var result = [ResultType]()
for element in input {
let transformedElement = transform(element)
result.append(result)
}
but there are a lot of issues:
for
is such a general construct, many things are possible here. To find out exactly what happens, you need to look into more detail.You've missed an optimization opportunity, by not calling Array.reserveCapacity(_:)
. These repeated calls to append
can reach the max capacity of an the result
arrays's buffer. At that point:
result
need to be copied overtransformedElement
has to be added inThese operations can get expensive. And as you add more and more elements, you can run out of capacity several times, causing multiple of these regrowing operations. By callined result.reserveCapacity(input.count)
, you can tell the array to allocate a perfectly sized buffer, up-front, so that no regrowing operations will be necessary.
The result
array has to be mutable, even though you might not ever need to mutate it after its construction.
This code could instead be written as a call to map
:
let result = input.map(transform)
This has many benefits:
map
is a very specific tool, that can only do one thing. As soon as you see map
, you know that input.count == result.count
, and that the result is an array of the output of the transform
function/closure.map
calls reserveCapacity
, and it will never forget to do so.result
can be immutable.Calling map
is following a more declarative style of programming. You're not fiddling around with the details of array sizes, iteration, appending, or whatever. If you have input.map { $0 * $0 }
, you're saying "I want the input's elements squared", the end. The implementation of map would have the for
loop, append
s, etc. necessary to do that. While it's implemented in an imperative style, the function abstracts that away, and lets you write code at higher levels of abstraction, where you're not mucking about with irrelevant things like for
loops.
First, about literals. You can use a literal anywhere you can use a variable containing that same value. There is no important difference between
let arr = [1,2,3]
let c = arr.count
and
let c = [1,2,3].count
Second, about whitespace. Simply put, Swift doesn't care if you split a statement before a dot. So there is no difference between
let c = [1,2,3].count
and
let c = [1,2,3]
.count
And when you are chaining a lot of functions one after another, splitting is actually a great way to increase legibility. Instead of
let c = [1,2,3].filter {$0>2}.count
it's nicer to write
let c = [1,2,3]
.filter {$0>2}
.count
or for even greater clarity
let c = [1,2,3]
.filter {
$0>2
}
.count
That's all that's happening in the code you showed: a literal followed by a long chain of method calls. They are split onto separate lines for legibility, that's all.
So nothing you mentioned in your question has anything to do with Combine. It's just basic stuff about the Swift language. Everything you're talking about could (and does) happen in code that doesn't use Combine at all.
So from a syntactical point of view, nothing is "going on that is not visible", except to know that each method call returns a value to which the next method call can be applied (just like in my own example code above, where I apply .count
to the result of .filter
). Of course, since your example is Combine, something is "going on that is not visible", namely that each of these values is a publisher, an operator, or a subscriber (and the subscribers do actually subscribe). But that is basically just a matter of knowing what Combine is. So:
[1,2,3]
is an array which is a sequence, so it has a publisher
method.
The publisher
method, which can be applied to a sequence, produces a publisher.
The map
method (Combine's map
, not Array's map
) can be applied to a publisher, and produces another object that is a publisher.
The sink
method can be applied to that, and produces a subscriber, and that's the end of the chain.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With