[Pitch 3] File level defaults

Hi Swift Evolution!

Based on feedback from the previous pitch as well as the recently accepted SE-0522: Source-Level Control Over Compiler Warnings, I've revised SE-0478 to encompass per-file actor isolation, availability, and diagnostic control with a new default syntax (open to other options though!). You can find a draft of the revised proposal here: swift-evolution/proposals/0478-default-isolation-typealias.md at 0478-revision-3 · a-viv-a/swift-evolution · GitHub

The gist is a new syntax for specifying file level default attributes and modifiers:

// LegacyLogger.swift

default @available(*, deprecated, message: "this logging system uses global state")

// Implicitly @available(*, deprecated, message: "this logging system uses global state")
func log(message: String) { ... }

// Implicitly @available(*, deprecated, message: "this logging system uses global state")
func initLogging() { ... }
// Data.swift

default @MainActor

// Implicitly '@MainActor'
class Profile { ... }

// Implicitly '@MainActor'
class Settings { ... }
default @diagnose(StrictMemorySafety, as: error)

Please leave design feedback in this pitch thread, and editorial feedback on the swift-evolution PR. I'm excited to read your thoughts, questions, and other constructive feedback!

5 Likes

This looks great! I do have one concern though:

In general, the intention is that default behaves like writing the attribute or modifier on each appropriate top level declaration, although exact propagation and inference rules are specific to each case.

I like the intention—that part makes a lot of sense. It suggests that this feature composes nicely with existing (and already bespoke) rules about how attributes, for example, on an extension propagate to extension members.

For that same reason, I am very worried about the caveat. This would suggest that something doesn’t compose very well at all, and that this proposal finds it necessary to layer bespoke rules for file-level defaults onto specific attributes or modifiers that already have bespoke rules of their own (on top of somewhat bespoke rules about what the default default is).

On an initial read, I’m not picking out where that situation arises, and at minimum it’d be essential to get clarity on that.

I’m very leery these exceptions to the general rule will make the feature difficult to reason about and prone to misunderstanding, which to me is a fatal flaw for a default: if it doesn’t default where I expect it to after I’ve explicitly asked for it, it’d be an anti-feature.

6 Likes

Yeah, I agree that carveout is unfortunate; it's referring to the inference rules in SE-0466, and not having those carveouts is discussed briefly in alternative directions. I agree it should be highlighted before the actor isolation defaults subsection.

I don't believe there are any other carveouts, since @available on the top level decl should control availability for the entire decl, and @diagnose is lexically scoped to the entire decl. It should be more clear that those cases don't require carveouts.

My understanding of the SE-0466 rules is that they cover exactly the cases where naively applying the default would always break, and not doing that would have a chance at working. If we honored default @MainActor over SendableMetatype then we would require nonisolated to be written. We would also be introducing a second 'default isolation' system with different inference rules from -default-isolation. If you know of any case where SE-0466 gets it wrong (naively applying the default isolation would compile without annotations, but the inference rules lead to an error unless you override them with an annotation) that would change my perspective.

An earlier version of my revision didn't have the carveout (I'm really sympathetic to not including it) but my understanding is that it increases the odds of "reasonable looking" code compiling.

2 Likes

Perhaps I'm just too used to the fact that @Thing always goes before normal looking thing, but still I can't help but notice that this looks unusual... Maybe this?

@Default @MainActor

Secondly, without looking in the pitch I didn't realise that this is per file (compared to, say, fileprivate which is more or less self explanatory).

2 Likes

That's a parsing problem:

@Default @MainActor

struct Foo {}

would be parsed as a declaration with the two attributes @Default @MainActor, not something that applies to the whole file. We'd have to privilege @Default to make this work, but not just in a "treat @Default as a special kind of attribute" way, but in a "treat @Default as starting its own special kind of declaration" way. So what you've written here is actually more unusual in the context of how attributes work in Swift.

I haven't formed a strong opinion about the use of default here yet, but a couple easily identifiiable advantages of it are that (1) it's a keyword that already exists, and (2) default isn't a valid statement/declaration starter on its own, so it's not ambiguous at the file level.

3 Likes

Just brainstorming… has a kind of attribute scope been explored? Something like:

@MainActor
scope {

// Implicitly '@MainActor'
class T1 { ... }

// Implicitly '@MainActor'
class T2 { ... }

}

// Not Implicitly '@MainActor'
class T2 { ... }

And an option for whole file:

@MainActor
scope(file)
2 Likes

When would you want this, and when would you want it and be disinclined to rearrange the scopes into files?

What this does regarding SE-0466 @preconcurrency and inference rules is probably the most controversial part of this, and I don't feel like I know the "right" answer (if it even exists) so I'm especially interested in other people's opinions on this.

Maybe to deprecate a bunch of methods of a class but not the entire class. Also could be used to public/private scope a group of methods.

class C {

   func a() { }
   
   @MainActor
   public scope {
   
       func b() { } // Implicitly 'public' and '@MainActor'
       func c() { } // Implicitly 'public' and '@MainActor'
       func d() { } // Implicitly 'public' and '@MainActor'

   }

}
2 Likes

Too radical change?

SomeBikeShedNameHere {
	// Implicitly @available(*, deprecated, message: "this logging system uses global state")
	func log(message: String) { ... }

	// Implicitly '@MainActor'
	class Profile { ... }
}
.deprecated("this logging system uses global state")
.isolation(.mainActor)
.diagnoseMemorySafety(.errors)

I think that feels too much like a framework api rather than a language feature.

2 Likes

FYI, this "scope" concept you're discussing already exists: extensions. We don't need to reinvent those with a different syntax because they can already do what you're describing:

struct S {}

@available(*, deprecated, message: "can't use")
extension S {
    func foo() {}
}

@MainActor
extension S {
    func bar()
}

S().foo()
// ^-- warning: 'foo()' is deprecated: can't use
Task {
    S().bar()
    // ^-- error: main actor-isolated instance method 'bar()'
    // cannot be called from outside of the actor
}

I'm not sure if "any attribute applied to an extension is applied to its members" is a general rule in the language or if it's been special-cased for specific attributes. If the latter, it would be worthwhile to audit the ones we have and see if there are any that should have the same distribution rule applied. But that's a different proposal.

So with that in mind, the scope of the file is really the gap we need to be concerned about. I think we should resist the tempting urge to try to achieve maximal generality by wrapping everything in a new curly brace scope. There are nice syntactic properties of Swift that matter for tooling (and now, macros) where iterating over the members of a type or the code block items in a file gives you the members of that type or the statements/decls in the file and not other structural nodes that you have to continue looking through.

In fact, the one exception to this is #if blocks, which are treated as their own unique declaration which has the decls/statements inside it. Dealing with those correctly is already a huge pain when you're writing tooling that works with code, because you have to recursively look through them and reconstruct them on the way back out, and I would hate to see that pattern become even more widespread.

3 Likes

The pitched examples show free functions and standalone classes, those we can't put in extensions.... the #if #endif blocks analogy is very relevant, and yes it's quite tempting to invent a generalised solution.. maybe too radical for current Swift, I admit.

And that's why I specifically said

To make sure my thoughts on this specific pitch are clear:

  • Yes, we should have a general way to provide a set of attributes that are applied to every top-level declaration in a file by default. I don't have strong opinions about the actual keywords used, but the pitch at top of thread seems good at first blush. I like that it reuses an existing keyword (and in a way that makes sense with its meaning!) to make something that is actually practical to implement without moving mountains.

  • It shouldn't be structural, as some others have suggested (i.e., a scope). That opens too many cans of worms in terms of compiler implementation, downstream impact on other tooling, and understandability. We already have that for types, and there's no significant need to have it for files. Sometimes simpler really is better.

  • If folks really did need multiple different top-of-file regions, perhaps an avenue to explore would be to allow multiple default declarations throughout the file, but that starts getting into C #pragma land and I don't know if the juice is worth the squeeze there.

1 Like

Part of the rationale for forbidding default below anything but import is to ensure that if we discover a need to extend it like this and decide it’s worth it, we can do it without any kind of source break. Most of the rationale is for avoiding any paranoia about defaults scattered elsewhere in the file, and if you wanted defaults to be able to interact only below the default with imports you’d wish we’d required them to always be before (or after? hard to know) imports… but we are leaving that option pretty open.

Personally I don’t think the juice is worth the squeeze and most cases would be better served by reorganizing into additional files, but the community could decide differently after taking this proposal and still add that behavior with this syntax in the future.

4 Likes

Thinking about this more and I have another question: do people see the need for default @preconcurrency / default @preconcurency @MainActor especially given the idea of not inheriting SE-0466's preconcurrency behavior in Swift 5 but wanting some way to migrate to this? I'm wondering if this might be a more elegant and useful proposal with these changes:

  • default applies to every legal top level declaration with no per case caveats (per feedback from @xwu)
  • @preconcurrency can be used on its own, like default @preconcurrency (all top level declarations marked @preconcurrency, since you can already write @preconcurrency on top level declarations, thanks to SE-0337)
  • @preconcurrency can be combined with nonisolated or @MainActor for a default isolation likedefault @preconcurrency nonisolated or default @preconcurrency @MainActor for top level declarations that don't have an explicit isolation. Combining them in one default is "both or neither" (if you wrote nonisolated on a top level declaration, you don't still get the @preconcurrency from default @preconcurrency @MainActor), the preconcurrency is just modifying theisolation, and don't allow combining any of the other defaults in one default declaration

Some counterarguments I see include:

  • Risk of proliferating @preconcurrency on new APIs that happen to share a file and don't need it; better to do as an editor feature to 'apply to all' and not set as a default
  • Not using SE-0466 rules means programmers are more likely to encounter 'weird behavior' when they interact with those carveouts... but maybe they'd like to be alerted by the compiler that their default won't work (via the error about concurrency), instead of having the compiler secretly try to figure out their intention. Not sure how well understood SendableMetatype and similar are by the programmers who'd use this feature...

Edit: default @preconcurrency @MainActor is actually ambiguous if you allow default @preconcurrency and don't consider newlines...

I agree with your judgment that having a second 'default isolation' rule that diverges from SE-0466 doesn't sound great, just as having carveouts (for the headline use case, no less) from the general rule here isn't great either.

I don't have any ready ideas to square that circle here. But it does make me wonder whether file-level default @MainActor is simply just not of-a-kind with the other things that are being generalized to, and whether we should be trying to shoehorn necessarily different rules into the same syntax.

4 Likes

Was some kind of token to introduce compiler directives considered?

#pragma (default @MainActor)

or

#compiler-directive (default @MainActor)

Something like this would make it more conspicuous that we are asking the compiler to change its behaviour.

5 Likes

In the "alternatives considered" of SE-0522, although I found the reasoning "against" laid out there unconvincing...

... and partly inapplicable. in particular:

  • Swift generally favors attaching metadata and behavior to declarations, making developer intent clear and self-documenting.

This reasoning point is either weak, or (if not) should go against SE-0478

  • Asking the developer to define the “end” of a region means requiring careful manual state management - forgetting or misplacing a region delimiter could lead to complex unintended behaviors when multiple scopes are overlapping and potentially affect nested diagnostic groups.

This is also weak as:

  • we already do this for #if / #endif
  • pragma approach does not necessarily require push / restore brackets, if those are show stoppers for some reason (which is not at all obvious) we could consider pragma approach without those brackets in it's original pure "applied until the end of the file of the next pragma whichever happens first".
2 Likes

@xwu I think you are right that these are not really the same kind of thing, and it's a question of if it is convenient to use the same mechanism. It is possible this should be two separate proposals, one like default @MainActor and one like using @available (strawman). It's also possible this is not so surprising in practice, or that we could alleviate the concerns about the exceptions being surprising:

One option could be to emit warnings in the cases where we don't do the naive thing, within a group, which can be silenced with an explicit annotation, or with a file level diagnose attribute:

default @MainActor
default @diagnose(IsolationAssumption, as: ignored)

or (these diagnostics are not up to snuff but they convey the gist. could also be a remark?)

default @MainActor // note: file-level default '@MainActor' isolation specified here
// note: silence warnings about 'IsolationAssumption' with a default here

// ... (example from 466)

struct S: Codable {
  var a: Int

  // warning: 'CodingKeys' was inferred to be 'nonisolated' in spite of file-level default due to 'nonisolated' requirements of 'CodingKey' [#IsolationAssumption]
  enum CodingKeys: CodingKey { // note: silence this warning by writing 'nonisolated' here
    case a
  }
}

And we could emit these diagnostics any time we have a file-level default, but choose to use another default (or maybe just when it seems needed? I don't find type alias surprising), along with a tailored reason based on the logic that overruled the file-level default.

Aside: I think we would want to be very careful about offering default @diagnose(Group, as: ignored), as a fix-it for warnings... Lot of risk of being blindly accepting and swallowing issues.


I don't think it looks as nice, and to me pound directives imply "hacky" and evoke c preprocessor (and have motivations in c that swift doesn't have) but that's an aesthetic judgement and I think it certainly could be a right answer. Note that we do consider newlines, and it's not actually ambiguous in practice afaik.

I agree! However, I disagree that SE-0522 is really addressing the same question, I read that alternative considered as addressing a different question; why is SE-522 not region based pragmas, to which the answer is favoring attaching to declarations, and needing to think about the end of a region. I think the arguments made are compelling in the context of SE-522 (I would oppose a "to the end of the file if unspecified" behavior for ignoring diagnostics via pragma, it seems footgun prone). You could make the argument that SE-522 should have pursued that design to avoid the need for this proposal, I guess.

The design of SE-0522 is such that the normal way of interacting with the feature is already naturally declaration scoped. This pitch of SE-0478 is about extending attributes which today can only be attached to declarations so they can be specified for a whole file, sort of like a pragma. I don't particularly mind syntax like #pragma default @MainActor or #compiler-directive default @diagnose(...) although I would strongly advocate for it being restricted to the top of the file (which makes it hardly a typical pragma...) for the same reasons I advocate that in this proposal already. In that case, it's just an alternative syntax that aims to be more conspicuous, which is fine by me although I suspect rather verbose for many.

That being said, an argument against #pragma; c family compilers need this to interoperate between different compilers and versions, with support for different pragmas (afaik unrecognized #pragma is largely ignored). A compiler can add whatever they need (optimization hints, diagnostic suppression) within a pragma, and still make an effort at portable c. Swift does not have this constraint; there are not parallel implementations of the language, so we are able to add new, privileged syntax without worrying about alternative implementations that don't wish to adopt it or adopt it with the same syntax. I don't think that's really an argument against using pound in the syntax, but I do lean against #pragma ... for that reason.