4

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

bhartsb
  • 1,316
  • 14
  • 39
  • Could you elaborate on what exactly your understanding of "declarative" syntax is? I think that's the source of the confusion – Alexander Jan 18 '20 at 17:07
  • What I think it is can be found by google searching "declarative syntax swift". The first three results. I edited my question to clarify where the confusion was. – bhartsb Jan 19 '20 at 07:22

2 Answers2

8

Cleaning up the code first

Formatting

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)")
        }
    )

Cleaning up the map expression

We can further clean up the map, by:

  1. Using an implicit return

    map({ (val) in
        return val * 3
    })
    
  2. Using an implicit return

    map({ (val) in
        val * 3
    })
    
  3. Remove unecessary brackets around param declaration

    map({ val in
        val * 3
    })
    
  4. 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 })
    
  5. Use an implicit param, instead of a val, which is non-descriptive anyway

    map({ $0 * 3 })
    
  6. Use trailing closure syntax

    map { $0 * 3 }
    

Final result

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 */    )

Going through it.

Line 1, [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.

Line 2, .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).

Line 3, 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.
  • Then 2 will go in, and 6 will come out.
  • Finally 3 would go in, and 6 would come out.

The result of this expression so far is another Combine.Publishers.Sequence<Array<Int>, Never>

Line 4, sink(receiveCompletion:receiveValue:)

Line 4 is a call to Combine.Publishers.Sequence<Array<Int>, Never>.sink(receiveCompletion:receiveValue:). With two closure arguments.

  1. The { completion in ... } closure is provided as an argument to the parameter labelled receiveCompletion:
  2. The { 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.

Discussion on "declarative"

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:

  1. It's a lot of boiler-plate code you end up repeating all over your code base, with only subtle differences.
  2. It's trickier to read. Since for is such a general construct, many things are possible here. To find out exactly what happens, you need to look into more detail.
  3. 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:

    • a new larger buffer must be allocated
    • the existing elements of result need to be copied over
    • the old buffer needs to be released
    • and finally, the new transformedElement has to be added in

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

  4. 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:

  1. Its shorter (though not always a good thing, in this case nothing is lost for having it be shorter)
  2. It's more clear. 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.
  3. It's optimized, internally map calls reserveCapacity, and it will never forget to do so.
  4. The 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, appends, 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.

Alexander
  • 59,041
  • 12
  • 98
  • 151
  • I love this answer. It's incredibly detailed, but well formatted and well explained. I do think that reducing (heh) the `map` expression to just `map { $0 * 3 }` can be a bit confusing to someone who's not completely comfortable with declarative programming, though. – NRitH Jan 29 '20 at 17:08
  • 1
    @NRitH That's not really a "comfortable with declarative programming" thing, it's just a very common/practical syntax that exists in Swift. I always push back on suggestions to not use a particular Swift feature because non-Swift developers won't understand it. Doing so takes you in a lowest-common-denominator land, in which you limit yourself from using the existing tools made to easily and conveniently solve your problem. – Alexander Jan 29 '20 at 17:54
2

Literals

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

Whitespace

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

Chaining

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

Conclusions

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.

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • 1
    Unclear what the downvote is for; I _think_ this answers the question clearly, completely, and correctly. – matt Jan 17 '20 at 02:18
  • 1
    Idk man, I've gotten some mysterious down votes. Like this one lol: https://stackoverflow.com/a/40779509/3141234 – Alexander Jan 17 '20 at 02:40