Skip to Content
Course content

178: Custom Collection Conformance

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this happen a dozen times: a developer wants their custom data structure to feel like a first-class citizen in Swift, so they slap Collection conformance on it. They want to be able to use .map, .filter, and for-in loops without writing a manual wrapper. But then, the app crashes the moment they actually try to iterate over it.

Take a look at this PagedResults wrapper. It's intended to wrap an array of items coming back from an API, but it's crashing with an "Index out of range" error during a simple loop.

struct PagedResults<Element>: Collection {
    let items: [Element]
    
    typealias Index = Int
    
    var startIndex: Index {
        return 0
    }
    
    var endIndex: Index {
        // The developer thinks endIndex should be the last valid index
        return items.count - 1
    }
    
    subscript(position: Index) -> Element {
        return items[position]
    }
}

let results = PagedResults(items: ["Apple", "Banana", "Cherry"])
for fruit in results { 
    print(fruit) 
}
// CRASH: Index out of range

The Off-By-One Boundary Error

The crash happens because of a fundamental misunderstanding of how Swift defines the endIndex. In a Swift Collection, the endIndex is not the index of the last element. It is the "past-the-end" position.

When you write for fruit in results, Swift starts at startIndex and continues until it reaches endIndex. However, the loop logic internally checks if the current index is less than the endIndex. By setting endIndex to items.count - 1, you've effectively told Swift that the collection ends one element earlier than it actually does. In many cases, this just means you miss the last element. But if your collection is empty, items.count - 1 becomes -1, and the whole thing falls apart instantly.

Aligning the Index Boundary

To fix this, we need to treat the endIndex as a sentinel value. It represents the boundary where the collection stops, not a piece of data you can actually access via the subscript. I usually tell people to think of it as a "Stop" sign at the end of the road.

struct PagedResults<Element>: Collection {
    let items: [Element]
    
    typealias Index = Int
    
    var startIndex: Index {
        return 0
    }
    
    var endIndex: Index {
        // Correct: the count is exactly one past the last valid index
        return items.count
    }
    
    subscript(position: Index) -> Element {
        return items[position]
    }
}

Now, the loop works perfectly. When items.count is 3, the valid indices are 0, 1, and 2. The loop runs while index < 3, so it hits 0, 1, and 2, then stops exactly when it hits 3. No crash, no missing elements.

Connecting the Subscript to the Index

Once you've nailed the boundaries, the subscript is where the actual work happens. Because we used Int as our Index, we can just pass that integer directly into the underlying array.

But here is a pro tip: if you are wrapping a more complex structure (like a Linked List or a custom Tree), don't use Int. Create a dedicated struct for your index. This prevents users of your collection from passing in random integers that might not make sense for your specific data layout. If you use a custom index type, you'll also need to conform that index to Comparable so the collection knows how to move from startIndex to endIndex.

The beauty of conforming to Collection is that you get a massive amount of functionality for free. By defining just those four things—startIndex, endIndex, index(after:) (which Int provides automatically), and the subscript—you suddenly have access to first, last, contains, isEmpty, and every single sequence operation in the Swift Standard Library.




📋 Practical Task

Implement a Restricted-Access Buffer Collection

You are building a system that manages a raw buffer of data, but for security reasons, only a specific "window" of that data should be accessible to the rest of the application. You need to create a SecureWindow collection that wraps a larger array but only allows iteration and access within a specified range.

Requirements:

  • Create a struct SecureWindow<Element> that conforms to Collection.
  • The struct should take an internal array allData: [Element] and a range: Range<Int>.
  • The startIndex must be the lower bound of the range.
  • The endIndex must be the upper bound of the range.
  • The subscript must return the element from allData at the given position.
  • Ensure that the collection only iterates over the elements within the specified range, ignoring everything else in the allData array.

Test Case:

let data = [10, 20, 30, 40, 50, 60]
let window = SecureWindow(allData: data, range: 1...3) // Elements 20, 30, 40
print(window.count) // Should be 3
for item in window {
    print(item) // Should print 20, 30, 40
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.