Section 2 · Modelling with Types
Structs, Classes and Value Semantics
17 min3 min readChoose wisely
Lesson at a glance
Predict how structs and classes behave when you copy them, use mutating methods correctly, and break a closure retain cycle with weak self.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
You open an expense, change its amount from 4.50 to 5.00 in an edit screen, then tap Cancel. Should the list still say 4.50? With a struct it does, automatically. With a class, the list already shows 5.00, because the edit screen and the list were looking at the same object. That one difference, value versus reference semantics, decides more about how an iOS app behaves than any other modelling choice.
Structs copy
Here is the expense type, now with var properties so it can be edited:
import Foundation
struct Expense {
var title: String
var amount: Decimal
}
var original = Expense(title: "Coffee", amount: 4.5)
var draft = original
draft.amount = 5
print(original.amount) // 4.5
print(draft.amount) // 5var draft = original makes a complete, independent copy. Editing the draft can’t affect the original, so the edit screen can work on a draft and the Cancel button costs nothing: you throw the draft away. Swift gives every struct a free memberwise initializer, which is why Expense(title:amount:) works without you writing init.
Strings, arrays, dictionaries, Decimal and Int are all structs. That’s why var copy = amounts in the collections lesson gave you an independent array. Swift makes those copies cheap by sharing storage until one side actually changes it.
Classes share
final class Account {
var balance: Decimal
init(balance: Decimal) {
self.balance = balance
}
}
let checking = Account(balance: 500)
let sameAccount = checking
sameAccount.balance -= 50
print(checking.balance) // 450
print(checking === sameAccount) // true: same instanceA class instance lives in one place; variables hold references to it. let sameAccount = checking copies the reference, so both names point at the same account. === asks “is this the very same instance?”, a question that makes no sense for structs. Classes need an explicit init for stored properties without defaults. Mark them final unless you plan to subclass, which is rare in modern Swift.
let means different things
let freezes whatever the name holds. For a struct, the name holds the whole value, so nothing inside can change. For a class, the name holds a reference, so the reference is fixed but the object’s var properties are still changeable, which is exactly why sameAccount.balance -= 50 compiled even though sameAccount is a let.
Struct methods that change properties must say so with mutating:
struct Budget {
var limit: Decimal
var spent: Decimal = 0
var remaining: Decimal { limit - spent } // computed property
mutating func record(_ amount: Decimal) {
spent += amount
}
}
var food = Budget(limit: 300)
food.record(42)
print(food.remaining) // 258Which one to choose
Apple’s guidance, and mine, is to start with a struct. Values are easier to reason about because nothing can change them behind your back, and they’re safe to pass between threads, which matters a great deal in Swift 6. Use a class when you need identity: one shared thing that several parts of the app see and update. In Pocket Budget, each Expense is a struct, and the store that owns the list and that several screens observe will be a class. The Observation framework’s @Observable and SwiftData’s @Model, which you’ll use later, both require classes for exactly this reason.
A quick test when you’re unsure: imagine handing a copy to another screen. If that screen should get its own version to edit freely, you want a struct. If it should see every change the rest of the app makes, and changes it makes should show up everywhere, you want a class. Most data in most apps passes the first test.
ARC and retain cycles
Classes are freed by automatic reference counting (ARC): each strong reference adds one to a count, and when the count reaches zero the object is freed and its deinit runs. Trouble starts when an object stores a closure that captures the object itself:
final class BudgetAlert {
let threshold: Decimal
var onTrigger: (() -> Void)?
init(threshold: Decimal) {
self.threshold = threshold
}
func arm() {
onTrigger = { [weak self] in
guard let self else { return }
print("Spent more than \(self.threshold)")
}
}
deinit { print("BudgetAlert freed") }
}Without [weak self], the alert owns the closure and the closure owns the alert. Neither count ever reaches zero, deinit never runs, and you’ve leaked memory. The capture list [weak self] makes the closure’s reference weak and optional, so guard let self unwraps it while it’s still alive. A struct on its own can’t form these cycles, which is one more reason structs are the default.
Value or reference is half of modelling. The other half is describing data that comes in different shapes, like an expense paid by card versus cash. That’s what enums with associated values are for, and they’re next.
Key takeaways
- Assigning a struct makes an independent copy; assigning a class instance makes a second reference to the same object.
- A
letstruct is frozen all the way down, while aletclass reference can still have itsvarproperties changed. - Struct methods that change properties must be marked
mutating, and they can only be called on avar. - Default to structs for your data; reach for a class when you need shared identity, such as one object many screens observe.
- A class that stores a closure which captures
selfstrongly creates a retain cycle; capture[weak self]to break it.