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.
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:
- Struct = value. Assignment copies the value. Two names → two values.
- Class = reference. Assignment copies the reference. Two names → one instance.
Where Does the Instance Actually Live?
A quick mental model. Swift stores things in two regions of memory:
- The stack is fast and tightly managed. Variables in the current function live here and disappear when the function returns. Value types (structs, enums) usually live on the stack.
- The heap is a flexible pool of memory the system hands out on demand. Class instances live here. The variable you hold on the stack is just a reference (a memory address) pointing into the heap.
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' constantWith 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' referenceRead 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 instanceThat'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:
- 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.
- Do many parts of the app need to see the same updates? Download queues, user sessions, document editors, network clients -- classes, almost always.
- 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:
- Semantics -- struct: value · class: reference
- Lives on -- struct: stack · class: heap
- Assignment -- struct: copies the value · class: copies the reference
- Memberwise init -- struct: free · class: write your own
leton an instance -- struct: whole thing frozen · class: only the reference frozen, properties still mutablemutatingkeyword -- struct: required to changeself· class: not used- Identity check -- struct: n/a · class:
=== - Inheritance -- struct: no · class: yes (covered in a later chapter)
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.
- Define
Taskas a struct withtitle: StringandisDone: Bool. - Define
TaskListas a class withname: Stringandtasks: [Task]. Add methodsadd(_ task: Task)andmarkDone(at index: Int). - Create one
TaskList, then make two more references (sidebarandwidget) pointing at the same list. Add tasks fromsidebar. Mark some done fromwidget. Readtasksfrom the original. Confirm all three views see the same state. - Now imagine
TaskListwere a struct instead. What breaks? Spend one paragraph (in a code comment) explaining what the experience would feel like for the user.
Challenge 2: identity vs equality
Define a User class with email: String and displayName: String. Write two functions:
func isSameUser(_ a: User, _ b: User) -> Bool-- returns true only ifaandbare the exact same instance.func sameProfile(_ a: User, _ b: User) -> Bool-- returns true if they have the same email, even if they're different instances.
Test on:
let u1 = User(email: "x@y.com", displayName: "X")let u2 = u1let u3 = User(email: "x@y.com", displayName: "X")
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.
Track-- a song's title, artist, duration, and file URL.Playlist-- a name and an ordered list of tracks the user reorders by dragging.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.Equalizer-- a snapshot of 10 band gains the user can save as a preset.
Key Points
- A class is a named type with properties and methods, just like a struct -- but it's a reference type. Assigning a class instance to a new variable copies the reference, not the instance.
- Classes do not get a free memberwise initializer. You must write at least one
initthat assigns every stored property. - Class instances live on the heap; the variable holding them is a reference (an address) stored on the stack.
- The identity operator
===checks whether two references point to the same instance, regardless of contents. It complements==, which compares contents. - Methods on classes can mutate stored properties without the
mutatingkeyword. That keyword is struct-only. - A
letclass variable locks the reference, not the instance. The properties of the instance can still be modified through that reference. - Reference semantics give you cheap sharing of a single source of truth -- and the matching risk that mutations propagate to callers who didn't expect them. Be deliberate about who holds and mutates class references.
- Default to struct; reach for a class when the type genuinely has identity, needs long-lived shared state, or naturally coordinates across multiple parts of the app.
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.
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