swiftswift-concurrency

How can I simplify/generalise the logic of writing a task group with different child result types?


I am trying to create a task group where each child task returns a different type of value. I found this approach, which uses an enum with associated values as the child result type, and each task returning a different case of that enum. For example:

enum TaskResult {
    case intTask(Int)
    case stringTask(String)
}

try await withThrowingTaskGroup(of: TaskResult.self) { group in
    group.addTask { TaskResult.intTask(try await getInt()) }
    group.addTask { TaskResult.stringTask(try await getString()) }

    var int: Int? = nil
    var string: String? = nil
    for try await result in group {
        switch result {
            case .intTask(let x): int = x
            case .stringTask(let x): string = x
        }
    }
    return (int!, string!)
}

That is a lot of boilerplate - the declaration of TaskResult, declaring the vars for each task, and the switch cases for every task. How can I reduce this boilerplate?


I am aware of async let, but async let forces each task to be awaited in a fixed order. This means I cannot "short-circuit" the parent task if any of the tasks throws an error. For example

async let a = getString()
async let b = getInt()
let result = try await (a, b)

This will always wait for getString to finish before anything else, even if getInt throws an error immediately.


Solution

  • I have written a macro to generate the boilerplate. The declaration uses a parameter pack to support any number of child tasks:

    @freestanding(expression)
    public macro variadicTaskGroup<each T: Sendable>(
        childResultTypes: (repeat each T).Type,
        tasks: repeat (() async throws -> each T)
    ) -> (repeat each T) = #externalMacro(module: "...", type: "VariadicTaskGroupMacro")
    

    Usage:

    let result = try await #variadicTaskGroup(
        childResultTypes: (String, Int).self,
        tasks:
            { try await getString() },
            { try await getInt() }
    )
    

    The implementation is mostly straightforward. Notably, I first assign the closure expressions in the macro arguments to a local variable c in group.addTask { ... }, and then call c. Otherwise I would have needed to find all the return statements in the ClosureExprSyntax and wrap the expression to be returned with TaskResult.valueN(...).

    enum VariadicTaskGroupMacro: ExpressionMacro {
        static func expansion(
            of node: some FreestandingMacroExpansionSyntax,
            in context: some MacroExpansionContext
        ) throws -> ExprSyntax {
            guard let firstArg = node.arguments.first?.expression.as(MemberAccessExprSyntax.self),
                  let tuple = firstArg.base?.as(TupleExprSyntax.self),
                  tuple.elements.count > 1 else {
                throw MacroExpansionErrorMessage("First argument must be a tuple metatype of more than 1 element")
            }
            let childResultTypes = tuple.elements.map { $0.expression }
            let closures = node.arguments.dropFirst().map { $0.expression }
    
            // for the TaskResult enum
            let taskResultDecl = try EnumDeclSyntax("enum TaskResult") {
                for (i, type) in childResultTypes.enumerated() {
                    "case value\(raw: i)(\(raw: type))"
                }
            }
    
            // list of statements in the withThrowingTaskGroup { ... } closure
            let taskGroupClosure = try CodeBlockItemListSyntax {
                for (i, (closure, type)) in zip(closures, childResultTypes).enumerated() {
                    """
                    group.addTask {
                        let c: () async throws -> \(raw: type) = \(closure)
                        return try await TaskResult.value\(raw: i)(c())
                    }
                    """
                    "var value\(raw: i): Optional<\(raw: type)> = nil;"
                }
                try ForStmtSyntax("for try await value in group") {
                    try SwitchExprSyntax("switch value") {
                        for i in 0..<childResultTypes.count {
                            SwitchCaseSyntax("case .value\(raw: i)(let x):") {
                                "value\(raw: i) = x;"
                            }
                        }
                    }
                }
                let tuple = TupleExprSyntax {
                    for i in 0..<childResultTypes.count {
                        LabeledExprSyntax(expression: ForceUnwrapExprSyntax(expression: DeclReferenceExprSyntax(baseName: "value\(raw: i)")))
                    }
                }
                "return \(tuple)"
            }
    
            // finally wrap the withThrowingTaskGroup call in another closure, so that the enum can be declared in there
            return """
            {
                \(taskResultDecl)
                return try await withThrowingTaskGroup(of: TaskResult.self, returning: \(tuple).self) { group in
                    \(taskGroupClosure)
                }
            }()
            """
        }
    }
    

    A limitation to this design is that there is no control over what happens between the group.addTask { ... } calls.