Crafting Custom Swift Collections: Protocols, Performance, and Real-World Patterns

Why Build Your Own Collection?

Swift’s Array, Set, and Dictionary are workhorses. They’re fast, well-tested, and handle the vast majority of data storage tasks you’ll encounter. But sometimes, a general-purpose container doesn’t quite fit. Maybe you need a fixed-size window that automatically discards old data, or a structure that enforces strict ordering rules at the type level. That’s when rolling a custom collection starts to make sense—not as a replacement for the standard library, but as a precise tool for a specific job.

In this walkthrough, we’ll build a RingBuffer that conforms to Swift’s Collection protocol. It’s a fixed-capacity circular buffer: when it fills up, new elements overwrite the oldest ones. We’ll cover the protocol hierarchy, index design, copy-on-write semantics, and the tradeoffs you need to weigh before committing to a custom type. By the end, you’ll have a repeatable pattern you can adapt to your own domain-specific containers.

Close-up of Swift code on a MacBook screen showing protocol conformance

Navigating the Collection Protocol Hierarchy

Before you write a single line of your own collection, it’s worth understanding the ladder of protocols Swift provides. At the bottom is Sequence—the ability to iterate over elements one at a time. Step up to Collection, and you get subscript access, index manipulation, and a guarantee that you can traverse the elements multiple times without consuming them. BidirectionalCollection lets you move an index backward, and RandomAccessCollection promises O(1) index advancement. At the top, MutableCollection and RangeReplaceableCollection add mutation and insertion/deletion powers.

For our ring buffer, we’ll stick to Collection and MutableCollection. We won’t touch RangeReplaceableCollection because a fixed-size buffer can’t support arbitrary insertions and deletions without changing its capacity—and pretending it can would just mislead callers. A good rule of thumb: only promise the capabilities your data structure can deliver efficiently. Over-conforming is a recipe for surprising performance cliffs and broken invariants.

Associated Types and the Indexing Model

Every collection needs an Index type that’s Comparable. The index is a lightweight position marker—usually a struct wrapping an integer offset. For a ring buffer, the index has to account for wrap-around, but we can keep things simple by storing just the logical offset and letting the collection resolve it against its internal state. We’ll define RingBufferIndex as a struct with an integer offset. The collection provides startIndex and endIndex, and the subscript maps the logical offset to the physical storage slot using modular arithmetic. Keeping the index small and copyable matters for iteration speed.

Designing the RingBuffer Storage

A ring buffer needs a fixed-size array and two pointers: a head (next write position) and a tail (next read position). When the buffer is full, writing advances both pointers, effectively dropping the oldest element. This gives you O(1) append and O(1) indexed access—ideal for real-time scenarios where you can’t afford allocations.

We’ll use a struct with a reference-counted storage class to maintain value semantics. This is the same trick Swift’s own collections use: the struct is a thin wrapper around a class instance, and mutation triggers a copy-on-write check. Callers get the safety and predictability of value types without the overhead of copying large buffers on every assignment.

Swift code displayed on a monitor with a dark background

Implementing Copy-on-Write

The storage class is a plain reference type holding the buffer array, capacity, head, and tail indices. The wrapper struct checks isKnownUniquelyReferenced(_:) before mutating. If the storage is shared, the wrapper creates a fresh copy of the storage and then applies the mutation. Two ring buffer values can share storage until one of them needs to change—at that point they diverge. This pattern is well documented in the Swift standard library and is essential for predictable performance.

Conforming to Collection: The Minimum Requirements

To satisfy Collection, our ring buffer must provide:

  • startIndex and endIndex properties
  • A subscript that returns elements for a given index
  • An index(after:) method that advances an index

For a ring buffer, startIndex returns an index with offset 0, and endIndex returns an index with offset equal to the count of stored elements. The subscript uses modular arithmetic to map the logical offset to the physical storage index. index(after:) simply increments the offset. With just these three requirements fulfilled, your type automatically gains dozens of free methods: map, filter, reduce, first(where:), and many more.

Conforming to MutableCollection

Adding MutableCollection conformance requires one extra piece: a subscript setter. This lets callers modify elements in place. But there’s a catch—if the ring buffer uses copy-on-write, the setter must ensure the storage is uniquely referenced before mutating. Otherwise, a modification could unexpectedly affect other copies. This is a common source of bugs in custom value-type collections and deserves careful unit testing.

Index Validation and Safety

Swift’s collection model assumes that indices are valid only for the collection that produced them. Using an index from one ring buffer instance on another is a programmer error. In debug builds, we can add assertions to verify that the index belongs to the current storage. In release builds, these checks are stripped for performance. This mirrors the behavior of Array and other standard library types.

We also need to handle the empty case. startIndex and endIndex should be equal, and any attempt to access an element should trigger a precondition failure. This is consistent with how Swift’s built-in collections behave and prevents undefined behavior in production.

Performance Considerations and Tradeoffs

A ring buffer offers O(1) access and append, but it’s not a general-purpose replacement for Array. The fixed capacity means callers must decide on a maximum size upfront, and elements beyond that capacity are silently dropped. This is perfect for logging the last N events or maintaining a sliding window of sensor readings, but it would be surprising in a general-purpose list. Document these semantics clearly so that users of your collection understand the tradeoffs.

Another consideration is the overhead of copy-on-write. Each mutation checks whether the storage is uniquely referenced, which adds a small but measurable cost. If your use case involves frequent single-owner mutations, you might consider a reference-type collection instead. For most application-level code, though, the safety of value semantics outweighs the performance cost.

Swift code on a laptop screen with a blurred background

Integrating with Swift Algorithms

Once your type conforms to Collection, it automatically works with the open-source Swift Algorithms package from Apple. You can chain operations like chunked(by:), striding(by:), or randomSample(count:) directly on your ring buffer. This is a powerful reason to invest in proper protocol conformance: your custom data structure immediately gains access to a growing library of high-performance, well-tested algorithms.

Testing Your Custom Collection

Swift’s standard library includes a checkCollection method in the SwiftCheck testing framework that can verify many semantic requirements automatically. While we won’t reproduce the full test suite here, you should at minimum test:

  • Empty collection behavior (startIndex == endIndex)
  • Single-element insertion and retrieval
  • Wraparound when the buffer is full
  • Copy-on-write isolation between copies
  • Index validation in debug builds

These tests will catch the most common mistakes in custom collection implementations and give you confidence that your type behaves correctly under the standard library’s expectations.

Practical Example: A History Tracker

Let’s ground this in a concrete example. Suppose you’re building a drawing app and want to keep the last 50 user actions for an undo feature. A ring buffer is a natural fit: you can append each action, and when the buffer is full, the oldest action is automatically discarded. Because the buffer conforms to Collection, you can iterate over the actions in order, map them to UI elements, or filter by action type—all using standard Swift APIs.

This is where the value semantics shine. You can pass the history buffer between view models, save a snapshot for later comparison, or duplicate it to create a branched undo tree—all without worrying about unintended sharing. The copy-on-write optimization ensures that these operations are efficient until a mutation actually occurs.

When to Use a Custom Collection vs. Standard Library Types

Not every problem requires a custom collection. Before writing one, ask whether you can achieve your goals by wrapping an existing collection in a struct with a well-designed API. For example, a fixed-size history can be built by wrapping an Array and capping its count. However, if you need O(1) prepend, O(1) drop-oldest, or specialized indexing semantics, a custom collection may be justified.

Another factor is domain clarity. A type called UndoHistory that conforms to Collection communicates intent more clearly than an Array with a comment. It can expose domain-specific methods like undoLatest() or groupActions(in:) while still participating in the full Swift collection ecosystem. This is the sweet spot for custom collections: when the type itself becomes part of your domain language.

FAQ

Do I need to implement every Collection method?

No. The Swift standard library provides default implementations for most methods based on the minimal requirements: startIndex, endIndex, index(after:), and subscript. You only need to override methods where you can provide a more efficient implementation, such as count or first.

How does copy-on-write affect performance?

Copy-on-write adds a small overhead to each mutation because the storage must be checked for unique ownership. For most application-level code, this overhead is negligible. If you are writing performance-critical code, you can measure the impact and consider a reference-type collection or manual uniqueness management.

Can I make my custom collection conform to BidirectionalCollection?

Yes, if your data structure supports efficient backward traversal. For a ring buffer, this requires implementing index(before:) to decrement the offset. However, be aware that BidirectionalCollection also implies certain semantic guarantees—for example, that reversing a collection and then reversing it again yields the original order. Make sure your implementation satisfies these.

What is the best way to handle index invalidation?

Swift’s collection model does not guarantee index validity after mutation. If your collection supports mutation, document which operations invalidate existing indices. For a ring buffer, appending an element when the buffer is full shifts the logical start, invalidating all existing indices. Callers should not hold indices across mutations that may change the buffer’s state.