[Pitch] Isolated default value expressions

I guess I still think it's unfortunate that for a non-isolated default argument you'd write arg: Type = value() but for an isolated default argument you'd have to write arg: @autoclosure @GlobalActor () -> Type = globalActorValue():

Now

  • in the function, you have to evaluate each default argument manually
  • you have to be careful to create a local variable for each evaluation, to avoid multiply-evaluating the argument, which might have side-effects
  • caller-specified arguments are also autoclosed, which may incur additional unexpected sendability or isolation checking on the caller side:
struct StackViewParameters: Sendable {
    // ...
    var spacing: CGFloat = UIStackView.spacingUseDefault // uh-oh, this is marked `@MainActor`
}

Under the original proposal, this is OK-ish; you get the default argument if constructing this type on the main actor, you have to await it if you're not on the main actor.

If instead this gets turned into autoclosures, the type inferred for this autoclosure is @MainActor () -> CGFloat, which now effectively forces all initializations of this type onto the main actor? So I think that can't be the "standard" transformation done automatically by the compiler?

I don't think there's a "having your cake and eating it" solution here, though.

3 Likes

Of course there isn't! There almost never is :slightly_smiling_face: I also think the tension is fundamental; actor isolation introduces another dimension for API authors to think about because there's a big semantic difference between the caller's context and the callee's context.

If you're introducing an API that can cross an isolation boundary when called, you need to think carefully about both actor isolation and Sendability of the arguments and the result value. If you want an argument value to always be evaluated in the caller's isolation, then you don't have to do anything and your default argument must always be nonisolated. If you want an argument value to always be evaluated in the callee's isolation, then the argument should be wrapped in a closure (and today that must be done explicitly due to the issues you mentioned with @autoclosure). There's also the possibility that you want the evaluation of an argument to be polymorphic over actor isolation. This is how the Task initializer behaves using @_inheritsActorContext with an async closure, and we'd need more sophisticated isolation polymorphism tools if we wanted to introduce a static func run method on the GlobalActor protocol (or a func run method on the Actor protocol) so that the given closure is isolated to self.

There is no straightforward answer. I think the most common use case will follow a pattern where the default argument and the explicit argument want the same isolation context. For example, if you have a family of @MainActor-isolated types that are composed together, an explicit argument to a @MainActor-isolated memberwise initializer is commonly some custom initialized MainActor-isolated type:

@MainActor struct MediaPlayer {
  var state: PresentationState = PresentationState()
}

@MainActor struct PresentationState {
  var currentlyPlaying: Video? = nil
}


// From off the MainActor

await MediaPlayer() // pretend this is allowed somehow

await MediaPlayer(state: .init(currentlyPlaying: /*existing Video here*/))

Basing the actor isolation of the argument on whether or not that argument is explicit doesn't help here; if you provide an explicit argument, you probably still want it to be evaluated in the initializer's isolation domain to avoid unnecessary actor hops.

I think the solution in the proposal right now privileges the case where the API is primarily called from within the same isolation domain. Using closures privileges the case where the API is primarily called from across isolation boundaries.

You're right about actor isolation, but not about Sendable; an argument that violates Sendable rules when captured in a closure will also violate Sendable rules when passed directly. However, wrapping an expressions in a closure can remove Sendable diagnostics in some cases; for example, if the expression is constructing a new non-Sendable value, delaying that evaluation until after you're already in the callee's isolation domain is totally fine. Evaluating it in the caller's context and then passing it across isolation boundaries is not.

EDIT: Sorry, you're not wrong about the Sendable implications. Of course, if your expression makes a call within the caller's isolation boundary that accepts non-Sendable arguments but returns a Sendable value, then that's okay to do directly but that would violate Sendable when capturing those arguments in a closure.

I was imagining that @MainActor @autoclosure () -> CGFloat only applies to the memberwise initializer. The same rules for using default stored property values in the proposal would apply for explicit initializers and the default initializer. Of course, this means that the memberwise initializer would not be able to initialize stored properties with conflicting actor isolation, but I think that's reasonable behavior.

2 Likes

If we wanted to go down the road of callee-evaluated default arguments, I think we’d probably have to do something like make the “real” parameter implicitly optional and then evaluate the default argument in the callee if nil was passed. But yeah, this still has the cost of making the presence of a default argument ABI, and it’s not clear that it’s not better to just have the programmer do this explicitly.

4 Likes

I agree this is likely to be the most common case, and furthermore I think it will be common that the argument (implicit or explicit) additionally wants the same isolation as the body of the callee—is it feasible to have this as a special case that will 'just work'? I.e., if we see that the callee f and an argument e both require isolation to global actor G we would emit the call as the equivalent of:

await G.run { f(e) }

rather than doing multiple hops? This wouldn't work in situations like the one you note above where bits of the caller's context are required to remain in the caller's isolation domain:

but if we aren't requiring API authors to mark their arguments as @autoclosure maybe that would be okay, and we can emit the multi-hop code in this case?

To me, it just feels like the intent is clear that if I write:

@MainActor
func f(_ a: Arg = requiresMainActor()) { ... }

I would expect this to 'just work' on the caller's side because they are by definition going to be calling into a context that requires @MainActor isolation anyway. And on the caller's side, it seems to me like between the "caller shouldn't care where callee needs to run" tenet and the "a single await can swallow all suspension points in an expression" rule, a caller who writes:

await f()

has also expressed sufficient intent of "suspend if necessary and let f do it's thing" that we shouldn't be bothering them with details unless we have a good reason to—e.g., they're using non-Sendable arguments in the expression. At that point I think a warning makes sense to say, for example "use of non-Sendable values in argument expression requires multiple suspension points to call f."

2 Likes

Speaking as someone who's casually following this thread but not versed on the details, the above seems like a particularly important point, from the "lay-coder" perspective.

You can perhaps imagine someone might specifically design their API so that everything - their functions and default arguments thereof - are correctly all aligned into the same isolation domain. But then to find that this doesn't actually work if the caller isn't also in the same domain, well, that would be disappointing

I don't fully comprehend the implementation difficulties that @hborla et al have alluded to, but this feels like it might be one of those things that's worth a little herculean effort from the compiler (re. managing concurrency domains, minimising hops, etc), because the result is elegant.

It's totally possible that my concerns about allowing this are exaggerated, and perhaps the optimizer can eliminate unnecessary actor hops in most cases. I'm just uncomfortable with the fact that the basic model of hopping back and forth for default arguments is not what I think people will expect, and we'd be leaning on the optimizer to meet that expectation.

Maybe it's possible to add some additional restrictions to the current proposal that allow the model to meet the expectation. Something like:

  • All default arguments that require isolation must require the same isolation. So, the same function cannot have one default argument that requires @MainActor and another that requires @AnotherActor.
  • Required isolation for default arguments of functions that themselves have isolation should be the same. So, a nonisolated function can have a default argument that requires @MainActor, but a @MainActor-isolated function cannot have a default argument that requires @AnotherActor.

With these restrictions in place, maybe it's possible to always evaluate isolated default arguments after the hop to the callee's isolation? Some implications / open questions that I can think of:

  • The caller needs to hop for global-actor isolated async functions if the function has any isolated default arguments. (Today, the hop for async functions happens in the callee)
  • Argument evaluation between formal variable access and default arguments would be different for functions with default arguments that share an isolation domain with the callee. I don't know if this difference is observable or if there are any implications that make this a nonstarter.
4 Likes

I think that's reasonable, at least as a starting point (removing those restrictions could hypothetically be the subject of some future proposal). In cases where there's multiple different isolation domains (amongst the default arguments), it's not unambiguous what should happen (or at least, in what order - and order might be significant). Whereas if they're all in the function's domain it's not a question of what should happen, just (for the compiler) how.

I'm with @Jumhyn that I see no issue - from a programmer's perspective - with just putting this all under the same await as for the function call itself, and letting the compiler figure out the boilerplate. Even if that means there needs to be some arbitrary rules about execution order or similar.

On that note, does Swift have specific ordering guarantees for argument evaluation? I guess left-to-right? That could permit some pathological behaviour if Swift ever does allow multi-isolation-domain function declarations, e.g. if you have ten arguments alternating between @OneActor and @AnotherActor.

Yes, it's left-to-right for each of the categories (explicit rvalue arguments, default arguments, formal access)

Right, that's exactly the behavior I want to define away. If a line of code that looks like this

await useDefaults()

can hop back and forth between two different actors an arbitrary number of times before entering the call to useDefaults, it seems like we'd have taken a wrong turn somewhere!

1 Like

How is that any different than useDefaults itself hopping between domains before performing its local work? From the callers perspective there's no difference whether useDefaults executes everything on one actor or a hundred. In fact, that sounds exactly like the previous suggestion where the default parameters execute (as if they were defined) in the callees context. Overall it doesn't seem difficult to explain this behavior to users. This doesn't seem at all inconsistent with how async functions work in general, so what's the problem here?

2 Likes

It's that the implementation is unclear. Should you actually hop between isolation domains for every argument or would it actually be fine to just 'batch' all the arguments within each domain into one hop? Noting that the latter changes the order in which they're evaluated from what the language has, at least until now, always promised.

There's not just the principled angle - Swift (the language) has rules that humans need to be able to understand, reason about successfully, and rely on - but also the practical one of e.g. what if the arguments have dependencies such that their order of evaluation actually matters?

So, I'm assuming it's a crazy if not bad idea to change the ordering of argument evaluation…but admittedly I'm not sure. Because if the compiler were free to reorder them to minimise hops, then I'd be inclined to have it actually support and do that automatically, since it seems like that's indeed the best you can achieve in any case. Having the caveat that function authors need to be mindful of that order, re. dependencies between default arguments, seems plausibly acceptable.

That seems like a different discussion and not what prompted the statement I quoted.

Because, based on my understanding of argument evaluation, the difference is observable due to formal access evaluation happening after default argument evaluation. That means that an argument that you provide can be evaluated after all of those hops at the caller that are invisible to you. I think that's unexpected. I did already acknowledge above that it's totally possible that my concern is exaggerated.

EDIT: I also think that it's desirable to define away unnecessary hops where possible. For most common case that I anticipate that would make use of this proposal -- MainActor-isolated functions with MainActor-isolated defaults -- the hops seem completely avoidable. IMO, if there's a way to define the semantics such that that's always the case, we should do that rather than hand waving and leaving it up to the optimizer. For example, I've seen a ton of confusion around unnecessary hops to the generic executor when kicking off tasks, and while you can argue that the caller shouldn't need to care if the implementation of Task does that internally, or those unnecessary hops should be optimized away, it'd be much better if a direct hop to the executor that the task closure is isolated to is guaranteed in the semantic model. Sometimes programmers really do need to be able to reason about this.

2 Likes

I always thought the disconnect was between definition and implementation (as defined, there doesn't seem to be any need to hop to default executor before executing Task bodies, but the compiler just hasn't optimized that case away yet), not in the definition itself. But I've been talking about syntax (what I want to see), not other behavior, so I'll review more and come back.

The proposal seems strong to me. It fixes an issue and clarifies some other behaviors.

Sitting on this for a while, I found myself wondering whether it could be simplified by disallowing (at least in Swift 6) some of the more unusual cases. I'm thinking something like,

  • A function argument or stored property can always have a nonisolated default
  • An @GlobalActor function can have arguments with @GlobalActor defaults
  • An @GlobalActor type can have stored properties with @GlobalActor defaults
  • All other mixtures of isolations are disallowed, including but not limited to:
    • @GlobalActorA function with argument with @GlobalActorB default
    • type without isolation with property with @GlobalActor default
    • type with stored properties with mixed isolations, having defaults for any property whose isolation doesn't match that of the type
  • Nonisolated default arguments to functions are evaluated as currently, in their caller's actor and context
  • isolated default arguments to functions are evaluated in the caller's context, but after the jump to the callee's actor (so, still a single await)
    • this is the bit where I don't know enough about how actor hopping actually works to be sure if it's a reasonable thing to even suggest :woman_shrugging:
  • autogenerated memberwise initializer for a struct gains the isolation of the type, if any of the stored properties have isolated defaults (it can be explicitly nonisolated if none of the properties have isolated defaults?)

Most of the edge cases seem just that, edge-case-y, and this seems to cover the actually useful cases whilst providing actually useful behavior for default arguments to isolated functions?

1 Like

Yeah, I definitely agree that more restrictions at the declaration site would allow us to solve the default-argument-evaluation-from-another-domain problem in a pretty natural way. This is very much inline with the direction I mentioned a bit upthread:

This direction seems very promising, and I agree with this

It seems like the vast majority of use cases want default arguments that share the same isolation as the enclosing function, which would be covered with these restrictions in place. I'm going to revise the design to incorporate this. Thanks all for the discussion so far!

4 Likes