Skip to content

Building a Caching Custom Combine Operator: The Chain

Combine provides a wide variety of Publishers that can be combined to fulfill a wide variety of use cases. But as with any framework, you may find at some point that you need something custom that Combine does not provide out of the box.

You may have your own custom operation or you may want to wrap another Combine chain in a convenient wrapper. But how? If you look at Apple’s documentation for even how to add a method to the Publisher as an extension, the signature looks something like this:

😖

The steps are a little complicated, because Combine heavily uses a couple of features of Swift to perform its magic, but once you’ve followed along with an example, those difficult steps become much easier to apply to your own unique case.

Over the next several articles, we’ll trace through the development of a caching operator, which will take an upstream input and a Combine chain that performs the operation we’d like to cache, running the operation if the upstream value received hasn’t been seen before, or sending the cached result if it has. In part 1, we will build out the Combine publisher chain that implements the cache operation. In part 2, we’ll learn how we can make it so that somebody can use upstream.cache(...) to add our operator to their own Combine chains. Part 3 will cover taking our cache operator and turning it into a full-blown Publisher type. Part 4 will move away from Combine and talk about how to package that code into a Swift package that can be easily downloaded and included in your own, and everybody else’s app.

Lots to cover, so let’s get going!


The cache operator

First, let’s figure out how this caching operator would look to somebody using it. You would have an upstream chain which would provide the input to the caching operator, and you would have an operation you would perform if the input wasn’t already in the cache:

upstream.cache(operation: ...)

This is pretty similar to an existing operator: flatMap. We’ll actually be using that in the implementation later, but for now, let’s just use that as kind of a guide for our interface (the AnyPublisher is just a placeholder for now):

upstream.cache(operation: { input -> AnyPublisher<...> in ... } )

As an aside, you can also make small modifications below if you want your operation to just return a result instead of a Publisher. Which you choose is more a matter of personal preference; I prefer to keep everything “Combine-looking,” so my operation returns a Publisher.

Building the Combine chain

Now let’s work on the Combine chain that will implement the cache. We’ll start with an operation that takes a Double input and returns a Double; we’ll worry about making it general using Swift generics in part 2.

Our chain will take the input, and check whether the value is already in the cache. If it is, it will return the result for that value from the cache. Otherwise, it will perform our operation (which is itself a Combine chain), store the result in the cache, as pass it downstream. Because we have a Combine chain for our operator that will provide the values further downstream, this is a perfect application for flatMap:

import Combine

var publisher: AnyPublisher<Double,Never>
var upstream = PassthroughSubject<Double,Never>()
var operation = { x in Just(x+1.0) }       // example operation
do {
  var cache: [Double:Double] = [:]
  publisher = upstream.flatMap({ input -> AnyPublisher<Double,Never> in
    if let result = cache[input] {
      print("Used cache")
      return Just(result).eraseToAnyPublisher()
    }
    else {
      print("Used operation")
      return operation(input).map({ result in
        cache[input] = result
        return result
      }).eraseToAnyPublisher()
    }
  }).eraseToAnyPublisher()
}

The do {} block gives us a scope where we can store our cache which our operation will use. From there, it is a simple application of flatMap on the upstream input:

  • If the input is present in the cache already, we return a Just publisher with the result from the cache.
  • Otherwise, we call our operation to get the Publisher that calculates the result, and apply a map operation to that Publisher to associate that result with the input in the cache, and return that mini-chain.

Also notice the liberal use of eraseToAnyPublisher. We need them for two different reasons:

  • Inside the flatMap, it is required, because the two branches of the if return two different Publisher types: a Just and a FlatMap. We can’t return results of two different types, so we need to translate both of those to one type: hence the eraseToAnyPublisher for both.
  • The eraseToAnyPublisher on the flatMap itself hides the implementation detail from the user that our caching operation is a flatMap operator.
    • This goes away in part 3 when we turn this code into a full-fledged Publisher type of its own

Testing our cache operator

The code we wrote for the caching operator above is nicely self-contained, so a great way to test it out to see if it works is to open up an Xcode playground, copy in that code, and then send some test values into the upstream to see that values are properly cached.

The code above already contains print()s in each branch of the if that checks the cache, so we will get visual confirmation whether our operator used the cache or called the operation to get its value.

Now below that code in the playground, add a couple of test lines:

// subscribe first 
publisher.sink {value in print(value)}

upstream.send(2.0)
upstream.send(2.0)
upstream.send(3.0)

And you should get the output:

Used operation
3.0
Used cache
3.0
Used operation
4.0

Next steps

Excellent! We now have a Combine chain that will implement our caching operator. In part 2, we will tackle taking that code and wrapping it in a function so that you can use it just by using upstream.cache(operation: {...}), just like any other built-in operator.

Check out part 2 now!

Comments are closed.