DEV Community

Cover image for Value Semantics vs Reference Semantics in Swift: A Practical Guide
Thai Dao
Thai Dao

Posted on

Value Semantics vs Reference Semantics in Swift: A Practical Guide

When learning Swift, we often hear:

struct is a Value Type, while class is a Reference Type.

That statement is correct, but it doesn't explain what actually happens when we copy, mutate, or share data.

In real-world iOS development, understanding Value Semantics, Reference Semantics, Copy-on-Write (CoW), and storage is much more useful than simply remembering "struct = stack, class = heap".

Let's break it down.

1. Value Semantics
A type has Value Semantics when copying it creates an independent value.

var a = 10
var b = a

b = 20

print(a) // 10
print(b) // 20
Enter fullscreen mode Exit fullscreen mode

Changing b doesn't affect a.

The same applies to a struct:

struct User {
    var name: String
}

var user1 = User(name: "Alice")
var user2 = user1

user2.name = "Bob"

print(user1.name) // Alice
print(user2.name) // Bob
Enter fullscreen mode Exit fullscreen mode

Conceptually:

user1 → User("Alice")
user2 → User("Bob")
Enter fullscreen mode Exit fullscreen mode

The important idea is:

A copy behaves as an independent value.

2. Reference Semantics
A class has Reference Semantics.

final class User {
    var name: String

    init(name: String) {
        self.name = name
    }
}

let user1 = User(name: "Alice")
let user2 = user1

user2.name = "Bob"

-------------------
Now:
print(user1.name) // Bob
print(user2.name) // Bob

-------------------
Why?
Because `user1` and `user2` reference the same object:

user1 ─────┐
           
       User Object
       name = "Bob"
           
           
user2 ─────┘

Enter fullscreen mode Exit fullscreen mode

Copying a reference type copies the reference, not the object.

This is useful when we actually want shared identity and shared state—for example, a ViewModel, service, coordinator, or resource owner.

3. Don't Think struct = Stack and class = Heap
A common explanation is:

struct → Stack
class  → Heap
Enter fullscreen mode Exit fullscreen mode

This is too simplistic.

Stack and Heap describe storage implementation, while Value and Reference Semantics describe behavior.

For example:

struct Point {
    let x: Int
    let y: Int
}
Enter fullscreen mode Exit fullscreen mode

A small, fixed-size value like this can be stored inline. If it's a local variable, that representation may be on the stack, but the compiler is free to optimize it differently.

On the other hand:

struct RingtoneStore {
    var ringtones: [Ringtone]
}
Enter fullscreen mode Exit fullscreen mode

Array can contain 100,000 elements, so its data requires dynamic storage.

Conceptually:

RingtoneStore
┌─────────────────────┐
│ Array representation│
└──────────┬──────────┘
           │
           ▼
     Backing Storage
     [Ringtone ...]
Enter fullscreen mode Exit fullscreen mode

The Array value is still a Value Type, even though its backing storage is dynamically allocated and commonly lives on the heap.

So the better question is not:

"Is this struct on the Stack or Heap?"
but:
"How is this value represented and where is its backing storage?"

4. Copy-on-Write
This becomes especially interesting with large collections.

Suppose we have 100,000 ringtones:

let ringtones = [Ringtone](...)
Enter fullscreen mode Exit fullscreen mode

Now:

let a = ringtones
let b = a
Enter fullscreen mode Exit fullscreen mode

Does Swift immediately copy all 100,000 elements?

No.

Array uses Copy-on-Write (CoW).

Conceptually:

a ─────┐
       ├──> Shared Storage
b ─────┘
Enter fullscreen mode Exit fullscreen mode

There are two Array values, but they can temporarily share the same backing storage.

If we only read:

print(b[0])
Enter fullscreen mode Exit fullscreen mode

there is no reason to copy the storage.

This makes copying a large Array relatively cheap.

5. What Happens When We Mutate?

Now:

var a = [1, 2, 3]
var b = a

b.append(4)
Enter fullscreen mode Exit fullscreen mode

Before mutation:

a ─────┐
       ├──> Storage A
b ─────┘
       [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

If b modified Storage A directly, a would also change. That would violate Value Semantics.

So Swift separates the storage:

a ─────────> Storage A
             [1, 2, 3]

b ─────────> Storage B
             [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

This is Copy-on-Write.

The important optimization is:

Don't copy until mutation actually requires it.

6. What Does "Uniquely Referenced" Mean?

CoW doesn't mean every mutation requires an O(n) copy.

Consider:

var a = [1, 2, 3]

a.append(4)
Enter fullscreen mode Exit fullscreen mode

If a is the only value using its backing storage:

a ─────> Storage A
Enter fullscreen mode Exit fullscreen mode

the storage is uniquely referenced.

Swift can mutate it directly.

But:

var a = [1, 2, 3]
var b = a
Enter fullscreen mode Exit fullscreen mode

gives:

a ─────┐
       ├──> Storage A
b ─────┘
Enter fullscreen mode Exit fullscreen mode

The storage is shared.

If b mutates, CoW may need to create a new storage.

So the simplified model is:

Mutation
   ↓
Is storage shared?
   │
   ├── No  → mutate in place
   │
   └── Yes → copy → mutate
Enter fullscreen mode Exit fullscreen mode

This is the key to understanding the performance of Swift collections.

7. A Struct Can Still Contain a Class

Here's an important edge case:

final class AudioPlayer {
    var volume = 50
}

struct Ringtone {
    var name: String
    var player: AudioPlayer
}
Enter fullscreen mode Exit fullscreen mode

Although Ringtone is a struct, player is a reference to a class instance.

After:

let ringtone2 = ringtone1

we can have:

ringtone1 ──┐
            │
            ▼
       AudioPlayer
       volume = 50
            ▲
            │
ringtone2 ──┘

Therefore:

ringtone2.player.volume = 100

can also be observed through:

ringtone1.player.volume

because both structs share the same `AudioPlayer`.
Enter fullscreen mode Exit fullscreen mode

This gives us an important rule:

A struct provides Value Semantics at its own level, but it does not automatically make every property deeply independent.

This is why exposing mutable reference-type state directly from a value type should be done carefully.

8. Value Semantics and Concurrency

Value Semantics are also useful in concurrent code because they reduce shared mutable state.

For example:

struct UserState {
    var name: String
    var age: Int
}
Enter fullscreen mode Exit fullscreen mode

If two tasks work with independent copies:

Task A → UserState A
Task B → UserState B

then mutation in Task A doesn't change Task B's value.

This significantly reduces opportunities for Data Races.

However, it's important to be precise:

struct does not automatically mean thread-safe.

A struct can still contain a mutable class reference or access external shared state.

So a better statement is:

Value Semantics reduce shared mutable state, which makes concurrent code easier to reason about and reduces potential data races.

Conclusion

The most useful mental model is not:

struct = Stack
class  = Heap
Enter fullscreen mode Exit fullscreen mode

Instead:

Value Type
    ↓
Value Semantics
    ↓
Independent state
    ↓
CoW can optimize storage sharing
Enter fullscreen mode Exit fullscreen mode

and:

Reference Type
    ↓
Reference Semantics
    ↓
Shared identity
    ↓
Shared mutable state
    ↓
ARC manages lifetime
Enter fullscreen mode Exit fullscreen mode

And when performance is involved:

Array copy
    ↓
Share backing storage
    ↓
Read → no copy
    ↓
Mutation
    ↓
Is storage unique?
   ├── Yes → mutate in place
   └── No  → Copy-on-Write
Enter fullscreen mode Exit fullscreen mode

Once you understand these relationships, concepts like Swift Concurrency, Sendable, ARC, data races, and collection performance become much easier to reason about.

The key takeaway is simple:

Value vs Reference Semantics describe how data behaves. Stack vs Heap describes how data may be stored. Don't confuse the two.

Top comments (0)