'#if' for postfix member expressions

Hi all,

Here's a pitch for a feature that expands #if functionality to postfix member expressions. I'll keep an update-version of this proposal here. Implementation is here

#if for postfix member expressions

Swift has conditional compilation block #if ... #endif which allows code to be conditionally compiled depending on the value of one or more compilation conditions. Currently, unlike #if in C family languages, the body of each clause must surround complete statements. However, in some cases, especially in result builder contexts, demand for applying #if to partial expressions has emerged. This proposal expands #if ... #endif to be able to surround postfix member expressions.

Motivation

For example, when you have some SwiftUI code like this:

VStack {
  Text("something")
#if os(iOS)
    .iOSSpecificModifier()
#endif
    .commonModifier()
}

This doesn’t parse today, so you end up having to do something like:

VStack {
  let basicView = Text("something")
#if os(iOS)
  basicView
    .iOSSpecificModifier()
    .commonModifier()
#else
  basicView
    .commonModifier()
#endif
}

which is ugly and has duplicated .commonModifier(). If you want to eliminate the duplication:

VStack {
  let basicView = Text("something")
#if os(iOS)
  let tmpView = basicView.iOSSpecificModifier()
#else
  let tmpView = basicView
#endif
  tmpView.commonModifier()
}

...which is even uglier.

Proposed solution

This proposal expands #if functionality to postfix member expressions. For example, in the following example:

baseExpr
#if CONDITION
  .someOptionalMember?
  .someMethod()
#else
  .otherMember
#endif

If CONDITION evaluates to true, the expression is parsed as

baseExpr
  .someOptionalMember?
  .someMethod()

Otherwise, it’s parsed as

baseExpr
  .otherMember

Detailed design

Grammar changes

This proposal adds postfix-ifconfig-expression to postfix-expression. postfix-ifconfig-expression
is a postfix-expression followed by a #if ... #endif clause.

+ postfix-expression → postfix-ifconfig-expression
+ postfix-ifconfig-expression → postfix-expression conditional-compilation-block

postfix-ifconfig-expression is parsed only if the body of the #if clause starts with a period (.) followed by a identifier, a keyword or an integer-literal. For example:

// OK
baseExpr
#if CONDITION_1
  .someMethod()
#else
  .otherMethod()
#endif

But the following is not a postfix-ifconfig-expression because it does not start with .. In such cases, #if ... #endif is not considered a part of the expression, but is parsed as a normal compiler control statement.

// ERROR
baseExpr      // warning: expression of type 'BaseExpr' is unused.
#if CONDITION
  { $0 + 1  } // error: closure expression is unused
#endif

baseExpr      // warning: expression of type 'BaseExpr' is unused.
#if CONDITION
  + otherExpr // error: unary operator cannot be separated from its operand
#endif

Also, the body must not contain any other characters after the expression.

// ERROR
baseExpr
#if CONDITION_1
  .someMethod()

print("debug") // error: unexpected tokens in '#if' expression body
#endif

Expression kind inside #if/#elseif/#else body

There are several kinds of postfix expressions in Swift grammar.

  • initializer expression
  • postfix self expression
  • explicit member expression
  • function call expression
  • subscript expression
  • forced value expression
  • optional chaining expression
  • postfix operator expression

Body of postfix #if expression must start with explicit member expression, initializer expression, or postfix self expression. But you can continue the expression with any other postfix expression suffixes. e.g.

// OK
baseExpr
#if CONDITION_1
  .someMember?.otherMethod()![idx]++
#else
  .otherMethod(arg) {
    //...
  }
#endif

Note that you however cannot continue infix operator binary expressions inside the body because binary expression is not a postfix expression.

// ERROR
baseExpr
#if CONDITION_1
  .someMethod() + 12 // error: unexpected tokens in '#if' expression body
#endif

Starting with other postfix expressions is not allowed because most of them must start on the same line as the base expression.

Empty #elseif/#else body

#elseif and #else body can be empty.

// OK
baseExpr
#if CONDITION_1
  .someMethod()
#elseif CONDITION_2
  // OK. Do nothing.
#endif

Though unrelated statements in the body is not allowed.

// ERROR
baseExpr
#if CONDITION_1
  .someMethod()
#else
return 1 // error: unexpected tokens in '#if' expression body
#endif

Consecutive postfix #if expressions

#if ... #endif blocks for postfix expression can be followed by an additional postfix expression including another #if ... #endif:

// OK
baseExpr
#if CONDITION_1
  .someMethod()
#endif
#if CONDITION_2
  .otherMethod()
#endif
  .finalizeMethod()

Nested #if blocks

Nested #if blocks are supported as long as the first body starts with an explicit member-like expression. Each inner #if must follow the rule for postfix-ifconfig-expression too.

// OK
baseExpr
#if CONDITION_1
  #if CONDITION_2
    .someMethod()
  #endif
  #if CONDITION_3
    .otherMethod()
  #endif
#else
  .someMethod()
  #if CONDITION_4
    .otherMethod()
  #endif
#endif

Postfix #if expression inside another expression

Postfix #if expressions can be nested inside another expression or statement.

// OK
someFunc(
  baseExpr
    .someMethod()
#if CONDITION_1
    .otherMethod()
#endif
)

This is parsed as someFunc(baseExpr.someMethod().otherMethod()) or someFunc(baseExpr.someMethod()) depending on the condition.

Source compatibility

This proposal does not have any source breaking changes.

baseExpr
#if CONDITION_1
  .someMethod()
#endif

This is currently parsed as

baseExpr
#if CONDITION_1.someMethod()
#endif

And it is error because CONDITION_1.someMethod() is not a valid compilation condition. This proposal changes the parser behavior so .someMethod() is not parsed as a part of the condition. As a bonus, this new behavior applies to non-postfix #if expressions too. Consequently,

enum MyEnum { case foo, bar, baz }

func test() -> MyEnum {
#if CONDITION_1
  .foo
#elseif CONDITION_2
  .bar
#else
  .baz
#endif
}

Now becomes valid swift code. This change doesn’t break anything because explicit member expressions have always been invalid at the compilation condition position.

Alternatives considered

Lexer based #if preprocessing

Like C-family languages, we could pre-process conditional compilation directives purely in Lexer level as discussed in Allow conditional inclusion of elements in array/dictionary literals? - #29 by Chris_Lattner3. Although it is certainly a design we should explore some day, in this proposal, we would like to focus on expanding #if to postfix expressions.

24 Likes

Personally, I'd prefer us to go with your alternative. The AST-based #if handling stuff is a failed experiment in my opinion. I'd rather we drop it and allow arbitrary tokens to be #ifdef'd out.

-Chris

12 Likes

FWIW I disagree; the approach of the parser being able to understand all of the code, independent of if sections and compiler arguments (truly context-free), is what allowed us to support syntactic understanding of the code everywhere (syntax coloring, indentation, formatting, structure view, etc.) with just a source file or text as input.

7 Likes

IMHO avoiding the horrors of the C preprocessor was one of the best decisions of the Swift language.

6 Likes

Having implemented Clang's preprocessor :-) I assure you that allowing lexical #if processing is FAR FAR FAR from the horrors of the C preprocessor.

I agree with you that there are some cases where it is convenient to have the #if out code in the AST (e.g. I think some warnings get disabled based on fuzzy matches with identifiers in excluded out code), but it isn't very useful. You can't even resolve operator precedence in such code.

The downside of the current approach is that you can't #ifdef out arbitrary things, (e.g. individual function attributes), and people keep bumping into these limitations in lots of places. We have had proposals in the past to expand #if processing, but have rejected them because they make the AST too complex to be worth it. This seems like a failed model to me.

There are well known ways to attach excluded tokens to declarations and we could achieve the same results as the current implementation. Moving to such an approach would enable a simple and consistent usage model for Swift users without penalizing the narrow cases that tooling can work with today.

-Chris

13 Likes

I couldn't agree more. Good C programmers use the C preprocessor
sparingly, as a last resort, and only under service panels with
appropriate electrocution-risk labels in place. They reap the
benefits when other human beings can read their code, and program
transformation tools can read the code, too.

If it wouldn't be a source-breaking change, I would be glad to see
tighter restrictions on #if/#endif in Swift. For example, do not allow
#if/#endif inside of any function.

Dave

An idea I just had (which is probably bad):
What if #if were a generic AST expression.
That is, #if ␤ <#then#> ␤ #else ␤ <#else#> ␤ #endif is valid anywhere in the AST, so long as both the then and else clauses would also be valid independently. An #else clause is omit-able if the AST kind the then clause is parsed as accepts an empty parse.

swift-format does handle operator precedence within the libSyntax tree :slightly_smiling_face:

It is beyond "convenient" to be able to represent any piece of Swift text into a structured tree, it is what guarantees that swift-format can work comprehensively in any context, without any access to compiler arguments.

The issue is not whether the skipped tokens get lost or not, the issue is that you lose the guarantee that any piece of Swift code can be represented structurally in a context-free manner, since any preprocessor block may contain garbage tokens and you don't know which block is the active one.

The complexity stems from the fact that the AST tries to satisfy many different use cases and concerns at the same time. It both serves as the output of the syntax parser, trying to make available the syntax structure of the source, and as input to the typechecker which only cares about semantic understanding.

I would propose an alternative as a middle way, for making expansion of if less complex without resorting to just treating #f blocks as a bag of tokens. We can refactor the parser to separate it from the AST and have its only concern to be to produce the libSyntax tree, which use is for the syntactic understanding. That tree then can be resolved down to the "semantic" AST that the typechecker and the rest of the pipeline uses.

In this model we only expand the libSyntax tree in its understanding of if, without worrying at all about touching the AST structures, so we don't affect any other component. It is not as simple as just skipping tokens but it should be less complex than trying to modify the AST.

6 Likes

If you can implement this, then please also allow #warning(...) be wherever #if can?