|
| 1 | +# Allow TaskGroup's ChildTaskResult Type To Be Inferred |
| 2 | + |
| 3 | +* Proposal: [SE-NNNN](NNNN-allow-taskgroup-childtaskresult-type-to-be-inferred.md) |
| 4 | +* Author: [Richard L Zarth III](https://github.com/rlziii) |
| 5 | +* Review Manager: TBD |
| 6 | +* Status: **Awaiting review** |
| 7 | +* Implementation: [apple/swift#74517](https://github.com/apple/swift/pull/74517) |
| 8 | +* Review: ([pitch](https://forums.swift.org/t/allow-taskgroups-childtaskresult-type-to-be-inferred/72175)) |
| 9 | + |
| 10 | +## Introduction |
| 11 | + |
| 12 | +`TaskGroup` and `ThrowingTaskGroup` currently require that one of their two generics (`ChildTaskResult`) always be specified upon creation. Due to improvements in closure parameter/result type inference introduced by [SE-0326](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0326-extending-multi-statement-closure-inference.md) this can be simplified by allowing the compiler to infer both of the generics in most cases. |
| 13 | + |
| 14 | +## Motivation |
| 15 | + |
| 16 | +Currently to create a new task group, there are two generics involved: `ChildTaskResult` and `GroupResult`. The latter can often be inferred in many cases, but the former must always be supplied as part of either the `withTaskGroup(of:returning:body:)` or `withThrowingTaskGroup(of:returning:body:)` function. For example: |
| 17 | + |
| 18 | +```swift |
| 19 | +let messages = await withTaskGroup(of: Message.self) { group in |
| 20 | + for id in ids { |
| 21 | + group.addTask { await downloadMessage(for: id) } |
| 22 | + } |
| 23 | + |
| 24 | + var messages: [Message] = [] |
| 25 | + for await message in group { |
| 26 | + messages.append(message) |
| 27 | + } |
| 28 | + return messages |
| 29 | +} |
| 30 | +``` |
| 31 | + |
| 32 | +The type of `messages` (which is the `GroupResult` type) is correctly inferred as `[Message]`. However, the return value of the `addTask(...)` closures is not inferred and currently must be supplied to the `of:` parameter of the `withTaskGroup(of:returning:body:)` function (e.g. `Message`). The correct value of the generic can be non-intuitive for new users to the task group APIs. |
| 33 | + |
| 34 | +Note that `withDiscardingTaskGroup(returning:body:)` and `withThrowingDiscardingTaskGroup(returning:body:)` do not have `ChildTaskResult` generics since their child tasks must always be of type `Void`. |
| 35 | + |
| 36 | +## Proposed solution |
| 37 | + |
| 38 | +Adding a default `ChildTaskResult.self` argument for `of childTaskResultType: ChildTaskResult.Type` will allow `withTaskGroup(of:returning:body:)` to infer the type of `ChildTaskResult` in most cases. The currently signature of `withTaskGroup(of:returning:body:)` looks like: |
| 39 | + |
| 40 | +```swift |
| 41 | +public func withTaskGroup<ChildTaskResult, GroupResult>( |
| 42 | + of childTaskResultType: ChildTaskResult.Type, |
| 43 | + returning returnType: GroupResult.Type = GroupResult.self, |
| 44 | + body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult |
| 45 | +) async -> GroupResult where ChildTaskResult : Sendable |
| 46 | +``` |
| 47 | + |
| 48 | +The function signature of `withThrowingTaskGroup(of:returning:body:)` is nearly identical, so only `withTaskGroup(of:returning:body:)` will be used as an example throughout this proposal. |
| 49 | + |
| 50 | +Note that the `GroupResult` generic is inferrable via the `= GroupResult.self` default argument. This can also be applied to `ChildTaskResult` as of [SE-0326](0326-extending-multi-statement-closure-inference.md). As in: |
| 51 | + |
| 52 | +```swift |
| 53 | +public func withTaskGroup<ChildTaskResult, GroupResult>( |
| 54 | + of childTaskResultType: ChildTaskResult.Type = ChildTaskResult.self, // <- Updated. |
| 55 | + returning returnType: GroupResult.Type = GroupResult.self, |
| 56 | + body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult |
| 57 | +) async -> GroupResult where ChildTaskResult : Sendable |
| 58 | +``` |
| 59 | + |
| 60 | +This allows the original example above to be simplified: |
| 61 | + |
| 62 | +```swift |
| 63 | +// No need for `(of: Message.self)` like before. |
| 64 | +let messages = await withTaskGroup { group in |
| 65 | + for id in ids { |
| 66 | + group.addTask { await downloadMessage(for: id) } |
| 67 | + } |
| 68 | + |
| 69 | + var messages: [Message] = [] |
| 70 | + for await message in group { |
| 71 | + messages.append(message) |
| 72 | + } |
| 73 | + return messages |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +In the above snippet, `ChildTaskResult` is inferred as `Message` and `GroupResult` is inferred as `[Message]`. Not needing to specify the generics explicitly will simplify the API design for these functions and make it easier for new users of these APIs, as it can currently be confusing to understand the differences between `ChildTaskResult` and `GroupResult`. This can be especially true when one or both of those is `Void`. For example: |
| 78 | + |
| 79 | +```swift |
| 80 | +let logCount = await withTaskGroup(of: Void.self) { group in |
| 81 | + for id in ids { |
| 82 | + group.addTask { await logMessageReceived(for: id) } |
| 83 | + } |
| 84 | + |
| 85 | + return ids.count |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +In the above example, it can be confusing (and not intuitive) to know that `Void.self` is needed for `ChildTaskResult` and the compiler does not currently give great hints for what that type should be or steering the user into fixing the generic argument if it is mismatched (for example, if the user swaps `Int.self` for `Void.self` in the above example). With the proposed solution, the above can become the following example with type inference used for both generic arguments: |
| 90 | + |
| 91 | +```swift |
| 92 | +let logCount = await withTaskGroup { group in |
| 93 | + for id in ids { |
| 94 | + group.addTask { await logMessageReceived(for: id) } |
| 95 | + } |
| 96 | + |
| 97 | + return ids.count |
| 98 | +} |
| 99 | +``` |
| 100 | + |
| 101 | +## Detailed design |
| 102 | + |
| 103 | +Because type inference is top-down, it relies on the first statement that uses `group` to infer the generic arguments for `ChildTaskResult`. Therefore, it is possible to get a compiler error by creating a task group where the first use of `group` does not use `addTask(...)`, like so: |
| 104 | + |
| 105 | +```swift |
| 106 | +// Expect `ChildTaskResult` to be `Void`... |
| 107 | +await withTaskGroup { group in // Generic parameter 'ChildTaskResult' could not be inferred |
| 108 | + // Since `addTask(...)` wasn't the first statement, this fails to compile. |
| 109 | + group.cancelAll() |
| 110 | + |
| 111 | + for id in ids { |
| 112 | + group.addTask { await logMessageReceived(for: id) } |
| 113 | + } |
| 114 | +} |
| 115 | +``` |
| 116 | + |
| 117 | +This can be fixed by going back to specifying the generic like before: |
| 118 | + |
| 119 | +```swift |
| 120 | +// Expect `ChildTaskResult` to be `Void`... |
| 121 | +await withTaskGroup(of: Void.self) { group in |
| 122 | + group.cancelAll() |
| 123 | + |
| 124 | + for id in ids { |
| 125 | + group.addTask { await logMessageReceived(for: id) } |
| 126 | + } |
| 127 | +} |
| 128 | +``` |
| 129 | + |
| 130 | +However, this is a rare case in general since `addTask(...)` is generally the first `TaskGroup`/`ThrowingTaskGroup` statement in a task group body. |
| 131 | + |
| 132 | +It is also possible to create a compiler error by returning two different values from an `addTask(...)` closure: |
| 133 | + |
| 134 | +```swift |
| 135 | +await withTaskGroup { group in |
| 136 | + group.addTask { await downloadMessage(for: id) } |
| 137 | + group.addTask { await logMessageReceived(for: id) } // Cannot convert value of type 'Void' to closure result type 'Message' |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +The compiler will already give a good error message here, since the first `addTask(...)` statement is what determined (in this case) that the `ChildTaskResult` generic was set to `Message`. If this needs to be made more clear (instead of being inferred), the user can always specify the generic directly as before: |
| 142 | + |
| 143 | +```swift |
| 144 | +await withTaskGroup(of: Void.self) { group in |
| 145 | + // Now the error has moved here since the generic was specified up front... |
| 146 | + group.addTask { await downloadMessage(for: id) } // Cannot convert value of type 'Message' to closure result type 'Void' |
| 147 | + group.addTask { await logMessageReceived(for: id) } |
| 148 | +} |
| 149 | +``` |
| 150 | + |
| 151 | +## Source compatibility |
| 152 | + |
| 153 | +Omitting the `of childTaskResultType: ChildTaskResult.Type` parameter for both `withTaskGroup(of:returning:body:)` and `withThrowingTaskGroup(of:returning:body:)` is new, and therefore the inference of `ChildTaskResult` is opt-in and does not break source compatibility. |
| 154 | + |
| 155 | +## ABI compatibility |
| 156 | + |
| 157 | +No ABI impact since adding a default argument value is binary compatible change. |
| 158 | + |
| 159 | +## Implications on adoption |
| 160 | + |
| 161 | +This feature can be freely adopted and un-adopted in source |
| 162 | +code with no deployment constraints and without affecting source or ABI |
| 163 | +compatibility. |
| 164 | + |
| 165 | +## Future directions |
| 166 | + |
| 167 | +### TaskGroup APIs Without the "with..." Closures |
| 168 | + |
| 169 | +While not possible without more compiler features to enforce the safety of a task group not escaping a context, and having to await all of its results at the end of a "scope..." it is an interesting future direction to explore a `TaskGroup` API that does not need to resort to "with..." methods, like this: |
| 170 | + |
| 171 | +```swift |
| 172 | +// Potential long-term direction that might be possible: |
| 173 | +func test() async { |
| 174 | + let group = TaskGroup<Int>() |
| 175 | + group.addTask { /* ... */ } |
| 176 | + |
| 177 | + // Going out of scope would have to imply `group.waitForAll()`... |
| 178 | +} |
| 179 | +``` |
| 180 | + |
| 181 | +If we were to explore such API, the type inference rules would be somewhat different, and a `TaskGroup` would likely be initialized more similarly to a collection: `TaskGroup<Int>`. |
| 182 | + |
| 183 | +This proposal has no impact on this future direction, and can be accepted as is, without precluding future developments in API ergonomics like this. |
| 184 | + |
| 185 | +## Alternatives considered |
| 186 | + |
| 187 | +The main alternative is to do nothing; as in, leave the `withTaskGroup(of:returning:body:)` and `withThrowingTaskGroup(of:returning:body:)` APIs like they are and require the `ChildTaskResult` generic to always be specified. |
| 188 | + |
| 189 | +## Acknowledgments |
| 190 | + |
| 191 | +Thank you to both Konrad Malawski ([@ktoso](https://github.com/ktoso)) and Pavel Yaskevich ([@xedin](https://github.com/xedin)) for confirming the viability of this proposal/idea, and for Konrad Malawski ([@ktoso](https://github.com/ktoso)) for helping to review the proposal and implementation. |
0 commit comments