Structs vs Classes — Value Semantics, ARC, and Copy-on-Write
In Swift, Int, String and Array are all structs. Understanding value versus reference semantics is the single key to writing predictable Swift — let's see why.
Value vs reference semantics
Here is the single most important question in all of Swift. When we assign one variable to another, or pass a value into a function, does the second name get its own copy, or do both names point at the same thing?
The answer depends entirely on whether we are dealing with a value type or a reference type. A struct is a value type: it is copied on assignment and copied again when passed into a function. A class instance is a reference type: it is shared, so two names can point at one underlying object. Get this distinction wrong and Swift will surprise you in the worst way — by quietly changing data you thought was untouched.
Let's run the classic experiment. Change one thing, then ask: did the other change too?
struct Point { var x: Int }
var a = Point(x: 1)
var b = a // b gets a COPY
b.x = 99
print(a.x) // 1 — a is untouched
print(b.x) // 99
class Box { var x: Int; init(x: Int) { self.x = x } }
let p = Box(x: 1)
let q = p // q points at the SAME object
q.x = 99
print(p.x) // 99 — p sees the change too!
Same code shape, opposite outcome. The struct copy gave b its own independent x. The class assignment handed q a second reference to the one and only Box, so mutating through q is visible through p.
So, hold this picture in mind for the rest of the episode. Value types are like cash — hand someone a copy and your own wallet is untouched. Reference types are like a shared document link — anyone with the link edits the same page. Almost everything else about Swift's memory model follows from this one idea.
Structs and classes — what each gives you
Both structs and classes let us bundle data and behaviour. They share most of the same syntax: stored properties, computed properties, methods, initialisers, subscripts, protocol conformance. So why two of them? Because they make a deliberate trade between two different sets of powers.
A struct is a value type. It gets a synthesised memberwise initialiser for free, it has no shared identity, and copying it is cheap and predictable. A class is a reference type. It supports inheritance, it is managed by ARC, it can have a deinit, and many references can share one instance.
struct Coordinate {
var latitude: Double
var longitude: Double
// No init written — Swift synthesises one:
}
let here = Coordinate(latitude: 28.6, longitude: 77.2)
class Vehicle {
var speed = 0.0
}
class Bicycle: Vehicle { // inheritance — classes only
var hasBasket = false
}
Notice the struct never declared an initialiser, yet Coordinate(latitude:longitude:) exists. That memberwise initialiser is generated automatically, with one parameter per stored property in declaration order. Classes do not get this — you must write your own.
Apple's own guidance is blunt: prefer structs by default. Reach for a class only when you genuinely need one of its specific powers — reference identity, inheritance, deinitialisers, or interoperation with Objective-C frameworks that expect reference types. This is a real shift from older object-oriented traditions where the class was the default tool. In SwiftUI this guidance is visible everywhere: your views are structs, and the framework recreates them constantly because copying a value is cheap.
A nice piece of history: Swift was announced at Apple's WWDC in June 2014 and made open source in December 2015. Its standard library was designed from day one around value types, partly to escape a whole category of shared-mutable-state bugs that had haunted Objective-C. When you write idiomatic Swift, you are mostly writing values, not objects.
Identity vs equality
We have two different questions we might ask about two things. "Are these the same object?" and "Do these hold the same value?" Swift keeps them strictly separate, and the second question is the only one that makes sense for structs.
Identity is tested with === and !==. These ask whether two references point at the exact same instance in memory. Because identity is about a shared instance, === applies to classes only. Under the hood it compares the object's ObjectIdentifier, which is essentially its address.
Equality is tested with == and comes from the Equatable protocol. It asks whether two values are meaningfully equal, regardless of where they live.
class Node { var id: Int; init(id: Int) { self.id = id } }
let n1 = Node(id: 1)
let n2 = Node(id: 1)
let n3 = n1
print(n1 === n2) // false — two distinct objects
print(n1 === n3) // true — same object
struct Tag: Equatable { var name: String }
let t1 = Tag(name: "swift")
let t2 = Tag(name: "swift")
print(t1 == t2) // true — same value
// print(t1 === t2) // compile error: === needs class types
Why do structs have no identity? Because there is nothing stable to point at. Every copy of a struct is just as much "the" value as any other — there is no single shared instance whose address would mean anything. Asking whether two Ints are "the same object" is a category error; we can only ask whether they are equal.
For your own structs, conforming to
Equatable is usually a one-liner. If every stored property is already Equatable, Swift will synthesise == for you the moment you declare conformance. No boilerplate, no forgetting a field.
ARC — Automatic Reference Counting
Class instances live on the heap, so something has to decide when to free them. That something is ARC, Automatic Reference Counting. Every class instance carries a count of how many strong references currently point at it. When that count drops to zero, the instance is deallocated immediately and its deinit runs.
Here is the part people most often get wrong: ARC is not a tracing garbage collector. There is no background thread periodically scanning the heap looking for unreachable objects, and there are no unpredictable pause times. Instead, the Swift compiler inserts the retain and release calls at compile time, right where references are created and destroyed. The bookkeeping is deterministic: an object dies the instant its last reference goes away.
class FileHandle {
let name: String
init(name: String) {
self.name = name
print("opened \(name)")
}
deinit {
print("closed \(name)")
}
}
var h: FileHandle? = FileHandle(name: "log.txt") // opened log.txt
h = nil // count hits 0 → closed log.txt
A little history. This model did not start with Swift. Apple shipped Objective-C ARC in 2011, replacing years of error-prone manual retain and release calls that developers wrote by hand. The compiler took over that chore. When Swift arrived in 2014, it simply inherited the same proven reference-counting model, which is why Swift and Objective-C interoperate so smoothly around object lifetimes.
And remember: ARC only governs reference types. Structs and enums are not reference counted at all — they are copied, and a copy simply ceases to exist when its scope ends. No retain, no release, no deinit.
Reference cycles — and how to break them
ARC is reliable, but it has one blind spot. What if two objects each hold a strong reference to the other? Object A keeps B alive, and B keeps A alive. Neither count can ever reach zero, so neither is freed — even after every outside reference is gone. This is a strong reference cycle, and it is the main way you can still leak memory in Swift.
class Person {
let name: String
var card: Card?
init(name: String) { self.name = name }
deinit { print("\(name) freed") }
}
class Card {
let number: Int
var owner: Person? // strong → cycle!
init(number: Int) { self.number = number }
deinit { print("card freed") }
}
var p: Person? = Person(name: "Ada")
var c: Card? = Card(number: 1)
p?.card = c
c?.owner = p
p = nil; c = nil // nothing prints — both leaked
We break the cycle by making one of the two references non-owning. Swift gives us two tools for this.
A weak reference does not keep its target alive. Because the target can vanish at any moment, a weak reference must be an
Optional, and ARC automatically sets it to nil when the target is deallocated. An unowned reference also does not keep its target alive, but it is non-optional and assumes the target will outlive the reference. Use unowned only when you are certain the relationship can never become dangling; otherwise prefer weak.
class Card {
let number: Int
weak var owner: Person? // weak → no cycle
init(number: Int) { self.number = number }
deinit { print("card freed") }
}
// now p = nil; c = nil prints: Ada freed, card freed
The same cycle problem appears constantly with closures, since a closure that captures self holds a strong reference to it. That is why you so often see [weak self] in Swift capture lists. It is the exact same fix, applied to the implicit reference a closure holds.
Copy-on-write — cheap value semantics
Here is a puzzle. Array, String, Dictionary and Set are all value-type structs. By the rules of topic 1, copying one should copy all of its contents. So if you assign a million-element array to a new variable, does Swift really duplicate a million elements every time? That would be ruinously slow.
It does not. Swift uses copy-on-write, often shortened to COW. When you assign one of these collections to another variable, the two share a single underlying storage buffer. No elements are copied. The copy is deferred — and only happens at the moment a mutation needs to occur on a buffer that more than one variable is using.
var a = [1, 2, 3]
var b = a // shares a's buffer — O(1), no copy
b.append(4) // b is not unique → fork a private copy now
print(a) // [1, 2, 3] — untouched
print(b) // [1, 2, 3, 4]
How does the collection know whether it must fork? Before mutating, it checks whether its buffer is uniquely referenced, using the standard-library function isKnownUniquelyReferenced(&_). If the buffer is shared, it copies first, then mutates the private copy. If the buffer is already unique, it mutates in place with no copy at all.
This is the beautiful resolution of the puzzle. You get clean value semantics — assigning
a to b never lets one accidentally mutate the other — at the cost of a reference type internally. You pay for the copy only when you actually diverge the data, and never a moment sooner. If you build your own value type wrapping a class, you can use isKnownUniquelyReferenced to give it exactly this behaviour.
Putting it together
Let's tie the threads together with the rules you will use every day.
To change a stored property from inside a method on a struct, that method must be marked mutating. A value type cannot quietly rewrite itself; the keyword makes the intent explicit and tells the compiler the method needs a writable copy of self. Classes never need this, because mutating a property does not change the reference.
struct Counter {
var count = 0
mutating func increment() { count += 1 }
}
var c = Counter()
c.increment() // fine — c is var
let d = Counter()
// d.increment() // error: cannot mutate a let value type
And there is the second rule, hiding in that example. For a value type, let means the entire value is immutable — not just the binding, but every property inside it. A let struct is frozen solid; you cannot call a mutating method on it or reassign any of its fields. This is different from a class, where let only freezes the reference: you may still mutate the object's var properties through that constant reference, because the reference itself never changes.
let pt = Point(x: 1) // struct
// pt.x = 2 // error — whole value is constant
let box = Box(x: 1) // class
box.x = 2 // fine — reference is constant, object is not
Finally, the trivia that ties the whole episode to the language you use. Nearly every type you reach for first in Swift is a value type. Int, Double, Bool, String, Array, Dictionary, Set and even Optional are all defined as structs or enums in the standard library — not as the built-in primitives or reference types you might expect from other languages. An Int is a struct. Optional is an enum with .none and .some cases.
That single design decision is why Swift feels so predictable. When you pass a number, a string or an array around, you are passing a copy, and nobody can reach back and mutate your data behind your back. Reference types and ARC are still there for when you need shared identity and lifetimes — but value semantics are the default, and now you know exactly what that means.
Test your understanding
7 questions
Seven questions on value versus reference semantics, ARC, reference cycles, and copy-on-write in Swift.