Skip to content
cyberbarker.xyz

Swift pointers in Swift 6: Span by default, unsafe at the edges

Written Updated iOS / macOS Development

At work I wrote a custom network transport for an iOS app and sent custom binary data over WebRTC. That kind of task — packing and parsing bytes, handing buffers to C libraries, keeping a buffer alive longer than one function — is where Swift pointers stop being theory. If you're building something like that, this is what you need to know about them.

Use Span and scoped access by default for any memory work inside Swift code. Drop down to Unsafe*Pointer only at the boundary with C or Objective-C APIs, or when you manage allocation yourself. This is the standard recommendation, and it is the whole point of the approach: the compiler checks that a view into memory never outlives the memory.

Every example below was compiled and run with Swift 6.4.2 in Swift 6 language mode.

The thirteen types

Eight of them are the unsafe family, in immutable and mutable pairs:

Immutable Mutable
UnsafePointer<T> UnsafeMutablePointer<T>
UnsafeBufferPointer<T> UnsafeMutableBufferPointer<T>
UnsafeRawPointer UnsafeMutableRawPointer
UnsafeRawBufferPointer UnsafeMutableRawBufferPointer

Typed pointers know the type they point to. Raw pointers don't: their Pointee is UInt8, a byte. Buffer pointers add a collection interface over a contiguous block.

The other five exist for interoperability and special storage: AutoreleasingUnsafeMutablePointer and CVaListPointer for Objective-C and C calling conventions, OpaquePointer for C types Swift can't see into, Unmanaged for object references with manual retain and release, and ManagedBufferPointer for building buffer-backed storage.

The default: scoped access and Span

Most of the time you don't need to allocate anything. Ask the collection for its memory for the duration of a closure:

var numbers = [1, 2, 3]
let sum = numbers.withUnsafeBufferPointer { $0.reduce(0, +) }   // 6
numbers.withUnsafeMutableBufferPointer { $0[0] = 10 }           // [10, 2, 3]

The pointer is valid only inside the closure. Nothing stops you from smuggling it out, though — the "unsafe" part is still on you.

Span (Swift 6.2) is the same view without that hole. It's a safe, non-owning window into contiguous memory, and the compiler won't let it escape:

let values = [3, 1, 4, 1, 5]
let span = values.span
var total = 0
for i in span.indices { total += span[i] }   // 14
func leak(_ a: [Int]) -> Span<Int> {
    return a.span
}
// error: a function cannot return a ~Escapable result

Returning a span tied to an argument is what lifetime annotations are for. In Swift 6.4.2 they are still experimental: the attribute is spelled @_lifetime and needs -enable-experimental-feature Lifetimes.

@_lifetime(borrow array)
func prefixSpan(_ array: borrowing [Int], _ n: Int) -> Span<Int> {
    array.span.extracting(0..<n)
}

When you do need a scratch buffer, withUnsafeTemporaryAllocation gives one for the closure and frees it after — no allocate/deallocate pair to forget:

let squares = withUnsafeTemporaryAllocation(of: Int.self, capacity: 4) { buf -> Int in
    for i in 0..<4 { buf.initializeElement(at: i, to: i * i) }
    defer { _ = buf.deinitialize() }
    return buf.reduce(0, +)   // 14
}

Making unsafe code visible

Swift 6.2 adds -strict-memory-safety. With it, every use of an unsafe construct is a warning until you mark it with unsafe. It is precise about where the danger is:

func firstByte(_ data: [UInt8]) -> UInt8 {
    data.withUnsafeBufferPointer { buf in buf[0] }
    // warning at `buf[0]`: expression uses unsafe constructs
    // but is not marked with 'unsafe'
}

The warning points at the subscript of the UnsafeBufferPointer, not at withUnsafeBufferPointer. Put unsafe on the outer call and the compiler says there are no unsafe operations there.

At the C boundary

C APIs speak pointers, so this is where Unsafe*Pointer, OpaquePointer and Unmanaged belong.

Binary data arrives as raw bytes, often without alignment guarantees. loadUnaligned reads a value from any offset:

let bytes: [UInt8] = [0xAA, 0x01, 0x00, 0x00, 0x00]
let value = bytes.withUnsafeBytes {
    $0.loadUnaligned(fromByteOffset: 1, as: UInt32.self)   // 1
}

C types that Swift can't see into come back as OpaquePointer. You never read through it; you only hand it back to the API that created it:

import Accelerate

if let setup = vDSP_create_fftsetupD(4, FFTRadix(kFFTRadix2)) {   // OpaquePointer
    // … use setup with vDSP FFT functions
    vDSP_destroy_fftsetupD(setup)
}

Unmanaged carries a Swift object through a void * context and back, with the retain under your control:

let context = Unmanaged.passRetained(object).toOpaque()      // +1, now a raw pointer
// … the C side stores `context` and later gives it back
let restored = Unmanaged<MyObject>.fromOpaque(context).takeRetainedValue()   // -1

When a type owns its memory

The other legitimate case is a data structure that owns its storage. I looked at how the standard collections are built and wrote my own structures that Swift doesn't provide — my own Deque among them — on Unsafe*Pointer. The pointer to the heap buffer has to live longer than any single scope. Span is ~Escapable and doesn't own memory, so it can't be that storage: the structure itself must own the memory.

A minimal ring buffer shows the shape — allocate once, initialize and move elements yourself, free everything in deinit:

final class RingBuffer<Element> {
    private let storage: UnsafeMutablePointer<Element>
    private let capacity: Int
    private var head = 0
    private(set) var count = 0

    init(capacity: Int) {
        precondition(capacity > 0)
        self.capacity = capacity
        storage = .allocate(capacity: capacity)
    }

    deinit {
        for i in 0..<count { (storage + (head + i) % capacity).deinitialize(count: 1) }
        storage.deallocate()
    }

    func append(_ element: Element) {
        precondition(count < capacity, "full")
        (storage + (head + count) % capacity).initialize(to: element)
        count += 1
    }

    func removeFirst() -> Element {
        precondition(count > 0, "empty")
        let element = (storage + head).move()
        head = (head + 1) % capacity
        count -= 1
        return element
    }
}

Every element is initialized exactly once and moved out or deinitialized exactly once. A run under Address Sanitizer with class elements, including a wrap-around, reports no errors, and every element's deinit fires once.

The pointers stay private inside the type; callers only see append, removeFirst and count.