Objective-C's Designated Secret
For the past several iterations of Xcode, Apple has quietly but steadily improved the quality of the backing toolchain. In particular, the clang static analyzer has gotten quite a few improvements; however, LLVM hasn’t been neglected.
Far from it. In Xcode 5.1, Apple shipped an updated version of LLVM that
included upstream revision 196314. The summary of that change is
brief – “Introduce attribute objc_designated_initializer
” – but the
potential implications are wide-ranging. But first: what are they even talking
about?
Objective-C Designated Initializers
In many object-oriented languages, there’s the concept of the designated initializer: the one constructor or setup method that all others call. Few languages, however, encourage use of this pattern quite as much as Objective-C and the associated frameworks do.
Designated initializers provide a great convenience for subclassers. Consider,
for example, a hypothetical (and stereotypical) class BankAccount
that tracks
a person’s balance. You could imagine how such a class would have two
initializers:
@interface BankAccount : NSObject
@property (copy) NSString *name;
@property (copy) NSDecimalNumber *balance;
- (id)initWithName:(NSString *)name;
- (id)initWithName:(NSString *)name balance:(NSDecimalNumber *)balance;
@end
A naive implementation would set both of these up separately:
@implementation BankAccount
- (id)initWithName:(NSString *)name {
if ((self = [super init])) {
self.name = name;
self.balance = [NSDecimalNumber zero];
}
return self;
}
- (id)initWithName:(NSString *)name balance:(NSDecimalNumber *)balance {
if ((self = [super init])) {
self.name = name;
self.balance = balance;
}
return self;
}
@end
On the surface, this all looks OK. But in the case that someone attempted to
subclass BankAccount
– or even to make a change to one of the init
methods –
they might find themselves writing duplicate code, or even introducing a subtle
bug. Let’s take the former approach and try to make a subclass for jointly held
accounts:
@interface JointBankAccount : BankAccount
@property (strong) NSMutableArray *coOwners;
@end
Now, since each initializer sets up all the account’s properties separately, our subclass is required to do the same:
@implementation JointBankAccount
- (id)initWithName:(NSString *)name {
if ((self = [super initWithName:name])) {
self.coOwners = [NSMutableArray array];
}
return self;
}
- (id)initWithName:(NSString *)name balance:(NSDecimalNumber *)balance {
if ((self = [super initWithName:name balance:balance])) {
self.coOwners = [NSMutableArray array];
}
return self;
}
@end
See the trouble? Both initializers do the exact same setup – future changes have to be made in two places, and it’s a pain to write this boilerplate to begin with. We could refactor out a common subclass init helper, but for a single extra line of setup that’s hard to justify. Here’s the kicker: all of this could be cleaned up significantly with a designated initializer.
Making Use of Designated Initializers
The grand idea behind designated initializers is a deceptively simple one: all
of your class’s init
methods should eventually call through to the same final
method. That method is your designated initializer.
For our BankAccount class above, we can designate the more verbose initializer and rewrite as:
@implementation BankAccount
- (id)initWithName:(NSString *)name {
return [self initWithName:name balance:[NSDecimalNumber zero]];
}
- (id)initWithName:(NSString *)name balance:(NSDecimalNumber *)balance {
if ((self = [super init])) {
self.name = name;
self.balance = balance;
}
return self;
}
@end
Notice how the former initializer no longer calls super
, nor does it set up
any properties on self
. Instead, it simply picks a default value for a new
account’s balance, then passes that argument – along with the provided name
– through to the more verbose initializer. That latter initializer, as the
designated initializer, is responsible for fully configuring the new instance.
This immediately gives us some benefits in our JointBankAccount
subclass.
Instead of overriding both initializers to do the same work, we can be assured
that the designated initializer will eventually run no matter which initializer
is originally called. That lets us only subclass the designated initializer. No
more duplicated code!
Do What I Say
There’s just one problem left: how do we communicate which initializer is the designated one to a user of the class? Most frequently, this is accomplished with documentation; however, such things can get out of date, be inaccurate, or just plain get ignored by the developer writing code against our class. This makes it easy for future development to introduce other subtle bugs.
For example, let’s add one more initializer to our base BankAccount
class.
This time, let’s add a balance-only initializer. (Pretend we’re writing software
for a Swiss bank: you can have an anonymous account if you so desire.)
@interface BankAccount : NSObject
// ... other properties and initializers ...
- (id)initWithBalance:(NSDecimalNumber *)balance;
@end
@implementation BankAccount
// ... other initializer implementations ...
- (id)initWithBalance:(NSDecimalNumber *)balance {
if ((self = [super init])) {
self.name = NSLocalizedString(@"Anonymous", nil);
self.balance = balance;
}
return self;
}
@end
Suddenly we’ve written a bug! This code will compile cleanly and run just fine… until your subclasser doesn’t get a chance to run their custom init code, since this initializer doesn’t call the designated initializer.
If we remembered, we could pretty easily correct this initializer’s implementation: just pass the name “Anonymous” and the given balance through to the designated initializer. But that’s one more thing to remember (or read) about this class, and in a larger set of changes, this kind of detail could easily slip past even an experienced developer.
The NS_DESIGNATED_INITIALIZER
Attribute
This is where the compiler can help us out. The major benefit of r196314 is the addition of a method attribute notifying LLVM that a particular initializer is considered designated, and that other initializers on the class must call through to it.
Note: Although the raw compiler attribute is available as early as Xcode 5.1, Apple did not expose public macros for it until Xcode 6. The following code uses Apple’s name; if compatibility with earlier versions is required, use LLVM’s
__attribute__((objc_designated_initializer))
instead.
To add the designated initializer attribute to a method, append the following incantation to the declaration in the header (after the last attribute, but before the semicolon):
NS_DESIGNATED_INITIALIZER
Applying this to our BankAccount
class, we wind up with:
@interface BankAccount : NSObject
@property (copy) NSString *name;
@property (copy) NSDecimalNumber *balance;
- (id)initWithName:(NSString *)name;
- (id)initWithBalance:(NSDecimalNumber *)balance;
- (id)initWithName:(NSString *)name balance:(NSDecimalNumber *)balance
NS_DESIGNATED_INITIALIZER;
@end
At this point, attempting to compile this class – with the three initializer
implementations we’ve defined earlier – will throw a compiler warning in the
-initWithBalance:
method, since it’s an initializer that is neither designated
nor calls through to the designated initializer. More specifically, if we
compile this BankAccount implementation, we see:
BankAccount-v1.m:26:24: warning: secondary initializer should not invoke an initializer on 'super' [-Wobjc-designated-initializers]
if ((self = [super init])) {
^
BankAccount-v1.m:25:1: warning: secondary initializer missing a 'self' call to another initializer [-Wobjc-designated-initializers]
- (id)initWithBalance:(NSDecimalNumber *)balance {
^
2 warnings generated.
(Again, this would be relatively easy to correct; a revised
implementation silences these warnings by forwarding the
secondary -initWithBalance:
initializer to the more verbose designated
initializer.)
Causing More Trouble
There are several other ways that we can upset the compiler once we’ve started
using NS_DESIGNATED_INITIALIZER
. You can cause a warning by doing any of the
following.
Designate an initializer, but forward it to a secondary initializer. We
might do this on our BankAccount by trying to use -initWithName:
to set
self.name
, then calling that initializer from -initWithName:balance:
. Since
the latter is our designated initializer, it’s not allowed to call across to any
secondary initializers. (See this implementation.)
Subclass and call a non-designated initializer on the superclass. Borrowing
our JointBankAccount subclass idea from earlier: we might be tempted to call
-initWithBalance:
, then manage the list of owners separately instead of using
the designated initializer (which takes a name as well). This causes its own
trouble, since calling the superclass’s initializers has to call through to a
designated initializer. (See this example.)
Subclass and designate a new initializer. Continuing with the JointBankAccount: what if we wanted a brand new initializer that took co-owners as a parameter? You can get away with this one, but only if you also override the old designated initializer. (See this warning-free sample.)
Though these cases may seem daunting at first, you’ll soon fall into a pattern of forwarding to designated initializers everywhere you need to. In the more complex situations, Apple provides the definitive rules for designated initializers over at their Object Initialization page; you can refer to them as you need to.
Looking to Swift
Up to this point, we’ve discussed NS_DESIGNATED_INITIALIZER
entirely in
Objective-C, and Apple certainly introduced the attribute first in that
language. But Swift is the way forward, and so it’s only fitting that we close
on Swift’s implementation of designated initializers.
In Swift, designated status has been promoted from a compiler attribute to a few language keywords, and the sense of the thing has flipped around. Now:
- Any custom initializer you define (using the
init
keyword) is assumed to be a designated initializer - You can mark your initializers as not designated using the
convenience
keyword
With these rules, reworking our BankAccount in Swift might look something like this:
class BankAccount {
var name: String
var balance: Int
convenience init(name: String) {
self.init(name: name, balance: 0)
}
convenience init(balance: Int) {
self.init(name: NSLocalizedString("Anonymous", nil), balance: balance)
}
init(name: String, balance: Int) {
self.name = name
self.balance = balance
}
}
Notice here how the designated initializer doesn’t have any keyword marking it as such; instead, all the non-designated initializers are explicitly marked “convenience.” (The other rules about subclassing and forwarding init calls all still apply, though!)
Wrapping Up
Though it’s been quite some time since designated initializers were made available in Objective-C, they’re gaining traction quickly thanks to Swift and its implicit designation behavior. By starting to mark designated initializers in Objective-C and convenience initializers in Swift, you can more clearly communicate the intent of your code and rely on some compiler help to reduce bugs in your apps.
(Got comments? Have a particularly interesting application of designated initializers? Am I wrong on the Internet? Find me on Twitter!)
Update: @peternlewis pointed out an accidental bug in a code
sample (as opposed to all the deliberate ones). A couple
others also mentioned the use of instancetype
vs. id
for
initializers. Though the former is perhaps more modern, the latter works
perfectly well due to some related type inference on
the part of LLVM – any method in the init
family returning id
is inferred to
work as if it returned instancetype
. Thanks for all the feedback!