Why does Swift need the `convenience` keyword?

I'm wondering why the Swift language actually needs the convenience keyword.

Using convenience on initializers in structs is an error. In classes, the compiler requires the keyword whenever we delegate to another constructor of the same class. But why?

Why can we not just write

class C {
  let i: Int

  init(i: Int) {
    self.i = 2
  }

  init() {  // Compiler complains about the missing `convenience` keyword.
    self.init(i: 1)
  }

Which problem does it solve?

7 Likes

Swift has a feature, originally from Objective-C, where initializers are inherited from the base class. Swift is a little stricter about this than Objective-C, being a language without implicit nullability, so by default it will only do this if you give all your properties initial values (or make them optional), and don’t provide any explicit initializers of your own.

What’s different about Swift from other languages with initializer inheritance is that it also allows a subset of initializers to be automatically inherited even if you implement your own. For this to be safe even for arbitrary subclasses, the inherited initializers must defer to another initializer to initialize all the class’s properties—even if it’s a subclass. But because the subclass can’t see the body of a superclass’s initializer, it can’t know whether it’s a designated initializer that the others call, or a convenience initializer that ends up calling one of the designated ones.* So we mark the convenience ones specially: the ones that can be inherited if you provide the other initializers yourself. (Note: marking the class final removes this requirement.)

This feature can mostly be replicated with factory methods and required initializers, and indeed the places where this feature is most useful in Objective-C that probably would have been fine. But in Objective-C, initializers are just a kind of method, which means they’re always inherited by default, and so the idea was that every initializer was “required” all time, and the convenience init methods were a way to deal with that and also make factory methods look the same as direct initialization idioms.

Swift ended up with the complexity of formalizing what was largely unchecked-but-worked in Objective-C, because of Swift’s stricter type system and deciding to not inherit initializers by default.** I think if Swift hadn’t had ObjC integration as a top goal, it would have left required inits and factory methods as the way to do this kind of thing.

* The name “designated initializer” makes the most sense when there is one initializer that everything else ends up calling, but sometimes it makes sense to have more than one. :person_shrugging:

** I wrote about the rules around convenience inits, and some of their problems, in a doc that predates the forums: swift/InitializerProblems.rst at b0c2dbe910564d2e3f97cfeefc71816b6fa0740a · apple/swift · GitHub

28 Likes

Hey Jordan, thanks for that write up, super insightful!

I have a different (but related) question. LMK if you think it belongs in a separate thread.

One difference I've noticed between Objective C and Swift is the fact that allocating memory for an object is a caller responsibility in Objective C. (For those unaware, in Objective C you would allocate an object by sending alloc to a class, and then sending init to the resulting object, [[C alloc] init]).

In Swift, the allocation is automatic and hidden from user code. I have a mental model of initializers just being methods with a fancy definite initialization mechanism (is that wrong?). So my question is: how is it decided which initializer call should allocate memory?

2 Likes

A reasonable question, if one more on the implementation side than the first! Internally Swift has the notion of “allocating initializers”, which allocate memory, and “non-allocating initializers”, which just initialize it.* An allocating initializer is essentially [[ThisClass alloc] init…], packed up in a method for a handful of reasons but I think mainly to save code size at the call site. The base rules for this:

  • If you instantiate a class statically, you’re calling an allocating initializer.
  • If you call super.init, you don’t want a new allocation, so you’re calling a non-allocating initializer.
  • A required initializer can be called on an unknown subclass, so the dynamic entry point for this is an allocating initializer. (Otherwise you’d have to look up the class’s layout separately, which doesn’t buy us much.)

Looking at this, you can see that the only time you need non-allocating entry points is for subclasses to call with super. So pure-Swift convenience initializers are also allocating, since they can delegate to an allocating entry point. Now call sites never have to do allocation themselves, and if a non-allocating initializer isn’t part of an open class, it doesn’t need to be exposed outside the library at all, and may be inlined away. (We’ll come back to this later…)

Objective-C makes this a bit more complicated:

  • Objective-C doesn’t provide allocating initializers, so Swift synthesizes them when you use them to instantiate an ObjC class. These still call +alloc; you can think of it as factoring out common code. (And I think they can be inlined back in.)

  • Objective-C designated init methods behave like non-allocating initializers…most of the time. But sometimes they replace self instead. So Swift subclasses of ObjC subclasses have to be prepared for this possibility, and carefully deinitialize the already-initialized properties in the old instance. I forget if this logic lives in the constructor or in the deinitializer, but it was a pain to work out.

  • Objective-C convenience init methods are non-allocating! Because they directly call non-allocating designated init methods! Because in Objective-C, init methods are just instance methods and the compiler doesn’t treat them specially! Or didn’t before ARC, anyway. So Swift has to patch over this too when exposing a Swift convenience init to Objective-C: in this case the initial result from +alloc is thrown away and the Swift convenience init implementation will do its own allocation.

  • Finally, while it’s extremely rare, Objective-C classes can override +alloc, and Swift subclasses of ObjC classes will respect that instead of using the default Swift allocator.

But there’s yet another twist thrown in here: stack promotion. If Swift statically knows the layout of a class, and can prove that it doesn’t escape, it can allocate the class on the stack instead of the heap. Oops, all your allocating entry points are useless; you need the non-allocating ones again! Or do you? Because initializers can save a reference to self off somewhere, they already have to be inlinable to check that the object doesn’t escape, which means the compiler can, say, copy everything in the allocating initializer into a new function that skips the allocation. (Disclaimer: I don’t know how stack promotion is actually implemented; I didn’t work on that part of the compiler. But it does work!)

If we wanted to support stack promotion for non-inlinable initializers in other modules (say, with a new attribute @unsafePromiseThatSelfDoesNotEscape), we’d have to revisit some of this. Same for initializing specific memory locations, instead of relying on Swift’s allocator (tricky because you have to handle deallocation safely as well). There are reasons to want to do both of those but I think they’re relatively niche.

With all this complexity, should Swift have stuck to the ObjC/C++ model instead, at least at the implementation level? I don’t know. The ObjC model is certainly more flexible, at the cost of some code size in the vastly most common case. There may be some additional optimization opportunities in the future that come from the allocation logic living in the library rather than its clients (for instance, language-level object pooling). I’m not an expert in this area of the language implementation. But hopefully I’ve explained the mechanisms as they are and how they got that way. :-)

* You’ll often see initializers referred to in the compiler internals as “constructors”, which reflects both the influence of Clang’s C++ implementation and the additional DI constraints that ObjC init methods don’t have. In very early builds of Swift pre-1.0 the keyword was actually constructor instead of init! I’m glad we changed that one.

20 Likes