Post

Understanding Noncopyable Types in Swift

Understanding Noncopyable Types in Swift

At WWDC24, Apple introduced a groundbreaking feature for Swift developers: noncopyable types. This enhancement expands Swift’s value ownership system, allowing developers to express intent more clearly and write safer, more predictable code. Let’s explore what noncopyable types are, why they’re useful, and how you can leverage them in your projects.

Copying in Swift: A Quick Overview

In Swift, values are copyable by default. This means that when you assign a value to a new variable or pass it to a function, Swift automatically creates a copy of that value. For example:

1
2
3
4
5
6
7
8
9
10
struct Player {
    var icon: String
}

func test() {
    let player1 = Player(icon: "🐸")
    var player2 = player1
    player2.icon = "🚚"
    assert(player1.icon == "🐸") // ✅ player1 is unaffected by changes to player2
}

Here, player2 is a copy of player1, so modifying player2 doesn’t affect player1.

In contrast, reference types behave differently:

1
2
3
4
5
6
7
8
9
10
11
class PlayerClass {
    var icon: String
    init(_ icon: String) { self.icon = icon }
}

func test() {
    let player1 = PlayerClass("🐸")
    let player2 = player1
    player2.icon = "🚚"
    assert(player1.icon == "🐸") // ❌ Assertion fails: both references point to the same object
}

Here, player1 and player2 point to the same object in memory, so changes to one affect the other.

What Are Noncopyable Types?

While copyable types work well for most scenarios, there are situations where copying values can lead to bugs or unexpected behavior. Noncopyable types solve certain problems that reference types cannot solve easily or effectively. While both noncopyable types and reference types involve constraints on how values are used, noncopyable types provide value semantics with strict ownership rules, offering benefits that reference types alone cannot achieve. Let’s explore the key advantages of noncopyable types over reference types.

Improved Program Correctness

With reference types, shared ownership can lead to aliasing issues and unexpected mutations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Task {
    var isComplete = false
    func run() {
        assert(!isComplete)
        isComplete = true
    }
}

func example() {
    let task1 = Task()
    let task2 = task1 // task2 is a reference to the same object
    task2.run()
    task1.run() // ❌ Assertion fails because task1 and task2 are the same object
}

To prevent such issues, developers often resort to runtime checks, locks, or throwing errors—each with its own trade-offs. Noncopyable types eliminate these risks by enforcing ownership rules at compile time:

1
2
3
4
5
6
7
8
9
10
11
struct Task: ~Copyable {
    consuming func run() {
        // Perform task
    }
}

func example() {
    var task = Task()
    task.run() // ✅ Task cannot be reused after this
    task.run() // ❌ Compile-time error: task has already been consumed
}

This guarantees that a Task is used only once, ensuring correctness without runtime checks or manual assertions.

Value Semantics with Ownership

Noncopyable types retain value semantics, meaning each instance is a distinct, independent value. Reference types, on the other hand, rely on reference semantics, where multiple variables can point to the same object in memory.

Value semantics are particularly useful in concurrent or functional programming, where immutable data structures and unique ownership are key. Noncopyable types allow you to maintain value semantics while explicitly managing ownership and preventing copies.

Example: Modeling a bank transfer:

  • With a reference type, you risk accidentally sharing the same transfer object across different parts of the program.
  • With a noncopyable type, you can ensure that a transfer is moved (not shared) and cannot be reused after it’s processed.
1
2
3
4
5
6
7
8
9
struct BankTransfer: ~Copyable {
    consuming func process() {
        // Perform transfer
    }
}

func handleTransfer(transfer: consuming BankTransfer) {
    transfer.process() // Transfer is consumed here
}

Here, BankTransfer cannot be reused after being processed, ensuring each transfer is handled exactly once.

Safer Resource Management

Noncopyable types make resource management safer and more predictable. Reference types rely on reference counting for memory management, but deinitialization only occurs when all references to an object are destroyed. This can lead to dangling references or delays in cleanup if references are unintentionally retained.

Noncopyable types, however, guarantee that a value is consumed and destroyed when it goes out of scope or is explicitly discarded. This ensures timely cleanup and prevents multiple references from delaying deinitialization.

Example: Closing a file or canceling a task:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct FileHandle: ~Copyable {
    deinit {
        close() // Ensure the file is closed when the handle is destroyed
    }

    consuming func read() -> Data {
        // Read data from the file
    }
}

func processFile(handle: consuming FileHandle) -> Data {
    return handle.read() // Handle is consumed here
}

Here, the FileHandle is destroyed immediately after use, ensuring no dangling references remain.

You can manually call discard self within a consuming function. However, note that doing so comes with a caveat: the deinit method will not be invoked in this case.

Borrowing and Temporary Access

Noncopyable types offer fine-grained control over how values are passed to functions:

  • Borrowing: Read-only access (like a let binding).
  • Consuming: Full ownership transfer, preventing further use by the caller.
  • Inout: Temporary write access, requiring reinitialization before returning.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct FloppyDisk: ~Copyable {}

func loadDisk(disk: borrowing FloppyDisk) {
    // Read-only access
}

func consumeDisk(disk: consuming FloppyDisk) {
    // Use and destroy the disk
}

func formatDisk(disk: inout FloppyDisk) {
    var tempDisk = disk
    // Modify tempDisk
    disk = tempDisk // Reinitialize before returning
}

Noncopyable Generics

Noncopyable types also extend to generics. Previously, all generic types in Swift were implicitly constraint to Copyable.

1
2
3
func execute<T: Runnable>(_ t: T) {
    t.run()
}

By default, T is constrained to Copyable. If you want to allow noncopyable types, you can explicitly relax this constraint using ~Copyable:

1
2
3
4
5
6
7
protocol Runnable: ~Copyable {
    consuming func run()
}

func execute<T: Runnable & ~Copyable>(_ t: consuming T) {
    t.run()
}

Simply removing the Copyable constraint from Runnable is not enough. The generic parameter T still has an implicit Copyable constraint, meaning T is constrained to both Runnable and Copyable. To fully support noncopyable types, you must explicitly remove the Copyable constraint from T.

With this change, the execute function can now work with both copyable and noncopyable types.

Think of ~Copyable as meaning: “It might be Copyable, but it also might not.”

Conditionally Copyable Types

Sometimes, you might want a type to be copyable only if its generic parameter is copyable. For example:

1
2
3
4
5
6
struct Job<Action: Runnable & ~Copyable>: ~Copyable {
    var action: Action?
}

// Allow Job to be copyable if Action is copyable
extension Job: Copyable where Action: Copyable {}

In this case:

  • A Job containing a noncopyable type is noncopyable.
  • A Job containing a copyable type is copyable.

Extensions and Noncopyable Types

When extending types with noncopyable generic parameters, extensions default to requiring Copyable constraints. For example:

1
2
3
4
5
extension Job {
    consuming func getAction() -> Action? {
        return action
    }
}

This extension works only if Action is copyable. To support noncopyable types, remove the Copyable constraint:

1
2
3
4
5
extension Job where Action: ~Copyable {
    consuming func getAction() -> Action? {
        return action
    }
}

Summary: What Noncopyable Types Solve That Reference Types Cannot

FeatureReference TypesNoncopyable Types
Prevent accidental reuseRequires manual checksEnforced at compile time
Value semanticsNot supportedFully supported
Timely resource cleanupDepends on reference countingGuaranteed via ownership
Explicit ownershipNot possibleBorrowing, consuming, inout
Generic constraintsLimitedSupports noncopyable generics

Noncopyable types introduce a new dimension of ownership and correctness to Swift. They solve problems that reference types cannot handle effectively, especially in scenarios involving value semantics, resource management, and ownership enforcement. By leveraging noncopyable types, you can write safer, more efficient, and more predictable code.


For more details, check out the WWDC24 session on noncopyable types.

This post is licensed under CC BY 4.0 by the author.