Tutorials Ultimate Swift Series Chapter 16

Classes

SwiftChapter 16 of the Ultimate Swift Series32 minMay 16, 2026Beginner

For the past few chapters, every type you defined was a struct -- a value type that gets copied on assignment. That makes structs safe: nothing you do to one copy ever bleeds into another.

But sometimes "copy on assignment" is the wrong default. Imagine your indie iOS app maintains a download queue that the toolbar, the sidebar, and a background sync task all need to talk to. If those three references each held their own copy of the queue, adding a download from the toolbar wouldn't show up in the sidebar -- they'd silently drift apart.

For situations like that, Swift gives you the class -- a reference type. Multiple variables can point to the same underlying instance, and changes anywhere are visible everywhere.

Defining a Class

The syntax mirrors struct almost exactly. Swap the keyword:

class AppRecord {
    var bundleID: String
    var displayName: String
    var version: String
 
    init(bundleID: String, displayName: String, version: String) {
        self.bundleID = bundleID
        self.displayName = displayName
        self.version = version
    }
}
 
let shipper = AppRecord(
    bundleID: "com.example.shipper",
    displayName: "Simple App Shipper",
    version: "1.4.7"
)

There's one immediate difference from structs: classes don't get a free memberwise initializer. You have to write init yourself. If you omit it, the compiler refuses to build:

class AppRecord {
    var bundleID: String
    var displayName: String   // ⛔ Class 'AppRecord' has no initializers
}

Beyond that, classes look familiar -- methods, computed properties, type-level static members, observers, lazy properties all work the same way they did for structs.

Inheritance is its own topic

Classes can inherit from other classes -- something structs cannot do. That brings in a whole set of extra initialization rules (designated vs convenience initializers, calling super.init, overriding). This chapter sticks to single, standalone classes; inheritance gets its own treatment in a later chapter.

Reference Semantics: the Headline Difference

Here's the moment when classes start to feel different. Assign a class instance to a new variable:

let toolbar = shipper
let sidebar = shipper
 
toolbar.version = "1.4.8"
 
shipper.version   // "1.4.8"
sidebar.version   // "1.4.8"

Changing toolbar.version updated all three names. That's because toolbar, sidebar, and shipper aren't separate copies of an AppRecord -- they're three references to the same AppRecord instance.

Contrast with a struct version of the same thing:

struct AppRecordValue {
    var bundleID: String
    var displayName: String
    var version: String
}
 
var a = AppRecordValue(bundleID: "com.x", displayName: "X", version: "1.0")
var b = a              // copy
 
b.version = "2.0"
 
a.version   // "1.0"  ← unchanged
b.version   // "2.0"

With the struct, b = a produces an independent value. With the class, b = a only copies the reference. The instance itself lives in one place and the two names both see it.

This is the single most important thing to internalize about classes:

Where Does the Instance Actually Live?

A quick mental model. Swift stores things in two regions of memory:

When you write let shipper = AppRecord(...), Swift allocates an AppRecord-sized chunk on the heap, then stores its address in the stack variable shipper. When you say let toolbar = shipper, you copy that address -- both stack slots now point to the same heap block.

You don't usually have to think about heap vs stack to write Swift -- the compiler handles allocation and cleanup for you. But knowing the model makes the next few sections obvious instead of confusing.

Object Identity: ===

With value types, "are these equal?" is a question about their contents. Two Version structs with the same numbers are equal because they hold the same data.

With reference types, there's a second, sharper question you can ask: are these the same object? Two AppRecord instances can have identical bundleID, displayName, and version and still be two different blocks of memory.

Swift gives you the identity operator === (three equals) to ask exactly that:

let a = AppRecord(bundleID: "com.x", displayName: "X", version: "1.0")
let b = a
let c = AppRecord(bundleID: "com.x", displayName: "X", version: "1.0")
 
a === b   // true  -- same object, same heap address
a === c   // false -- different objects that happen to have identical fields

=== checks "do these references point to the same instance?" -- not "do they hold the same data?". For most app code you'll lean on == (value equality), but === is invaluable when you need to be sure you're looking at this exact instance and not a doppelgänger.

Mini-exercise

Create three AppRecord instances: two with identical field values, and one that's just an alias (let alias = first). Use === to find which pairs share identity. Then check == -- it won't compile, because AppRecord doesn't conform to Equatable. Why does === work without conformance?

(Answer: === is built into the language for any class, since every class instance has a memory address. == requires the type to opt in to Equatable and define what "equal contents" means.)

Methods That Mutate -- Without mutating

In Chapter 15 you saw that struct methods had to be marked mutating to change the struct's properties. Classes don't need that keyword. A class method can freely modify the instance's stored properties:

class AppRecord {
    var bundleID: String
    var displayName: String
    var version: String
    var buildCount: Int = 0
 
    init(bundleID: String, displayName: String, version: String) {
        self.bundleID = bundleID
        self.displayName = displayName
        self.version = version
    }
 
    func recordBuild() {
        buildCount += 1
    }
}

Why no mutating? Because the method isn't mutating "the reference" -- it's mutating the thing the reference points to, on the heap. The reference itself never changes. And since multiple variables may share that reference, the language can't promise you any "this won't mutate" guarantees the way it can for structs. mutating wouldn't mean what you think it means.

let Doesn't Stop Mutation the Way You Might Expect

Here's a small gotcha. With a struct, declaring it let locks the whole thing:

let v = AppRecordValue(bundleID: "com.x", displayName: "X", version: "1.0")
v.version = "2.0"   // ⛔ Cannot assign to a 'let' constant

With a class, let locks the reference -- not what it points to:

let shipper = AppRecord(
    bundleID: "com.x", displayName: "X", version: "1.0"
)
 
shipper.version = "2.0"   // ✅ allowed -- mutates the instance on the heap
shipper.buildCount += 1   // ✅ also allowed
 
shipper = AppRecord(...)  // ⛔ Cannot assign -- can't rebind a 'let' reference

Read it like this: let on a class means "this variable will always point to the same instance," not "the instance can never change."

If you want to prevent a property from changing, declare the property itself let inside the class:

class AppRecord {
    let bundleID: String   // immutable once set in init
    var displayName: String
    var version: String
    // ...
}

bundleID can't be reassigned after init regardless of whether the enclosing reference is a let or a var.

Shared State and Hidden Side Effects

Reference semantics are powerful: a single source of truth that the whole app can read and write. They're also a footgun. Mutations through one reference are immediately visible to every other holder of that reference -- which is exactly what you want until it isn't.

Picture the download queue from the intro:

class DownloadQueue {
    var items: [String] = []
 
    func enqueue(_ url: String) {
        items.append(url)
    }
}
 
let queue = DownloadQueue()
let toolbarQueue = queue
let backgroundSyncQueue = queue
 
toolbarQueue.enqueue("https://x.com/a.dmg")
backgroundSyncQueue.enqueue("https://x.com/b.dmg")
 
queue.items.count   // 2 -- both writes landed on the same instance

That's the upside. The downside shows up when an instance is mutated from a place you didn't expect:

// Some helper, written months earlier:
func auditQueue(_ q: DownloadQueue) {
    q.items.removeAll()   // accidentally clears every caller's queue
}

Pass any DownloadQueue to auditQueue and every variable referencing that instance now sees an empty list. With a struct, this wouldn't happen -- the function would have received a copy. With a class, you've handed over a live wire.

This is the central trade-off of reference types: cheap sharing, easy accidental mutation. Be intentional about who's allowed to hold a class reference and who's allowed to mutate it.

Extensions on Classes

Extensions work on classes exactly as they do on structs. Add computed properties, methods, and initializers without touching the original definition:

extension AppRecord {
    var slug: String {
        bundleID.replacingOccurrences(of: ".", with: "-")
    }
 
    func bumpPatch() {
        let parts = version.split(separator: ".").compactMap { Int($0) }
        guard parts.count == 3 else { return }
        version = "\(parts[0]).\(parts[1]).\(parts[2] + 1)"
    }
}
 
let s = AppRecord(bundleID: "com.example.shipper", displayName: "Shipper", version: "1.4.7")
s.slug         // "com-example-shipper"
s.bumpPatch()
s.version      // "1.4.8"

Notice that bumpPatch() mutates version and doesn't need mutating. As discussed: that's reference semantics in action.

When to Reach for Which

You'll spend most of your Swift career writing structs. Reach for a class when the type genuinely represents an object with identity -- something the rest of your app holds a long-lived reference to, that multiple subsystems coordinate around, that should share mutations.

Three guiding questions:

  1. Is identity meaningful? Two users with the same name are still two different users. Two delivery zones with the same coordinates are interchangeable. The first is a class; the second is a struct.
  2. Do many parts of the app need to see the same updates? Download queues, user sessions, document editors, network clients -- classes, almost always.
  3. Is this a small, transient value? Coordinates, version numbers, color palettes, settings snapshots -- structs.

A useful default: start with a struct. Switch to a class only when the value-semantics behavior gets in your way. Going from struct to class later is a five-keyword edit; going the other way is usually painful, because callers may have come to depend on shared mutation.

Quick cheat sheet:

Challenges

Challenge 1: shared task list

Imagine a todo app where multiple screens (a sidebar list, a "today" widget, a search view) all need to see the same set of tasks.

Challenge 2: identity vs equality

Define a User class with email: String and displayName: String. Write two functions:

Test on:

Predict each combination's isSameUser and sameProfile result before running the code, then verify.

Challenge 3: class or struct?

For each of the following entities in a music player app, decide whether it should be a struct or a class, and give one sentence of reasoning. There's not always one right answer -- defend your choice.

  1. Track -- a song's title, artist, duration, and file URL.
  2. Playlist -- a name and an ordered list of tracks the user reorders by dragging.
  3. Player -- the actual playback engine; holds the current track, play/pause state, scrub position, and is referenced by the now-playing UI, the lock screen widget, and the AirPlay handler.
  4. Equalizer -- a snapshot of 10 band gains the user can save as a preset.

Key Points

Next, the language opens up further with enums (precise, finite sets of cases), protocols (shared contracts that any type can adopt), and generics (code that works across many types). Each builds on the structs-and-classes foundation you've just put down.

Ch 15: MethodsCh 17: Advanced Classes
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.DeliveryModern Delivery PipelineCI/CD, review, runner, and deploy workflows for teams shipping apps and websites safely.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access