CodeGym /Courses /Swift SELF /Value semantics in practice: copies, mutations

Value semantics in practice: copies, mutations

Swift SELF
Level 23 , Lesson 3
Available

1. Value semantics: the idea

When you are just starting to write code, it is easy to think: “If two variables look the same, then they must be related somehow.” And indeed, in some languages and types that is true. But in Swift, the default idea for struct is value semantics: a value behaves like a number or a string — like an independent “thing.” If you assign one value to another, you get a copy of the value, and then they live independent lives.

To keep this from sounding like philosophy, let us pin it down with a simple everyday analogy. A struct is like a printed recipe on a sheet of paper. If you made a photocopy of the recipe and crossed out “200 g of sugar” on the copy and wrote “50 g,” the original sheet in the folder at home did not change. And that is great: otherwise your grandma would one day get borscht with the taste of cheesecake, and you and the “shared references” would be to blame.

In a schematic form, you can imagine it like this:

flowchart LR
    A[Variable a: Book] -->|assignment| B[Variable b: Book]
    B -->|change b.title| B2["b: Book (new value)"]
    A -->|does not change| A2["a: Book (old value)"]

2. Copying and immutability

Copy on assignment

The easiest place to start is assignment. This is where the usual “expectation break” happens: a student changes a field in b and expects a to change too, because “they are the same.” But with struct, that is not how it works: a and b are two different values. Yes, they started out the same, but they are not connected.

Let us describe our “book” model for a mini library. For now, no complexity: just id, title, author.

import Foundation

struct Book {
    let id: Int
    var title: String
    var author: String
}

Now let us look at copying directly.

import Foundation

struct Book {
    let id: Int
    var title: String
    var author: String
}

var a = Book(id: 1, title: "Dune", author: "Frank Herbert")
var b = a

b.title = "Dune (Director's Cut?)"

print(a.title) // Dune
print(b.title) // Dune (Director's Cut?)

Notice: we changed b.title, but a.title stayed the same. That is value semantics “in pure form.”

let freezes the instance as a whole

A very common beginner thought is: “Well, I have a struct field declared as var, so I should be able to change it.” And Swift calmly answers: “You can. But only if the instance itself is a var.” If the instance is declared with let, it becomes completely immutable. That is not a punishment, but protection: a value should stay a value, not turn into a “half-frozen object.”

Let us catch this in a small example and reinforce the rule at the same time.

import Foundation

struct Book {
    let id: Int
    var title: String
}

let book = Book(id: 1, title: "Dune")
// book.title = "Dune 2" // ❌ not allowed: the instance is declared as let

print(book.title) // Dune

If you need to change fields, declare the instance as var. If you do not, let helps both the compiler and your brain: fewer places where something can “change unexpectedly.”

3. Passing into functions and returning a copy

Why changes inside a function are not visible outside

The next surprise point: you pass Book into a function, change title inside the function, but nothing changes outside. And that is value semantics too: the function gets its own copy of the value (logically independent), and you are changing exactly that.

There is also another point: function parameters in Swift are immutable by default. So you cannot even write book.title = ... directly on the parameter — the compiler will stop you. Swift does this deliberately so as not to confuse “local editing” with “changing the caller’s argument.”

Historically, Swift even discussed that mutable parameters (var param) confuse people with inout, and that approach was abandoned.

Let us look at an example.

import Foundation

struct Book {
    let id: Int
    var title: String
}

func renameToUppercased(_ book: Book) -> Book {
    var copy = book
    copy.title = copy.title.uppercased()
    return copy
}

let a = Book(id: 1, title: "Dune")
let b = renameToUppercased(a)

print(a.title) // Dune
print(b.title) // DUNE

The important thing here is that we are not trying to “change a.” We are honestly saying: “give me a book — I will return a new book.” This is the “copy and return” style.

Draft and rollback: a realistic scenario

So far we have copied Book and Library at the level of “one variable.” In real code, you more often see this scenario: you have the current state, you make a “draft,” try changes, and then decide whether to accept them or not.

Value semantics fit this perfectly: you can freely make copies without worrying about “accidentally breaking the original.”

Let us play it out as a little story: “let us try renaming a book and roll back if the title turns out to be empty.”

import Foundation

struct Book {
    let id: Int
    var title: String
}

func tryRename(_ book: Book, to newTitle: String) -> Book {
    var copy = book
    if !newTitle.isEmpty {
        copy.title = newTitle
    }
    return copy
}

let original = Book(id: 1, title: "Dune")
let attempt = tryRename(original, to: "")

print(original.title) // Dune
print(attempt.title)  // Dune

Notice: we “played around” with attempt, but left the original alone. This is a very calm model: fewer surprises, fewer “why did it change by itself?”

4. How to update data: copy or mutation

In real code, you constantly need to “update state.” And in Swift with value semantics, there are usually two basic paths: either you return a new version of the value (this often reads very cleanly), or you change the existing variable “in place” through inout.

Both approaches are fine, but they read differently, and they carry different risks.

To avoid speaking in abstractions, let us take our mini library: it is just a list of books in an array.

import Foundation

struct Book {
    let id: Int
    var title: String
    var author: String
}

struct Library {
    var books: [Book]
}

Two options: return a new copy or change in place

Now let us make two versions of “add a book.”

Option A: return a new Library copy.

import Foundation

struct Book { let id: Int; var title: String; var author: String }
struct Library { var books: [Book] }

func adding(_ book: Book, to library: Library) -> Library {
    var copy = library
    copy.books.append(book)
    return copy
}

Option B: change the existing variable through inout.

import Foundation

struct Book { let id: Int; var title: String; var author: String }
struct Library { var books: [Book] }

func addInPlace(_ book: Book, to library: inout Library) {
    library.books.append(book)
}

The difference in usage is immediately noticeable.

import Foundation

struct Book { let id: Int; var title: String; var author: String }
struct Library { var books: [Book] }

func adding(_ book: Book, to library: Library) -> Library {
    var copy = library
    copy.books.append(book)
    return copy
}

func addInPlace(_ book: Book, to library: inout Library) {
    library.books.append(book)
}

let dune = Book(id: 1, title: "Dune", author: "Frank Herbert")

var lib1 = Library(books: [])
let lib2 = adding(dune, to: lib1)

print(lib1.books.count) // 0
print(lib2.books.count) // 1

addInPlace(dune, to: &lib1)
print(lib1.books.count) // 1

Notice the & with inout: it is not decoration, but a special marker meaning “attention, mutation is about to happen.” And that is good: you can see the mutation right at the call site.

Mini summary: when to choose which

Sometimes students want “one rule for everything.” Alas (or fortunately), that is rare in programming. But you can keep a clear comparison in mind: a returned copy makes the code more “functional” and predictable, while inout emphasizes that a specific variable is being changed.

Approach What the call looks like What is visible at the call site Typical meaning
Return a new copy
let new = updated(old)
“I am creating a new version” safe transformation
inout
update(&value)
“value will change now” intentional state mutation

And yes, sometimes you will combine both. For example, inside a mutating method you can use the “create a copy, change it, assign back” style — that is fine. The main thing is that the external contract is clear.

5. inout, mutating, and exclusive access

inout is a contract for change, not a pointer

At this stage, students often get a dangerous idea: “Oh, inout is like a reference/pointer, now I can change everything from anywhere.” And Swift says: “Hold on. Breathe. inout is not ‘give me the variable’s address’, it is a contract for temporary exclusive mutation.”

In simple terms, inout in Swift works like “make edits to a document while it is on my desk, then return it back.” In Swift’s evolution documents, it is explicitly emphasized that the difference between inout and an ordinary mutable local copy is that the changes are written back to the caller.

There is another important detail: & in Swift is not “take the address like in C.” Swift does not promise that a variable must have a stable memory address. In many cases, & is just a way to grant temporary access during an operation, not a real “address-of.”

For us as beginners, the practical rule is this: think of inout as “permission for a function to change your variable,” not as “we now live in a world of pointers.”

mutating method and inout parameter: one idea

Inside a struct, you have already seen mutating methods. Outside, you see inout. These are actually very close ideas: in both cases, Swift requires “exclusive access” in order to safely change a value.

Let us make the library a bit more “object-like” in the good sense: add an add method.

import Foundation

struct Book { let id: Int; var title: String; var author: String }

struct Library {
    var books: [Book] = []

    mutating func add(_ book: Book) {
        books.append(book)
    }
}

Usage:

import Foundation

struct Book { let id: Int; var title: String; var author: String }

struct Library {
    var books: [Book] = []
    mutating func add(_ book: Book) { books.append(book) }
}

var library = Library()
library.add(Book(id: 1, title: "Dune", author: "Frank Herbert"))

print(library.books.count) // 1

Why is this useful? Because the rules for changing state live right next to the data. The further you go, the more often you will choose a “method inside a struct” instead of a “function somewhere outside,” simply because it reads better.

Why Swift sometimes complains about conflicting access

At this point many people first encounter compiler or runtime messages about “conflicting access” or “exclusivity violation.” It sounds intimidating, as if you had hacked the matrix, but in essence Swift is simply protecting a rule: when a value is changed through inout/mutating, the access must be exclusive. That means while mutation is happening, nobody else should simultaneously read or write the same value.

In Swift documents this is often called the “law of exclusivity,” and it applies to inout and mutating setters.

We will not go into multithreading and complicated cases right now. We only need one practical feeling: if you try to both read and mutate the same var in one expression, Swift may ask you to rewrite the code a little more clearly.

For example, instead of “do everything in one line,” you often pull a piece into a temporary variable, and the conflict disappears. That is not a “hack”; it is a way to make accesses obvious.

6. Collections inside struct and Copy-on-Write

Here a second layer of anxiety often appears: “Okay, Book is a value. But Library contains Array. What if the array is shared, and changes leak out?” The good news: from the API’s point of view, Array in Swift also behaves like a value. So when you copy Library, you still expect independence.

You have already encountered the idea of Copy-on-Write (COW) in the arrays topic: Swift tries not to copy memory too early, but logically it is still value semantics.

Let us check it “by hand,” without diving into optimizations.

import Foundation

struct Library {
    var tags: [String]
}

var a = Library(tags: ["sci-fi", "classic"])
var b = a

b.tags.append("epic")

print(a.tags) // ["sci-fi", "classic"]
print(b.tags) // ["sci-fi", "classic", "epic"]

From the outside, you see exactly what you would expect from values: a did not change. And the fact that inside Swift the array buffer might have been shared for a moment and then separated on mutation — that is a pleasant optimization that should not break your model of the world.

7. Example: a library as a value

Now let us put together a small project: we have a library, we make a copy, change the copy, and print both. This is exactly the moment when value semantics stops being a term and becomes a convenience.

import Foundation

struct Book {
    let id: Int
    var title: String
    var author: String
}

struct Library {
    var books: [Book] = []

    mutating func add(_ book: Book) {
        books.append(book)
    }
}

var mainLibrary = Library()
mainLibrary.add(Book(id: 1, title: "Dune", author: "Frank Herbert"))

var draftLibrary = mainLibrary
draftLibrary.add(Book(id: 2, title: "Neuromancer", author: "William Gibson"))

print(mainLibrary.books.count)  // 1
print(draftLibrary.books.count) // 2

If you have ever made a “draft document” or a “copy of settings before an experiment,” you already intuitively understand why this matters.

8. Common mistakes when working with value semantics

Mistake #1: expecting shared state after b = a.
If you assigned one struct to another, you got an independent copy of the value. A beginner often intuitively expects “two variables looking at one object” behavior, and then is surprised that changes did not “propagate.” This is fixed not by tricks, but by the right model: a struct in Swift is a value, and assignment makes a copy.

Mistake #2: trying to “change a function argument” without inout, and then being surprised that everything outside stayed the same.
An ordinary function parameter is logically a separate value, and even if you create a local variable var copy = param and change it, that will not escape outward. If you need to change the caller’s variable, use inout and & as an explicit write-back contract. The difference between inout and an ordinary mutable local copy is precisely the “write-back” behavior.

Mistake #3: treating & as “take the variable’s address, now I am almost in C.”
& in Swift is not a promise of a stable address and not an invitation to live with pointers. It is a way to temporarily give a function exclusive access for mutation, and the compiler/runtime watch very carefully to make sure there are no conflicting accesses. Even in discussions of the standard memory model, it is emphasized that & is not address-of, but a mechanism for temporary access.

Mistake #4: trying to tuck inout into a closure “for later.”
Sometimes you want to do this: “I will receive an inout parameter, form a closure, and execute it later.” That leads to very confusing situations, because inout is, by meaning, alive only in a limited time window. Swift specifically restricts/discusses such cases because they led to unexpected results with “shadow copy.”

Mistake #5: turning inout into a universal hammer “just to make it work.”
inout is a powerful tool, but it should reflect the API intent: “this function changes your argument.” If you use inout just because you do not want to return a new value, the code becomes less predictable: it is harder to read and test. Often a returned copy is simpler and more honest, and inout is reserved for cases where mutation is truly natural (for example, state-management methods like Library.add(...)).

1
Task
Swift SELF, level 23, lesson 3
Locked
Two stickers
Two stickers
1
Task
Swift SELF, level 23, lesson 3
Locked
Playlist Copy
Playlist Copy
1
Task
Swift SELF, level 23, lesson 3
Locked
Renaming a book
Renaming a book
1
Task
Swift SELF, level 23, lesson 3
Locked
Two carts
Two carts
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION