Hands on Generics in Swift
05 Feb 2019Generics are a powerful language feature that we use daily when using the Swift standard library without really thinking about it. Things get tricky when people first start to write their own APIs with generics, there is often a confusion about what/why/when/how they should be used. This post runs through a worked example of writing a generic function and explaining the benefits as we go.
Problem Outline
Let’s imagine that we want a function that will take an array of shapes and remove any where the area is less than 100. The function should be able to handle multiple different shape types - here are two example types:
First Attempt
There are plenty of ways to tackle this problem so let’s just pick one to begin. Without generics we might try writing a function that ignores types by working with Any
.
To write the implementation we need to cast to the correct type, call the area function and compare it against 100.
This implementation has some design floors:
1) It can crash at run time if we use it on any type that is not a Square
or Rectangle
.
2) The size predicate logic is duplicated twice.
This is not great because it means we’ll need to update multiple places in our code base if the core business rules change.
3) The function will keep getting bigger for every type we support.
4) We get an array of Any
as the output, which means we’ll probably need to cast this output to a more useful type later.
1
2
3
4
5
6
7
8
func testFilterSmallShapes_removesSquaresWithAnAreaOfLessThan100() {
let squares: [Square] = [ .init(length: 100), .init(length: 10) ]
XCTAssertEqual(
[ .init(length: 100) ],
filterSmallShapes(squares).map { $0 as! Square }
)
}
On line 6 above we have to cast back to a more specific type in order to do anything useful, in this case a simple equality check. This might not seem too bad but we must remember that this cast happens at runtime, which means that we put more pressure on our testing to ensure that we are exercising all possible scenarios in our code.
Second Attempt
Let’s introduce a protocol
so that we don’t need to cast for each shape type. Doing this will resolve issues 1, 2 and 3.
This implementation is a big improvement but we now return [Sizable]
as the output, which is just as unhelpful as [Any]
from the first attempt, which will still require a runtime cast.
Third Attempt
To resolve all the issues that we have encountered so far we might decide our previous attempts were ok but it might be easier to just duplicate the code and keep everything type safe:
Our test from earlier now becomes really simple without the type cast:
This all works but we have reintroduced a couple of issues from our first attempt:
1) The size predicate logic is duplicated twice.
2) The function will keep being duplicated for every type we support.
The Generic Approach
This approach is a combination of the above attempts. The idea is that we’ll ask Swift to generate the various versions of our function (like in attempt 3) by providing a generic function that it can use as a blueprint. I’ll show the implementation and then explain the new bits:
The function body is identical to attempt two, the real change is in the function definition. We’ve introduced a “placeholder” type between <>
that we have called Shape
. This placeholder type has some constraints placed upon it where by we are saying it has to be a type that conforms to Sizable
, this is indicated by writing Sizable
after the :
.
Our test is identical to the one written in attempt three - it’s as if we have just duplicated the function.
To understand how this all works I like to imagine the following mental model:
- The compiler sees that I am calling the function with a type of
Square
. - The compiler will check that
Square
conforms toSizable
.- If it does not then it will cause a compiler error.
- The compiler will generate a specialised copy of the function where mentions of
Shape
are replaced withSquare
.
I have no idea about the technical implementation of this from the compiler’s point of view but externally as a language user this model works well for me.
Conclusion
Writing your first functions/types that have generics can seem a little daunting but the steep learning curve is worth it when things start to click and you see the possible use cases as well as understand when it’s not appropriate to use generics. In the example above we end up in a position where we have no duplication of our core business logic (checking the area is < 100) and we have kept compile time type safety. I think analysing a few versions of the function can help with understanding the benefits/disadvantages of our decisions and make us more aware of the tradeoffs we are making when designing our APIs.