Swifty Journey

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.

The capybara and the Swift bird comparing two worlds: stack and heap

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

Only classes have
  • 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? or is)
  • 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 — struct
struct Size {
var width: Double
var height: Double
}
var a = Size(width: 100, height: 200)
var b = a // b es una COPIA independiente
b.width = 999
print(a.width) // 100 — intacto
print(b.width) // 999 — solo b cambió
// REFERENCE SEMANTICS — class
class 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 objeto
y.width = 999
print(x.width) // 999 — ¡también cambió!
print(y.width) // 999

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

Value vs reference semantics: with value semantics, variables a and b each hold their own independent copy of a struct, so changing b leaves a untouched; with reference semantics, variables x and y both point with arrows to the same class object, so changing one changes both

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:

  1. malloc — finding free space on the heap
  2. 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)
  3. refcount — the reference counter introduced in the intro (ARC)
  4. The actual data (properties)
  5. free — when the last reference goes away

Stack vs heap memory layout: on the left a struct lives entirely inside the function's stack frame with its width and height fields inline; on the right a class keeps only an 8-byte pointer on the stack that arrows to a heap block holding metadata, refcount, and the width/height data allocated with malloc

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 instancia
let point3 = SizeClass(width: 10, height: 20) // otra instancia, mismos valores
point1 === point2 // true — MISMO objeto
point1 === point3 // false — objetos DIFERENTES

Structs 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 let

mutating 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 objeto

Static dispatch vs Dynamic dispatch

The Swift bird showing two paths: one direct and one with indirection

This is the performance difference that gets discussed the least — and the one that matters the most.

// STRUCT — static dispatch
struct 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

Static vs dynamic dispatch: on the left static dispatch goes directly from the call site to the function's code in a single jump; on the right dynamic dispatch routes the call site through a vtable lookup before jumping to the code, and marking the class final collapses it back to the direct path

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
}
Dispatch at a glance
  • 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 final if 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

Copy-on-Write in two states: first, original and copy both point to one shared buffer with refcount = 2 and no data is copied; second, calling copy.append(6) forces a copy so original keeps buffer A and copy gets a brand-new buffer B with the appended element, each now at refcount = 1

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:

  1. Escaping scope — captured by an escaping closure (article #6)
  2. Existential containers — when stored as a protocol (any Drawable)
  3. Too large — very large structs may be more efficient on the heap
  4. 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 heap
let shape: any Drawable = Circle(radius: 5)

When should you use struct? When should you use class?

Use struct when...
  • 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
Use class when...
  • 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; final turns 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