Swift Digest
SE-0442 | Swift Evolution

Allow TaskGroup’s ChildTaskResult Type To Be Inferred

Proposal
SE-0442
Authors
Richard L Zarth III
Review Manager
Doug Gregor
Status
Implemented (Swift 6.1)

01 何が問題だったのか

withTaskGroup(of:returning:body:) および withThrowingTaskGroup(of:returning:body:)TaskGroup / ThrowingTaskGroup を作る際、ジェネリックパラメータのうち子タスクの結果型 ChildTaskResult は必ず of: 引数で明示的に指定する必要がありました。

let messages = await withTaskGroup(of: Message.self) { group in
  for id in ids {
    group.addTask { await downloadMessage(for: id) }
  }

  var messages: [Message] = []
  for await message in group {
    messages.append(message)
  }
  return messages
}

withTaskGroup にはもう一つ GroupResult というジェネリックパラメータがあり、こちらはクロージャの戻り値から多くの場合で推論されます(上の例では [Message])。しかし ChildTaskResultaddTask(...) に渡すクロージャの戻り値から推論されず、初学者にとっては ChildTaskResultGroupResult の違いや、どちらにどの型を書くべきかが直感的ではありませんでした。

特に子タスクの結果が Void の場合、次のように of: Void.self を明示しなければならず、なぜこれが必要なのかが分かりづらいという問題がありました。

let logCount = await withTaskGroup(of: Void.self) { group in
  for id in ids {
    group.addTask { await logMessageReceived(for: id) }
  }

  return ids.count
}

なお、子タスクの結果を破棄する withDiscardingTaskGroup(returning:body:)withThrowingDiscardingTaskGroup(returning:body:) は、そもそも子タスクが常に Void を返す前提なので ChildTaskResult ジェネリックを持っておらず、この問題は発生しません。

02 どのように解決されるのか

withTaskGroup(of:returning:body:)withThrowingTaskGroup(of:returning:body:)of childTaskResultType: ChildTaskResult.Type 引数にデフォルト値 ChildTaskResult.self を追加し、ChildTaskResultGroupResult と同様にクロージャからの推論に委ねられるようにします。

public func withTaskGroup<ChildTaskResult, GroupResult>(
    of childTaskResultType: ChildTaskResult.Type = ChildTaskResult.self, // 追加されたデフォルト値
    returning returnType: GroupResult.Type = GroupResult.self,
    body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult
) async -> GroupResult where ChildTaskResult : Sendable

これは SE-0326 による複数文クロージャの引数・戻り値の型推論の改善により可能になったものです。これを利用すると、冒頭の例は次のように簡潔に書けるようになります。

// `(of: Message.self)` を書かなくてよい
let messages = await withTaskGroup { group in
  for id in ids {
    group.addTask { await downloadMessage(for: id) }
  }

  var messages: [Message] = []
  for await message in group {
    messages.append(message)
  }
  return messages
}

ここでは ChildTaskResultMessage に、GroupResult[Message] に推論されます。子タスクの結果が Void のケースも同様で、of: Void.self を書く必要がなくなります。

let logCount = await withTaskGroup { group in
  for id in ids {
    group.addTask { await logMessageReceived(for: id) }
  }

  return ids.count
}

推論の仕組みと注意点

型推論は top-down で行われ、クロージャの中で group を最初に使う文から ChildTaskResult が決まります。そのため、addTask(...) 以外の呼び出しが先に来ると推論に失敗することがあります。

// `ChildTaskResult` を `Void` にしたい想定
await withTaskGroup { group in // エラー: Generic parameter 'ChildTaskResult' could not be inferred
    // `addTask(...)` より先に `group` を使っているため推論できない
    group.cancelAll()

    for id in ids {
      group.addTask { await logMessageReceived(for: id) }
    }
}

このような場合は、従来どおり of: で明示的に型を指定すれば解決します。

await withTaskGroup(of: Void.self) { group in
    group.cancelAll()

    for id in ids {
      group.addTask { await logMessageReceived(for: id) }
    }
}

もう一つ、addTask(...) のクロージャが異なる型を返している場合もエラーになります。この場合は最初の addTask(...) の戻り値から推論された型に後続が一致しないため、従来どおり 2 番目以降のクロージャに対してエラーが出ます。

await withTaskGroup { group in
    group.addTask { await downloadMessage(for: id) }
    // エラー: Cannot convert value of type 'Void' to closure result type 'Message'
    group.addTask { await logMessageReceived(for: id) }
}

addTask(...) をタスクグループの最初の文にするという一般的な使い方であれば、これらの落とし穴に遭遇することはほとんどありません。型を明示したい場合はこれまでどおり of: を書けばよく、本提案は既存コードの互換性を損なわずに of: を省略可能にするものです。