EduFlow

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.

  1. 1Structs copy
  2. 2Classes share
  3. 3let means different things
  4. 4Which one to choose
  5. 5ARC and retain cycles

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:

Expense.swift
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)      // 5

var 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

Swift
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 instance

A 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.

On the left, original and draft are two separate Expense boxes with amounts 4.5 and 5. On the right, checking and sameAccount are two names whose arrows point to one shared Account box with balance 450. struct: two values original amount 4.5 draft amount 5 independent copies class: one object checking sameAccount Account balance 450
Copying a struct versus copying a class reference

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:

Swift
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)   // 258

Which 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:

BudgetAlert.swift
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 let struct is frozen all the way down, while a let class reference can still have its var properties changed.
  • Struct methods that change properties must be marked mutating, and they can only be called on a var.
  • 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 self strongly creates a retain cycle; capture [weak self] to break it.

Further reading

Check your understanding

4 questions

Question 1 of 4

Budget is a struct with a mutating func record(_:). Why does let food = Budget(limit: 300); food.record(42) fail to compile?

Question 2 of 4

Account is a class. let a = Account(balance: 500); let b = a; b.balance -= 50. What is a.balance?

Question 3 of 4

Which data in Pocket Budget is the best fit for a class rather than a struct?

Question 4 of 4

A class stores onTrigger = { print(self.threshold) } in its own property. What’s the consequence?

Practice

Choose wisely

Struct or class: predict the output

Limit is a struct and Wallet is a class. Read the snippet and predict the single line it prints.

Swift
import Foundation

struct Limit {
    var amount: Decimal
}

final class Wallet {
    var balance: Decimal
    init(balance: Decimal) { self.balance = balance }
}

var food = Limit(amount: 300)
var fun = food
fun.amount = 100

let mine = Wallet(balance: 500)
let shared = mine
shared.balance -= 50

print(food.amount, fun.amount, mine.balance)
Struct or class: predict the output