[Pitch] "Reusable" unique names in Macros

Introduction

This document outlines a proposal for adding support for creating unique names in macro expansions that can be reused across invocations of different roles for the same macro invocation.

Motivation

Today the MacroExpansionContext protocol provides an API for generating unique names that are unique to a given macro expansion: makeUniqueName(_:).

This function, as the name suggests, creates names (identifiers) that are unique to the macro's expansion for a given macro role. The unique names generated follow a specific pattern which is outlined for the macro-expansion-operator entity in the Mangling document:

macro-expansion-operator ::= decl-name identifier 'fMa' // attached accessor macro
macro-expansion-operator ::= decl-name identifier 'fMr' // attached member-attribute macro
macro-expansion-operator ::= identifier 'fMf' // freestanding macro
macro-expansion-operator ::= decl-name identifier 'fMm' // attached member macro
macro-expansion-operator ::= decl-name identifier 'fMp' // attached peer macro
macro-expansion-operator ::= decl-name identifier 'fMc' // attached conformance macro

For example, for the given macro some unique names that it generates might look at follows:

// Declaration
@attached(member, names: arbitrary)
macro FooBarMacro(...) = #externalMacro(...)

// In module "MyModule":
@FooBarMacro
struct FooBar {
    ...
}

// Implementation of `FooBarMacro`:
struct FooBarMacro: MemberMacro {
    static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        let uniqueName1 = context.makeUniqueName("foobar")
        // uniqueName1 == "$s8MyModule6FooBarfMm_6foobarfMu_"

        let uniqueName2 = context.makeUniqueName("foobar")
        // uniqueName2 == "$s8MyModule6FooBarfMm_6foobarfMu0_"

        let uniqueName3 = context.makeUniqueName("baz")
        // uniqueName3 == "$s8MyModule6FooBarfMm_3bazfMu_"
    }
}

As outlined above, these mangled unique names are specific to the role of the macro being expanded. This is where we can run in issues for specific use cases. For example, let's say that we wanted to create a macro for synthesizing accessors on a variable for getting and setting its value as an associated object on the type. An example expansion of this macro might look like follows:

// Macro declaration
@attached(peer, arbitrary)
@attached(accessor)
macro AssociatedObject() = #externalMacro(...)

// Macro usage (located in module "MyModule")
class MyClass {
    @AssociatedObject
    var foobar: AnyObject?
}

// expands to:

class MyClass {
    private static let <uniqueName>: UnsafeRawPointer = ...

    var foobar: AnyObject? {
        get { objc_getAssociatedObject(self, Self.<uniqueName>) }
        set { objc_getAssociatedObject(self, Self.<uniqueName>, newValue, .OBJC_ASSOCIATION_POLICY) }
    }
}

The implementation of this macro will conform to the PeerMacro and the AccessMacro protocols to fulfil the two macro roles that are declared on the macro. The implementation of the PeerMacro is responsible for creating the declaration of the static variable for the associated object key. In doing this it needs to create a unique identifier. The identifier might look something like the following:

extension AssociatedObjectMacro: PeerMacro {
    static func expansion(
        of node: AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        let uniqueVariableName = context.makeUniqueName("associatedObjectKey")
        // uniqueVariableName == "$s8MyModule7MyClass6foobarfMp_19associatedObjectKeyfMu_"
    }
}

Next in the implementation of the AccessorMacro we will create the accessors for getting and setting the variable's value. These accessors will need to reference the unique name of the variable we created in the implementation of the PeerMacro:

extension AssociatedObjectMacro: AccessorMacro {
    static func expansion(
        of node: AttributeSyntax,
        providingAccessorsOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [AccessorDeclSyntax] {
        let uniqueVariableName = context.makeUniqueName("associatedObjectKey")
        // uniqueVariableName == "$s8MyModule7MyClass6foobarfMa_19associatedObjectKeyfMu_"
    }
}

Carefully inspecting the variable names, however, we see that they are actually different given that the prefixes used for each of the roles differ by a single letter:

// AccessorMacro
$s8MyModule7MyClass6foobarfMa_19associatedObjectKeyfMu_
                          ^~~~

// Peer macro
$s8MyModule7MyClass6foobarfMp_19associatedObjectKeyfMu_
                          ^~~~

Becuase of this minute discrepancy the generated accessors will attempt to access a variable that doesn't exist in the expansion:

class MyClass {
    private static let $s8MyModule7MyClass6foobarfMp_19associatedObjectKeyfMu_: UnsafeRawPointer = ...

    var foobar: AnyObject? {
        get { objc_getAssociatedObject(self, Self.$s8MyModule7MyClass6foobarfMa_19associatedObjectKeyfMu_) }
                                                  ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                                                  // error: Type 'MyClass' has no member named '$s8MyModule7MyClass6foobarfMa_19associatedObjectKeyfMu_'
        set { objc_getAssociatedObject(self, Self.$s8MyModule7MyClass6foobarfMa_19associatedObjectKeyfMu_, newValue, .OBJC_ASSOCIATION_POLICY) }
                                                  ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                                                  // error: Type 'MyClass' has no member named '$s8MyModule7MyClass6foobarfMa_19associatedObjectKeyfMu_'
}

Proposed solution

To account for cases like the one described above, we need to extend the makeUniqueName(_:) function, or create a new one, that allows for the creation of "reusable" unique identifiers that will guaranteed to be equivalent across different roles of the same macro invocation. The proposed solution is to add an optional flag to the makeUniqueName(_:) function that, if provided and set to true, will create a unique name that follows the same pattern as above but will be the same when invoked from other macro role implementations:

protocol MacroExpansionContext {

    func makeUniqueName(_ name: String, reusable: Bool) -> TokenSyntax
                                        ^~~~~~~~~~~~~~ // new parameter

    ...
}

Detailed design

Firstly, the mangling specification would need to be updated to provide a new accepted identifier for this use case:

macro-expansion-operator ::= decl-name identifier 'fMA' // any attached macro

or:

macro-expansion-operator ::= decl-name identifier 'fMr' // reusable uniquely-named entity

The compiler should also be updated in the relevant areas to account for this new mangling specification/identifer.

Next the SwiftSyntax library would be updated to add in support for this additional parameter on the makeUniqueName(_:) function. The logic for this method wouldn't change very much from how its currently implemented today:

// Lines prefixed with '-' are part of the current implementation being changed.
// Lines prefixed with '+' are part of the new implementation.
// Lines without a '-' or '+' prefix are part of the current implementation that are *not* being modified.

func makeUniqueName(_ providedName: String, reusable: Bool = false) -> TokenSyntax {
    // If provided with an empty name, substitute in something.
    let name = providedName.isEmpty ? "__local" : providedName

    // Grab a unique index value for this name.
-   let uniqueIndex = uniqueNames[name, default: 0]
-   uniqueNames[name] = uniqueIndex + 1
+   let uniqueIndex: Int
+   if reusable {
+       uniqueIndex = reusableUniqueNames[name, default: 0]
+       reusableUniqueNames[name] = uniqueIndex + 1
+   } else {
+       uniqueIndex = uniqueNames[name, default: 0]
+       uniqueNames[name] = uniqueIndex + 1
+   }

    // Start with the expansion discriminator.
-   var resultString = expansionDiscriminator
+   var resultString: String
+   if reusable {
+       resultString = reusableExpansionDiscriminator
+   } else {
+       resultString = expansionDiscriminator
+   }

    // Mangle the name
    resultString += "\(name.count)\(name)"

    // Mangle the operator for unique macro names.
    resultString += "fMu"

    // Mangle the index.
    if uniqueIndex > 0 {
      resultString += "\(uniqueIndex - 1)"
    }
    resultString += "_"

    return TokenSyntax(.identifier(resultString), presence: .present)
}

Each macro expansion context will keep track of two separate sets of unique names, one for reusable names, and one for unique names as they exist today. With this implementation in place our running example of a macro for associated object accessors would generate the same unique identifier for both macro roles:

extension AssociatedObjectMacro: PeerMacro {
    static func expansion(
        of node: AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        let uniqueVariableName = context.makeUniqueName("associatedObjectKey")
        // uniqueVariableName == "$s8MyModule7MyClass6foobarfMA_19associatedObjectKeyfMu_"
    }
}

extension AssociatedObjectMacro: AccessorMacro {
    static func expansion(
        of node: AttributeSyntax,
        providingAccessorsOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [AccessorDeclSyntax] {
        let uniqueVariableName = context.makeUniqueName("associatedObjectKey")
        // uniqueVariableName == "$s8MyModule7MyClass6foobarfMA_19associatedObjectKeyfMu_"
    }
}

With the final expansion of the macro being as follows:

class MyClass {
    private static let $s8MyModule7MyClass6foobarfMA_19associatedObjectKeyfMu_: UnsafeRawPointer = ...

    var foobar: AnyObject? {
        get { objc_getAssociatedObject(self, Self.$s8MyModule7MyClass6foobarfMA_19associatedObjectKeyfMu_) }
        set { objc_getAssociatedObject(self, Self.$s8MyModule7MyClass6foobarfMA_19associatedObjectKeyfMu_, newValue, .OBJC_ASSOCIATION_POLICY) }
    }
}

As a side note, this only applies for different role invocations of the same macro, if the same macro is used more than once on a given declaration, the reusable names generated for each macro invocation would be distinct. For example, if we have the following macro that's used more than once on the same declaration, the generated names would be as follows:

// Declaration
@attached(member)
macro FooBarMacro() = #externalMacro(...)

// Usage (in module "MyModule")
@FooBarMacro @FooBarMacro @FooBarMacro
struct FooBar {
    ...
}

// Implementation
struct FooBarMacroImpl: MemberMacro {
    static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        let uniqueName = context.makeUniqueName("foobar")
        ...
    }
}

// Three unique names would be generated, one for each of the macros attached to `FooBar`:
// 
// First `@FooBarMacro` attribute:
// uniqueName == "$s8MyModule6FooBar11FooBarMacrofMm_6foobarfMu_"
//                                               ^~~~
// Second `@FooBarMacro` attribute:
// uniqueName == "$s8MyModule6FooBar11FooBarMacrofMm0_6foobarfMu_"
//                                               ^~~~~
// Third `@FooBarMacro` attribute:
// uniqueName == "$s8MyModule6FooBar11FooBarMacrofMm1_6foobarfMu_"
//                                               ^~~~~

If a given macro with multiple roles makes reusable unique identifers, each macro invocation will be able to reuse the created identifiers across role invocations of that same macro instanace, however, the other reusable identifiers created for the other macro invocations would be unique to their role invocations. Continuing with out associated object macro example, if the macro were used twice on the same property, the final output would be as follows:

class MyClass {
    private static let $s8MyModule7MyClass6foobarfMA_19associatedObjectKeyfMu_: UnsafeRawPointer = ...
                                                 ^~~~ 
    private static let $s8MyModule7MyClass6foobarfMA0_19associatedObjectKeyfMu_: UnsafeRawPointer = ...
                                                 ^~~~~

    var foobar: AnyObject? {
        get { objc_getAssociatedObject(self, Self.$s8MyModule7MyClass6foobarfMA_19associatedObjectKeyfMu_) }
                                                                            ^~~~
        set { objc_getAssociatedObject(self, Self.$s8MyModule7MyClass6foobarfMA_19associatedObjectKeyfMu_, newValue, .OBJC_ASSOCIATION_POLICY) }
                                                                            ^~~~
        get { objc_getAssociatedObject(self, Self.$s8MyModule7MyClass6foobarfMA0_19associatedObjectKeyfMu_) }
                                                                            ^~~~~
        set { objc_getAssociatedObject(self, Self.$s8MyModule7MyClass6foobarfMA0_19associatedObjectKeyfMu_, newValue, .OBJC_ASSOCIATION_POLICY) }
                                                                            ^~~~~
    }
}

Although this limits the reusability of the generated identifiers, this seems appropriate given a macro that would require or have different behavior being attached multiple times to the same declaration doesn't seem like a well-designed macro nor would be necessarily practical. This is an arbitrary decision that was made but isn't central to the propsal overall.

5 Likes