Any way to reimplement @NSManaged with newer language features?

One of my existing Obj-C classes provides dynamic, lazily-added implementations for properties at runtime. Take a declaration like this one:

@property (readwrite) NSInteger someValue;

…coupled with a @dynamic statement in the source to instruct the Obj-C compiler not to synthesize any methods or provide any storage for this property. And instead of providing methods in the style of a computed property (akin to your get {…} set {…} in Swift) the instance uses a bit of Obj-C runtime magic to add accessor methods for callers to access said property. Storage for this property is provided by an underlying key-value-based model.

This should sound relatively familiar to CoreData users, and it is no surprise than in my hasty adoption of Swift, I have been hijacking @NSManaged for use in my Swift sub-classes, e.g.:

@NSManaged var someValue: Int = 0

Both as a recap and test of my understanding, here are the benefits that @NSManaged gives you:

  • The compiler knows about the property’s name and type, so auto-completion works, as do any type-sensitive features.
  • The compiler does not allocate storage for this property, and assumes the base class will somehow provide implementation.
  • The compiler (interestingly enough) does not need this property to be declared @objc dynamic, suggesting that @NSManaged basically means “@objc dynamic but without any synthesized behaviors or storage… you get that precious type info… and your dynamic implementation may or may not be key-value observable, depending on how it’s implemented.”

The problem I have with @NSManaged is that it feels like an outlier, a beast that will be sacrificed as soon as SwiftData shows up with a hatchet at CoreData’s door. I have been trying to figure out if one can cobble together similar behaviors through this endless stream of features added to Swift over the years. Here are the candidates I lined up:

@dynamicMemberLookup

  • Pros: a single function can indeed be used to provide get/set access for any property.
  • Cons: the entire class has to buy into this… no autocomplete, no type information, able to access non-existent properties… @dynamicMemberLookup feels like a single attribute that destroys anything Swift in its path.

@propertyWrapper

  • Pros: you keep type info and potentially a single implementation that interfaces with the underlying model can provide read/write access for any declared property, as long as you are able to compute a key compatible with the underlying model from a Swift KeyPath or using reflection (but I hear that’s expensive and not designed for this usage).
  • Cons: funneling all property value reads/writes through a single storage model (as CoreData does) requires access to the instance that owns the property wrapper, since the instance has a “connection” to the model (again drawing a parallel to CoreData, where the MOC holds the connection). This seemingly forces you to opt into the (still-in-beta?) static subscript on your property wrapper… and due to some reasoning I do not fully understand, this static method is given an instance to the owning class (great) but not the instance to the actual property wrapper. Why not make the subscript non-static? Am I missing an obvious way in which the property wrapper instance can still be accessed from the static method?

Computed Properties (duh!)

  • Pros: retains all type info, auto-complete works
  • Cons: tons and tons of code duplication… every property needs its own get {} set {} implementations, even if ultimately all read/write access to the value is performed through identical code that maps property access to a single key-value-based model.

…but none of these three language features seems to exactly simulate what @NSManaged is doing for me. Am I missing something?

Apologies if I missed something obvious, and thank you!

Gabe

1 Like

The storageKeyPath parameter to the function is the keypath from the object instance to the property wrapper instance.

Realm's @Persisted does something similar to @NSManaged via property wrappers and may serve as a useful example. There's some gross hacks involved, though.

Thank you, that is indeed obvious! :man_facepalming:
Gonna try and figure out what @Persisted is doing next.

Many of these downsides go away if you use key-path-based dynamic member lookup.

Here's my attempt:

/// Placeholder for a real database.
/// Each key in the outer dictionary represents a database "table".
/// For simplicity, tables use ObjectIdentifiers (= memory addresses) as unique IDs.
var database: [String: [ObjectIdentifier: [AnyKeyPath: Any]]] = [:]

@dynamicMemberLookup
class ManagedObject<Fields> {
  /// Abstract, must be overridden
  class var tableName: String { fatalError() }

  subscript<Value>(dynamicMember key: KeyPath<Fields, Value>) -> Value? {
    get {
      database[Self.tableName]?[ObjectIdentifier(self)]?[key] as! Value?
    }
    set {
      database[Self.tableName, default: [:]][ObjectIdentifier(self), default: [:]][key] = newValue
    }
  }
}

class Person: ManagedObject<Person.Fields> {
  /// Declares the fields for `Person` objects.
  ///
  /// Essentially equivalent to:
  ///
  ///     @NSManaged var name: String
  ///     ...
  struct Fields {
    // Unfortunately, these can't be static because key paths
    // to static properties are not supported
    var name: String
    var age: Int
  }

  override class var tableName: String { "people" }
}

// The Person type allocates no storage for the fields (except its isa pointer):
assert(MemoryLayout<Person>.size == 8)

let alice = Person()
alice.name = "Alice"
alice.age = 23

let bob = Person()
bob.name = "Bob"

assert(alice.name == "Alice")
assert(alice.age == 23)
assert(bob.name == "Bob")
assert(bob.age == nil)

The code above uses a ManagedObject superclass, mirroring Core Data. You can also do this with protocols and value types, which I like even better:

/// Placeholder for a real database.
var peopleTable: [Person.ID: [PartialKeyPath<Person.Fields>: Any]] = [:]

@dynamicMemberLookup
protocol ManagedObject {
  associatedtype Fields
  associatedtype ID: Hashable

  static var storage: [ID: [PartialKeyPath<Fields>: Any]] { get set }

  init(id: ID)

  var id: ID { get }
}

extension ManagedObject {
  subscript<Value>(dynamicMember key: KeyPath<Fields, Value>) -> Value? {
    get { Self.storage[self.id]?[key] as! Value? }
    set { Self.storage[self.id, default: [:]][key] = newValue }
  }
}

struct Person: ManagedObject {
  typealias ID = Int

  /// Declares the fields for `Person` records.
  ///
  /// Essentially equivalent to:
  ///
  ///     @NSManaged var name: String
  ///     ...
  struct Fields {
    // Unfortunately, these can't be static because key paths
    // to static properties are not supported
    var name: String
    var age: Int
  }

  var id: Int

  static var storage: [ID : [PartialKeyPath<Fields> : Any]] {
    get { peopleTable }
    set { peopleTable = newValue }
  }
}

// The Person type allocates no storage for the fields (except id):
assert(MemoryLayout<Person>.size == 8)

var alice = Person(id: 1)
alice.name = "Alice"
alice.age = 23

var bob = Person(id: 2)
bob.name = "Bob"

assert(alice.name == "Alice")
assert(alice.age == 23)
assert(bob.name == "Bob")
assert(bob.age == nil)
4 Likes

Thanks @ole this is excellent material to learn from!

1 Like

I tried applying some of the techniques from @Persisted, @ole ’s example and Sundell’s posts... but I get the compiler to crash. I can boil down the offending code to the bit below. Paste it in a Playground in Xcode 13.3.1 and it will generate an internal error:

import Cocoa

@objc public class BaseObject: NSObject {
    private var storage: [String:Any] = [:]
    
    public func storedValue(forKey key: String) -> Any? {
        storage[key]
    }
    
}

@propertyWrapper
struct input<Value> {
    
    @available(*, unavailable)
    public var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }
    
    public let key: String
    
    public init(wrappedValue: Value, key: String) {
        // Initial value specifically ignored
        self.key = key
    }
        
    public static subscript<Host: BaseObject>(_enclosingInstance instance: Host,
                            wrapped wrappedKeyPath: ReferenceWritableKeyPath<Host, Value>,
                            storage storageKeyPath: ReferenceWritableKeyPath<Host, Self>) -> Value? {
        get {
            instance.storedValue(forKey: instance[keyPath: storageKeyPath].key) as! Value?
        }
        set {
            fatalError()
        }
    }
    
}

public class DerivedObject: BaseObject {
    @input(key: "value") var someValue: Int = 0
}

Hoping someone actually understands what might be triggering the compiler crash.
The above isn't the code I started with, it's just a minimized version that is still enough to trigger the crash. Ideally:

  • BaseObject is the base class that provides the key-value based storage. In this example I only bothered to include the getter.
  • DerivedObject is a subclass... but instead of having to call the superclass storedValue(forKey:), the goal is to use @propertyWrapper to elegantly funnel all accesses to the property through the superclass’s function.

I could tweak the code a bit, but in an actual project (rather than Playground) it would only change what type of crash the compiler would encounter.

Thank you!

The return type of the enclosing instance subscript must be the same as the type of the wappedValue property. So they must either both be optional or both non-optional.

Note that a compiler crash is always considered a bug, so you might want to file an issue at Issues · apple/swift · GitHub (although in this case the crash is related to an unofficial language feature, so perhaps it's not considered high priority :man_shrugging:).

1 Like

Thanks @ole I ended up filing a bug via Feedback Assistant, as suggested by the Xcode Playgrounds UI itself. Yes it's obvious we are treading on undocumented APIs, but frankly it’s not like the official APIs are any easier to grok. Thanks again for your help!

1 Like