Swift Async Algorithms Proposal: Chain

Chain

Introduction

Combining asynchronous sequences can occur in multiple ways. One such way that is common for non asynchronous sequences is iterating a prefix sequence and then iterating a suffix sequence. The asynchronous version is just as useful and common. This algorithm has been dubbed chain in the swift algorithms package.

The chain algorithm brings together two or more asynchronous sequences together sequentially where the elements from the resulting asynchronous sequence are comprised in order from the elements of the first asynchronous sequence and then the second (and so on) or until an error occurs.

This operation is available for all AsyncSequence types who share the same Element type.

let preamble = [
  "// Some header to add as a preamble",
  "//",
  ""
].async
let lines = chain(preamble, URL(fileURLWithPath: "/tmp/Sample.swift").lines)

for try await line in lines {
  print(line)
}

The above example shows how two AsyncSequence types can be chained together. In this case it prepends a preamble to the lines content of the file.

Detailed Design

This function family and the associated family of return types are prime candidates for variadic generics. Until that proposal is accepted, these will be implemented in terms of two- and three-base sequence cases.

public func chain<Base1: AsyncSequence, Base2: AsyncSequence>(_ s1: Base1, _ s2: Base2) -> AsyncChain2Sequence<Base1, Base2> where Base1.Element == Base2.Element

public func chain<Base1: AsyncSequence, Base2: AsyncSequence, Base3: AsyncSequence>(_ s1: Base1, _ s2: Base2, _ s3: Base3) -> AsyncChain3Sequence<Base1, Base2, Base3>

public struct AsyncChain2Sequence<Base1: AsyncSequence, Base2: AsyncSequence> where Base1.Element == Base2.Element {
  public typealias Element = Base1.Element

  public struct Iterator: AsyncIteratorProtocol {
    public mutating func next() async rethrows -> Element?
  }

  public func makeAsyncIterator() -> Iterator
}

extension AsyncChain2Sequence: Sendable where Base1: Sendable, Base2: Sendable { }
extension AsyncChain2Sequence.Iterator: Sendable where Base1.AsyncIterator: Sendable, Base2.AsyncIterator: Sendable { }

public struct AsyncChain3Sequence<Base1: AsyncSequence, Base2: AsyncSequence, Base3: AsyncSequence> where Base1.Element == Base2.Element, Base1.Element == Base3.Element {
  public typealias Element = Base1.Element

  public struct Iterator: AsyncIteratorProtocol {
    public mutating func next() async rethrows -> Element?
  }

  public func makeAsyncIterator() -> Iterator
}

extension AsyncChain3Sequence: Sendable where Base1: Sendable, Base2: Sendable, Base3: Sendable { }
extension AsyncChain3Sequence.Iterator: Sendable where Base1.AsyncIterator: Sendable, Base2.AsyncIterator: Sendable, Base3.AsyncIterator: Sendable { }

The chain(_:...) function takes two or more sequences as arguments.

The resulting AsyncChainSequence type is an asynchronous sequence, with conditional conformance to Sendable when the arguments also conform to it.

When any of the asynchronous sequences being chained together come to their end of iteration, the AsyncChainSequence iteration proceeds to the next asynchronous sequence. When the last asynchronous sequence reaches the end of iteration, the AsyncChainSequence then ends its iteration.

At any point in time, if one of the comprising asynchronous sequences throws an error during iteration, the resulting AsyncChainSequence iteration will throw that error and end iteration. The throwing behavior of AsyncChainSequence is that it will throw when any of its comprising bases throw, and will not throw when all of its comprising bases do not throw.

Naming

This function's and type's name match the term of art used in other languages and libraries.

This combinator function is a direct analog to the synchronous version defined in the Swift Algorithms package.

6 Likes

As much as I hate having to maintain my own functions for > 2, it will be bamboozling to have 3 here and not for the synchronous version or zip.

Aside from that, :+1:.

I have a feeling as soon as we can get variadic generics that will be a really prime candidate.

2 Likes

Any reason not provide an instance method on AsyncSequence for chaining?

extension AsyncSequence {
  // or some name like followed(by:)
  public func chained<S>(with other: S) -> some AsyncSequence<Element> where S.Element == Self.Element { ... }
}

The need for N-arity free chain functions would be alleviated:

let lines = preamble
  .chained(with: URL(fileURLWithPath: "/tmp/Sample.swift").lines)
  .chained(with: suffix)

This proposal followed the design of the swift-algorithms version; which is a free floating function.

The swift-algorithms version began as a chained method as you described, and evolved to the free function version before 1.0 was released. I never learned why that was, but I’d imagine the reasoning for switching to the free function version are on these forums somewhere.


+1 to the pitch.

It was discussed here and merged here. Personally I still disagree with the reasoning. Given there's an order to the chain, it makes a lot of sense that it's an instance method, not a global.

3 Likes

I see, I don't agree with the logic presented in that discussion but this is not the place to discuss it.

+1 to the pitch.

+1 This is a relatively simple but useful algorithm. Also took al look into the implementation and it is very straight forward.

+1 to the pitch as well.

+1