Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Mapping Swift Combine Future to another Future

Tags:

swift

combine

I have a method that returns a Future:

func getItem(id: String) -> Future<MediaItem, Error> {
  return Future { promise in
    // alamofire async operation
  }
}

I want to use it in another method and covert MediaItem to NSImage, which is a synchronous operation. I was hoping to simply do a map or flatMap on the original Future but it creates a long Publisher that I cannot erased to Future<NSImage, Error>.

func getImage(id: String) -> Future<NSImage, Error> {
  return getItem(id).map { mediaItem in
    // some sync operation to convert mediaItem to NSImage
    return convertToNSImage(mediaItem)  // this returns NSImage
  }
}

I get the following error:

Cannot convert return expression of type 'Publishers.Map<Future<MediaItem, Error>, NSImage>' to return type 'Future<NSImage, Error>'

I tried using flatMap but with a similar error. I can eraseToAnyPublisher but I think that hides the fact that getImage(id: String returns a Future.

I suppose I can wrap the body of getImage in a future but that doesn't seem as clean as chaining and mapping. Any suggestions would be welcome.

like image 649
Kon Avatar asked Mar 16 '20 00:03

Kon


2 Answers

You can't use dribs and drabs and bits and pieces from the Combine framework like that. You have to make a pipeline — a publisher, some operators, and a subscriber (which you store so that the pipeline will have a chance to run).

 Publisher
     |
     V
 Operator
     |
     V
 Operator
     |
     V
 Subscriber (and store it)

So, here, getItem is a function that produces your Publisher, a Future. So you can say

getItem (...)
    .map {...}
    ( maybe other operators )
    .sink {...} (or .assign(...))
    .store (...)

Now the future (and the whole pipeline) will run asynchronously and the result will pop out the end of the pipeline and you can do something with it.

Now, of course you can put the Future and the Map together and then stop, vending them so someone else can attach other operators and a subscriber to them. You have now assembled the start of a pipeline and no more. But then its type is not going to be Future; it will be an AnyPublisher<NSImage,Error>. And there's nothing wrong with that!

like image 90
matt Avatar answered Sep 19 '22 17:09

matt


You can always wrap one future in another. Rather than mapping it as a Publisher, subscribe to its result in the future you want to return.

func mapping(futureToWrap: Future<MediaItem, Error>) -> Future<NSImage, Error> {
    var cancellable: AnyCancellable?
    return Future<String, Error> { promise in
        // cancellable is captured to assure the completion of the wrapped future
        cancellable = futureToWrap
            .sink { completion in
                if case .failure(let error) = completion {
                    promise(.failure(error))
                }
            } receiveValue: { value in
                promise(.success(convertToNSImage(mediaItem)))
            }
    }
}

This could always be generalized to

extension Publisher {
    func asFuture() -> Future<Output, Failure> {
        var cancellable: AnyCancellable?
        return Future<Output, Failure> { promise in
            // cancellable is captured to assure the completion of the wrapped future
            cancellable = self.sink { completion in
                    if case .failure(let error) = completion {
                        promise(.failure(error))
                    }
                } receiveValue: { value in
                    promise(.success(value))
                }
        }
    }
}

Note above that if the publisher in question is a class, it will get retained for the lifespan of the closure in the Future returned. Also, as a future, you will only ever get the first value published, after which the future will complete.

Finally, simply erasing to AnyPublisher is just fine. If you want to assure you only get the first value (similar to getting a future's only value), you could just do the following:

getItem(id)
    .map(convertToNSImage)
    .eraseToAnyPublisher()
    .first()

The resulting type, Publishers.First<AnyPublisher<Output, Failure>> is expressive enough to convey that only a single result will ever be received, similar to a Future. You could even define a typealias to that end (though it's probably overkill at that point):

typealias AnyFirst<Output, Failure> = Publishers.First<AnyPublisher<Output, Failure>>
like image 42
kid_x Avatar answered Sep 19 '22 17:09

kid_x