Swifty Journey

Swift from Zero to Expert #10: Inheritance & Initialization

Subclassing, overriding, and super. Designated vs convenience initializers, two-phase initialization, failable and required init, and deinit — the full lifecycle of a class, explained in memory.

In the previous article we dissected properties, methods, and subscripts — the connective tissue of every type. Today we tackle the lifecycle of a class from birth to death: how it inherits from another class (inheritance), how it’s brought to life (initialization), and how it’s torn down (deinitialization).

This is a class-only story. Back in #8 we learned that only classes have inheritance, identity, and deinit. Everything here flows from that one fact: a class instance lives on the heap, it can be built on top of another class, and Swift has to guarantee that every stored property — including the ones it inherits — has a valid value before you ever touch the object.

Initialization isn’t “running some setup code.” It’s a contract the compiler enforces: by the time init returns, every stored property holds a real value — no exceptions, no nil-by-accident.

Inheritance: building on top of another class

A class that doesn’t inherit from anything is a base class. Unlike some languages, Swift has no universal root class — your base classes start clean:

class Vehicle {
var currentSpeed = 0.0
var description: String {
"traveling at \(currentSpeed) miles per hour"
}
func makeNoise() {
// do nothing — an arbitrary vehicle doesn't necessarily make a noise
}
}
let someVehicle = Vehicle()
print(someVehicle.description)
// traveling at 0.0 miles per hour

To subclass, write the subclass name, a colon, then the superclass name. The subclass automatically gains everything the superclass has:

class Bicycle: Vehicle {
var hasBasket = false
}
let bicycle = Bicycle()
bicycle.hasBasket = true
bicycle.currentSpeed = 15.0 // inherited from Vehicle
print(bicycle.description) // also inherited
// traveling at 15.0 miles per hour

Subclasses can themselves be subclassed — the chain goes as deep as you want:

class Tandem: Bicycle {
var currentNumberOfPassengers = 0
}

Tandem inherits from Bicycle, which inherits from Vehicle. A Tandem instance carries every property and method declared anywhere up that chain.

Inheritance chain: Vehicle (currentSpeed, description, makeNoise) nests into Bicycle (which adds hasBasket), which nests into Tandem (which adds currentNumberOfPassengers). Each subclass box accumulates the inherited members plus its own.

Overriding: replacing inherited behavior

class Train: Vehicle {
override func makeNoise() {
print("Choo Choo")
}
}
let train = Train()
train.makeNoise() // "Choo Choo"

That override keyword is doing real work. Remember from #8: class methods use dynamic dispatch by default — at runtime, Swift consults the vtable to find which implementation to actually run. Overriding is precisely what makes that indirection meaningful: Train’s makeNoise() replaces Vehicle’s entry in the vtable for Train instances.

Calling up with super

Sometimes you want to extend the superclass behavior rather than replace it outright. The super prefix reaches the superclass version of a method, property, or subscript:

class Car: Vehicle {
var gear = 1
override var description: String {
super.description + " in gear \(gear)"
}
}
let car = Car()
car.currentSpeed = 25.0
car.gear = 3
print(car.description)
// traveling at 25.0 miles per hour in gear 3

Car overrides the read-only computed property description, calls super.description to get the base text, then appends its own. You can override any inherited property with a custom getter (and setter, if appropriate) — a subclass can’t tell whether the inherited property was stored or computed in the superclass; it only knows its name and type.

Overriding observers on inherited properties

You can also bolt property observers onto an inherited property — even if you never declared the property yourself:

class AutomaticCar: Car {
override var currentSpeed: Double {
didSet {
gear = Int(currentSpeed / 10.0) + 1
}
}
}
let automatic = AutomaticCar()
automatic.currentSpeed = 35.0
print(automatic.description)
// traveling at 35.0 miles per hour in gear 4

final: shutting the door

Mark a method, property, or subscript final to forbid overriding it. Mark the whole class final to forbid subclassing it entirely:

final class APIClient {
func fetch() { /* ... */ }
}
// class CachedClient: APIClient {} // ERROR — APIClient is final

As we saw in #8, final isn’t just a design tool — it’s a performance tool. With no possible override, the compiler can drop the vtable lookup and use static dispatch, jumping straight to the code.

Initialization: the contract

The iron rule: classes and structures must set all their stored properties to a valid value by the time initialization finishes. There’s no “uninitialized” state you can read by accident.

struct Fahrenheit {
var temperature: Double
init() {
temperature = 32.0
}
}
var f = Fahrenheit()
print(f.temperature) // 32.0

If a property has a default value in its declaration, you don’t even need to set it in init — and structs you don’t write an initializer for get a memberwise one for free, exactly as we saw in #8. Classes never get a memberwise init, and now we’re about to see why: inheritance makes class initialization a genuinely harder problem.

Designated vs convenience initializers

Classes have two kinds of initializers, and the distinction is the heart of this whole chapter.

class Food {
var name: String
init(name: String) { // designated
self.name = name
}
convenience init() { // convenience
self.init(name: "[Unnamed]")
}
}
let bacon = Food(name: "Bacon") // name is "Bacon"
let mystery = Food() // name is "[Unnamed]"

The convenience init() doesn’t touch name directly — it delegates across to the designated init(name:). That delegation is mandatory, and it’s governed by three rules.

The three delegation rules

Initializer delegation rules
  • Rule 1 — A designated initializer must call a designated initializer from its immediate superclass.
  • Rule 2 — A convenience initializer must call another initializer from the same class.
  • Rule 3 — A convenience initializer must ultimately call a designated initializer.

The mnemonic the Swift book gives you is worth memorizing:

Designated initializers always delegate up. Convenience initializers always delegate across.

Initializer delegation in a Food / RecipeIngredient hierarchy: convenience inits delegate sideways ('across') to a designated init within the same class, while the subclass's designated init delegates 'up' to the superclass's designated init.

Two-phase initialization: under the hood

Here’s the part that explains why all those rules exist. Class initialization in Swift runs in two phases, and the compiler enforces it.

Why bother? Because of memory safety. A class instance occupies a single heap allocation holding the storage for every class in its chain. Until every one of those slots holds a real value, the object isn’t fully formed — and reading self would mean reading uninitialized memory.

The compiler enforces four safety checks to make this airtight:

The four safety checks
  • Check 1 — A designated init must initialize all properties its own class introduces before delegating up to super.
  • Check 2 — A designated init must delegate up to super before assigning a value to an inherited property (otherwise super would overwrite it).
  • Check 3 — A convenience init must delegate to another initializer before assigning to any property.
  • Check 4 — No initializer can read instance properties, call instance methods, or use self as a value until phase 1 is complete.

Let’s watch the phases unfold:

class Bicycle: Vehicle {
var hasBasket: Bool
init(hasBasket: Bool) {
// PHASE 1: set my own property first (safety check 1)
self.hasBasket = hasBasket
// ...then delegate up (safety check 2)
super.init()
// PHASE 2: now self is valid — I may read/customize it (safety check 4)
currentSpeed = 10.0 // inherited property, safe to touch now
}
}

Two-phase initialization timeline: phase 1 flows up the chain setting raw storage with a 'self is NOT usable yet' banner and checks 1-3, then a hard boundary, then phase 2 flows down with a 'self is now valid' banner and check 4 letting each class customize the live instance.

This ordering is exactly what Rules 1–3 guarantee. Phase 1 climbs to the top of the chain setting raw storage; phase 2 descends, and only now is self a real, usable object.

Initializer inheritance and overriding

Here’s a surprise if you come from Objective-C or Java: Swift subclasses do not inherit their superclass initializers by default. This prevents a specialized subclass from being created through a generic superclass initializer that leaves the subclass half-baked.

When you write a subclass initializer that matches a superclass designated initializer, you’re overriding it — so you write override:

class Vehicle {
var numberOfWheels = 0
var description: String { "\(numberOfWheels) wheel(s)" }
// gets a default initializer init() automatically
}
class Bicycle: Vehicle {
override init() {
super.init() // delegate up first
numberOfWheels = 2 // then customize inherited property
}
}
print(Bicycle().description) // "2 wheel(s)"

Automatic initializer inheritance

Subclasses do inherit superclass initializers automatically — but only when it’s safe. Assuming you give default values to every new property your subclass introduces:

Automatic inheritance rules
  • Rule 1 — If your subclass defines no designated initializers, it inherits all of the superclass’s designated initializers.
  • Rule 2 — If your subclass implements all of the superclass’s designated initializers (by inheriting them via Rule 1 or writing them), it also inherits all the superclass’s convenience initializers.

This is why you so rarely have to write boilerplate initializers in practice. Here’s the classic three-level hierarchy from the Swift book that ties it together:

class Food {
var name: String
init(name: String) { self.name = name } // designated
convenience init() { self.init(name: "[Unnamed]") }
}
class RecipeIngredient: Food {
var quantity: Int
init(name: String, quantity: Int) { // designated
self.quantity = quantity
super.init(name: name)
}
override convenience init(name: String) { // overrides Food's designated init
self.init(name: name, quantity: 1)
}
}
class ShoppingListItem: RecipeIngredient {
var purchased = false // default value, no init needed
var description: String {
"\(quantity) x \(name)" + (purchased ? " ✔" : " ✘")
}
}

RecipeIngredient overrides Food’s designated init(name:) — as a convenience initializer this time — so it gets marked override. Because it now implements all of Food’s designated initializers, it automatically inherits Food’s convenience init(). And ShoppingListItem introduces only a defaulted property and no initializers, so it inherits everything:

let a = ShoppingListItem() // inherited convenience init()
let b = ShoppingListItem(name: "Bacon") // inherited, quantity defaults to 1
let c = ShoppingListItem(name: "Eggs", quantity: 6) // inherited designated init

Failable initializers

Sometimes initialization should be allowed to fail — bad parameters, a missing resource, an invalid state. Write init? and return nil at the failure point. The result is an optional instance.

struct Animal {
let species: String
init?(species: String) {
if species.isEmpty { return nil }
self.species = species
}
}
let giraffe = Animal(species: "Giraffe") // Animal? -> not nil
let nothing = Animal(species: "") // Animal? -> nil

You’ve already used failable initializers without knowing it. Enums with raw values get init?(rawValue:) for free, and numeric conversions like Int(exactly:) are failable:

let pi = 3.14159
let n = Int(exactly: pi) // nil — the value can't be preserved exactly

Failure propagates: if a failable init delegates to another failable init that fails, the whole process aborts immediately:

class Product {
let name: String
init?(name: String) {
if name.isEmpty { return nil }
self.name = name
}
}
class CartItem: Product {
let quantity: Int
init?(name: String, quantity: Int) {
if quantity < 1 { return nil } // fail before delegating up
self.quantity = quantity
super.init(name: name) // may also fail -> aborts whole init
}
}
CartItem(name: "sock", quantity: 2) // CartItem? -> not nil
CartItem(name: "shirt", quantity: 0) // CartItem? -> nil (bad quantity)
CartItem(name: "", quantity: 1) // CartItem? -> nil (superclass fails on name)

Required initializers

Mark an initializer required to force every subclass to implement it. Subclasses write required too (not override) to propagate the requirement down the chain:

class SomeClass {
required init() {
// ...
}
}
class SomeSubclass: SomeClass {
required init() {
// subclass must provide this
}
}

You can skip writing it explicitly if an inherited initializer already satisfies the requirement. required matters most for protocol conformance (protocols are coming later in the series) and factory-style APIs (code that creates instances for you from a base type) — where the type system needs a guarantee that the initializer exists on every concrete subclass.

Deinitialization: the last act

This is the bookend to everything we’ve covered. Recall from #8: classes live on the heap and are managed by ARC (Automatic Reference Counting). When the last reference to an object disappears — its reference count hits 0 — ARC deallocates it. Just before that happens, your deinit gets one final chance to clean up:

class FileHandle {
let path: String
init(path: String) {
self.path = path
print("Opened \(path)")
}
deinit {
print("Closing \(path)") // release the resource before deallocation
}
}
var handle: FileHandle? = FileHandle(path: "/tmp/data")
// "Opened /tmp/data"
handle = nil
// "Closing /tmp/data" — refcount hit 0, deinit ran, then memory freed

The Swift book’s Bank/Player example shows why this matters — deinit returns a player’s coins to the bank the instant they leave the game:

class Bank {
static var coinsInBank = 10_000
static func distribute(coins requested: Int) -> Int {
let toVend = min(requested, coinsInBank)
coinsInBank -= toVend
return toVend
}
static func receive(coins: Int) { coinsInBank += coins }
}
class Player {
var coinsInPurse: Int
init(coins: Int) { coinsInPurse = Bank.distribute(coins: coins) }
deinit { Bank.receive(coins: coinsInPurse) } // return coins on the way out
}
var playerOne: Player? = Player(coins: 100)
print(Bank.coinsInBank) // 9900
playerOne = nil // deinit fires -> coins go back
print(Bank.coinsInBank) // 10000

init is the handshake that guarantees a valid object. deinit is the farewell that hands back what it borrowed. ARC decides when both happen — you decide what they do.

Memory: the lifecycle in one picture

Class instance lifecycle: init runs in two phases, then the instance is ALIVE on the heap with refcount = 1. New references add +1, dropped references subtract -1. When refcount hits 0, deinit runs (subclass first, then super), and finally the memory is freed.

Recap

  • Inheritance — only classes have it; a subclass gains everything from its superclass and can add more
  • Overriding — replace inherited behavior with override; the compiler verifies a real match exists
  • super — reach the superclass version to extend rather than replace
  • final — forbids overriding/subclassing; lets the compiler use static dispatch
  • Designated init — fully initializes its class, delegates up
  • Convenience init — a shortcut, delegates across to a designated init
  • Two-phase init — phase 1 sets storage up the chain, phase 2 customizes down it; four safety checks enforce it
  • No automatic init inheritance — except under the two automatic-inheritance rules
  • Failable initinit? returns an optional; failure propagates through delegation
  • Required init — every subclass must implement it
  • deinit — runs just before ARC deallocates; superclass deinit always runs too

What’s next

In the next article we explore Optionals & Optional Chaining — Swift’s billion-dollar-mistake antidote. We’ll see how Optional is just an enum under the hood, what ?, !, if let, guard let, and ?? really compile to, and why the type system forces you to confront the absence of a value head-on.

See you next week.

A class isn’t just data and methods — it’s a lifecycle. Master init and deinit and you stop fighting ARC; you start designing with it.

References

Related