kolboc
kolboc

Reputation: 825

Why Swift's Combine's map returns Publishers.Map<Self, T> instead of <T, Error>

My request resulting in AnyPublisher

let foo: AnyPublisher<API.Response, API.Error> = APIClient.get(API.Endpoints.Demo)

now I would like to map the value of this publisher to array of string, message in API.Response is of type [String : [String]], so my try is

foo.map { $0.message.map { $0.key } }

at this point I would expect I would end up with AnyPublisher<[String], API.Error>, or just any other publisher but parametrized to <[String], API.Error>,
instead I end up with Publishers.Map<AnyPublisher<API.Response, API.Error>, [String]>.

func map<T>(_ transform: @escaping (Self.Output) -> T) -> Publishers.Map<Self, T>

The docs above for .map function are correct, but why is it working like that - why would someone expect the map to keep the previous publisher type but somehow nested inside there. I can't figure it out, any help appreciated :)

Upvotes: 1

Views: 783

Answers (1)

rob mayoff
rob mayoff

Reputation: 385600

If you are used to other ReactiveX-style libraries, or you're used to a language (like Java or Kotlin or Scala) where objects are always heap-allocated, Combine types can be quite a shock.

Because Publishers.Map is generic over the type of its upstream publisher (in this case, AnyPublisher<API.Response, API.Error>), it can store its upstream publisher inline, without needing to wrap the upstream in an existential container or otherwise heap-allocate storage for the upstream.

Map also optimizes consecutive uses of the map operator. The Combine framework defines a map operator for all Publishers, and Map inherits that. But Map also defines a more specialized map operator which coalesces consecutive map operators.

Here's an example:

import Combine

let foo: AnyPublisher<[String: [String]], Error> = Empty().eraseToAnyPublisher()

let chained = foo
    .map { Array($0.keys) }
    .filter { !$0.isEmpty }
    .map { $0.joined(separator: ", ") }
    .map { "[\($0)]" }
print("chained", type(of: chained))

What's the type of chained? It is

Map<
  Filter<
    Map<
      AnyPublisher<
        Dictionary<String, Array<String>>,
        Error
      >,
      Array<String>
    >
  >,
  String
>

Notice two things here. There are only two Maps in the type, even though I used the map operator three times. That's because the final use of map uses the Map type's specialized map operator, which coalesces the two consecutive uses of map into a single Map instance.

And because Map and Filter are structs, they can be store on the stack or directly inline as properties of other data types. So the value in chained can be stored on the stack.

In general, Map might need to store its transform function on the heap, but it doesn't also need to perform a heap allocation to store its upstream. In the example above, since none of arguments passed to map and filter close over any outside variables, Swift might be able to optimize out all heap allocations when creating the chained value.

The use of generic types here also gives the compiler more knowledge about how the chained value is constructed, so it might be able to perform other optimizations.

So, applying an operator to a publisher usually returns a different type of publisher. If you need to write a function that takes a publisher as an argument, this means you should make the function generic over the type of publisher. For example, every SwiftUI View has an onReceive modifier with this signature:

func onReceive<P>(
    _ publisher: P,
    perform action: @escaping (P.Output) -> Void
) -> some View where P : Publisher, P.Failure == Never

You can pass any publisher to onReceive, as long as that publisher's Failure associated type is Never. So you can pass a Map or a Filter or an AnyPublisher to onReceive.

Upvotes: 3

Related Questions