Hands on Generics in Swift

Generics 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:

struct Rectangle: Equatable {
    let width: Double
    let height: Double

    func area() -> Double {
        return width * height
    }
}

struct Square: Equatable {
    let length: Double

    func area() -> Double {
        return length * length
    }
}

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.

func filterSmallShapes(_ shapes: [Any]) -> [Any]

To write the implementation we need to cast to the correct type, call the area function and compare it against 100.

func filterSmallShapes(_ shapes: [Any]) -> [Any] {
    return shapes.filter {
        if let square = $0 as? Square {
            return square.area() > 100
        } else if let rectangle = $0 as? Rectangle {
            return rectangle.area() > 100
        } else {
            fatalError("Unhandled shape")
        }
    }
}

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.

filterSmallShapes([ Circle(radius: 10) ]) // This will crash as we have no implementation for `Circle`

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.

protocol Sizeable {
    func area() -> Double
}

extension Rectangle: Sizeable {}
extension Square: Sizeable {}

func filterSmallShapes(_ shapes: [Sizeable]) -> [Sizeable] {
    return shapes.filter { $0.area() > 100 }
}

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:

func filterSmallShapes(_ shapes: [Rectangle]) -> [Rectangle] {
    return shapes.filter { $0.area() > 100 }
}

func filterSmallShapes(_ shapes: [Square]) -> [Square] {
    return shapes.filter { $0.area() > 100 }
}

Our test from earlier now becomes really simple without the type cast:

func testFilterSmallShapes_removesSquaresWithAnAreaOfLessThan100() {
    let squares: [Square] = [ .init(length: 100), .init(length: 10) ]

    XCTAssertEqual([ .init(length: 100) ], filterSmallShapes(squares))
}

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:

func filterSmallShapes<Shape: Sizeable>(_ shapes: [Shape]) -> [Shape] {
    return shapes.filter { $0.area() > 100 }
}

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:

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.