Swift Zero to Expert #8: Structs vs Classes — the decision that shapes your app
Value semantics vs reference semantics, static vs dynamic dispatch, and why Apple recommends structs by default. The article that changes how you think about Swift.
In the previous article we saw that enums are value types with an incredibly efficient memory representation. Today we tackle the most fundamental decision in Swift: struct or class. This isn’t an aesthetic preference — it’s a choice that directly affects where your data lives (stack vs heap), how it gets copied, how each method is dispatched (i.e. how Swift decides which function code to actually run — explained in detail later in this article), and how much pressure you put on memory — unlike Java or C# (which use a garbage collector, a background process that cleans up unused memory), Swift uses ARC instead. Swift uses ARC — Automatic Reference Counting (a reference is a variable that points to an object rather than holding its value). ARC keeps a tally per object: every new variable that points to it adds 1, every one that stops pointing subtracts 1, and when the count reaches 0 the object is freed — a full deep-dive will come in a later article.
This article will change how you think about your types.
Struct or class isn’t a matter of preference — it’s an architectural decision. Choose wrong and you’ll pay in performance, shared-state bugs, or both.

What they share
Before diving into the differences, it’s worth noting that structs and classes have a lot in common:
// Both can have:// ✓ Stored and computed properties// (computed: calculate their value on the fly instead of storing it — see article #9)// ✓ Methods// ✓ Subscripts// (subscripts let you access a type with [] like an array — covered in the next article)// ✓ Initializers// ✓ Extensions (extensions = adding new methods or properties to an existing type without editing its original definition)// ✓ Protocol conformance (a protocol is a contract of methods/properties a type promises to provide — full deep-dive in #13)If you only look at the feature list, they seem nearly identical. The differences are under the hood.
What sets them apart
- Inheritance — one class can build on another, automatically getting its properties and methods while adding or replacing some (full detail in #10)
- Type casting — asking at runtime whether an object is actually a more specific subclass (using
as?oris) - Deinitializers — code that runs before the instance is destroyed (
deinit), useful for cleanup like closing a file or releasing a resource — covered in a later article - Reference counting — multiple references to the same object (ARC)
- Identity — the
===operator to check whether two variables point to the same object
Value semantics vs Reference semantics
Let’s see this in action:
// VALUE SEMANTICS — structstruct Size { var width: Double var height: Double}
var a = Size(width: 100, height: 200)var b = a // b es una COPIA independienteb.width = 999
print(a.width) // 100 — intactoprint(b.width) // 999 — solo b cambió// REFERENCE SEMANTICS — classclass SizeClass { var width: Double var height: Double init(width: Double, height: Double) { self.width = width self.height = height }}
var x = SizeClass(width: 100, height: 200)var y = x // y apunta al MISMO objetoy.width = 999
print(x.width) // 999 — ¡también cambió!print(y.width) // 999This is the single most important distinction between the two. The diagram below shows both side by side — a value type makes an independent copy, while a reference type hands out a second pointer to the same object:

Where they live: Stack vs Heap
Structs: the stack
Structs live directly in the stack frame (the block of stack memory reserved for one function call) of the function that creates them:
func process() { var point = Size(width: 10, height: 20) // point vive aquí, en el stack de process() // Cuando process() termina, point desaparece // No hay malloc, no hay free, no hay refcount}The stack is LIFO (Last In, First Out) — like a stack of plates: the last plate added is the first one removed. Because of this, allocating or freeing stack space is just moving a single pointer by a fixed amount. It’s the fastest memory operation there is.
Classes: the heap
Classes are always allocated on the heap. Two terms show up in the comments below, so let’s define them first: a pointer is just a memory address (address = a numbered slot in RAM) — 8 bytes on 64-bit Apple devices — that says where the real object lives on the heap. And malloc and free are the low-level operations that reserve and release a chunk of heap memory — Swift calls them for you automatically under the hood (via ARC).
func process() { var point = SizeClass(width: 10, height: 20) // En el stack: 8 bytes (puntero al heap) // En el heap: metadata + refcount + width + height // malloc() al crear, free() cuando refcount llega a 0}Every class instance involves:
malloc— finding free space on the heap- metadata — bookkeeping info Swift stores about the object’s type, including a pointer to its vtable (a lookup table Swift uses to find method code — defined in the dispatch section below)
- refcount — the reference counter introduced in the intro (ARC)
- The actual data (properties)
free— when the last reference goes away

Memberwise initializers
Structs get an automatic initializer with all their stored properties. Memberwise = one parameter per stored property, in declaration order — Swift generates this for every struct automatically.
struct Color { var red: Double var green: Double var blue: Double}
// El compilador genera esto gratis:let white = Color(red: 1.0, green: 1.0, blue: 1.0)Classes don’t — you have to write your own init:
class ColorClass { var red: Double var green: Double var blue: Double
init(red: Double, green: Double, blue: Double) { self.red = red self.green = green self.blue = blue }}Identity: === vs ==
Classes have something structs don’t: identity. You can ask whether two variables point to the same object (not just whether they hold the same values):
let point1 = SizeClass(width: 10, height: 20)let point2 = point1 // misma instancialet point3 = SizeClass(width: 10, height: 20) // otra instancia, mismos valores
point1 === point2 // true — MISMO objetopoint1 === point3 // false — objetos DIFERENTESStructs don’t have === because it wouldn’t make sense — every variable is its own copy. You can only compare values with == (if the type conforms to Equatable — Equatable is a built-in Swift protocol — a contract — that a type adopts to declare that == works on it; full detail in #13).
mutating: the keyword that protects your structs
Since structs are value types, Swift forbids mutating their properties from methods by default — you opt back in by marking the method mutating, a keyword that promises the compiler “this method will change self” (the current instance):
struct Counter { var count = 0
mutating func increment() { count += 1 // Solo posible con 'mutating' }}
var counter = Counter()counter.increment() // OK — counter es var
let fixed = Counter()// fixed.increment() // ERROR — no puedes mutar un letmutating is a contract: it tells the compiler “this method will modify self” (self = the current instance). In classes it doesn’t exist because properties are always mutable through any reference (even let).
let classCounter = SomeCounterClass()classCounter.count += 1 // OK — let solo protege la REFERENCIA, no el objetoStatic dispatch vs Dynamic dispatch

This is the performance difference that gets discussed the least — and the one that matters the most.
// STRUCT — static dispatchstruct Calculator { func add(_ a: Int, _ b: Int) -> Int { a + b }}let calc = Calculator()calc.add(2, 3)// El compilador inserta directamente: a + b (puede hasta hacer inline)// (inlining = the compiler pastes the function body straight into the call site, skipping the call entirely)
// CLASS — dynamic dispatch (por defecto)class CalculatorClass { func add(_ a: Int, _ b: Int) -> Int { a + b }}let calcClass = CalculatorClass()calcClass.add(2, 3)// Runtime: busca add() en la vtable → salta al código → ejecuta
final: the optimization you should know
Marking a class or method as final tells the compiler “nobody will subclass/override this” — enabling static dispatch:
final class FastCalculator { func add(_ a: Int, _ b: Int) -> Int { a + b } // static dispatch — igual de rápido que un struct}- Struct methods → always static dispatch (resolved at compile time)
- Class methods → dynamic dispatch by default (vtable lookup at runtime)
- final class methods → static dispatch (compiler knows there’s no override)
- Protocol methods → witness table (similar to vtable, we’ll cover it in article #13)
- Whole Module Optimization → the compiler can infer
finalif it sees nobody subclasses
Copy-on-Write (CoW): the best of both worlds
If structs are copied every time you assign them… wouldn’t that be terribly slow for an Array with 10,000 elements? No — thanks to Copy-on-Write.
The buffer mentioned below is the chunk of memory where an Array actually stores its elements:
var original = [1, 2, 3, 4, 5]var copy = original // NO copia los datos — comparten el buffer interno
// Ambos apuntan al mismo buffer (refcount = 2)
copy.append(6) // AHORA se copia — copy necesita su propio buffer// original sigue con [1, 2, 3, 4, 5]// copy tiene [1, 2, 3, 4, 5, 6] en su propio buffer
CoW gives you the best of both worlds:
- Safety of value semantics (every variable is independent)
- Performance of sharing (no copying unless you mutate)
When do structs end up on the heap?
Structs normally live on the stack, but the compiler moves them to the heap when:
- Escaping scope — captured by an escaping closure (article #6)
- Existential containers — when stored as a protocol (
any Drawable) - Too large — very large structs may be more efficient on the heap
- Inside a class — if a struct is a property of a class, it lives in that class’s heap allocation
protocol Drawable { func draw()}
struct Circle: Drawable { var radius: Double func draw() { print("Drawing circle") }}
// Existential container — Circle puede terminar en el heaplet shape: any Drawable = Circle(radius: 5)When should you use struct? When should you use class?
- The data is self-contained and doesn’t need identity
- You want value semantics (independent copies)
- You don’t need inheritance
- You want the performance of stack allocation and static dispatch
- It’s the default option recommended by Apple
- You need identity (checking whether two variables are the same object with
===) - You need inheritance (type hierarchies)
- You need reference semantics (multiple parts of your code share the same state)
- You’re interoperating with Objective-C (which only has classes)
- The object’s lifecycle needs deinit for cleanup
Apple’s guidance is simple: use structs by default, and reach for classes only when you need their specific capabilities.
Memory: the definitive summary
┌─────────────────────────────────────────────────┐│ STRUCT ││ • Stack allocation (fast) ││ • Value semantics (independent copy) ││ • Static dispatch (resolved at compile time) ││ • No refcount, no malloc, no free ││ • CoW for collections (Array, Dict, Set) ││ • Free memberwise init ││ • No inheritance, no deinit, no identity │└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐│ CLASS ││ • Heap allocation (malloc + free) ││ • Reference semantics (shared state) ││ • Dynamic dispatch (vtable at runtime) ││ • Refcount management (ARC) ││ • Metadata/vtable pointer per instance ││ • Inheritance, deinit, identity (===) ││ • final → static dispatch (optimization) │└─────────────────────────────────────────────────┘It’s not that classes are “bad” — it’s that structs are the right choice for most cases. A struct is faster, safer, and more predictable. Reserve classes for when you truly need inheritance, identity, or shared state.
Recap
- Value semantics (struct) — each variable owns its own copy; mutating one doesn’t affect the others
- Reference semantics (class) — multiple variables share the same object
- Stack vs Heap — structs on the stack (fast), classes on the heap (malloc + refcount + free)
- Memberwise init — free for structs, manual for classes
- Identity — only classes have
===; structs only have== - mutating — required in structs to modify
self; unnecessary in classes - Static dispatch (struct) — resolved by the compiler at compile time
- Dynamic dispatch (class) — vtable lookup at runtime;
finalturns it into static - Copy-on-Write — Array/Dict/Set only copy on mutation
- Structs on the heap — existential containers, escaping closures, inside classes
What’s next
In the next article we explore Properties, Methods, and Subscripts — the connective tissue of your types. We’ll look at the difference between stored and computed properties (and why computed = zero storage), property observers, lazy properties, and how properties define the memory layout of your structs and classes.
See you next week.
The choice between struct and class isn’t about syntax — it’s about semantics. Value semantics gives you safety and performance by default. Reference semantics gives you power and flexibility when you need it. Choose with intention.
References
Related
-
- swift
- swift-zero-expert
- swift-fundamentals
Swift from Zero to Expert #7: Enumerations — more than a list of cases
Raw values, associated values, recursive enums with indirect, and how the compiler picks the minimal memory representation.
-
- swift
- swift-zero-expert
- swift-fundamentals
Swift from Zero to Expert #6: Closures — captures, memory, and functional power
Closure expressions, value capturing, capture lists, @escaping vs non-escaping, and why closures are reference types that live on the heap.
-
- swift
- ios
- performance
Mastering Instruments (Part 4): Flame Graphs, Swift Concurrency Under the Microscope, and Processor Trace in Action
Learn to read Flame Graphs, audit async tasks with Swift Tasks, and push Processor Trace to its limits with a real CLI project that uses Swift Concurrency intensively.