[Pitch] Isolated default value expressions

It's not different with respect to the suspension point being marked with await. I'm talking about the fact that all asynchronous function calls within the await'd expression are explicitly written in the code, and you can interact with them at the point of the call. Allowing default arguments to be evaluated asynchronously also begs the question of whether you should be allowed to write asynchronous function calls in the default expression itself. I prefer the simpler rule that I'm proposing here, at the expense of having to write out the default argument if you want to evaluate it from across isolation boundaries.

1 Like

I think it's reasonable to get everything working correctly first, at least, as @hborla is inclined to do. Afterwards, it can be considered whether the async initialiser case should get enhanced behaviour.

I don't have any qualms with the idea of default arguments executing (safely) in other actor contexts as part of my overall await init, but it doesn't seem like anyone could be really relying on this right now since - if I understand correctly - that situation is just as broken today as the sync case…?

The approach of enforcing constraints at the callsite and banning use of default values in non-isolated contexts seems consistent with the implementation of default arguments Swift uses, but I think it will be surprising to some API authors.

When I'm using default arguments, my goal is usually to spare the user from knowing that they need to provide a value until they actually need to set something other than the default.

But if users call my function from the wrong context, they suddenly become painfully aware of all the default arguments, and potentially have to look up the values so they can await them. If the defaults change in my library, the consumer's code won't change with it.

Looking at one of the examples in the pitch, I wonder if it's technically feasible to implement awaiting default arguments such that

useDefault(value: await requiresMainActor())

could become

await useDefault()

The error message if the user simply wrote useDefault() in that context could be something along the lines of "This function has default arguments that require isolation to MainActor, await the function itself to isolate them."

6 Likes

Would this proposal also apply to isolation domains that aren't global actors? For example, currently the below code generates a warning, because NSMutableString is not Sendable but is sent across an actor isolation boundary.

actor SomeActor {
  func doThing(string: NSMutableString = NSMutableString()) { ... }
}

let a = SomeActor()
await a.doThing()

We could make it so that the default value expression is executed within the isolation domain of a. Going further, we could do that for arguments that are written out at the call site. So this would be legal too:

let a = SomeActor()
await a.doThing(string: NSMutableString())
// okay, the `NSMutableString()` expression
// is implicitly isolated to `a`
1 Like

It's not about the syntax - it's definitely feasible to consider the await on the call as covering the default argument evaluation. The concern that I stated above is about the fundamental implications of evaluating an isolated default argument at a call-site that is across isolation boundaries. Argument evaluation semantics are a little complicated, but I believe the order of argument evaluation is:

  1. Explicit rvalue arguments
  2. Default arguments
  3. Formal variable access

Each of these evaluations happen in the caller's context. This means that even if we're able to hop to the required isolation only once to evaluate all default arguments (instead of hopping back and forth between the required isolation and the caller's isolation for each default argument), we still have to hop back to the caller's isolation for formal access evaluation, before hopping to the callee's isolation.

I think what people would expect for a @MainActor-isolated function with @MainActor-isolated default arguments is for the hop to the MainActor to happen once to evaluate the default arguments and then immediately enter the function, but I do not think we can make that work. Of course, writing out the arguments explicitly doesn't help with this at all. A better alternative is to write your code to get on the MainActor first before making the call:

let result = await MainActor.run { useDefault() }

The compiler could even emit a fix-it to do this in the case where the programmer attempts to use isolated default arguments from across isolation boundaries if all default arguments have the same isolation (or nonisolated).

3 Likes

No, I don't think we can change the fundamental isolation rules for call arguments. I agree that it should be valid to transfer non-Sendable values across isolation boundaries when they are not protected by the isolation domain they were created in (e.g. because it's just a local variable or a temporary), and that's the goal of a different pitch:

2 Likes

I think what people would expect for a @MainActor -isolated function with @MainActor -isolated default arguments is for the hop to the MainActor to happen once to evaluate the default arguments and then immediately enter the function, but I do not think we can make that work

Thanks for explaining this! I just want to make sure I understand: because of the existing argument evaluation order, the ways to "make it work" are

  • change the argument evaluation order for this special case, so that default args come last, after Formal Variable Access. And this is out of the question
  • have some potentially surprising and slow code get generated with an extra actor hop to preserve this evaluation order

And therefore it's better to let users decide exactly which set of actor hops make sense for their use case explicitly?

I think the only drawback to the explicit hop is that if you have a nonisolated function with a MainActor isolated argument like

@MainActor func requiresIsolation()

func useDefault(theDefault: Int = requiresIsolation()) // idk what the purpose of code like this is, but it sounds like it will be legal after the pitch is implemented, based on your prior comment? 

And then you change the definition of useDefault to switch to a new, non-isolated default, the compiler can't tell the author of the consuming code that their main actor hop is no longer necessary. Probably not a big deal in practice, unless there is a use case for this kind of code that I'm missing.

A better alternative is to write your code to get on the MainActor first before making the call... The compiler could even emit a fix-it to do this

Strong +1 to the fixit, though I understand fixing the bug is probably the first priority.

2 Likes

It still feels a bit weird to me that the function signature would essentially change depending on the caller's isolation context. It seems like the compiler should be able to communicate this clearly, which is a big mitigator, but still it feels a bit weird in principle.

Maybe it's just unfamiliarity; maybe in practice it'll work well enough and we'll get used to it.

Perhaps one of the FixIts offered should be to wrap the whole call [to init] in a movement to the necessary isolation context (await MainActor.run { … } or similar), where applicable (I guess only for global actors?).

Yes, that's exactly my thinking. I'll add this to the Alternatives Considered section so it's documented in the proposal.

Maybe the compiler can communicate this! There's an existing general problem where people write @MainActor (or @MainActor is inferred) on functions that really should be nonisolated because nothing in the function body actually requires running on the @MainActor. Part of my implementation for this proposal is adding a mode to the actor isolation checker that computes the required isolation of an expression or function body. I think we can use this mechanism to implement a warning for code that's running on the MainActor but doesn't need to be. If the required isolation of a function is nonisolated, there's no reason for it to have any specific isolation.

This is already the case for synchronous functions that are isolated to an actor, because calling such a function from another isolation domain will make the call implicitly asynchronous.

Yeah, default arguments can only ever be isolated to global actors, because there's no way to capture an actor instance in a default initializer expression to make it implicitly isolated to that instance. If there were ever a case where the compiler needed to emit a fix-it to isolate an expression to an actor instance, we'd need better control over closure actor isolation (not yet pitched, still undergoing early design iteration) in order to kick off a Task { [isolated actorInstance] in ... } that is explicitly isolated to the actor instance.

I think it'd be good to implement the fix-it we're discussing, because it seems like it'll important for understanding how best to address errors when you attempt to use a default argument from across isolation boundaries. I'll work on that, and hopefully we'll get some feedback from folks who try out the experimental implementation if/when this goes into review.

3 Likes

From a user's perspective I have a bit of concern about the fix-it. If I'm calling a function:

@MainActor
func usesMainActor(...) { ... }

func nonisolatedFunc() async {
  await usesMainActor()
}

and I get an error because usesMainActor has an isolated default expression somewhere in the signature, after applying the fix-it I have:

func nonisolatedFunc() async {
  await MainActor.run { usesMainActor() }
}

But to a subsequent reader of this code, I don't think the end state is particularly understandable... like, it's clear we're trying to move to the main actor, but part of the promise of Swift concurrency is that it's the callee which decides where it executes, and callers shouldn't need to care—they just have to suspend. IOW, I think your analysis here is totally right:

At the very least I think it may be worth privileging the above case. To me, using MainActor.run very much looks like an escape hatch used by someone who was trying to brute-force migrate old DispatchQueue.main.async code. If we had something to signify "move the evaluation of this entire expression to a global actor" e.g. await(MainActor) I think it would be wise to recommend that use here.

Again, though, I worry this slides back towards "callers need tell their callees where to run," counter to Swift concurrency's current philosophy.

This feels like an pretty unfortunate downside—the cost is not only additional code to write out, but we lose the behavior that default argument expressions may be updated transparently to the caller. Authors who wanted to maintain that behavior would essentially need to reinvent the current resilience regime where they define their own function for emitting each default argument they care about.

What sort of interactivity are you looking for here? In terms of debugging it seems like we should perfectly well be able to 'step to' the default argument expression in the interface to show when it's being evaluated. And if the concern is that there are potentially many suspension points at that line of source being hidden by an await, is that really meaningfully different than the fact that calling into an arbitrary async function may admit arbitrarily many suspension points internally?

I guess what I'm getting at is that I can forsee these isolation rules pushing authors of APIs like:

@MainActor class C { ... }
@MainActor func f(c: C = C()) { ... }

towards something like:

@MainActor func f(c: C? = nil) {
  let c = c ?? C()
  ...
} 

just to spare their users of the additional annoyance of using the default argument expression directly. Do we really think this is better?

6 Likes

Sorry I should have been more specific here; I meant that the programmer can tweak the call / break up the expression to explicitly control the isolation domain where different subexpressions are evaluated.

1 Like

+1 to the point about nil being a better default after this change lands. I can think of a few alternatives to this though:

  • use a property wrapper. Unfortunately you can't have a mutating get on a parameter property wrapper though, so the wrapper in question has to be a class. Probably not the best solution from a performance standpoint
  • a macro (@LateDefaults?) that rewrites all the default arguments of a function to be nil with the aliasing from your last code example, just before the rest of the function body

I think that the macro option is pretty reasonable, it lets the author of the API explicitly say "please break the order of argument evaluation for me, in this specific context."

Your points about the caller not needing to care about the callee's isolation are really interesting, and make me wonder whether we're going down the wrong path for how to solve this. If an API wants a default argument to always be evaluated in the callee's isolation domain -- which I think is the case for isolated functions with isolated default arguments -- it should probably use an @autoclosure. Maybe this proposal should only cover computing the required isolation for expressions that can be evaluated synchronously from anywhere, i.e. closures. If an API author tries to use a default argument expression that's isolated, the compiler can suggest wrapping it in an @autoclosure. This would mean that we cannot make memberwise initializers nonisolated, because the initializer would need to accept @autoclosures for each of the isolated stored properties and evaluate those inside the init body. Hmmm.

4 Likes

I agree with the people above me in this thread :slight_smile:

  • I'm happy with the proposal — it seems to cover the safety well, and gives clear enough errors that you'll be able to work your way through the issues involved with the interaction of default arguments and actor isolation.
  • I think that this direction doesn't address the common "intent" of default arguments — to effectively provide a suite of related functions with a single implementation — where the default arguments require isolation. In effect, it becomes necessary to use optional argument types and nil defaults and manually move the default arguments into the body of the function.

(@autoclosure arguments have other current problems with isolation: Autoclosures and global actors don't work together · Issue #67688 · apple/swift · GitHub)

It may not be fixable now, but I think in effect the original decision to evaluate default arguments in the caller rather than the callee was wrong (it was done this way for #file, etc., I'm sure, and by analogy to many other languages).

Could we instead consider something like:

  • two kinds of default argument, @callerEvaluated and @calleeEvaluated;
  • In swift 5 the default remains @callerEvaluated. In Swift 6 the default becomes @calleeEvaluated, with the source migrator suggesting adding @callerEvaluated where default arguments are macros, or where ABI stability is required;
  • In swift 5 where the argument has an isolation requirement, a fixit to suggest adding @calleeEvaluated

I guess the implementation of @calleeEvaluated would effectively need to create functions with each of the possible effective signatures (combinations of arguments omitted) each of which evaluates the default arguments in the correct isolation domain & passes them synchronously to the user-written function with all possible arguments? So the ABI would effectively be "as if I'd manually written out each of these signatures" (might be better anyway?)

1 Like

I don't see any evidence in this thread that the fundamental evaluation semantics of default arguments are harmful in general and warrant a widespread source and semantic breaking change. Magic literals are not the only benefit to caller-side defaults arguments. The alternative:

has a combinatorial code size impact and effectively makes default arguments ABI by default. If you're also talking about introducing these overloads into the type system, that will seriously impact overload resolution performance, and the argument matching behavior will differ between caller-side and callee-side default arguments.

As you point out, there are tradeoffs between caller-side and callee-side default arguments, but having both creates bigger problems than it solves, and we have other options for closing the data race safety hole with isolated stored properties.

We already have a tool to delay the evaluation of arguments to the callee. That tool is @autoclosure. If we decide that's the right tool for the job here, we can fix the existing problems with actor-isolated autoclosures.

3 Likes

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