[Pitch 2] Default actor isolation per-file

Hello, Swift evolution!

Based on feedback from the first review discussion, I've revised SE-0478 to introduce a new using declaration for specifying default actor isolation. You can find a draft of the revised proposal here: swift-evolution/proposals/0478-default-isolation-typealias.md at 0478-revision · hborla/swift-evolution · GitHub.

Please leave design feedback in this discussion thread, and editorial feedback on the swift-evolution PR. I look forward to your thoughts, questions, and other constructive feedback!

-Holly

20 Likes

Great syntax choice -- very simple / natural and extends easily to other use cases.

4 Likes

Quite happy with this update, we acknowledge that this is tied to the compiler and gets special treatment. The previous approaches looked like normal code, but weren't really.

Especially happy about us making space in the grammar to support any future () parameters to things. So this can evolve naturally in the future.
Should we include this in the grammar (allowing () after the enabled feature) right away so that syntax tools don't need to update their definitions when eventually a case with () appears? I see this is covered already in using call-expression :+1:

I think we need to clarify that if this is illegal (I think it is):

using nonisolated

func test() async {} 

using @MainActor // error?

func mainTest() async {} 

In the proposal we say that:

Specifying using @MainActor anywhere in the file will instruct the compiler to use @MainActor as the default isolation for unspecified declarations:

Which kind of implies this is a "file global thing" so repeated or conflicting using like this are illegal I'd say, but it's worth spelling out explicitly. One could assume that this is "sectional" otherwise (scala's imports are like that).

3 Likes

Is there an explicit rationale for this feature not working in a "sectional" way as @ktoso describes? The rule would of course be that a using declaration affects all code that is below it, until either a new using declaration or the end of the file is reached. The advantages here seem two-fold to me.

The bigger advantage would be that you aren't forced to create a new file in order to create a new bulk isolation specification. Surely it will often be the case that one will still choose to separate code with different isolations into different files, but it doesn't seem clear to me that 100% of the time this would be a better organization choice, so not forcing it seems positive to me.

The second advantage (which some people might actually argue is the bigger of the two advantages) is that it simplifies the process of locating the active default isolation, since you know it must be above the code you're looking at. For file-wide isolation this forces the declaration to be at the top of the file, which I think is the standard etiquette that we're assuming/hoping everyone follows anyway.

If we release this feature as proposed then we have inhibited such a future direction due to source compatibility, so another option is to, at least for now, require that using declarations appear above all other code (other than imports maybe). Given the mentioned future direction of expanding using dramatically to include things totally unrelated to concurrency, like using strictMemorySafety(), I think it seems precipitous and risky to make the assumption up front that we will never want sections of a file with different defaults, given how broad the potential use cases are.

3 Likes

I was following first pitch discussion, and with the second iteration on this change I still don’t fully get the need to provide such capability. The change from typealias to using looks definitely better to me though.

As for my primary confusion about the change, I see this per-file altering of the concurrency modes is really too much of a complication for some saved words to type. The trade with this is that instead of local reasoning within the method or type, this introduces the need to be aware of such overrides and that this particular file, however long it is, has declared one. That’s a lot of things to keep in your head along other logic in the code. Being explicit in such cases is much helpful to the reader of the code and reduces mental overhead needed to reason about an arbitrary piece of code in a file: one can just read what is the isolation for this declaration is and that’s all; especially when you come to the file via chain of calls and have not enough context.

5 Likes

Thank you for all the work you’ve put into this proposal! I was wondering if tooling could somehow highlight the default isolation throughout the file. Would it be possible, for example, to have the using blocks snap to the top of the file when scrolling down to remind the user of the difference in default settings? I’m envisioning something similar to how a struct decl might snap to the top of the editor when we’re in, say, line 200 of the struct.

3 Likes

It's great to have a declaration, but the keyword using seems ambiguous when it needs to be clear.

For clarity (and to avoid unrestricted bikeshedding), would you please propose a more specific candidate, e.g., fileisolation (or fileActorIsolation)?

Reasons being:

  • like fileprivate, file prefix indicates scope
  • scope implies usage:
    • (a) must be top-level
    • (b) must be sole instance in a file
    • (c) should be at or near top
  • isolation says something about what it's declaring

To elaborate (I hope needlessly)...

There's no indication of meaning in using

  • using has no prior usage like this
  • In other languages, using means something closer to import

There's extra pressure on this keyword to be clear and indicate its own meaning.

  • The keyword usage will be relatively arcane and rare (i.e., neither obviousness nor frequency will train readers)
  • The term should be unique enough to be searchable

fileisolation would work better:

  • file: Scope is an essential aspect, since it's not the default block or module scope
  • isolation is a known term of art in Swift
  • isolation as a term suggests the descriptor
    • It is reflected in the option nonisolated
    • It suggests the option of actors, which are known as establishing isolation domains
5 Likes

Sorry to reply to self, but on this point:

My personal preference would be "must", the clarity of strictness, requiring that it must come before any other declarations or imports (though it may reference imported types). That means readers know at a glance for any file whether the default actor isolation is declared. I can think of no strong reason to put in lower in the file near other declarations.

Here, using is always followed by nonisolated or @MainActor, so there isn't really the practical possibility of ambiguity here where users could read that as an import.

I would take it as a positive, actually, that we are using (ha) something that reads like an import statement, since the behaviors being proposed (specifically: file scope without limitation as to where it's written) have precedent precisely in import statements.

I do agree the blandness of the spelling may impact how searchable it is on Google, though. How much of a barrier that is in the era of LLM-assisted search technologies is interesting food for thought: I think I would have put more weight on that drawback two years ago than I do today.

A related thought I have here is that we may conceivably find other language-related toggles for which a using statement could be a good fit, and I'm a little worried about privileging unqualified using for this specific use (ha). I wonder if something wordier—maybe using default nonisolated—might hedge against that eventuality.

5 Likes

Also wonder what should happen when defining multiple usings in one file. For me making it illegal makes sense, but then questions arise for future directions, like could I write something like:

using @MainActor
using strictMemorySafety()

in that case are those two separate usings and can we do that? Guess yes. :thinking:

But then wonder how readable it will be to have multiple usings in the begging of the file, like how can one quickly spot what every using is for? One option could be ability to explicitly define it, like:

using isolation @MainActor
// or
using defaultIsolation none
using memorySafety strict()
using defaultActorSystem ClusterSystem
using namespace DesiredState // just idea

But not sure this is a better option as I like the idea that using is looking like import, and guess it won't be used often like that.

2 Likes

what about having some file-level macro system? something like

using #defaultIsolation(MainActor.self)

which would then be able to modify the contents of the file to add @MainActor to every declaration

1 Like

If/since we're designing for future uses beyond per-file default actor isolation....

Perhaps a file scope for file-specific declarations?

file {
  isolation: nonisolated // default actor isolation
  ... // lots of file-scoped policies...
  memory: strict  // Strict memory
  import: default public  // make unqualified imports public
  export: default package // consider internal  as package
  defaults: MasterFile.swift // adopt other file settings from another file
  swiftSettings: ...  // any file-scoped SwiftSettings?
}
1 Like

I like this better than the previous proposal. Two things (both already mentioned, but worth repeating I think)

  • It feels like it's missing a word. Either it's only for default isolation, in which case it should be defaultIsolation or similar instead of using, or it's general, in which case default isolation shouldn't be privileged above future uses, and it should be using isolation = @MainActor or similar.
  • It feels like it shouldn't be allowed at an arbitrary place in a file — either it should only be allowed above code, or restating it should be "sectional", applying the default isolation only to later declarations.
2 Likes

Overall, I like this direction. It gives us a lightweight syntax with extensibility for future features, without feeling like it's abusing existing features (whether typealias or macros).

With a very-general grammar for using declarations, the proposal should say what happens for a parsed-but-unrecognized using declaration. Is it always an error?

The syntax itself is general enough that I believe we'll be able to fit future features in there, but it would be nice to sketch out some of the more obvious ones so we're sure that we have something general enough that we won't regret it. The "Alternatives Considered" shows a few that aren't captured in the future directions, e.g., for warnings-as-errors control:

using treatAllWarnings(as: .error)
using treatWarning("DeprecatedDeclaration", as: .warning)

This does imply that the ordering of using declarations can matter, which might not otherwise be the case.

Presumably, we also have, e.g.,

using upcomingFeature("InferIsolatedConformances")
using experimentalFeature("SomethingNewAndExciting")

although specific upcoming/experimental features would have to opt in to supporting this syntax, because it doesn't come "for free" and not all features can be made file-sensitive.

I do think the proposal should provide more justification on why it's important to have such a concise syntax for default actor isolation, where the "default actor isolation" part is effectively implied. The syntax I'm showing above is basically pulled from the package manifest, with the leading .'s removed, and implies that we could have this proposal use syntax like:

using defaultIsolation(MainActor)
// or
using defaultIsolation(nil)

If we went this route, the proposal could be simplified by removing the attribute and modifier grammar productions:

using-declaration -> using attribute
using-declaration -> using declaration-modifier

Doug

20 Likes

+1 to adding defaultIsolation to the using syntax, I feel like its absence would cause some clarity issues with future uses for using.

I also agree with @wes1 that using statements should be at the top. Since there can only be one per file and it applies to all isolation-unspecified declarations, a using below a declaration that applies to said declaration does not feel appropriate:

// the behaviour of MyStruct is affected by something *below* it??
struct MyStruct {
    var myValue: Int
}

using defaultIsolation(nonisolated)

For clarity, I believe putting a using below a declaration should be an error.

On the "further directions" part, my personal preference would be something like this:

// singular `using` statement
using defaultIsolation(nonisolated)

// multiple `using` statements
using {
    defaultIsolation(nonisolated)
    treatWarning("DeprecatedDeclaration", as: .warning)
}

My thought process behind this is that any given using statement can only appear once in a file, eg. you can only have one using defaultIsolation. Extending that, we should not have multiple usings at all, and just bundle them all under a single one.

3 Likes

A strong +1 for using, but with a domain specifier.

`using` Domain `(` Arguments `)`

Domain := `defaultIsolation` | `memorySafety` ...
1 Like

The thing after using in the proposal is an attribute or modifier, which is something we already assume to be perfectly self-describing. I don’t see any reason to complicate it.

Requiring it to go at the top of the file, maybe at most mixed with the imports, seems like a reasonable change. If nothing else, it would remove any danger of it being interpreted as only applying to the following declarations (and preserve the flexibility to do so if we actually wanted to in the future).

8 Likes

Your example above doesn't lose clarity by being separate top-level declarations:

using defaultIsolation(nonisolated)
using treatWarning("DeprecatedDeclaration", as: .warning)

The suggestion of grouping these at near the top makes sure they won't be missed, so we don't need braces to group them together. The braces also make it more ambiguous with existing code than the proposed using syntax, because today one can have:

func using(_ body: () -> Void) { }

using {
  // ...
}

and we shouldn't break that.

Doug

3 Likes

If the using statement is defined as accepting an attribute, that would lock us out of being able to reuse the using statement for other purposes like the one suggested by @Douglas_Gregor:

Redefining the using statement to mean general-purpose compilation modifier for a file seems like a very welcome change that would facilitate a lot of use cases, some of which are already in the works (like the aforementioned diagnostics), which seems like more than good enough reason to justify the verbosity.

or... #swift?

If, or since, using is favored because it is a general solution to (file-scoped?) declarations, it's likely worth pitching as such.

Semantically (if not grammatically?), using seems like a compiler-control directive, not a general declaration.
Users should think of it logically upstream of all other code. Other code (imho) should remain oblivious to it (absent very good reasons, though diagnostics on other code of issues deriving from using inferences should also indicate the using statement). (It's different from imports which make names available for other declarations.)

The # helps because users understand existing compiler-control directives and macros as speaking directly to the compiler. (And perhaps just as macros can be expanded to their output form in the IDE, I wonder if these directives could be expanded in the IDE to show impacted code.) The ugliness of # is warranted to alert users to its great power!

Given using's purview of compiler/language semantics, #swift might be a clearer name.

We now permit # comment as the first line of a swift script.

Next lines could be #swift declarations, with the semantics of changing defaults (by default for file scope). Unknown suffixes would be ignored by earlier compilers (unless leading #swift errorOnUnknownDeclarations?). The #swift declarations could be surrounded by conditional compilation directives, but should precede any other declarations in any branch of such conditional directives.

Later?:

  • block form: #swift ... #endswift
  • position at the top of other lexical scopes, for local overrides