[Pitch] Conditional compilation for attributes and modifiers

Hi all,

After working on hasFeature a bit, I decided that I'd also like to solve a similar problem for attributes and modifiers, which we tend to add regularly and cause a bit of trouble when adopting new attributes/modifiers while supporting older compilers. Here's a pitch!

Introduction

Over time, Swift has introduced a number of new attributes and modifiers to communicate additional information in source code. Existing code can then be updated to take advantage of these new constructs to improve its behavior, providing more expressive capabilities, better compile-time checking, better performance, and so on.

However, adopting a new attribute or modifier in existing source code means that source code won't compile with an older compiler. Conditional compilation can be used to address this problem, but the result is verbose and unsatisfactory. For example, one could use #if to check the compiler version to determine whether to use the @preconcurrency attribute:

#if compiler(>=5.6)
@preconcurrency protocol P: Sendable {
  func f()
  func g()
}
#else
protocol P: Sendable {
  func f()
  func g()
}
#endif

This is unsatisfactory for at least two reasons. First, it's a lot of code duplication, because the entire protocol P needs to be duplicated just to provide the attribute. Second, the Swift 5.6 compiler is the first to contain the @preconcurrency attribute, but that is somewhat incidental and not self-documenting: the attribute could have been enabled by a compiler flag or partway through the development of Swift 5.6, making that check incorrect. Although these are small issues, they make adopting new attributes and modifiers in existing code harder than it needs to be.

Proposed solution

I propose three related changes to make it easier to adopt new attributes and modifiers in existing code:

  • Allow #if checks to surround attributes and modifiers wherever they appear, eliminating the need to clone a declaration just to adopt a new attribute or modifier.
  • Add a conditional directive hasAttribute(AttributeName) that evalutes true when the compiler has support for the attribute with the name AttributeName in the current language mode.
  • Add a conditional directive hasModifier(ModifierName) that evalutes true when the compiler has support for the modifier with the name ModifierName in the current language mode.

The first two of these can be combined to make the initial example less repetitive and more descriptive:

#if hasAttribute(preconcurrency)
@preconcurrency
#endif
protocol P: Sendable {
  func f()
  func g()
}

Similarly, hasModifier can benefit code that relies on modifiers, e.g.,

#if hasModifier(distributed)
distributed
#endif
actor MyActor { // distributed when we can be, otherwise a local actor
  // ...
}

Detailed design

The design of these features is relatively straightforward, but there are a few details to cover.

Grammar changes

The current production for an attribute list:

attributes → attribute attributes[opt]

will be augmented with an additional production for a conditional attribute:

attributes → conditional-compilation-attributes attributes[opt]

conditional-compilation-attributes → if-directive-attributes elseif-directive-attributes[opt] else-directive-attributes[opt] endif-directive
if-directive-attributes → if-directive compilation-condition attributes[opt]
elseif-directive-attributes → elseif-directive-attributes elseif-directive-attributes[opt]
elseif-directive-attributes → elseif-directive compilation-condition attributes[opt]
else-directive-attributes → else-directive attributes[opt]

i.e., within an attribute list one can have a conditional clause #if...#endif that wraps another attribute list.

The same applies to declaration-modifiers, adding a production for conditional compilation:

declaration-modifiers → conditional-compilation-modifiers declaration-modifiers[opt]

conditional-compilation-modifiers → if-directive-modifiers elseif-directive-modifiers[opt] else-directive-modifiers[opt] endif-directive
if-directive-modifiers → if-directive compilation-condition declaration-modifiers[opt]
elseif-directive-modifiers → elseif-directive-modifiers elseif-directive-modifiers[opt]
elseif-directive-modifiers → elseif-directive compilation-condition declaration-modifiers[opt]
else-directive-modifiers → else-directive declaration-modifiers[opt]

hasAttribute only considers attributes that are part of the language

A number of Swift language features, including property wrappers, result builders, and global actors, all introduce forms of custom attributes. For example, a type MyWrapper that has been marked with the @propertyWrapper attribute (and meets the other requirements for a property wrapper type) can be used with the attribute syntax @MyWrapper. While the built-in attribute that enables the feature will be recognized by hasAttribute (e.g., hasAttribute(propertyWrapper) will evaluate true), the custom attribute will not (e.g., hasAttribute(MyWrapper) will evaluate false).

Parsing the conditionally-compiled branches not taken

Due to support for custom attributes, attributes have a very general grammar that should suffice for any new attributes we introduce into Swift:

attribute → @ attribute-name attribute-argument-clause[opt]
attribute-name → identifier
attribute-argument-clause → ( balanced-tokens[opt] )

Therefore, a conditionally-compiled branch based on #if hasAttribute(UnknownAttributeName) can still be parsed by an existing compiler, even though it will not be applied to the declaration because it isn't understood:

#if hasAttribute(UnknownAttributeName)
@UnknownAttributeName(something we do not understand) // okay, we parse this but don't reject it
#endif
func f()

Modifiers are different, because there is neither a notion of custom modifiers nor is there an identifying token like @. Therefore, we treat #if hasModifier(UnknownModifierName) more like the #if compiler(>=7.2) directive, where the contents of the branch not taken is not processed at all:

#if hasModifier(UnknownModifierName)
it is okay to have total gibberish here
#endif
func g()

What do folks think?

Doug

21 Likes

Is it possible to combine the two hasXXX() macros into one? I feel that the feature will be easier to use if developers don't need to know which tokens are attributes and which are modifiers.

I don't see any valid use case of hasModifier and conditional modifier, and the distributed case is totally wrong. Distributed actors have different semantics than regular actors, which are never interchangeable.

hasAttribute is great, especially when we’re using an experimental attribute and it can be used to emit clearer error messages. I don’t think I will use conditional attributes frequently, but the example convinced me that it’s a valuable change✅

This is promising, could definitely help with preconcurrency and sendable adoption without source-breaks... I'll think more about it still.

One thing that came to mind immediately was @Sendable but I don't think this helps here, and we'd stick to the "usual" workaround of a typealias :thinking: What I mean by that is:

// public func test(
//    operation: @Sendable @escaping () async -> T) async -> T

// #if compiler(>=5.6) // forgot the version we introduced it
#if hasAttribute(Sendable) // I assume Sendable would work?
typealias Callback = @Sendable @escaping () async -> T
#else 
typealias Callback = @escaping () async -> T
#endif 

public func test(operation: Callback) async -> T

I guess trying to solve this inline of the function definition would require more sugar and may be beyond what we're trying to simplify here.


Agreed though that the "duplicating protocols" was very much one of the bigger pain points last we identified in a number of SSWG projects last time we looked at it!

WDYT about this @adam-fowler, would this help Soto?

2 Likes

An inline version of this would have helped, but even just being able to conditional add attributes based on their existence rather than which swift compiler we are running is cleaner.

This is all a little moot for @Sendable though, unless there is a plan to implement hasAttribute for earlier versions of swift, but for future features it will be useful.

1 Like

As @adam-fowler notes, this is a great idea, but it does not help with incremental Sendable adoption because it does not work in older compilers. A tool could be written to process source files for older compilers, but that tool could also be written today.

I'm not standing in opposition to this feature, I think it's great. We should just make sure we're clearly talking about the fact that this will help solve this problem for future language features, but not the specific set of problems we have today. That's totally fine: we'll eventually all be using whatever Swift version this lands in, at which point we can adopt it.

EDIT: I should clarify this a bit because what I said is not quite true. It will help with incremental concurrency adoption in the specific case where libraries and users have dropped support for all Swift versions prior to the one shipping this feature, but where libraries have not yet minted a new major version. In those cases it is possible they have users who have not yet adopted concurrency and who would benefit from these features. That's a small set of use-cases, I suspect, but it's not zero.

8 Likes

Attributes are spelled with an @, modifiers aren't. I don't think that's unreasonable to expect folks to understand. That said...

I was stretching for distributed. Modifiers generally have more impact on the type system, and are less amenable to being #if'd out. I'd be happy to remove hasModifier and simplify the proposal.

We can't fix the past, but we can lay some groundwork to make future changes easier to adapt to.

Doug

4 Likes

I believe this would ultimately become our guideline on deciding whether a new mark should be modifier or attribute.

I really like the future-proofing you’re doing here, but I’d like to suggest one more change that would future-proof even further.

#if swift and #if compiler allow invalid syntax in the inactive branch of the IfConfig. I’d like to suggest that they also allow invalid conditions on the right-hand side of an && or || when a swift or compiler on the left-hand side is decisive enough to short-circuit. This would help us to add new hasAttribute-like features in the future:

#if swift(>=7.5) && hasUnicode(30.0)
// In Swift 7.4 and below, `swift(>=7.5)` is false, so we don’t diagnose
// the fact that `hasUnicode(30.0)` was not recognized.
//
// Presumably Swift 7.5 supports `hasUnicode(30.0)`; if it didn’t, that
// version *would* diagnose it as invalid.
#endif

If I recall correctly, IfConfig works by parsing the condition as an expression and specially interpreting it. I think that would be compatible with this approach: the invalid parts of the condition would still need to be syntactically valid expressions; they’d just be able to include combinations of names, operators, literals, etc. that wouldn’t make any sense to the current compiler.

(If a previous Swift version had included this feature, we could write #if swift(<=5.7) && hasAttribute(whatever), but like the other changes we’re discussing in this thread, that’s not actually useful this time because the change hasn’t landed yet.)

8 Likes

This seems like a good idea. Actually parsing it might be tricky, because we can’t know when we see the #if whether it surrounds a declaration or just an attribute list, but that’s merely an implementation problem. :)

2 Likes

Ah, this is actually discussed in my hasFeature proposal in https://github.com/apple/swift-evolution/blob/b689aacfe92c7c0d6fa6a3c4b3167fd15366d2e6/proposals/nnnn-piecemeal-future-features.md#feature-detection-in-source-code.

To prevent this issue for any future extensions to the #if syntax, the compiler should interpret the "call" syntax to an unknown function as if it always evaluated false. That way, if we invent something like #if hasAttribute(Y) in the future, one can use

#if hasAttribute(Sendable) 
... 
#endif

Doug

Ah, I didn’t catch this bit. One issue I notice is that this doesn’t include #if swift’s feature of allowing unparsable syntax in the inactive block, although perhaps that’s just an oversight in the drafting. A more serious problem is that it doesn’t allow the compiler to diagnose mistakes—if someone accidentally wrote, say, hasAtribute(Sendable) (one t instead of two), the compiler would assume this was some future language feature and make it return false.

3 Likes

Yeah, even if more verbose, I agree that making && short-circuit after #if swift/compiler and specifically in that circumstance allowing arbitrary call syntax would seem to be the more targeted solution here :slight_smile:

2 Likes

This is a great point. I've taken your idea into Piecemeal adoption of future language improvements by DougGregor ¡ Pull Request #1660 ¡ apple/swift-evolution ¡ GitHub.

Doug

3 Likes

Hey all, I've updated the proposal to focus in on attributes and hasAttribute, removing all mention of modifiers.

Doug

2 Likes