swiftswiftuiobservation

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.


Solution

  • 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.