Macro protocols lack `async` from `expansion` function requirements despite proposals stating they should have it
roopekv opened this issue · 17 comments
Issue
Swift's concurrency features cannot be fully utilized in macros without being able to implement expansion
function requirements of macro protocols as async
functions.
Cause
While the Swift Evolution proposals for expression and attached macros state that the expansion
function requirements of macro protocols should be async
, the macro protocol declarations in swift-syntax are missing the async
keyword from their expansion
function requirements (see comparison below), making it impossible to implement them as async
functions.
Solution
Changing the declarations in swift-syntax to async
shouldn't break existing macros, as async
function requirements can be implemented as non-async functions.
Example
protocol Macro {
func expansion()
}
struct SyncMacro : Macro {
func expansion() {} // OK ✅
}
struct AsyncMacro : Macro {
func expansion() async {} // ERROR ❌
}
protocol Macro {
func expansion() async
}
struct SyncMacro : Macro {
func expansion() {} // OK ✅
}
struct AsyncMacro : Macro {
func expansion() async {} // OK ✅
}
I acknowledge that additional changes would have to be made within the swift-syntax package (changes at the expansion function call sites and possible further structural changes to facilitate asynchronous code), but depending on how they are implemented, the changes don't have to affect the user-facing parts of the package in a source breaking way.
Differences between Swift Evolution proposals and swift-syntax
ExpressionMacro
public protocol ExpressionMacro: FreestandingMacro {
/// Expand a macro described by the given freestanding macro expansion
/// within the given context to produce a replacement expression.
static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) async throws -> ExprSyntax
}
swift-syntax
PeerMacro
public PeerMacro: AttachedMacro {
/// Expand a macro described by the given attribute to
/// produce "peer" declarations of the declaration to which it
/// is attached.
///
/// The macro expansion can introduce "peer" declarations that
/// go alongside the given declaration.
static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) async throws -> [DeclSyntax]
}
swift-syntax
swift-syntax/Sources/SwiftSyntaxMacros/MacroProtocols/PeerMacro.swift
Lines 17 to 29 in 0b324f8
MemberMacro
protocol MemberMacro: AttachedMacro {
/// Expand a macro described by the given attribute to
/// produce additional members of the given declaration to which
/// the attribute is attached.
static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) async throws -> [DeclSyntax]
}
swift-syntax
AccessorMacro
protocol AccessorMacro: AttachedMacro {
/// Expand a macro described by the given attribute to
/// produce accessors for the given declaration to which
/// the attribute is attached.
static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) async throws -> [AccessorDeclSyntax]
}
swift-syntax
MemberAttributeMacro
protocol MemberAttributeMacro: AttachedMacro {
/// Expand a macro described by the given custom attribute to
/// produce additional attributes for the members of the type.
static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingAttributesOf member: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) async throws -> [AttributeSyntax]
}
swift-syntax
Additional quotes from Expression Macros proposal
* Make the
ExpressionMacro.expansion(of:in:)
requirementasync
.
Synced to Apple’s issue tracker as rdar://133763644
The swift-evolution proposals for macros were inconsistent about whether expansion
should be async or not and the motivation for making the expansion
functions async never happened.
Given that swift-syntax has always had synchronous expansion functions, I’m updating the proposals to also state synchronous expansions functions. swiftlang/swift-evolution#2546
@ahoppen that would be the easiest way to solve this issue, but wouldn't it be better for expansion
to be async? While MacroExpansionContext
doesn't currently have async operations, being able to use concurrency features inside macros would still be useful.
What kind of async operations would you like to do in the macro?
Use await
, and concurrency features in general.
What kind of concurrent operations would you like to perform that need to be asynchronous? I’m trying to understand the use cases in which an asynchronous expansion function would help.
The ability to perform expensive operations in parallel could significantly speed up the expansion of computationally heavy macros.
Being able to wait for completion and results of async functions is also an important feature in itself, especially for cases where a function you need to use has no sync alternatives.
Are there downsides to supporting async expansion? The change wouldn't break existing macros.
I don’t think there any fundamental obstacles to providing asynchronous expansion
functions. It mostly requires engineering work.
My thinking is that async expansion functions wouldn’t offer huge benefits though because macros can’t do IO or network requests, which are traditionally a good use case for asynchronous functions and operate on sufficiently small inputs that the computation should be sufficiently quick that it wouldn’t benefit from multi-threading. If you have concrete counter-examples I’m happy to hear them and prove my assumptions wrong.
Whether a macro benefits from multi-threading depends on what it does, not necessarily on the size of the inputs it receives.
Example of a macro that could hugely benefit from multi-threading if expensiveOperation
lives up to its name:
@freestanding(expression)
public macro lookupTable(input: [Input]) -> [Input: Output] = #externalMacro(module: "LookupTableMacros", type: "LookupTableMacro")
public struct LookupTableMacro: ExpressionMacro {
public static func expansion(of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) -> ExprSyntax {
let inputs: [Input] = getInputs(node)
var lookupTable = [String](repeating: String(), count: inputs.count)
for i in 0..<inputs.count {
let input = inputs[i]
let output = expensiveOperation(input)
lookupTable[i] = "\(input): \(output)"
}
return "[\n\(raw: lookupTable.joined(separator: ",\n"))\n]"
}
}
let lookupTable = #lookupTable(input: [
<#Input 1#>,
<#Input 2#>,
<#Input 3#>,
...
])
/* Expansion:
[
<#Input 1#>: <#Output 1#>,
<#Input 2#>: <#Output 2#>,
<#Input 3#>: <#Output 3#>,
...
]*/
The code above depicts a macro that could be used to generate a compile-time lookup table for the specified inputs. If expensiveOperation
could be performed for multiple inputs in parallel instead of serially, the speed-ups could be huge.
Let's say that:
expensiveOperation
takes 1 s to perform- You call
#lookupTable
with 24 inputs - Your machine can process 24 threads in parallel
Then the expansion could theoretically take:
- 24 s without multi-threading
- 1 s with multi-threading
Is this example sufficient @ahoppen ?
Also, regarding the engineering work required for providing asynchronous expansion
functions: if someone were to implement the feature and make an acceptable pull request, could it simply be accepted into the next release of swift-syntax, or would it require a Swift Evolution proposal or an amendment to the existing ones?
I understand how parallel processing helps speed up things. I am just wondering what kind of operation expensiveOperation
might come up in practice that takes 1s to run. And to be clear: I agree that having async expansion functions could make sense, I’m just wondering how much real-world benefits they could provide.
I think the most tricky part about this change is considering API compatibility. If expansion
becomes async, then all callers of it need to become async and the primary user facing caller would be assertMacroExpansion
. This means that users would need to update all their test cases and I think that’s a pretty big API change if the benefits from the async expansion function are mostly theoretical.
Thanks for bringing up assertMacroExpansion
. I hadn't considered how testing works for macros and incorrectly assumed that macros always run as their separate processes in user-facing code.
So yes, to be able to call assertMacroExpansion
from any context and guarantee forward progress, assertMacroExpansion
and certain internal swift-syntax functions would have to be changed to async, which would be an API-incompatible change for the users of assertMacroExpansion
.
However, as assertMacroExpansion
is mainly used in tests, and both of the major testing frameworks (XCTest and swift-testing) support asynchronous tests, the code that this change would break could be easily fixed by marking the tests that call assertMacroExpansion
as async and adding the await keyword in front of the assertMacroExpansion
function calls.
The amount of code this change would break should be relatively small, considering that macros have been around for less than a year at the time of writing. There have been similarly and more drastically API-incompatible changes made to swift-syntax in the past.
The benefits of async expansion
functions are not theoretical. Being able to call async functions and wait for their completion and results is a practical feature, crucial for situations where there are no sync alternatives for the function that you need to use in your macro. While parallelism is not a requirement, the speed-ups it can provide for certain macros are a practical benefit. Async expansion functions are also a requirement for adding async operations to MacroExpansionContext, as mentioned in the expression macros proposal:
As the swift-syntax package uses semantic versioning, minor API breakage is acceptable between major releases. Unlike some of the previous API-incompatible changes like renaming a property for clearer meaning, changing assertMacroExpansion
to async would have tangible benefits not only for the users of swift-syntax but for the Swift ecosystem as a whole.
The amount of code this change would break should be relatively small.
I don’t think it’s a small change. Macros tend to have tests and all of them need to be manually updated, so it’s certainly has a significant impact. It’s certainly not the end of the world but it needs to be factored in.
@ahoppen I might have found a solution that avoids breaking anything:
protocol Macro {
func expansion() // must be implemented
func expansion() async // can be implemented if required
}
// default async expansion is sync expansion
extension Macro {
func expansion() async {
let syncExpansion = { expansion() }
syncExpansion()
}
}
// calls sync expansion
func assertMacroExpansion(macro: some Macro) {
macro.expansion()
}
// calls async expansion
func assertMacroExpansion(macro: some Macro) async {
await macro.expansion()
}
struct SimpleMacro : Macro {
func expansion() {}
}
struct AsyncOnlyMacro : Macro {
func expansion() {
fatalError("must use async assertMacroExpansion")
}
func expansion() async {}
}
// calling macro always calls async expansion
#simpleMacro() // calls sync expansion through async expansion (default)
#asyncOnlyMacro() // calls async expansion
The above solution would require some code duplication, but shouldn't break anything. If wanted, sync expansion could be deprecated in a later version and eventually be removed to eliminate the duplicate code.
I think this could be an interesting approach for migration. I’m happy to review it. Would you like to flesh this out in a PR?
Gladly :D
I don't have much experience contributing to open-source projects, but I can try my best to make a PR. Not sure how long it will take, could be days or weeks, but I can start working on it tomorrow.
Thank you @roopekv. Let me know if you have any questions. https://github.com/swiftlang/swift-syntax/blob/main/CONTRIBUTING.md should be a good place to start.