Swift Combine: Collect with Sliding window like functionality

Viewed 489

So I have this combine publisher that say emits integers - 1,2,3,4,5,6,7,8,9,10,11 ....

These integers are though not always there. They are generated on the fly and sent to the publisher.

I am aware of the collect operator which when say called as .collect(5) gives me - [1,2,3,4,5], [6,7,8,9,10], ...

What I am instead looking for is somewhat like buffer and collect combined - [1,2,3,4,5], [2,3,4,5,6], [3,4,5,6,7] ....

Is there any way to do it without writing a custom operator? And if a custom operator is the way to go, can you provide some guidance towards that, I have not written a custom operator before. I hope what I am asking for is clear from the examples.

Thanks

Edit

This works, but I am not sure if this is the best solution -

publisher.zip(
    publisher.dropFirst(),
    publisher.dropFirst(2))
3 Answers

You could use a scan operator to achieve this effect. Scan gives you the ability to accumulate a value - in this case an array of previous values - and then to emit it. You'd just need to drop the initial N-1 smaller arrays.

You can create a custom operator sliding(window:) for convenience:

extension Publisher {
    func sliding(window: Int) -> AnyPublisher<[Output], Failure> {
        if window < 1 { return Empty().eraseToAnyPublisher() }
        return self
           .scan([], { arr, value in
               if arr.count < window {
                   return arr + [value]
               } else {
                   return arr.dropFirst() + [value]
               }
           })
           .dropFirst(window - 1)
           .eraseToAnyPublisher()
   }
}

Usage is:

[1,2,3,4,5,6,7,8,9,10].publisher
   .sliding(window: 5)
   .sink { print($0) }

and the output would be:

[1, 2, 3, 4, 5]
[2, 3, 4, 5, 6]
[3, 4, 5, 6, 7]
[4, 5, 6, 7, 8]
[5, 6, 7, 8, 9]
[6, 7, 8, 9, 10]

If you are sure that your publisher always emits at least N elements (N being the window size), then the solution New Dev provided will work just fine.

But if you want to cover all cases, then things get more complicated, since there's no trivial way to have the sliding window, while still retaining the first elements if they are not enough to fill the window.

One possible solution involves a longer and more complicated pipeline of operators, and looks like this:

extension Publisher {
    func sliding(window count: Int) -> AnyPublisher<[Output], Failure> {
        self
            // 1. collect batches of the given size
            .collect(count)
            // 2. we need to retain the last two batches, to be able to detect the case
            // where we have only one batch, that could be smaller than the window size
            .scan(([], [])) { ($0.1, $1) }
            // 3. now that the publisher emitted batches, we either publish the last batch
            // if we have only one, or we generate an array of batches by appending elements
            // from the last batch into the second last one
            .map { secondLast, last in secondLast.isEmpty ? [last] : last.indices.map { secondLast + last[...$0] } }
            // 4. now that we have an array of batches, we need to flatten them, and
            // for this we use MergeMany over an array of Just() publishers created from the batches
            .flatMap { Publishers.MergeMany($0.map(Just.init)).setFailureType(to: Failure.self) }
            // 5. now we only keep the N elements from the batch
            // we could've done this at step 3, however that would've complicate that step even more
            .map { $0.suffix(count) }
            // 6. and, done :)
            .eraseToAnyPublisher()
    }
}

Usages

_ = (1...4).publisher
    .sliding(window: 5)
    .sink(receiveValue: { print($0) })
// [1, 2, 3, 4]

_ = (1...10).publisher
    .sliding(window: 5)
    .sink(receiveValue: { print($0) })
// [1, 2, 3, 4, 5]
// [2, 3, 4, 5, 6]
// [3, 4, 5, 6, 7]
// [4, 5, 6, 7, 8]
// [5, 6, 7, 8, 9]
// [6, 7, 8, 9, 10]

I believe that OP's original solution is more elegant, idiomatic, and safe. The only downside is that we can't pass size as an argument, but if we think about it, in most cases we need to specify the size explicitly, so it's quite the same thing.

extension Publisher {
func window2() -> AnyPublisher<(Output, Output), Failure> {
    zip(dropFirst()).eraseToAnyPublisher()
}

func window3() -> AnyPublisher<(Output, Output, Output), Failure> {
    zip(dropFirst(), dropFirst(2)).eraseToAnyPublisher()
}

}

Related