g i i k
g i i k

Reputation: 385

Observing computed properties of derived class

I find myself using a derived class that I mark with the @Observable macro. For every computed property of the base class I need to go through the following for the observability for this property to work:

class Base {
  var size: CGSize { get set }
}

@Observable class Derived : Base {
  override var size: CGSize {
    set {
      withMutation(keyPath: \.size) {
        super.size = newValue
      }
    }
    get {
      access(keyPath: \.size)
      return super.size
    }
  }
}

The pattern is expected but really, I have to do all this typing for every one of the dozen or whatever properties?

Am I doing this wrong? Is there a better/cleverer/more efficient way, like a one-liner per property? I do not control the base class (e.g., SKScene from SpriteKit).

I was thinking of trying a macro for that but honestly, I've never written one so not sure if it's worth investing the time and effort at this point. But if anyone knows of a different approach here that is better, please show it.

Upvotes: 3

Views: 104

Answers (1)

Sweeper
Sweeper

Reputation: 274423

I think there is no other choice than macros. The simplest solution is to just write an accessor macro like this:

// declaration
@attached(accessor, names: named(set), named(get))
public macro TrackSuper() = #externalMacro(module: "...", type: "TrackSuper")

// implementation
enum TrackSuper: AccessorMacro {
    static func expansion(of node: AttributeSyntax, providingAccessorsOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [AccessorDeclSyntax] {
        guard let name = declaration.as(VariableDeclSyntax.self)?.bindings.first?.pattern
            .as(IdentifierPatternSyntax.self)?.identifier else {
            return []
        }
        return [
        """
        get {
            access(keyPath: \\.\(name))
            return super.\(name)
        }
        """,
        """
        set {
            withMutation(keyPath: \\.\(name)) {
                super.\(name) = newValue
            }
        }
        """
        ]
    }
}

@main
struct MyMacroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        TrackSuper.self
    ]
}

Usage:

class Foo {
    var size: Int = 0
}

@Observable
class Bar: Foo {
    // the order here is important - @TrackSuper must go first
    // this is perhaps due to a implementation detail of @Observable
    @TrackSuper
    @ObservationIgnored
    override var size: Int
}

A second design is a member macro, automatically generating overrides of the properties. The user can specify the properties that they want to override when applying the macro.

// declaration
@attached(member, names: arbitrary)
public macro TrackSuper<Root: AnyObject, each T>(_ properties: repeat (KeyPath<Root, each T>, (each T).Type)) = #externalMacro(module: "...", type: "TrackSuper")

// implementation
enum TrackSuper: MemberMacro {
    
    private struct PropertyNameAndType {
        let name: TokenSyntax
        let type: TokenSyntax
    }
    
    static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
        guard let tupleExprs = node.arguments?.as(LabeledExprListSyntax.self)?.compactMap({ $0.expression.as(TupleExprSyntax.self) })
        else {
            return []
        }
        let namesAndTypes = tupleExprs.compactMap(tupleExpressionToNameAndType)
        return namesAndTypes.map {
            """
            override var \($0.name): \($0.type) {
                get {
                    access(keyPath: \\.\($0.name))
                    return super.\($0.name)
                }
                set {
                    withMutation(keyPath: \\.\($0.name)) {
                        super.\($0.name) = newValue
                    }
                }
            }
            """
        }
    }
    
    private static func tupleExpressionToNameAndType(_ expr: TupleExprSyntax) -> PropertyNameAndType? {
        guard let keyPath = expr.elements.first?.expression.as(KeyPathExprSyntax.self),
              case let .property(component) = keyPath.components.first?.component,
              let type = expr.elements.dropFirst().first?.expression.as(MemberAccessExprSyntax.self)?.base,
              let typeToken = type.as(DeclReferenceExprSyntax.self)
        else {
            return nil
        }
        return PropertyNameAndType(name: component.declName.baseName, type: typeToken.baseName)
    }
}

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

Usage:

class Foo {
    var size = 0
    var somethingElse = ""
}

// in this case the order doesn't matter
@TrackSuper(
    (\Foo.size, Int.self), 
    (\.somethingElse, String.self) 
    /* and so on... */
)
@Observable
class Bar: Foo {
    // the class body can be left empty
}

Note that you also need to specify the types of the properties, otherwise the macro doesn't know the type of the property to generate.

Upvotes: 2

Related Questions