Skip to content

Commit eec5f1e

Browse files
authored
Allow TaskGroup's ChildTaskResult Type To Be Inferred (#2494)
* Initial WIP proposal document. * Minor updates. * Update proposal document with suggstions. * Update implementation link. * Add GitHub user links. * Update to provide addTask(...) closure bodies and provide clarity in the first sentence of the Design Details section. * Update examples.
1 parent 305861d commit eec5f1e

File tree

1 file changed

+191
-0
lines changed

1 file changed

+191
-0
lines changed
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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

Comments
 (0)