SE-0411: Isolated default value expressions

Hello Swift community,

The review of SE-0411 "Isolated default value expressions" begins now and runs through November 7th, 2023.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager via the forum messaging feature or email. When contacting the review manager directly, please put "SE-0411" in the subject line.

Try it out!
An implementation of this proposal is provided in PR #69391, behind the flag -enable-upcoming-feature IsolatedDefaultValues. Toolchains are provided:

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

What is your evaluation of the proposal?
Is the problem being addressed significant enough to warrant a change to Swift?
Does this proposal fit well with the feel and direction of Swift?
If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at

Thank you,
Doug Gregor
Review Manager

7 Likes

Very glad to see this fixed, particularly for default arguments to functions, which is a case I come across frequently in real code.

I don't know if it's just me being dense today, but I'm finding the initial paragraphs of this proposal very hard to follow. Once it gets to the "detailed design" section, it's pretty clear, but I really struggled with "motivation" and "proposed solution", despite having participated in the pitch thread and basically already knowing what this is about.

I find the reordering of argument evaluation for async functions being different to synchronous functions, to be slightly annoying. It's probably not a big deal either way, but maybe Swift 6 should adopt a consistent ordering for both cases?

I very much like the solution for default arguments of isolated functions, that feels "right".

One thing I'd like to see in all proposals is, what the deployment situation of the proposal is — whether this is something that we get with just a new compiler, or whether we have to wait to be able to set a new minimum version of iOS/macOS to get a new standard library. In this case I'm feeling that this is only compiler changes?

+1 from me.

1 Like

Sorry about that, this might've been the result of splicing the revised semantics into the old text. I can see how some of those initial paragraphs might be confusing; I'll open up a PR with some editorial changes.

To clarify, the argument evaluation order is different between isolated and nonisolated functions. If an isolated function has any isolated default arguments, then all default arguments will be evaluated last regardless of whether the function is called synchronously or asynchronously.

It's only the default arguments that move, so the default argument must have a side effect, or the default argument must access the same value that's being formally accessed, for the difference to be observable, which is probably fairly rare in practice.

My concern about changing default argument evaluation order across the board in Swift 6 is that if a project has some bizarre semantic dependency on argument evaluation order between formal access and default arguments, that's not something that will manifest as a compile-time error, so it'd be extremely difficult to evaluate the impact of making such a change.

You can determine whether a language proposal is made available by a new compiler alone from the "Implications on adoption" section. This proposal states

This feature can be freely adopted and un-adopted in source code with no deployment constraints and without affecting source or ABI compatibility.

The bit about "no deployment constraints" means that this feature does not have any dependencies on a particular version of the Swift runtime or standard library.

2 Likes

:+1:

Clearly the right direction and fixing a pretty insidious gap in concurrency safety.

Confusing example?

@MainActor func requiresMainActor() -> Int { ... }

class C {
  @MainActor var x1 = requiresMainActor()

  nonisolated init() async {
    self.x1 = await requiresMainActor()
  }
}

Forgive me if I'm missing something here, or if it's tangential, but isn't the default value for x1 unusable? Will this SEP introduce suitable compiler warnings (or errors) for unreachable code like this?

(that example aside, I like the rules laid out in Stored property initial values, they seem intuitive and flexible)

Argument evaluation order

For a given call, argument evaluation happens in the following order:

  1. Left-to-right evalution of explicit r-value arguments
  2. Left-to-right evaluation of default arguments
  3. Left-to-right evaluation of formal access arguments

Is that stating that this is what the order already is, or that the proposal will make it this way? And I assume it's talking about the case where (post this proposal at least) none of the default arguments require isolation?

Unlike the explicit argument list, isolated default arguments must be evaluated in the isolation domain of the callee. As such, if any of the argument values require the isolation of the callee, argument evaluation happens in the following order:

  1. Left-to-right evalution of explicit r-value arguments
  2. Left-to-right evaluation of formal access arguments
  3. Hop to the callee's isolation domain
  4. Left-to-right evaluation of default arguments

Is there a particular reason to evaluate all default arguments in the callee's isolation domain, as opposed to just the ones that are explicitly isolated to it?

From a practical perspective there's a difference in performance, potentially - e.g. if default argument evaluation is non-trivial then moving it to the callee might be beneficial for parallelism, even for arguments that don't need that isolation. At least, in cases such as actor initialisation… it could be harmful to performance, conversely, if the callee's isolation domain is a global actor (especially the main actor).

And so on the other hand, it creates a slightly more complicated and ill-defined ruleset as to what order arguments are evaluated in, arguably. Some might find it simpler if only callee-isolated default arguments are evaluated "out of [normal] order", leaving non-isolated default arguments unaffected. That makes it more predictable, at least, as to where the 'burden' of nonisolated default argument evaluation will land, as it depends solely on the call site rather than the callee's declaration (which can change out from under callers without any notice).

At least, if I'm understanding the proposal correctly.

Autosynthed inits with 'conflicting' isolation requirements

It is an error for two different default values to require different actor isolation, because it's not possible to ever use those default values. Initializing an instance of a type using two different initial value expressions with different actor isolation must be done in an async initializer, with suspension points explicitly marked with await .

Should the compiler synthesise suitable async initialisers in this case?

Multi-isolation-domain default arguments

Just to be crystal clear, what's the expected behaviour / usability of something like:

func foo(x: Int = requiresMainActor(),
         y: Int = requiresOtherGlobalActor())

Will that be usable from anywhere (albeit with the available argument defaults varying based on caller isolation context), or is it not permitted at all?

1 Like

The default value is not evaluated in the body of nonisolated init in this example, so requiresMainActor() is only invoked once when calling this init. The default value is still usable in other initializers that are @MainActor-isolated, e.g.

@MainActor func requiresMainActor() -> Int { ... }

class C {
  @MainActor var x1 = requiresMainActor()

  nonisolated init() async {
    self.x1 = await requiresMainActor()
  }

  @MainActor
  init() {} // okay
}

The rule in the proposal is that isolated default initializer expressions are not evaluated in init bodies with different isolation.

These are the existing rules in the language today. They're also slightly incorrect, which I discovered yesterday and corrected in [SE-0411] Minor editorial changes to the motivation and proposed solution. by hborla · Pull Request #2198 · apple/swift-evolution · GitHub

I think it would be strange to evaluate default arguments out of order, and it would be difficult as a programmer to determine that order because default argument isolation is not explicitly annotated. I think the rule in the proposal is easier to understand because default arguments are always evaluated in order with respect to each other, and the only observable difference in the argument evaluation order is if you have inout arguments in the mix.

I think it would be feasible to evaluate only the isolated default arguments in the callee's isolation domain, if the LSG decides that something like this is better:

  1. Left-to-right evaluation of explicit r-value arguments
  2. Left-to-right evaluation of formal access arguments and nonisolated arguments
  3. Hop to the callee's isolation domain
  4. Left-to-right evaluation of isolated default arguments

I'm not convinced that this is better, but I don't have too strong of an opinion because I don't think it will make much of a difference in practice.

Maybe, but most default arguments are not all that expensive. They're things like literals, constants, and initializers. Calls to functions with isolated default arguments will also commonly happen from other actor isolated contexts, so we wouldn't always be freeing up an actor by always evaluating nonisolated arguments in the caller.

For what it's worth, I think that the order of evaluation for default arguments is so rarely observable in practice. I don't anticipate programmers needing to reason about the order of evaluation for default arguments, and I don't anticipate performance issues that stem from evaluating default arguments of a main actor function on the main actor. What I care most about is having only one actor hop to the callee's isolation domain, and I'd be okay with a different solution as long as it preserves that property.

I thought about that, but I don't think it's in the scope of this proposal. I also don't think that having stored instance properties that are isolated to two different global actors is a good pattern, and I don't think we need to go out of our way to make it easier to express. If those properties with different actor isolation have non-Sendable type, it is impossible to initialize that type at all.

This code is invalid because of this restriction:

If a function or type is nonisolated, then the required isolation of its default value expressions must be nonisolated.

1 Like

Right, but if there aren't any other initialisers, it'd be nice to get a warning where possible (even if that can only apply to final or frozen types, for example).

Doesn't seem critical to this proposal in any case, just putting it out there.

This supports the argument you've made on this - that argument order actually matters less than one might think - since I didn't know this was the current rule (I though it was plainly left-to-right) and even you didn't know precisely what the current rules were.

Like you, I don't feel strongly about the argument ordering aspects. I was mostly curious and thinking about it out loud.

I've been pondering if one approach is better than the other for the potential future amendment of supporting multiple isolation domains (as was discussed in the pitch thread), but as far as I can guess it makes no real difference.

Plus, I suppose there's no real difference in the init case specifically because (a) the callee needs the argument values in order to complete initialisation and (b) the caller needs initialisation to complete so that it can get the initialised object back. It's all effectively serial anyway.

In the non-init cases it seems very hard to say which bias would come out best - if any. Unless you did a rule like "arguments are preferentially evaluated off of global isolation domains". And I can't immediately decide if that's somewhat clever or horrible. :grin:

I concur. Minimal hops is important. Exploring it rigorously is still on my todo list, but incidentally I've observed that hopping between isolation domains is unfortunately not insignificant in overhead.

And 'law of least [negative] surprise' factors in here too, given default arguments are subtle to begin with.

Fair enough…

I'm fine with it being punted to a future todo / to-review list, but I do find it a bit surprising that this would be flat-out disallowed. It seems at odds with how member properties behave, where this proposal specifically says it's okay to have a property be isolation-bound and it'll just have to be manually initialised in other isolation domains.

This proposal has been accepted, thank you everyone!

Doug

4 Likes