Skip to content

Swift: Struct vs Class vs Actor, Value vs Reference Types, Stack vs Heap | Modern Swift Concurrency #8

Choosing between a struct, class, and actor is not just a stylistic preference — it determines memory layout, ownership semantics, and whether your code can have data races. Understanding why structs are thread-safe by design and why classes are not is the foundation for making sense of why actors were introduced.

What You'll Learn

  • How value types (structs) and reference types (classes) differ in memory layout: stack allocation vs heap allocation, copying vs sharing
  • Why classes are not thread-safe by default and what categories of bugs arise when two threads modify the same class instance simultaneously
  • How actors extend the class model with compile-time-enforced serialized access, and which type to reach for in different architectural roles

Mental Model

Think of a struct as a paper form. When you give someone your form, you photocopy it first — they get their own copy. Whatever they write on their copy has no effect on yours. This is value semantics: assignment and passing create independent copies. It is inherently thread-safe because each thread has its own copy.

A class is like a shared Google Doc. When you give someone access, they get a link to the same document. Whatever they type affects what you see. This is reference semantics: assignment and passing share identity. Two threads with the same reference can read and write simultaneously, which causes a data race — like two people typing in the same document cell at the same time.

An actor is like a Google Doc with a lock: only one person can edit at a time. Others can request access (by await-ing), and the system queues their requests. The content is still shared (reference type), but concurrent mutation is structurally prevented.

Detailed Explanation

The stack and heap are two memory regions with fundamentally different allocation characteristics. The stack is per-thread, last-in-first-out, and extremely fast: allocating stack memory is just moving a pointer. Stack frames are created when a function is entered and destroyed when it returns. Every thread has its own stack, which means stack-allocated values (structs, enums, most Swift value types) are implicitly thread-safe — thread A's copy of a struct is on thread A's stack, physically separate from thread B's copy.

The heap is a shared memory region where all threads can access the same memory addresses. Heap allocation involves the allocator finding a free region, potentially acquiring a lock, recording the allocation, and returning a pointer. It is slower than stack allocation. More importantly, heap-allocated objects are accessible from any thread — which is both their power (shared state) and their danger (data races).

Classes live on the heap. When you write let objectB = objectA for a class, you copy the pointer — both variables point to the same memory. When thread A calls objectA.title = "foo" and thread B simultaneously calls objectB.title = "bar", they are racing to write to the same memory address. Without synchronization, the result is undefined behavior: a crash, corrupted data, or an intermittent bug that only manifests under load. This is a data race, and it is one of the most common bugs in concurrent iOS apps.

The traditional Swift solution was to use DispatchQueue barriers or NSLock to serialize access to class instances. The MyDataManager class in lesson 9 demonstrates this with a DispatchQueue(label:). This works but has several problems: the compiler cannot verify you used the lock correctly, you have to remember to use it at every access point, and lock-based code is vulnerable to deadlocks if the lock is acquired twice on the same thread.

Actors solve this at the language level. An actor is a reference type (heap-allocated, shared identity, can be passed around) but with a built-in serialization guarantee enforced by the compiler. Every property and method of an actor is actor-isolated: the compiler prevents you from accessing them without await, which forces all access to be serialized through the actor's executor. Unlike manual locking, you cannot forget to await — the compiler errors if you try.

The comments at the top of the sample code file are the most concise summary you will find: value types are stored on the stack, are thread-safe, and copy on assignment; reference types are stored on the heap, are not thread-safe by default, and share identity. Actors are reference types that add thread safety.

For SwiftUI architectural guidance, the comment block says: "Structs: Data Models, Views. Classes: ViewModels. Actors: Shared Managers and Data Stores." This is idiomatic. SwiftUI views are structs (re-created on every render). ViewModels are classes because SwiftUI's observation system requires reference types. Shared state managers that multiple views touch — caches, data stores, network managers — are actors.

Code Structure

StructClassActorBootcamp.swift is the most comprehensive file in the series. It defines MyStruct, CustomStruct, and MutatingStruct to explore mutation models. MyClass demonstrates reference semantics with objectA/objectB identity sharing. MyActor shows the same experiment with the actor requiring await. The structTest1, classTest1, and actorTest1 functions in the view extension run these comparisons and print results so you can observe the behavior. The structTest2 and classTest2 extensions explore mutation patterns in more depth.

Complete Code

StructClassActorBootcamp.swift

swift
/*
 
 Links:
 https://blog.onewayfirst.com/ios/posts/2019-03-19-class-vs-struct/
 https://stackoverflow.com/questions/24217586/structure-vs-class-in-swift-language
 https://medium.com/@vinayakkini/swift-basics-struct-vs-class-31b44ade28ae
 https://stackoverflow.com/questions/24217586/structure-vs-class-in-swift-language/59219141#59219141
 https://stackoverflow.com/questions/27441456/swift-stack-and-heap-understanding
 https://stackoverflow.com/questions/24232799/why-choose-struct-over-class/24232845
 https://www.backblaze.com/blog/whats-the-diff-programs-processes-and-threads/
 https://medium.com/doyeona/automatic-reference-counting-in-swift-arc-weak-strong-unowned-925f802c1b99
 
 VALUE TYPES:
 - Struct, Enum, String, Int, etc.
 - Stored in the Stack
 - Faster
 - Thread safe!
 - When you assign or pass value type a new copy of data is created
 
 REFERENCE TYPES:
 - Class, Function, Actor
 - Stored in the Heap
 - Slower, but synchronized
 - NOT Thread safe (by default)
 - When you assign or pass reference type a new reference to original instance will be created (pointer)
 
 - - - - - - - - - - - - - -
 
 STACK:
 - Stores Value types
 - Variables allocated on the stack are stored directly to the memory, and access to this memory is very fast
 - Each thread has it's own stack!
 
 HEAP:
 - Stores Reference types
 - Shared across threads!
 
 - - - - - - - - - - - - - -
 
STRUCT:
 - Based on VALUES
 - Can be mutated
 - Stored in the Stack!
 
CLASS:
 - Based on REFERENCES (INSTANCES)
 - Stored in the Heap!
 - Inherit from other classes
 
ACTOR:
 - Same as Class, but thread safe!
 
 - - - - - - - - - - - - - -
 
Structs: Data Models, Views
Classes: ViewModels
Actors: Shared 'Manager' and 'Data Stores'

 */



import SwiftUI

actor StructClassActorBootcampDataManager { // actor: reference type with compiler-enforced serialized access
    
    func getDataFromDatabase() {
        // actor-isolated: calling this from outside the actor requires await
    }
    
}

class StructClassActorBootcampViewModel: ObservableObject {
    
    @Published var title: String = ""
    
    init() {
        print("ViewModel INIT") // class instances are heap-allocated; this init fires once per reference creation
    }
    
}

struct StructClassActorBootcamp: View {
    
    @StateObject private var viewModel = StructClassActorBootcampViewModel()
    let isActive: Bool // let stored property: value is copied into this struct instance on creation
    
    init(isActive: Bool) {
        self.isActive = isActive
        print("View INIT") // struct views re-init frequently; this fires on every SwiftUI re-evaluation that touches this type
    }
    
    var body: some View {
        Text("Hello, World!")
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .ignoresSafeArea()
            .background(isActive ? Color.red : Color.blue) // isActive is a local copy; changing it here does not affect the caller
            .onAppear {
//                runTest() // uncomment to observe struct/class/actor behavior in the console
            }
    }
}

struct StructClassActorBootcampHomeView: View {
    
    @State private var isActive: Bool = false // @State wraps the value in a heap-allocated box so SwiftUI can observe changes
    
    var body: some View {
        StructClassActorBootcamp(isActive: isActive) // isActive is COPIED into StructClassActorBootcamp — not shared
            .onTapGesture {
                isActive.toggle() // mutates the @State box; SwiftUI re-renders with the new isActive value
            }
    }
    
}

struct StructClassActorBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        StructClassActorBootcamp(isActive: true)
    }
}

extension StructClassActorBootcamp {
    
    private func runTest() {
        print("Test started!")
        structTest1()
        printDivider()
        classTest1()
        printDivider()
        actorTest1() // runs inside a Task because actor methods require async context
        
//        structTest2()
//        printDivider()
//        classTest2()
    }
    
    private func printDivider() {
        print("""
        
         - - - - - - - - - - - - - - - - -
        
        """)
    }
    
    private func structTest1() {
        print("structTest1")
        let objectA = MyStruct(title: "Starting title!")
        print("ObjectA: ", objectA.title)
        
        print("Pass the VALUES of objectA to objectB.")
        var objectB = objectA // copies the entire struct — objectB is an independent instance, not a pointer to objectA
        print("ObjectB: ", objectB.title)
        
        objectB.title = "Second title!" // mutates only objectB's copy; objectA is unchanged
        print("ObjectB title changed.")
        
        print("ObjectA: ", objectA.title) // still "Starting title!" — copy semantics preserved independence
        print("ObjectB: ", objectB.title)
    }
    
    private func classTest1() {
        print("classTest1")
        let objectA = MyClass(title: "Starting title!")
        print("ObjectA: ", objectA.title)

        print("Pass the REFERENCE of objectA to objectB.")
        let objectB = objectA // copies the pointer — both objectA and objectB point to the same heap memory
        print("ObjectB: ", objectB.title)

        objectB.title = "Second title!" // writes to the heap memory that both objectA and objectB reference
        print("ObjectB title changed.")
        
        print("ObjectA: ", objectA.title) // "Second title!" — shared identity means objectA sees objectB's mutation
        print("ObjectB: ", objectB.title)
    }
    
    private func actorTest1() {
        Task {
            print("actorTest1")
            let objectA = MyActor(title: "Starting title!")
            await print("ObjectA: ", objectA.title) // await required: crossing into the actor's isolation domain

            print("Pass the REFERENCE of objectA to objectB.")
            let objectB = objectA // still reference semantics: objectB is another reference to the same actor instance
            await print("ObjectB: ", objectB.title) // await required for the same reason

            await objectB.updateTitle(newTitle: "Second title!") // serialized: actor ensures no concurrent mutation
            print("ObjectB title changed.")
            
            await print("ObjectA: ", objectA.title) // "Second title!" — actors share identity like classes, but access is serialized
            await print("ObjectB: ", objectB.title)
        }
    }
    
}


struct MyStruct {
    var title: String // mutable stored property; struct mutation requires marking the enclosing binding as var
}

// Immutable struct
struct CustomStruct { // all let properties: this struct cannot be mutated after initialization
    let title: String
    
    func updateTitle(newTitle: String) -> CustomStruct {
        CustomStruct(title: newTitle) // returns a new instance rather than mutating self — functional update pattern
    }
}

struct MutatingStruct {
    private(set) var title: String // external callers can read but not write; mutation only via the mutating func
    
    init(title: String) {
        self.title = title
    }
    
    mutating func updateTitle(newTitle: String) { // mutating keyword required: struct mutation replaces self with a new copy
        title = newTitle
    }
}

extension StructClassActorBootcamp {
    
    private func structTest2() {
        print("structTest2")
        
        var struct1 = MyStruct(title: "Title1")
        print("Struct1: ", struct1.title)
        struct1.title = "Title2" // direct mutation: var binding allows in-place modification of the copy
        print("Struct1: ", struct1.title)
        
        var struct2 = CustomStruct(title: "Title1")
        print("Struct2: ", struct2.title)
        struct2 = CustomStruct(title: "Title2") // reassignment: replaces the old instance with a new one
        print("Struct2: ", struct2.title)
        
        var struct3 = CustomStruct(title: "Title1")
        print("Struct3: ", struct3.title)
        struct3 = struct3.updateTitle(newTitle: "Title2") // functional update: updateTitle returns a new struct
        print("Struct3: ", struct3.title)
        
        var struct4 = MutatingStruct(title: "Title1")
        print("Struct4: ", struct4.title)
        struct4.updateTitle(newTitle: "Title2") // mutating func: under the hood, Swift replaces struct4 with a modified copy
        print("Struct4: ", struct4.title)
    }
    
}

class MyClass {
    var title: String
    
    init(title: String) {
        self.title = title
    }
    
    func updateTitle(newTitle: String) { // no mutating keyword needed: classes are reference types, mutation is always allowed
        title = newTitle
    }
}

actor MyActor {
    var title: String // actor-isolated: compiler prevents access without await from outside the actor
    
    init(title: String) {
        self.title = title
    }
    
    func updateTitle(newTitle: String) { // runs serialized: only one caller can execute this at a time
        title = newTitle
    }
}

extension StructClassActorBootcamp {
    
    private func classTest2() {
        print("classTest2")

        let class1 = MyClass(title: "Title1")
        print("Class1: ", class1.title)
        class1.title = "Title2" // let binding prevents reassigning class1, but NOT mutating the heap object it points to
        print("Class1: ", class1.title)

        let class2 = MyClass(title: "Title1")
        print("Class2: ", class2.title)
        class2.updateTitle(newTitle: "Title2") // equivalent to direct property mutation; both routes modify the heap object
        print("Class2: ", class2.title)

        
    }
    
    
}

Code Walkthrough

  1. structTest1 — value semantics in actionvar objectB = objectA creates a complete, independent copy of MyStruct. When you print objectA.title after mutating objectB, it still shows "Starting title!" — the two variables are completely independent. This independence is what makes structs thread-safe: there is nothing to race over because each context has its own copy.

  2. classTest1 — reference semantics in actionlet objectB = objectA copies the pointer, not the data. Both objectA and objectB point to the same MyClass instance on the heap. After objectB.title = "Second title!", both objectA.title and objectB.title print "Second title!" — they refer to the same memory. This is why passing a class instance into a concurrent context is dangerous: any mutation by one thread is immediately visible to all other threads holding that reference.

  3. actorTest1 — reference + serialization — Like classTest1, let objectB = objectA shares the same actor instance. Both references see the same title. But unlike a class, every property access and method call requires await. The compiler enforces this — you cannot accidentally race on the actor's state because direct access without await is a compile error, not a runtime risk.

  4. CustomStruct.updateTitle — the functional update pattern — Instead of mutating in place, it returns a new CustomStruct. This is the pattern for truly immutable structs: you never change the existing instance, you replace it. It is verbose but makes state changes obvious at every callsite.

  5. MutatingStruct and the mutating keywordprivate(set) prevents external mutation while allowing internal mutation via the mutating func. Under the hood, a mutating function receives a mutable copy of self, performs changes, and replaces self with the modified copy. This is why you need a var binding to call a mutating function — a let binding cannot be replaced.

  6. class1.title = "Title2" with a let binding — This surprises many Swift beginners. let class1 means you cannot reassign class1 to a different MyClass instance. But it does not prevent you from mutating the heap object that class1 points to. let for classes is "let the pointer be constant," not "let the object be immutable." This is the opposite of struct behavior where let prevents any mutation.

Common Mistakes

Mistake: Passing a class instance to multiple concurrent tasks without synchronization Because classes share heap memory, two tasks holding the same class reference can simultaneously read and write its properties. In Swift 5.10+ with strict concurrency checking enabled, this is a compile error. In older codebases, it compiles fine but produces a data race that manifests as corrupted state or intermittent crashes under load. Use actors for shared mutable state, or use value types that are copied into each task.

Mistake: Using actors for purely UI-facing view models Actors serialize access, which means every property read from the SwiftUI view requires await. SwiftUI's observation system (via @Published or @Observable) needs to read properties synchronously on the main thread. Putting a view model in an actor breaks this. The correct choice is @MainActor class — all access happens on the main thread synchronously, which is fine because SwiftUI only reads on the main thread.

Mistake: Assuming let on a class property makes it immutablelet someClass = MyClass(title: "foo") means someClass will always point to that same instance — you can't do someClass = MyClass(title: "bar"). But someClass.title = "bar" is completely valid. The let is on the reference, not the object's contents. If you want an immutable class object, mark all its stored properties as let.

Key Takeaways

  • Structs are stack-allocated, copied on assignment, and inherently thread-safe because each copy is independent — they are the default choice for data models in Swift
  • Classes are heap-allocated, share identity on assignment, and are not thread-safe by default — two threads with the same class reference can race to mutate it with no compiler protection
  • Actors are reference types with compiler-enforced serialized access — they have the sharing semantics of a class but require await for all cross-actor property access, making data races a compile error rather than a runtime bug

Last updated: June 27, 2026

Released under the MIT License.