Macro experiment with Dynamic Member Lookup code synthetization

Hi! This post is about my adventure into learning how to implement macros while implementing one that uses information from property declarations to insert boilerplate code relating to the @dynamicMemberLookup declaration attribute.

It's my first post ever so if I need to move it or fix something feel free to tell me and I'll do it as fast as I can. :slight_smile:

The macro examples I talk about throughout the post come from @Douglas_Gregor's macro repository.

Use case context

Recently I have been looking into using @dynamicMemberLookup in the development of a group project I'm participating.

We have a model that is composed of two pieces, represented by two smaller models. This is necessary for reasons I won't get into, but as expected it adds an extra step before accessing every property because we first need to access the property which represents the piece of the model we want, to then be able to access the property.

struct Whole {
    var piece1: Piece1
    var piece2: Piece2
}

The @dynamicMemberLookup with KeyPath subscript strategy seems like a great solution. Our goal is to expose the inner properties of two properties of the main type.

@dynamicMemberLookup
class Whole {
    var piece1: Piece1
    var piece2: Piece2

    subscript<T>(dynamicMember keyPath: WritableKeyPath<Piece1, T>) -> T {
        get { self.piece1[keyPath: keyPath] }
        set { self.piece1[keyPath: keyPath] = newValue }
    }

    subscript<T>(dynamicMember keyPath: WritableKeyPath<Piece2, T>) -> T {
        get { self.piece2[keyPath: keyPath] }
        set { self.piece2[keyPath: keyPath] = newValue }
    }
}
Implementation comments

As we want to be able to use the setters while also being able to access computed properties, we seem to need to have two subscripts for each KeyPath root type. One using the WritableKeyPath with get and set and one using KeyPath with only the get implemented.

Goal

I've been reading a lot about macros recently and some weeks ago I saw that we are able to play with them using a compiler snapshot. So, while trying to come up with a goal to try to achieve in order to keep me motivated to learn about them I remembered the @dynamicMemberLookup situation I found myself and started trying to design a macro that makes it easier to achieve that same code.

At this moment the open question was: could I find a useful use case for macros relating to @dynamicMemberLookup and be able to implement it while having only the proposals and a few examples as documentation?

I decided to create a macro that could be attached to a property and it would generate the dynamic member subscript implementation, substituting the KeyPath's root type with the property's type and associating the getter and setter with the property.

If that was possible, all you had to do was to put the @dynamicMemberLookup attribute on the declaration and mark the properties you wanted to access with the macro, like on the following code:

@dynamicMemberLookup
class Whole {
    @DynamicLookup var piece1: Piece1
    @DynamicLookup var piece2: Piece2
}

First attempt

My first attempt was to implement a PeerMacro, such as the AddAsync or AddCompletionHandler macro examples. The idea behind it was to do just like those examples and create a method* based on the member declaration they were attached to.

After some very fun and satisfying hours of learning about macros (and some very unfun and unsatisfying moments of having no auto-complete or complete documentation to help me :joy: ) I managed to do that!
But it didn't work as I expected :confused:. I knew I was outputting the right subscript. I checked by introducing an error at the end, which made Xcode show me the macro substitution when pointing at the error. I also checked it by implementing the subscript and seeing the warning of a redeclaration, which means that at least the signature was correct.
The error I got was about how the @dynamicMemberLookup was missing a subscript method, which was the one that the macro was apparently correctly providing.

I have two hypothesis to justify this error:

  1. A comment from Doug saying that that using init didn't compile using a february compiler snapshot makes me believe that there seems to have a bug on the current macro implementation that seemingly makes it impossible to expose names for macro inserted identifiers if those names are keywords, and subscript is a keyword. I tried using backticks and it compiled, but maybe something is still faulty.
    Is this keyword bug still present on the 2023-04-20-a snapshot?

  2. Or, the @dynamicMemberLookup attribute searches for a valid subscript implementation on the type declaration it's attached to before the members of that type are have the chance to be evaluated and that would mean that the macro didn't have the chance to be executed before the error stopped the compilation.
    Does it work like that?

I kept trying anyway and got to the second strategy I could think of.

Second Attempt

Member Macro + Markers

The other idea I had was to mimic the @CustomCodable macro, which has a @CodableKey "marker" macro. And so I did just that, implemented a macro that searched for members attached to the @DynamicLookup macro and inserted the subscript code. And it's working! :smile:

Solution

Most of the code I wrote was based on the examples from the repository I referenced at the beginning.

SynthesizedDynamicLookupMacro
//
//  SynthesizedDynamicLookupMacro
//  MacroExamplesPlugin
//
//  Created by Victor Martins on 21/04/23.
//

import Foundation
import SwiftSyntax
import SwiftSyntaxMacros

public struct SynthesizedDynamicLookupMacro { }

extension SynthesizedDynamicLookupMacro: MemberMacro {
  
  public static func expansion<Declaration, Context>(
    of node: AttributeSyntax,
    providingMembersOf declaration: Declaration,
    in context: Context
  ) throws -> [DeclSyntax]
  where Declaration : DeclGroupSyntax, Context : MacroExpansionContext {
    
    let memberList = declaration.memberBlock.members
    
    let markedMembers = memberList.compactMap { member -> (String, String)? in
      // is a property
      guard
        let propertyName = member.decl.as(VariableDeclSyntax.self)?.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
        return nil
      }
      
      let attatchedMacroNames = member.decl.as(VariableDeclSyntax.self)?.attributes?
        .compactMap { attr -> String? in attr.as(AttributeSyntax.self)?.attributeName.as(SimpleTypeIdentifierSyntax.self)?.trimmed.description
        }
      // has been marked with the DynamicLookup macro
      guard let attatchedMacroNames, attatchedMacroNames.contains("DynamicLookup") else {
        return nil
      }
      
      let variableType = member.decl.as(VariableDeclSyntax.self)?.bindings.first?.typeAnnotation?.type.description.trimmingCharacters(in: .whitespacesAndNewlines) ?? "?"
      
      return (propertyName, variableType)
    }
    
    // TODO: check if there's already a declaration with the same signature
    
    let subscriptDeclarations = markedMembers.map { member in
      DeclSyntax("""
                 subscript <T> (dynamicMember keyPath:WritableKeyPath<\(raw: member.1), T>) -> T {
                     get { self.\(raw: member.0)[keyPath: keyPath] }
                     set { self.\(raw: member.0)[keyPath: keyPath] = newValue }
                 }
                 """)
    }
    
    return subscriptDeclarations
  }
}
DynamicLookup

This is the marker macro. It's implementation is basically a renamed CodableKey implementation, from the macro examples repository.

import SwiftSyntax
import SwiftSyntaxMacros

public struct DynamicLookup: MemberMacro {
  public static func expansion(
    of node: AttributeSyntax,
    providingMembersOf declaration: some DeclGroupSyntax,
    in context: some MacroExpansionContext
  ) throws -> [DeclSyntax] {
    // Does nothing, used only to decorate members with data
    return []
  }
  
}

@attached(member, names: named(`subscript`))
public macro SynthesizeDynamicMemberLookups() = #externalMacro(module: "MacroExamplesPlugin", type: "SynthesizedDynamicLookupMacro")

@attached(member)
public macro DynamicLookup() = #externalMacro(module: "MacroExamplesPlugin", type: "DynamicLookup")

Usage

@dynamicMemberLookup
@SynthesizeDynamicMemberLookups
class Whole {
    @DynamicLookup var piece1: Piece1
    @DynamicLookup var piece2: Piece2
}
// ...
let wholeInstance = Whole(...)
wholeInstance.piece1.exampleProperty // works
wholeInstance.exampleProperty // works too!

Conclusion

It has been very fun to learn about macros through this trial and error approach, even though they're in this stage were things can still change, and are changing.

I still have those two open questions from above. They either highlight problems on the current macro implementation or highlight some misunderstandings on my part.

The idea behind this macro was just a random thing that popped into my mind. I don't have a rock solid opinion if this is definitely a good macro to have on real-life scenarios. My opinion is leaning towards believing it's usage is positive. I believe so because, just like something we do with property wrappers, it can be positive to hide this kind of boilerplate code from the implementation. The difference is that now we're using a macro, which means that we're in a different level of abstraction.

I would love to hear what you all think about it! :speech_balloon:

7 Likes