07 · Structs & Classes Basics¶
Swift gives you two ways to bundle data and behavior: struct (a value
type) and class (a reference type). Apple's own guidance, and Swift's
standard library, favor structs by default — reach for a class only when you
specifically need reference semantics or inheritance.
Defining a struct¶
struct Point {
var x: Double
var y: Double
// computed property
var distanceFromOrigin: Double {
(x * x + y * y).squareRoot()
}
func offsetBy(dx: Double, dy: Double) -> Point {
Point(x: x + dx, y: y + dy)
}
}
let origin = Point(x: 0, y: 0) // memberwise initializer, free
let p1 = Point(x: 3, y: 4)
print(p1.distanceFromOrigin) // 5.0
let p2 = p1.offsetBy(dx: 1, dy: 1)
print(p2.x, p2.y) // 4.0 5.0
Every struct gets a memberwise initializer for free — Point(x:y:) above
was never written explicitly.
Value semantics — the key difference¶
struct Counter {
var count = 0
}
var original = Counter()
var copy = original // COPIES the value
copy.count = 100
print(original.count) // 0 -- unaffected by the copy's mutation
print(copy.count) // 100
Compare the same scenario with a class:
class CounterBox {
var count = 0
}
let original = CounterBox()
let copy = original // COPIES the reference -- both point to the same object
copy.count = 100
print(original.count) // 100 -- same underlying object!
print(copy.count) // 100
This is the single most important thing to internalize about Swift: structs copy on assignment (independent values), classes share on assignment (same underlying instance via a reference).
Mutating methods¶
Struct methods that modify self must be marked mutating (the struct
instance itself must also be a var, not let):
struct Counter {
var count = 0
mutating func increment() {
count += 1
}
}
var counter = Counter()
counter.increment()
counter.increment()
print(counter.count) // 2
let frozenCounter = Counter()
// frozenCounter.increment() // compile error: cannot mutate a "let" struct
Defining a class¶
class Vehicle {
var speed: Double = 0
let make: String
init(make: String) {
self.make = make
}
func accelerate(by amount: Double) {
speed += amount
}
func describe() -> String {
"\(make) traveling at \(speed) mph"
}
}
let car = Vehicle(make: "Toyota")
car.accelerate(by: 30)
print(car.describe()) // Toyota traveling at 30.0 mph
Unlike structs, classes require an explicit init once you declare any
non-defaulted stored property — there's no free memberwise initializer.
Inheritance (classes only)¶
class ElectricVehicle: Vehicle {
var batteryPercent: Double = 100
override func describe() -> String {
super.describe() + ", battery at \(batteryPercent)%"
}
}
let tesla = ElectricVehicle(make: "Tesla")
tesla.accelerate(by: 60)
print(tesla.describe())
// Tesla traveling at 60.0 mph, battery at 100.0%
Structs cannot inherit from another struct — for shared behavior across structs, Swift uses protocols and extensions instead (covered in Level 2).
struct vs class¶
| Aspect | struct |
class |
|---|---|---|
| Semantics | Value (copied on assignment) | Reference (shared on assignment) |
| Inheritance | No | Yes (single inheritance) |
| Free initializer | Memberwise, automatic | No — must write init yourself |
| Mutation | Needs mutating methods, instance must be var |
Any method can mutate properties freely |
| Deinitializers | No | Yes (deinit) |
| Typical use | Most data models: points, records, configs | Shared, identity-based objects; UI controllers |
| Default choice | Yes, start here | Only when you need reference semantics or inheritance |
How It Actually Works¶
This is the single most consequential design decision in Swift, and the two kinds compile to genuinely different machine-level behavior:
- Structs are copied by value at assignment, but the compiler doesn't
necessarily emit a real memcpy every time. For simple structs like
Pointabove (twoDoubles), an assignmentlet p2 = p1really is a bit-for-bit copy of 16 bytes — cheap, stack-allocated, no heap traffic, no ARC involved at all. Structs containing only other value types (numbers, other structs, enums) need no reference counting whatsoever; the compiler can prove there's nothing to retain/release. - Classes are always heap-allocated and reference-counted.
let obj2 = obj1copies a pointer, not the object, and the compiler inserts aretaincall at that copy and a matchingreleasewhenobj2's scope ends (see the memory management chapter for ARC's full mechanics). Two variables pointing at the same class instance observe each other's mutations — that's reference semantics, and it's a direct consequence of both variables holding the same heap address. - Structs containing a class reference or an array/dictionary/string are
where it gets subtle: the struct itself copies its fields, but if one of those
fields is a class reference, the copy duplicates the pointer, not the
pointed-to object — so two "independent" struct copies can still observe
mutations through a shared class field.
Array,String, andDictionarysolve this differently via copy-on-write (their internal storage buffer is only actually duplicated the moment one copy is mutated while another reference to the same buffer is still alive — checked viaisKnownUniquelyReferencedon the buffer's own reference count). - Memberwise initializers are synthesized by the compiler only for structs
(and only when you don't write your own
init), because the compiler can trivially prove the exact field layout needed; classes never get this for free because inheritance means the "complete" set of fields isn't knowable purely from the class's own declaration.
🔀 See this in another language¶
Exercise¶
Define a struct BankAccount with a var balance: Double, and mutating
methods deposit(_:) and withdraw(_:) (withdraw should refuse — print an
error and not mutate balance — if the amount exceeds the balance). Create
two var accounts, copy one into the other, mutate the copy, and print both
balances to confirm they're independent. Then define a class Logger with
an array of message strings and an append(_:) method; create one instance,
assign it to a second variable, mutate through the second, and print the
first to confirm they share state.