[Second Review] SE-0478: File-level defaults

On composition

In a lot of cases it would "do nothing", but they compose. The module feature performs inference, which this feature does not. For example, in MainActor-by-default mode, conforming to a nonisolated protocol would make your conformance nonisolated. If you write that conformance in a file with default @MainActor, then it is as though @MainActor was written on the conformance, overriding the inference rule and potentially leading to an error (which alerts you that you wrote something impossible). In Swift 5 + MainActor-by-default mode, default @MainActor would apply @MainActor without @preconcurrency, while the module default would have @preconcurrency. Note that this is the same behavior as writing the annotation explicitly in this configuration.

If you are aware of behavior this wouldn't cancel, I'm extremely interested to hear it! My intention and belief is that it would cancel out all the behaviors of the flag besides the other feature flags (NonisolatedNonsendingByDefault and NoExplicitNonisolated) that MainActor-by-default enables (turning off other associated feature flags per file would cause issues for adoption and unadoption, as well as being extremely surprising in my opinion).

[Second Review] SE-0478: File-level defaults - #17 by xwu looks correct, that reflects my intention with the proposal, and divergence from this in the implementation would be a bug.

Should we allow default anywhere in a file?

I'm not confident we can rule this out, so it seems to have some (minimal) value to leave this door open. If we discovered a need to do this in the future but allow default anywhere in the file, we would need to invent new syntax, or conditionalize the behavior of default declarations based on their contents. However I'm personally opposed to a #pragma style feature that can be toggled throughout the file, since I feel it makes local reasoning harder.

I agree! Swift is very amenable to splitting things between files, so I see no issue with this.

What is the argument for allowing a file default to be written anywhere? We can support a fix-it for moving the default declaration to the top of the file. I would even prefer it was not allowed after import, but I was told that would be onerous. In my mind, requiring file defaults to be written at the top of the file means "is there a default somewhere else" is never a question you need to wonder about when debugging, even when you are using a plain text editor. When I open a file, I see what defaults it has right away. For IDE features like jumping to definition or PR review this is less applicable... Yes, the compiler can understand it anywhere, but why is it ever desirable to allow writing a file level default in the middle of a file? My position/bet is that no programmer would feel hindered by needing to write file level defaults at the top of the file. If we discover that is wrong, we can always lift this restriction later, but we can never introduce it without staging and migration. Is it good that import statements can be written anywhere, beyond empowering more confusing code?

Is this desirable? Maybe the consensus is that Swift is already like this, so there's no reason to try to prevent it for new features?

I don't think forbidding file level defaults below the top of the file is necessary, but I think it's nice.

On language dialects

NonisolatedNonsendingByDefault and NoExplicitNonisolated are not intended to remain as dialects, there just needs to be staging and care there to not break source compatibility. See SE-0461 and this reply from Holly. -default-isolation is a permanent dialect, which I personally think is slightly unfortunate.

However I'd argue file-level default isolation is not a dialect; it's something that can be freely adopted and unadopted per-file!

IDE experience

This should be the case with my current PR! File level default isolation is treated as another kind of inferred isolation (with the rule of applying to every top level declaration lacking an annotation), applied after explicit attributes but before other inference rules, so any editor that is able to show the type of a decl with an isolation that wasn't explicitly written (via an inlay hint, hover to show type, color coding, whatever your editor does already) should apply to file defaults. I personally agree it would be extremely valuable for Xcode to show inferred isolation as an inlay hint if it doesn't already.

The only order sensitivity is priority. No rules exist today prohibiting repeated @available or @diagnose on a declaration, and the order determines which wins. Which default @available wins is determined by order in the same way:

default @available(*, deprecated, message: "first default")
default @available(*, deprecated, message: "second default")

// CHECK:      @available(*, deprecated, message: "first default")
// CHECK-NEXT: @available(*, deprecated, message: "second default")
// CHECK-NEXT: public func defaultedOrderedFunc()
public func defaultedOrderedFunc() {}

// ...

func useOrdering() {
  defaultedOrderedFunc() // expected-warning {{'defaultedOrderedFunc()' is deprecated: second default}}
}

We can add a warning for this case if we add a warning for repeated available attributes more generally.

On inference, and being different from -default-isolation

They are very different features! The module default is a language dialect that tries its best to infer the intention of code without annotations and get it to compile. It is intended to make it easier to write mostly single-threaded things, and even if you feel it doesn't achieve this, it certainly does better than file-level default isolation at this purpose. A file-level default isolation, as proposed, attempts to apply your specified default to every single declaration where it is coherent, and give you an error when it causes issues. This would be a very poor experience for writing single threaded code that wants to interact with external functions and protocols. These two cases not being the "same kind" of default personally biases me back to using.

Do we need this?

While it doesn't exonerate a new feature, Swift is full of features that can be used to write both clean and illegible code. Powerful but risky features like operator overloading are eschewed in other languages but present in Swift, and it comes down to taste and convention to use them well.

I think the strongest argument for a feature like this regarding readability is highlighting the novel case; in a file where all but one declaration is nonisolated, the repeated nonisolated keyword becomes noise. Using default nonisolated, the one @MainActor declaration is highlighted. If something is actually boilerplate, it can improve readability to eliminate it. In a case like @unsafe, littering it throughout the file might instead be a smell and not something to eliminate.

I feel this is another tool in the toolbox for writing clean code. Programmers can use it where it is appropriate, and be explicit when that is more readable--just like other features that can hide information, like type inference! I see it being used primarily by -default-isolation MainActor projects for a couple files that implement concurrent types and algorithms, to isolate a file of UI state to @MainActor, and to be explicit about assumed inferred isolation.

Existing typealias based defaults

I think it would be great to create attribute forms of the existing typealias configurations, and support them as file-level defaults! I forgot to mention distributed actors, but they could potentially also benefit from treatment like that in the future (if we defined an attribute syntax for them).

My feelings on syntax

I agree that it's clean! I was more excited about it when the pitch had file-level defaults and module-level defaults behave in the same way, and I do wonder if using or similar is worth consideration given that they are not the same "kind" of default. While no syntax choice will be fully explanatory without being overly verbose (file-level-apply-to-all-decls-default-isolation @MainActor) choosing using could make it clear that default isolation does something different from the language default. It is worth noting that we can implement using without any source compatibility issues since you can't write top level code like this proposal uses today. If we want using { ... } or using( ... ) in the future I suspect we will run into a bit of trouble.

2 Likes

I wasn't aware of -fno-objc-arc! I would suggest that in-source defaults are more ergonomic and flexible than compilation flags. If we took a route like this, there would be no syntax besides a comment to spell a file level default in a code snippet. Determining what isolation is the default would require understanding the build system (and in weirder configurations could pose issues for LSP / IDE features that want to understand isolation).

It would be difficult to support @available and @diagnose under that model too...

1 Like

As a thought exercise, let's imagine a future direction where default can apply to user-defined macros or result builders:

// In some file or module
@attached(...)
macro SomeMacro() = ...

// In another file
default @SomeMacro

struct Foo {}
struct Bar {}
struct Baz {}

But hypothetically, what if that macro is only needed in that one source file, and I want to declare it there for the purpose of locality? What would the expected behavior of this be?

default @SomeMacro

@attached(...)
fileprivate macro SomeMacro() = ...

struct Foo {}
struct Bar {}
struct Baz {}

This either (1) works, even though default references a macro before it's defined, or (2) doesn't and requires the macro to be declared before the default. Option 1 feels like the most likely choice since traditional macro lookup would already check the entire file, in which case we're already sort of admitting an "out of order" quality to the feature.

But I think even without that example, the choice to allow default to follow import feels wholly arbitrary. The following is valid Swift code:

import A
import B

func f() { C.something }

import C

So why then allow this:

default @MainActor

import A
import B

func f() { C.something }

import C

and allow this:

import A
import B

default @MainActor

func f() { C.something }

import C

but not allow this?

import A
import B

func f() { C.something }

import C

default @MainActor

Likewise, by carving out an exception for import, how does it intersect with #if-guarded imports?

// This seems fine
import A
default @MainActor
#if os(macOS)
  import B
#endif
import C

// Is this ok?
import A
#if os(macOS)
  import B
#endif
default @MainActor
import C

// Is this ok?
// Does it depend on the truthiness of `os(macOS)`?
import A
#if os(macOS)
  import B
  typealias T = B.X
#endif
default @MainActor
import C

The exception for import creates more problems/special cases than it solves. I think the only two viable rules are "defaults must be the first thing in the file, period", or "defaults can appear anywhere in the file and they affect the whole file". Between those two, I weakly prefer the same ordering requirements that we have for other top-level declarations like import; that is, none at all.

2 Likes

As a general rule, Swift is not sensitive to the order of top-level declarations (and is only sensitive to the order of nested member declarations in that it affects the order of synthesized declarations that list all the members). It is, for instance, perfectly valid to declare a function that uses a type before the type itself, or to call a function before it is declared, or (as I mentioned earlier) to use an external declaration before the import that makes it visible. This means Swift does not need forward declarations; it’s a key difference from its C-family ancestors, is something the compiler is specifically architected to support, and is a property Swift developers have become accustomed to. It just strikes me as a very strange decision to exclude this new declaration from that general rule purely to enforce a style decision.

The general rule that Swift doesn’t care about declaration ordering is good even if some specific decisions it enables are not particularly good. And not having a lot of specific exceptions to general rules, even if those exceptions do have some motivation, is also good.

3 Likes

I believe Xcode’s main mechanism for showing you the inferred attributes of a declaration is Quick Help; I would expect these inferred attributes to be shown there. Beyond that, Xcode user interface designs are outside the scope of Swift Evolution reviews.

2 Likes

Not in favor of this, especially w.r.t. isolation.

In my experience, people tend to think of code not in terms of files, but in terms of entities (functions, types etc.); files are almost incidental and serve simply as a container.

A lot of teams have a one-file-one-class convention, in which case this feature is a bit redundant. Or they do not, in which case there's no language-level reason (other than access level control perhaps) why several entities would end up in the same file — but then any mechanical code move has an awkward side effect of potentially changing the execution semantics, which IMO should never happen and would be extremely surprizing.

5 Likes

I don't understand why interacting with external functions would necessarily be painful. Certainly if the external functions are calling back on an arbitrary thread or something… but in that case, I think the explicit annotations are quite useful. But are you suggesting that even without crossing into concurrent code, it would still be a poor experience?

I've been thinking a lot about this proposal, exclusively because of isolation control feature. I find myself kind of torn here. Because on the one hand, I would really appreciate the ability to glance at the top of a file to better understand what mode I'm in.

But on the other hand, the default main actor mode can still be challenging to use. There are some lingering inference behaviors that, while are not new, come up much more frequently. I'm particularly concerned, given how subtle these areas are, that it will be difficult to anticipate further complication with this new mechanism.

The mode can greatly increase the risk of using incorrectly-annotated APIs when in 6 language mode due to dynamic actor isolation assertions.

I'm also pretty sure there are still some situations where macros are challenging without the mode being visible on the code generation side.

And these kinds of problems are more likely to affect those that are the least-well equipped to understand and deal with them.

I've also been thinking through how I might advise someone on choosing between module-based and file-based isolation control, and I'm finding it non-trivial. Given all this, I think it might make sense to proceed a little more conservatively here. Try out the file-based defaults as a general tool, but hold off on isolation control specifically for now.

3 Likes

This is convincing! I weakly prefer default required above everything, but that’ll be unpopular and I think no special case handling for default would also make sense.

3 Likes

Sorry if I was unclear, i was trying to make a point about file-level and module-level isolation solving different problems, in response to a question about replacing module-level with file level. As an example of what I mean, module-level default isolation will prioritize the isolation of a protocol you are conforming to over the module default. If you wrote default @MainActor then an unannotated conformance will be @MainActor even if the protocol is nonisolated. To personify them, module-level is guessing that a nonisolated conformance is more likely to be what you want (since module-level is meant to make concurrency easier and stay out of your way) and file-level is trusting that you want every top level declaration to take that isolation, and giving you an appropriate diagnostic when that doesn’t make sense. In a sentence, module-level has inference rules to reduce errors and file-level does not to reduce surprise.

My mention of external functions and protocols was referring to conformances and generic treatment of protocols. Module-level default main actor isolation will try not to give you an error when you conform to Hashable, but file-level default isolation will apply to the extension, which then needs to alert you that your MainActor conformance could cross isolations. The module default is an inference hint and the file default is a requirement that needs to be upheld or overridden per declaration.

4 Likes

My implementation accommodates this by treating default nonisolated the same as writing nonisolated on a declaration for the purposes of the unfortunate "explicit nonisolated" behaviors unless NoExplicitNonIsolated is on. It's certainly possible we will miss something like this during implementation, but is that not an argument against adding anything to the concurrency system? (It's ok if that is, I don't think that's necessarily unreasonable)


Could you elaborate on these? When you say "the mode" do you mean module-level? We opt macro generated code into file-level defaults but that could be the wrong choice, that's something we could change or conceptually provide the macro insight into.


I would argue that file-level default isolation doesn't have the module-level drawbacks you mention in your blog post. Specifically, its not something invisible that you need to know when to opt out of and its not trying to delay encountering errors; even if you put default @MainActor at the top of every file without understanding it, I would hope that errors about "@MainActor isolated" would remind you of that line... The intention is that it's not a language dialect and is appropriate for libraries and modules. Pedagogically, there aren't inference rules and exceptions to dig into where your default wasn't used. Either your default was appropriate for every single declaration, or you got an error. It's "just" a way to make code with a given isolation less repetitive, or to opt out of module-level mode for a file without writing an annotation on every declaration, so its not something you need to choose in advance.

I definitely see the argument that a file default should behave identically to a module-level default. But I also understand why a different approach could make sense. I don't envy having to decide here, but what is being proposed at least sounds reasonable.

In fact, I was unaware of the NoExplicitNonIsolated flag. That's interesting and something I need to look into!

I apologize if this came off sounding like "we should not make any changes at all", that is most certainly not my position. This is more me saying that I did a terrible job of predicting the challenges of Whole-Module-MainActor and this makes me believe I'm also going to do a bad job of predicting the challenges here. But in the meantime, it would sure make me feel a lot better if quirks around the effects of explicit nonisolated (and my gut says this will make it common) were addressed.

Yes, my "mode" I meant the Whole-Module-MainActor. More stuff marked (implicitly) MainActor means more closures are inferred MainActor. And if any of those happen to be missing a needed @Sendable, more runtime assertions. This is working as-designed for types that are actually have a requirement to be MainActor. So the phenomenon only really negatively impacts types which are MainActor but should not (or at least could not) be.

Unfortunately, fixing this can also be difficult because of the explicit nonisolated stuff.

The macro troubles aren't something that I understand deeply. But I believe the issue is that in some cases, it has proven difficult to generate code that is mode-agnostic. If the active isolation mode was visible to the macro-generating code, I think that could help. But please don't quote me on this one.

I partially agree. The visibility definitely helps. And it's also not bad (I think) if you are in a nonisolated module and selectively applying MainActor.

However, the community internalization of "default MainActor is the new way because that's what new Xcode projects use" has proven to be extremely strong. So, I suspect it will be more common to see a default MainActor module that uses default nonisolated. And in that case, I see no reason why similar problems would not present themselves. A programmer now needs to decide if a type in a file has the wrong isolation, if perhaps the entire file is the problem, or if the module itself is the issue. The space of complexity has increased, though again, I agree that the nature of the complexity has not. Though that nature has proven to be a big challenge for people not familiar with the concurrency system.

And further, I think you are correct that file-level control could be a nice way to reduce repetition.

1 Like

I agree, I think file-level defaults as specified here has the potential to proliferate the amount of declarations that have "explicit" nonisolated and get surprising and unfortunate behavior. It's possible we add this feature without support for isolation until NoExplicitNonIsolated is the default? We could consider a world where file-level default is not treated as explicit for the purpose of NoExplicitNonIsolated... but file-level defaults become harder to turn on and off. Going back and forth about this does worry me wrt -default-isolation nonisolated + default @MainActor + the current default of explicit nonisolated != implicit nonisolated in some cases + a file where you want to turn off the file-level default on one declaration: writing nonisolated extension to supersede the file level default is different from the behavior you had before you added a file-level default (assuming you didn't have it explicitly until you wanted to override the file level default). That might be sufficient motivation to wait to do this until we can solve NoExplicitNonIsolated, or to pick some other interaction between this behavior and file-level default isolation? I'm not sure. However, if you expect default nonisolated to be paired with -default-isolation MainActor (and I do in most cases!) this issue does not arise because NoExplicitNonIsolated is enabled by -default-isolation MainActor.

"Macro generated code is excluded from file-level defaults" may be a fine rule. I don't write enough Swift macros to say with any confidence.

Is this a messaging problem rather than a language design problem? It should be possible to emphasize that file-level default isolation exists only to reduce repetition and is equivalent to writing that isolation on every top level declaration... the rule for a new programmer can be as simple as "use file-level default isolation when every declaration in a file has the same isolation, to reduce the mental burden of readers and writers who would otherwise need to look for exceptions".

1 Like

Thanks for reviving this, overall the new spelling looks pretty good! I've spent the last few days thinking about this and I'm going back and forth on some concerns.

I like the default keyword reuse on global scope, we can use using somewhere else (if ever) or people might have had methods like it indeed; default already being a keyword and matching that we "default behavior" change matches well enough here. I'm also definitely positive about using a keyword and not a #... because this most definitely isn't just a macro, it affects language so it deserves to be a keyword.

What I am concerned about though is the grammar -- specifically, that we're not qualifying what the default is. I worry this is focusing on short term win rather than on long term tools finally learning to parse Swift correctly -- github still is struggling with relatively simple Swift code, and I'm concerned about having to modify the grammar of default-declaration -> default attribute into default-declaration -> default-qualifier? default-attribute in the future... :thinking:

There's two things in near future that I think might want to use default which lead me down this concern path.

First, I thought of default global executors -- would it make sense to default them per file? Probably not -- so maybe we're fine here.

Second, I wanted to move away from magical typealias of typealias DefaultDistributedActorSystem = ... so that files could declare default ...?.

In the end though I arrived at something that feels half way between a workaround, and workable thing:

By not having qualifiers -- we're forcing the attributes to be the qualifiers, with @MainActor being lucky enough to be "well known enough" that we don't qualify it further.

I'll use the distributed as an example of how I think our future looks like if we accept this grammar as is: I think we'll end up having to introduce semi-fake attributes for the purpose of using them with default which them are explicit in their meaning, e.g. for the distributed case:

default @DistributedActorSystem(SomeType.self)

which we'd do by:

  • introduce an attribute only for purpose for expressing it in default, sources would not use this -- typealias of where ActorSystem == ... constraint is preferable

Which... I guess I'm okey with?

Bottom line here is that we've acknowledged these are potentially language altering things, by making it a keyword, so it can be pretty flexible; rather than pretending it's "just a macro" which it isn't.

Long story short, I think I'm okey with this but have had some concerns about future evolution; but in the end, I think they're solved by "we'll do more attributes when we need them".

2 Likes

If default supported attached macros, you could do it that way, right? It’d be a member macro that injected the typealias into distributed actor declarations and ignored everything else. That’s actually pretty clean.

It's not that trivial, there's various cases where just "blindly slap a macro on everything" wouldn't be right. Like if a type already had an extension somewhere that provided the typealias etc.

All I'm talking about here though is if there is a need to qualify what the default is for, or not -- current proposal leaves no space for qualification and what I'm pondering about here is if that's a mistake that'll bite us in the long run or not.

It definitely feels we're nudging future use into a specific direction by adopting this style, and it'll be like the distributed example shows making up some fake attributes to then use with default .... And my conclusion is that it's probably fine, but worth keeping in mind that that's what we're setting up for us in the future here, for all kinds of future usage.

I find this surprising! This would go a long way towards me feeling less nervous here except the only times I've ever personally run into problems are when -default-isolation MainActor is present. But perhaps this was because during debugging I was bouncing back and forth between dialects and got confused.

I think messaging is certainly one problem here. But I still hold the opinion that going a little slower in this area specifically when it comes to isolation control comes with few downsides.

1 Like

This is what I'd expect, yeah. I don't see that as a fake attribute, since I would expect it to work on a declaration too. If we really dislike that, it wouldn't be source incompatible to modify the grammar as you describe, but I do understand the concern around changing the grammar repeatedly.

I think a way to specify these types of configurations as a modifier or attribute is desirable, because the alternative is default DistributedActorSystem ... for file-level and typealias DefaultDistributedActorSystem which is "mismatched".

We could even consider something like @typealias(name=...) in the future that adds a typealias name to declarations that don't already have a type alias of that name, although I find that overly clever and kind of confusing. It lets you write default @typealias... but this sort of configuration being done with a type alias is not very intuitive or widely known in my opinion, and not something we would choose today.

The module-level default changing other inference behavior is not ideal yeah, I also don't know when that association between the two flags was added, so it's possible you were testing before that?

2 Likes

Yeah, overall I think I've convinced myself this is a reasonable enough direction.

1 Like

I tried to parse the proposal and discussion but there's a lot of it so apologies if this has already been discussed:

Has there been a discussion or at least possible future direction to make this into a more generalized per-scope defaults setup?

With the ever-increasing number of qualifiers that we keep adding to Swift I feel like such a thing would both increase readability in many cases and simplify the adoption/addition of new features.