Mastodon

Clash of the Optionals

…or, how to accidentally break Swift initialization rules.

Today I’m going to talk about optionals. Swift optionals. And also another kind of optional. And how you might break the ironclad rules of Swift without realizing it until it’s too late.

What is an “optional” anyway? It depends who you ask. Swift will give you one answer, but Core Data has other ideas. So what happens when you bring them together?

Why are my properties optional?

A common occurrence: A developer is working on a Core Data model. There’s a checkbox in the model editor that says “optional”, which can be on or off. Ah, the developer thinks, this property should never be nil, so I’ll turn off the “optional” setting.

Data model editor with the optional checkbox turned off

But then Xcode generates some code for them and the property looks like this:

 @NSManaged var timestamp: Date?

Why is it optional? Didn’t you just uncheck the “optional” box? You did, but you unchecked optionality for Core Data.

And the thing is, Core Data doesn’t know anything about Swift. It has something called an optional value but it’s unrelated to Swift optionals.

In some ways they’re the same. For both, “optional” means you don’t have to have a value. You can have nil, indicating “no value”. Trouble starts with things that aren’t optional. What does non-optional mean?

  • For Swift, “not optional” means the property must not be nil at any time after initialization. The compiler enforces this, so that it’s not even possible to check for nil values.
  • For Core Data, “not optional” means the property must not be nil when you are saving changes in Core Data. At any other time, nil is fine. The compiler doesn’t know anything about it, but the framework enforces the rule when the app runs.

Xcode uses optional values in generated code because it’s the safest way to deal with this difference. If the property is a Swift optional, it can handle Core Data’s looser restrictions safely. If it’s not optional? Read on.

What if I change the code?

It doesn’t have to be this way. But as often happens, if you decide to not use the safe approach, you need to be aware of the trouble you might be asking for.

It’s pretty common to let Xcode generate Core Data classes but it’s not necessary. Since checking for nil all the time can be a pain when your property isn’t supposed to be optional, you might wonder about changing the declaration. You could write your own NSManagedObject subclass code and remove the ?:

 @NSManaged var timestamp: Date

And… it works! Both Swift and Core Data are OK with this. Why?

Swift and Core Data don’t know shit about each other, that’s why.

Swift has no idea about the data model, so it has no way to know if the property declaration is correct. Core Data doesn’t know anything about your subclass declarations, so it also has no way to check for accuracy.

When you see @NSManaged in the code, it’s a red flag that Swift rules may not apply here. An @NSManaged property doesn’t, well, exist when the code compiles. Instead, it’s a promise that Core Data will add the property dynamically when the app runs. Literally, Core Data will change the class definition in memory to add properties that match the data model, and you promise that the property is the same type. This kind of magic is built in to Objective-C, which probably means that Core Data will never be fully Swiftified without code-breaking changes.

That’s why generated code is a good thing here. The data model and the code are separate, but they must declare the same data type. If not, your app will crash with an error saying something like Unacceptable type of value for attribute.

But you said it works!

It does work! In this case. Since Swift and Core Data aren’t on speaking terms, changing the declaration works as long as you only change optionality.

Well, it works if you’re careful. But there’s a catch. Let’s say you create a new managed object and then forget to give timestamp a value.

let newEvent = Event(context:myContext)
// Oops! Forgot to assign a value to newEvent.timestamp!

Guess what, you just broke Swift’s initializer rules! Specifically, you initialized an object but didn’t provide a values for all of its non-optional properties. Nobody stopped you because @NSManaged says the rules don’t apply. You’ve just built a bomb.

Later on you try to use newEvent.timestamp.

print("Date: \(newEvent.timestamp)")

What do you think will happen? What value do you expect? Let’s think this through.

  • Core Data initialized the object, but it doesn’t know Swift’s rules and it doesn’t care about nil values until you save changes. So the value is nil.
  • Swift knows that initialization finished and there’s a non-optional property. It’s impossible for non-optional properties to be nil at this point, according to Swift. You can’t even check for nil values, that’s how definite this is. So it’s… not nil?

There’s no built-in resolution for this conflict. When your code reaches the line above, it crashes with an error attempting to bridge Objective-C and Swift. The backtrack will end in something like

Foundation.Date._unconditionallyBridgeFromObjectiveC(Swift.Optional<__C.NSDate>) -> Foundation.Date + 48

Ouch.

There is a way to check for nil first, but you have to pretty much forget that you’re using Swift first and fall back on key-value coding. Do something like this:

if newEvent.value(forKey: "timestamp") != nil {
	print("Date: \(newEvent.timestamp)")
}

That checks for a nil value! Except that this is even uglier than just checking for Swift nil values everywhere.

If you’re careful, none of this matters. Assign a value in your code, and you’re good. Don’t forget. Swift protects you from a lot of developer errors but it can’t help you here.

Working around the workaround

Or not! There’s another way. For simple properties, default values are useful. You can add them in the data model, if you always want the same default. Then you won’t have nil.

Data model editor with a default value for a date attribute

Of course, you’ll always have the same date. Maybe that’s OK in your app? For a more dynamic default, implement awakeFromInsert in the class, to assign a calculated default value.

 public override func awakeFromInsert() {
	 timestamp = Date()
 }

That gets called when the new managed object first gets inserted into a managed object context. Add that and you don’t need to check for nil in other code anymore.

Properties where you can’t predict the value are harder. You could add convenience initializers, but that only means you have to be careful in different ways. If you do that, don’t forget that creating instances like we did above with Event(context:myContext) won’t call your convenience initializer. Now you have to be careful to never do that, even though it’s valid and so easy to use.

Maybe don’t do that?

You can make managed object properties non-optional. Whether you should is up to you. It might simplify your code, but it also means you have to be extra careful about those properties to avoid problems Swift would normally prevent. Are you OK with that?