This blog post goes into detail for how to design an efficient and reusable valid index checker for all Collection
s. We will use Protocol Extensions, OOP (Object Oriented Programming), Generics and extend our solution for good API design.
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.
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.
@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 Collection
s. Range
s 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 ArraySlice
s and other SubSequence
types. This is an important distinction because ArraySlices
do not necessarily start from 0. ArraySlice
s 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.
For Collection
s 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 Array
s 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 Collection
s by extending Array
instead.
extension Array {
func isIndexValid(index: Index) -> Bool {
return self.endIndex > index && self.startIndex <= index
}
}
For Array
s, you don't need to use an Index
type explicitly:
let check = [1,2,3].isIndexValid(index: 2)
Feel free to adapt the code here for your own use cases, there are many types of other Collection
s e.g. LazyCollection
s. 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
Collection
s. 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.
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 should find that the same principles can improve your code in other parts of a Swift project as well.
TIP: Check out the Swift Open Source GitHub repository for the source code. You may even be able to contribute.