Allow TaskGroup's ChildTaskResult Type To Be Inferred

Currently to create a new task group, there are two generics involved: ChildTaskResult and GroupResult. The latter can often be inferred, but the former must always be supplied as part of either the withTaskGroup(of:returning:body:) or withThrowingTaskGroup(of:returning:body:) function. For example:

let s = withTaskGroup(of: Void.self) { group in
  group.addTask { /* ... */ }
  group.addTask { /* ... */ }
  group.addTask { /* ... */ }

  return "Hello, world!"
}

The type of s (which is the GroupResult generic) is inferred above. However, the return value of the addTask closures cannot be inferred and must be supplied via of childTaskResultType: ChildTaskResult.Type (above as Void). However, it seems like this can be inferable if the function signatures are updated. Right now, withTaskGroup(of:returning:body:) looks like:

public func withTaskGroup<ChildTaskResult, GroupResult>(
    of childTaskResultType: ChildTaskResult.Type,
    returning returnType: GroupResult.Type = GroupResult.self,
    body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult
) async -> GroupResult where ChildTaskResult : Sendable

withThrowingTaskGroup(of:returning:body:) essentially looks the same with regards to the two generics. If this is updated to add a default value for childTaskResultType like so:

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

Then it seems like in most cases the ChildTaskResult generic can be inferred. I'm curious if there's a reason that this cannot be done: as in, is there something I'm missing or if there is another reason that the API was designed to not have this generic inferable (with the default argument). Reading through SE-0304 Structured Concurrency I couldn't find anything about this API being designed specifically in this way.

It seems like this would be a good improvement for the API overall. The initial (albeit silly) example could become:

let s = withTaskGroup { group in
  group.addTask { /* ... */ }
  group.addTask { /* ... */ }
  group.addTask { /* ... */ }

  return "Hello, world!"
}

The withTaskGroup(of:returning:body:) and withThrowingTaskGroup(of:returning:body:) APIs are already somewhat confusing, especially for new developers of the APIs, so being able to reduce the need to understand the different generics by having them inferred more often seems like a win.

10 Likes

Hmmm interesting idea, it actually works out pretty well and even the errors are not too bad.

Although I don't think the void case I'd like to "make work" because it would mean yet another overload... The of childTaskResultType: ChildTaskResult.Type, inference doesn't happen for a "returns nothing" group.addTask {} but it does for a group.addTask { () } . I wouldn't want to add yet another overload to the multiple ones we have there tbh just to handle the void case.

But for actual values yeah it seems this might be viable:

@available(macOS 10.15, *)
func test() async {
    await withTaskGroup { group in // pretty ok error: Generic parameter 'ChildTaskResult' could not be inferred
        group.addTask {
        }
    }

    await withTaskGroup { group in
        group.addTask {
            () // OK inferred TaskGroup<Void>
        }
    }
    
    await withTaskGroup { group in
        group.addTask {
            "" // OK inferred TaskGroup<String>
        }
    }
    
  await withTaskGroup { group in
      group.addTask {
          ""
      }
      group.addTask {
          12 // pretty good error: Cannot convert value of type 'Int' to closure result type 'String'
      }
  }
}

Since adding default values to parameters doesn't seem to have ABI impact (as far as I can tell in a quick check), we could easily roll this out hmm...

WDYT @hborla @Slava_Pestov - unless there's some type inference reasons we shouldn't do so it seems like a possible change.

1 Like

As a new developer, and somebody who has recently been learning about concurrency in a toy project, I agree that this would simplify things. I would welcome this quality of life improvement.

Question: How would errors be caught if we create Tasks with different return types? How would this surface in the IDE? Would it possibly more confusing than having a โ€œground truthโ€ at the top of the statement?

My snippet pasted above directly answers this question, the error you get is the following (on that specific line):

You could still spell it out as withTaskGroup(of: String.self).


I'm pretty supportive of this, just need to verify if there's some unpredicted problem with this in practice.

We should also keep in mind that once we get ~Escapable types I hope we'll be able to get rid of the "with" in the API entirely, since an un-escapable task group could -- potentially -- then just be initialized:

func test() async {
  var group = TaskGroup(of: Int.self) // : ~Escapable 
  group.addTask {}

in which case I think the of isn't as confusing as it might be with the with... style tbh...

So that's another thing to consider as we evolve these APIs to become more ergonomic.

6 Likes

Thanks!

This would be an even better quality of life improvement. In my opinion, Swift's closures are one of the most confusing language features, and they complicate understanding of (already complicated) working with Tasks.

1 Like

In my quick look at this, it seemed like this case did work for me. Curious what we might be doing differently. However, I'm simply doing something like making a new withTaskGroup2 function that mimics the withTaskGroup signature, adding the = ChildTaskResult.self bit, and then having that function body call out to withTaskGroup(of:returning:body:), so perhaps we're doing different things to appease the compiler.

public func withTaskGroup2<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 {
    await withTaskGroup(of: childTaskResultType, returning: returnType, body: body)
}

...

// This compiles without issue.
await withTaskGroup2 { group in
    group.addTask { }
}

I personally think that the Void.self case is one of the most important, because I often find that people who are learning Swift Concurrency for the first time don't intuitively think of withTaskGroup(of: Void.self) immediately when trying to create a task group where the ChildTaskResult type is Void (which seems to be a fairly common case).

I wasn't aware that ~Escapable would enable this kind of setup. That definitely seems much more ergonomic in the long run for many use cases (though potentially more confusing in some situations; seems use case dependent perhaps, similar to the AsyncStream APIs). I'm actually curious to see how ~Escapable might fit into the Continuation APIs now since those are so often escaped; though I'm not sure if it's possible to have those fit the same model at this example. :thinking:

2 Likes

Hmm, Void case compiled okey actually on latest compiler... I must have had some old compiler because I copied those errors in my snippet from an Xcode build... I'll verify this further.

Having that said, the Void case looking ugly was IMHO good because it made people sometimes realize that maybe they should be using a withDiscardingTaskGroup instead :wink: But yeah I'm not hung up on making anything look uglier than it needs to.

Continuations (e.g withCheckedContinuation etc) are intended to be escaped, so I don't think nonescapable will be affecting them much tbh.

I like the idea in principle, although one stumbling block is that even with the explicit typing right now, it's still too easy to get weird, unhelpful compiler errors regarding the closure's signature vs what withTaskGroup wants. I worry that adding more type inference to this mix will make it exponentially worse.

To be clear, it's not nearly as bad as result builders, but it's bad enough that even after years of Swift I still hit problems with this situation what seems like half the time.

3 Likes

+1, type inference is also quite often can be the cause of significant slow down of compilation, so even though Swift can do that, it better hint in complex cases, where withTaskGroup is one of that cases.

It is probably better to actually wait for non-escapable types to get just better API for them in overall as in @ktoso's example.

2 Likes

Any API that does not have default metatype arguments should be audited and amended as detailed here.

Wouldnโ€™t it just be: var group = TaskGroup<Int>()?

Yeah we could do that, that's a fair point.

Although it's also worth forward-thinking about that we'll want to converge the ThrowingTaskGroup and TaskGroup thanks to typed throws (as part of the Typed throws in the Concurrency module proposal which sadly had to be delayed out of 6.0 precisely because of this task group renaming/converging issue being pretty tricky to pull off).

So in the not-so-far-away future, where we've gotten rid of ThrowingTaskGroup, we'd have TaskGroup<Int, Never>() or TaskGroup<Int, Error>(), the Discarding group would still be different but we'd cut down from 4 independent types to just 2.

3 Likes

@ktoso this will not be possible without being able to do async cleanup and "catch" errors. with[Throwing]TaskGroup waits until all child tasks have completed and neither ~Escapable nor ~Copyable have the ability to do this as pitched today. In addition, if an error is thrown from the withThrowingTaskGroup closure all child task are cancelled, also not supported by any pitched feature with the proposed syntax.

2 Likes

Yeah, I should probably emphasize that this at this is aspirational end-goal I know we're interested in exploring, I'm not proposing this right now so I've not really even thought about what work it entails. It has been recognized through that the ergonomics of groups are troublesome and we'd like to improve them. Yeah, what I wrote here isn't possible with just ~escaping; I oversimplified heh we'd need some "resource handler / scope" or "using thing" :slight_smile:

Anyway, today we could do the inference of the child return type, but I'm not super sure if it really helps that much. I've not heard back if there's any type checker worries about adopting this pattern, which is something we should consider before applying this specific change.

3 Likes

I'd certainly be curious to hear about the potential type checker concerns. If there aren't any, I feel like this change would be beneficial since it seems like we're probably a ways out for any large changes to the task group API (though it does seem like potential changes could help with the API usage eventually :slightly_smiling_face:).

3 Likes

We looked into this a bit with the rest of the team and it seems to be a change we're able to make indeed.

@xedin noted that if groups were commonly passed as an argument to something overloaded then this might end up causing some not great type checking performance. And also that the first expression using a group is what determines the type, so an example like this may be surprising to some:

func test<T, U>(_: T.Type = T.self, _: U.Type = U.self, body: (inout TaskGroup<T>) -> U) {
}

test { g in 
  // this fails/passes depending on order of cancel / addTask within the closure
  // as the inference is based off the addTask and goes "top to bottom"

    g.cancelAll()

    g.addTask {
        print("hello")
        ()
    }
}

which fails if like this

/private/tmp/test/Sources/main.swift:9:1: error: generic parameter 'T' could not be inferred
test { g in
^
/private/tmp/test/Sources/main.swift:6:6: note: in call to function 'test(_:_:body:)'
func test<T, U>(_: T.Type = T.self, _: U.Type = U.self, body: (inout TaskGroup<T>) -> U) {
     ^

however if the addTask is the first thing in the closure this would compile fine. Having that said, it is not typical to do "other things" to the group before the addTask, so this is a minor concern - just something to be aware of.

In the end though none of these are frequent or big problems with groups, so while there's some time until we'd be able to implement a with...-less task group, let's improve this bit for now.

@rlziii I think this is worthy of a proposal, I'll reach out to figure out how I can help you out to get it done swiftly :slight_smile:

// For reference, task groups were introduced in 5.5, and the type inference capability that enables this idea here was introduced in Swift 5.7 (SE-0326), and we missed that we could apply it to groups it seems.

3 Likes

That does seem to generally be the case, looking at the first page of GitHub results.

But what if you do cancellAll inside a defer block first, e.g. like Signal does?

You'd have to keep the current spelling with the explicit type. That's just inference works in these situations and that's expected / fine tbh.

That sounds great. I know itโ€™s a busy week right now (:eyes:), but I look forward to getting this setup. :slightly_smiling_face:

1 Like