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 var
s 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.
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.