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
willSetordidSetin 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?