Cleanly check if an Index is valid with Swift

This blog post goes into detail for how to design an efficient and reusable valid index checker for all Collections. We will use Protocol Extensions, OOP (Object Oriented Programming), Generics and extend our solution for good API design.

Introduction

Checking if an index is valid is an extremely common problem, and thus I wanted to talk about how to solve this problem in a clean, efficient way rather than array.indices.contains(index).

We first need to define a function. This function can then be used in multiple places within a codebase without repeating the same logic (code smell when repeating common operations). I considered this approach (efficiency is an important part of good API design, and should be preferred where possible as long as readability is not too badly affected).

In addition to enforcing good Object-Oriented design with a method on the type itself, I think Protocol Extensions are great and we can make the existing answers even more Swifty. Limiting the extension is great because you don’t create code you don’t use. This is the idea behind the YAGNI principle. Making the code cleaner and extensible makes maintenance easier, but there are trade-offs (more lines of code)

So, you can note that if you'd ONLY like to use the extension idea for reusability but prefer the contains method referenced above you can rework the code with different type constraints. I have tried to make the code flexible for different uses.

The top answer of StackOverflow

Here it is:

let contains = array.indices.contains(index)

This is simple, right? Copy, paste, done. However, it's not ideal for production code for multiple reasons.

Efficiency

@Manuel's answer is indeed very elegant but it uses an additional layer of indirection.

As we can see from the Swift Standard library here, the indices property acts like a CountableRange<Int> under the hood created from the startIndex and endIndex without reason for this problem. So we have used an additional wrapper type for now reason which causes marginally higher Space Complexity, especially if the String is long.

@frozen
public struct DefaultIndices<Elements: Collection> {
	@usableFromInline
	internal var _elements: Elements
	@usableFromInline
	internal var _startIndex: Elements.Index
	@usableFromInline
	internal var _endIndex: Elements.Index
...
}

That being said, the Time Complexity should be around the same as a direct comparison between the endIndex and startIndex properties because N = 2 even though contains(_:) is O(N) for Collections. Ranges only have two properties for the start and end indices.

For the best Space and Time Complexity, we should manually compare the startIndex and endIndex.

let contains = array.endIndex > index && array.startIndex <= index

For more extensibility and only marginally longer code, we can wrap the function into a Protocol Extension for Collection:

extension Collection {
  func isIndexValid(index: Index) -> Bool {
    return self.endIndex > index && self.startIndex <= index
  }
}

Example usage:

let check = digits.isIndexValid(index: index)

Note here how I've used startIndex instead of 0 - this is to support ArraySlices and other SubSequence types. This is an important distinction because ArraySlices do not necessarily start from 0. ArraySlices are created when referencing a subsection of the array using a subscript range operator.

let subArray = array[1...4]

Here, the startIndex of subArray would be 1 and NOT 0.

Limit Method Scope

For Collections in general, it's pretty hard to create an invalid Index by design in Swift because Apple has restricted the initializers for associatedtype Index on Collection - ones can only be created from an existing valid Collection.Index (like startIndex).

For Array, Index is Int which makes this easy to write and read.

The above code works across all Collection types (extensibility), but you can restrict this to Arrays only if you want to limit the scope for your particular app. This is because we may not need to query an index for a String in our app, for example.

So you may want to limit the method to fewer Collections by extending Array instead.

extension Array {
  func isIndexValid(index: Index) -> Bool {
    return self.endIndex > index && self.startIndex <= index
  }
}

For Arrays, you don't need to use an Index type explicitly:

let check = [1,2,3].isIndexValid(index: 2)

Generic Protocol Extension Constraints

Feel free to adapt the code here for your own use cases, there are many types of other Collections e.g. LazyCollections. You can also use generic constraints, for example:

extension Collection where Element: Numeric {
  func isIndexValid(index: Index) -> Bool {
    return self.endIndex > index && self.startIndex <= index
  }
}

This limits the scope to Numeric Collections. We can use String as well because under the hood it's also a Collection. It's better to limit the function to what you specifically use to avoid code creep. Notice how we call the function on the Object with a cleaner syntax than before.

Referencing the method across different modules

The compiler already applies multiple optimizations to prevent generics from being a problem in general, but these don't apply when the code is being called from a separate module. For cases like that, using @inlinable can give you interesting performance boosts at the cost of an increased framework binary size. If you're really into improving performance and want to encapsulate the function in a separate Xcode target for good Separation of Concerns, we can define the function in a new module:

extension Collection where Element: Numeric {
  // Add this signature to the public header of the extensions module as well.
  @inlinable public func isIndexValid(index: Index) -> Bool {
    return self.endIndex > index && self.startIndex <= index
  }
}

I can recommend trying out a modular codebase structure rather than a monolith. It helps to ensure Single Responsibility (and SOLID) in projects for common operations. We can try following the steps here and that is where we can use this optimisation. Swift uses this because it's important to be as performant as possible. In our codebase, if we don't have separate modules then we should only use this annotation sparingly.

It's OK to use the attribute for this function because the compiler operation only adds one extra line of code per call site. In a new module, this makes a lot of sense. We can improve performance further since a method is not added to the call stack, saving memory at runtime and compilation. Inlinable methods are useful for bleeding-edge speed with a modular project. Don't overuse them in your main target to ensure that the binary size only marginally increases.

We can implement a new module either as a target or an XCFramework. XCFrameworks are new but work well for pure Swift projects. Be careful with the ObjC runtime compatibility for < iOS 13 though.

You're now ready to write safe index checks! 👏

You should find that the same principles can improve your code in other parts of a Swift project as well.

Resources

  1. DefaultIndices in Swift stdlib
  2. Modular codebase
  3. My related SO post

TIP: Check out the Swift Open Source GitHub repository for the source code. You may even be able to contribute.