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 | |
“I am creating a new version” | safe transformation |
|
|
“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(...)).
GO TO FULL VERSION