Surprising behavior of non-optional @NSManaged properties
Core Data is not a first-class citizen in the Swift world. Its inherently dynamic nature is lurking at us through an attribute created specifically for it: @NSManaged
. Let me show you how this dynamic nature caught me off guard. I ended up with a property having a value I’d never assigned to it!
@NSManaged means dynamic
Imagine we’re starting a new project and need a way to represent a user. We’ve got:
class User {
var name: String
}
which doesn’t yet compile in this form. We have to either 1) add an initial value to the name
property, or 2) add an initializer that sets the value of that property. Another option is to store the user’s data with Core Data. If we just inherit from NSManagedObject
and add @NSManaged
attribute, the code will … compile cleanly:
class User: NSManagedObject {
@NSManaged var name: String
}
This is Core Data’s way of saying I’ll take it from here. Getter and setter for name
are created dynamically by NSManagedObject
class. We can confirm that fact by using @NSManaged
on a class not inheriting from NSManagedObject
:
class FakeUser: NSObject {
@NSManaged var name: String
}
let fakeUser = FakeUser()
fakeUser.name = "John"
leading to -[FakeUser setName:]: unrecognized selector sent to instance 0x608000009fb0
exception. With all the safety Swift brings, we can hit a runtime error with as little as 5 lines of code, as long as it uses Core Data.
Peculiar non-optionals
Let’s go back on track, though. We initialize a user, passing an NSManagedObjectContext
instance to it:
let user = User(context: context)
user.name = "John"
print(user.name) // => "John"
So far, so good. We add a method responsible for creating users, to make sure a user always has a name and forget about this code:
func createUser(in context: NSManagedObjectContext, withName name: String) -> User {
precondition(name != "")
let user = User(context: context)
user.name = "John"
return user
}
One day though, we notice that in some parts of the app, the user’s name is equal to an empty string:
print(user.name) // => ""
That’s weird: precondition
call makes sure that the name is initially never set to an empty string. It’s also never changed by other parts of the codebase!
A few minutes (or more honestly hours) of debugging later, we notice that the name
is an empty string only on instances that were deleted from a context and the context was saved. (Deletion of managed objects is unrelated to ARC, so we have objects living in memory even though they are already treated as deleted.)
Because the name
property is dynamic, we can’t check who changes its value to ""
. Let’s attack from a different angle by changing its type to optional:
@NSManaged var name: String?
In this case, after a User
object was deleted and a context was saved, the value of name
is correct: nil
, not an empty string. Which leads us to the conclusion: returned value depends on the way the property is declared. When it’s non-optional, NSManagedObject
does what it can to never return nil
, substituting a default value instead.
(UPDATE: Sep 20, 2017: As pointed out on Reddit replacement of nil
with an empty string is actually caused by bridging from NSString
to String
.)
The same silent substitution happens for these types too:
- numeric types use a value equal to
0
Data
uses an emptyData
instance
The substitution doesn’t happen for other types. In these three cases we get nil
s when the type system doesn’t expect them, often causing crashes somewhere inside stdlib:
Date
UUID
URI
It’s worth mentioning, that the substitution happens even when there’s no default value set for an attribute in the xcdatamodel
file.
Relationships
This behavior spans relationships too. Let’s say we also have an Event
class:
class Event: NSManagedObject {
@NSManaged var timestamp: Date
@NSManaged var user: User
}
and run this code:
let event = Event(context: context)
print(event.user.name)
Can you guess what happens? We didn’t set up the user
relationship, so we should get either a nil
or a fatal error, right? Well, we don’t. An empty string is printed in this case too!
This is completely counter-intuitive when compared to the normal Swift code: event.user
is nil
, yet event.user.name
isn’t.
Possible solution
The safest approach is to make all @NSManaged
properties optional but it’s not great as far as a codebase’s readability goes.
The specific approach I’m considering switching to is:
- Optionals for
@NSManaged
properties which are optional in the regular Swift sense. - Implicitly unwrapped optionals for
@NSManaged
properties which should benil
only twice in their lives:- before the first time their value is set
- after a managed object they belong to was deleted
Implicitly unwrapped optionals will make some call-sites look worse but they’ll allow us to fail early with the familiar:
fatal error: unexpectedly found nil while unwrapping an Optional value
instead of having an unexpected value propagating through the program.
Conclusion
In cases like this, it becomes clear that Core Data predates Swift. There are some really rough edges around it. If breaking changes were possible on the framework level, I’d propose for Core Data to:
- trap on reads when a value behind a non-optional attribute/relationship is
nil
, at least in the debug configuration, or - don't allow
@NSManaged
properties to be declared as non-optional