|
| 1 | +# Expression macro as caller-side default argument |
| 2 | + |
| 3 | +* Proposal: SE-NNNN |
| 4 | +* Authors: [Apollo Zhu](https://github.com/ApolloZhu) |
| 5 | +* Review Manager: TBD |
| 6 | +* Status: **Awaiting review** |
| 7 | +* Implementation: https://github.com/ApolloZhu/swift/tree/macro/expression-as-default-argument |
| 8 | +* Upcoming Feature Flag: `ExpressionMacroDefaultArguments` |
| 9 | +* Review: ([pitch](https://forums.swift.org/t/pitch-expression-macro-as-caller-side-default-argument/69019)) |
| 10 | + |
| 11 | +## Introduction |
| 12 | + |
| 13 | +This proposal aims to lift the restriction afore set in [SE-0382 "Expression macros"](https://github.com/apple/swift-evolution/blob/main/proposals/0382-expression-macros.md) to allow non-built-in expression macros as caller-side default argument expressions. |
| 14 | + |
| 15 | +## Motivation |
| 16 | + |
| 17 | +Built-in magic identifiers like [#line](https://developer.apple.com/documentation/swift/line()) and [#fileID](https://developer.apple.com/documentation/swift/fileID()) are documented as expression macros in the official documentation, but if Swift developers try to implement a similar macro themselves and use it as the default argument for some function, the code will not compile: |
| 18 | + |
| 19 | +```swift |
| 20 | +public struct MakeLabeledPrinterMacro: ExpressionMacro { |
| 21 | + public static func expansion( |
| 22 | + of node: some FreestandingMacroExpansionSyntax, |
| 23 | + in context: some MacroExpansionContext |
| 24 | + ) throws -> ExprSyntax { |
| 25 | + return "{ value in print(\"\\(#fileID):\\(#line): \\(value)\") }" |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +public macro LabeledPrinter<T>() -> (T) -> Void |
| 30 | += #externalMacro(module: ..., type: "MakeLabeledPrinterMacro") |
| 31 | + |
| 32 | +public func greet<T>( |
| 33 | + _ thing: T, |
| 34 | + print: (T) -> Void = #LabeledPrinter |
| 35 | +// error: ^ non-built-in macro cannot be used as default argument |
| 36 | +) { |
| 37 | + print("Hello, \(thing)") |
| 38 | +} |
| 39 | +``` |
| 40 | + |
| 41 | +This is because built-in expression macros/magic identifiers have a special behavior: when used as default arguments, instead of been expanded at where the expressions are written like all other macros, they are expanded by the caller using the source-location information of the call site: |
| 42 | + |
| 43 | +```swift |
| 44 | +// in MyLibrary.swift |
| 45 | +public func greet<T>(_ thing: T, file: String = #fileID) { |
| 46 | + print("\(fileID): Hello, \(thing)" |
| 47 | +} |
| 48 | + |
| 49 | +// in main.swift |
| 50 | +greet("World") |
| 51 | +// prints "main.swift: Hello, World" instead of "MyLibrary.swift: ... |
| 52 | +``` |
| 53 | + |
| 54 | +This a useful existing behavior that should be supported, but could be surprising as it differs from all other macro expansions, and might not be desired for all expression macros. |
| 55 | + |
| 56 | +## Proposed solution |
| 57 | + |
| 58 | +The proposal lifts the restriction and makes non-built-in expression macros behave consistently as built-in magic identifier expression macros: |
| 59 | + |
| 60 | +* if expression macros are used as default arguments, they’ll be expanded with caller side source location information and context; |
| 61 | +* if they are used as sub-expressions of default arguments, they’ll be expanded at where they are written |
| 62 | + |
| 63 | +```swift |
| 64 | +// in MyLibrary.swift ======= |
| 65 | +@freestanding(expression) |
| 66 | +macro MyFileID<T: ExpressibleByStringLiteral>() -> T = ... |
| 67 | + |
| 68 | +public func callSiteFile(_ file: String = #MyFileID) { file } |
| 69 | + |
| 70 | +public func declarationSiteFile(_ file: String = (#MyFileID)) { file } |
| 71 | + |
| 72 | +public func alsoDeclarationSiteFile( |
| 73 | + file: String = callSiteFile(#MyFileID) |
| 74 | +) { file } |
| 75 | + |
| 76 | +// in main.swift ============ |
| 77 | +print(callSiteFile()) // print main.swift, the current file |
| 78 | +print(declarationSiteFile()) // always prints MyLibrary.swift |
| 79 | +print(alsoDeclarationSiteFile()) // always prints MyLibrary.swift |
| 80 | +``` |
| 81 | + |
| 82 | +Macro author can inquire the source location information using `context.location(of:)` just like before and implement `#fileID`, `#line`, and `#column` as shown below: |
| 83 | + |
| 84 | +```swift |
| 85 | +public struct MyFileIDMacro: ExpressionMacro { |
| 86 | + public static func expansion( |
| 87 | + of node: some FreestandingMacroExpansionSyntax, |
| 88 | + in context: some MacroExpansionContext |
| 89 | + ) -> ExprSyntax { |
| 90 | + context.location( |
| 91 | + of: node, at: .afterLeadingTrivia, filePathMode: .fileID |
| 92 | + )!.file |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +public struct MyLineMacro: ExpressionMacro { |
| 97 | + public static func expansion( |
| 98 | + of node: some FreestandingMacroExpansionSyntax, |
| 99 | + in context: some MacroExpansionContext |
| 100 | + ) -> ExprSyntax { |
| 101 | + context.location(of: node)!.line |
| 102 | + } |
| 103 | +} |
| 104 | + |
| 105 | +public struct MyColumnMacro: ExpressionMacro { |
| 106 | + public static func expansion( |
| 107 | + of node: some FreestandingMacroExpansionSyntax, |
| 108 | + in context: some MacroExpansionContext |
| 109 | + ) -> ExprSyntax { |
| 110 | + context.location(of: node)!.column |
| 111 | + } |
| 112 | +} |
| 113 | +``` |
| 114 | + |
| 115 | +## Detailed design |
| 116 | + |
| 117 | +### Type-checking default argument macro expressions |
| 118 | + |
| 119 | +Since the macro expanded expression might reference declarations that are not available in the scope where the function is declared, macro expressions are not expanded at the primary function declaration. However, macro expression used as a default argument is type checked without expansion to make sure that |
| 120 | + |
| 121 | +1. it is at least as visible as the function using it, |
| 122 | +2. its return type matches what that parameter expects, and |
| 123 | +3. its arguments, if any, are literals without string interpolation. |
| 124 | + |
| 125 | +### Type-checking macro expanded expressions |
| 126 | + |
| 127 | +For each call to a function that has an expression macro default argument, the macro will be expanded with each call-site’s source location and type-checked in the corresponding caller-side context, as if the macro expression is written at where it is expanded: |
| 128 | + |
| 129 | +```swift |
| 130 | +@freestanding(expression) |
| 131 | +// expands to `foo + bar` |
| 132 | +public macro VariableReferences() -> String = ... |
| 133 | + |
| 134 | +public func preferVariablesFromCallerSide( |
| 135 | + param: String = #VariableReferences |
| 136 | +) { |
| 137 | + print(param) |
| 138 | +} |
| 139 | + |
| 140 | +// in another file ========== |
| 141 | +var foo = "hi " |
| 142 | +var bar = "caller" |
| 143 | +preferVariablesFromCallerSide() // prints: hi caller |
| 144 | +// ^ same as #VariableReferences written here |
| 145 | +``` |
| 146 | + |
| 147 | +## Source compatibility |
| 148 | + |
| 149 | +As non-built-in macro expressions aren’t allowed as default argument, this change is purely additive and has no impact on existing code. |
| 150 | + |
| 151 | +## ABI compatibility |
| 152 | + |
| 153 | +This feature does not affect the ABI. |
| 154 | + |
| 155 | +## Implications on adoption |
| 156 | + |
| 157 | +This feature can be freely adopted and un-adopted in source code with no deployment constraints and without affecting source or ABI compatibility. |
| 158 | + |
| 159 | +## Future directions |
| 160 | + |
| 161 | +### Allow arguments to default argument macro expressions to be arbitrary expressions |
| 162 | + |
| 163 | +If these arguments can be arbitrary expressions, type-checking the macro expression at function declaration will require any declarations referenced in these expressions to be also in scope: |
| 164 | + |
| 165 | +```swift |
| 166 | +@freestanding(expression) |
| 167 | +// expands to: "Hello " + string |
| 168 | +public macro PrependHello(_ string: String) -> String = ... |
| 169 | + |
| 170 | +// this is needed so it can be referenced in the default argument |
| 171 | +public var shadowedVariable: String = "World" |
| 172 | + |
| 173 | +public func preferVariablesFromCallerSide( |
| 174 | + param: String = #PrependHello(shadowedVariable) |
| 175 | +) { |
| 176 | + print(param) |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +However, as the expanded expression is type-checked in the caller-side context, it’s rather unintuitive that one must add the public variable in the example above, yet it might not be what the macro expanded expressions use. For example, if there's a variable with the same name in scope on the caller side, that variable will be used, and the call to the function might fail to type-check: |
| 181 | + |
| 182 | +```swift |
| 183 | +// in another file ========== |
| 184 | +var shadowedVariable: Int = 42 |
| 185 | +preferVariablesFromCallerSide() |
| 186 | +// #PrependHello(shadowedVariable) expands to "Hello " + 42 |
| 187 | +// error: binary operator '+' cannot be applied to operands of type 'String' and 'Int' |
| 188 | +``` |
| 189 | + |
| 190 | +## Alternatives considered |
| 191 | + |
| 192 | +### Expand non-built-in expression macro default arguments at the primary declaration |
| 193 | + |
| 194 | +While this allows all macro expansions to be expanded at where they are written, it creates an inconsistency for expression macros where they behave differently depending on whether they are built-in or not. Therefore, this alternative won’t be a solution for addressing the surprising behavior of built-in expression macros as caller-side default arguments, while the proposed solution unifies, and clarifies how to make expression macro default arguments expand at caller-side vs. at function declaration. |
| 195 | + |
| 196 | +## Acknowledgments |
| 197 | + |
| 198 | +Thanks to Doug Gregor, Richard Wei, and Holly Borla for early feedback and suggestions on design and implementation. |
0 commit comments