Accessor Macro With Lazy Initializer

Hi there,

I am trying to implement an accessor macro for a property. The initializer of the property depends on self to be fully initialized. Normally this does not work unless we make the property lazy. Once we add an accessor macro we cannot use lazy anymore, because we convert the annotated code into a computed property where lazy is not allowed—see Accessor macros and lazy properties.

Let’s try to write a @Lazy macro as an example to see if there is a workaround. It expands this code:

struct Demo {
    @Lazy var foo: Int = computeValue()
    
    private func computeValue() -> Int {
        // not available during initialization
        return 42
    }
}

to

struct Demo {
    @Lazy var foo: Int = computeValue() {
/*       compiler error: ^ Cannot use instance member 'computeValue'
                           within property initializer; property
                           initializers run before 'self' is available */
        mutating get {
            guard _foo != nil else {
                let newValue = computeValue()
                _foo = newValue
                return newValue
            }
            return _foo
        }
    }
    private var _foo: Int!
    
    private func computeValue() -> Int {
        // not available during initialization
        return 42
    }
}

This is almost great. We moved the property initializer expression computeValue() into the getter, where it should compile since self is fully initialized.

According to the proposal:

The expansion of an accessor macro that does not specify one of willSet or didSet in its list of names must result in a computed property. A side effect of the expansion is to remove any initializer from the stored property itself; it is up to the implementation of the accessor macro to either diagnose the presence of the initializer (if it cannot be used) or incorporate it in the result.

Fantastic: there should be no compiler error if the original property initializer is removed. However, the annotated code is apparently type-checked before the macro is expanded. This produces the error, even though the offending code is removed during macro expansion.

@Lazy macro implementation
@attached(accessor, names: named(get))
@attached(peer, names: prefixed(_))
public macro Lazy() = #externalMacro(module: "LazyMacroMacros", type: "LazyMacro")
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct LazyMacro {}

extension LazyMacro: AccessorMacro {
    public static func expansion(
        of node: SwiftSyntax.AttributeSyntax,
        providingAccessorsOf declaration: some SwiftSyntax.DeclSyntaxProtocol,
        in context: some SwiftSyntaxMacros.MacroExpansionContext
    ) throws -> [SwiftSyntax.AccessorDeclSyntax] {
        guard let (identifier, _, initializer) = syntaxItems(for: declaration) else { return [] }
        
        return [
            AccessorDeclSyntax(stringLiteral:
                """
                mutating get {
                    guard _\(identifier) != nil else {
                        let newValue = \(initializer)
                        _\(identifier) = newValue
                        return newValue
                    }
                    return _\(identifier)
                }
                """
            )
        ]
    }
}

extension LazyMacro: PeerMacro {
    public static func expansion(
        of node: SwiftSyntax.AttributeSyntax,
        providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol,
        in context: some SwiftSyntaxMacros.MacroExpansionContext
    ) throws -> [SwiftSyntax.DeclSyntax] {
        guard let (identifier, type, _) = syntaxItems(for: declaration) else { return [] }
        
        return [ "private var _\(identifier): \(type)!" ]
    }
}

private extension LazyMacro {
    static func syntaxItems(for declaration: some SwiftSyntax.DeclSyntaxProtocol) -> (identifier: TokenSyntax, type: TypeSyntax, initializer: ExprSyntax)? {
        guard let variableDecl = declaration.as(VariableDeclSyntax.self),
              let patternBinding: PatternBindingSyntax = variableDecl.bindings.first,
              let identifier = patternBinding.pattern
                                             .as(IdentifierPatternSyntax.self)?
                                             .identifier.trimmed,
              let type = patternBinding.typeAnnotation?.type.trimmed,
              let initializer = patternBinding.initializer?.value
        else {
            return nil
        }
        return (identifier, type, initializer)
    }
}

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct LazyMacroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        LazyMacro.self,
    ]
}

Do you have any ideas for a workaround?

1 Like

Make computeValue() static?

That isn't necessarily possible in the general case, and isn't a limitation of the built-in lazy var.

1 Like

I understand that. :slightly_smiling_face: Just offering a possible workaround as requested.

1 Like

Yes, unfortunately I would need something from the instance, so static would not work. Thanks for the suggestion though!

To be more concrete: I like to create an @Observable viewModel for a SwiftUI view. The viewModel has a few parameters in its initializer. Sometimes the arguments for the viewModel are not yet available in the view’s initializer, for example they may be provided by the environment.

There are workarounds:

  • make those values in the viewModel optional and provide them with an onAppear modifier
  • add parameters to the view’s initializer and make it the parent view’s responsibility to read them from the environment

These don’t feel elegant though. The optionals shouldn’t be necessary, and the onAppear is annoying boilerplate. Involving the parent view breaks encapsulation.

In a different experiment I created a peer macro to be attached to a makeViewModel() function. It creates the viewModel computed property and the private stored property for the State. It seems to work, but it looks really weird. E.g.:

struct MyView: View {
    @Environment(\...) private var someValue

    // generates a "lazy" viewModel property backed by a State property
    @LazyState
    private func makeViewModel() -> MyViewModel {
        return MyViewModel(value: someValue)
    }

    var body: some View {
        Text("\(viewModel.value)")
    }
}

versus what I would prefer:

struct MyView: View {
    @Environment(\...) private var someValue

    @LazyState
    private var viewModel: MyViewModel = MyViewModel(value: someValue)

    var body: some View {
        Text("\(viewModel.value)")
    }
}

The latter looks quite natural, but won’t work with the current implementation of accessor macros unless I missed something.

1 Like