Using generic type constraints in Swift
Discover page available: GenericsGenerics is arguably one of the major benefits of using Swift over Objective-C. By being able to associate generic types with things like collections, we can write code that is a lot more predictable and safe. Just like switch
statements (which we took a look at last week), generics is not a new concept - but Swift's powerful type system takes them to new levels and enables some really cool features.
One such feature is generic type constraints. Using them, you are able to only add certain APIs and behaviors to implementors of a generic type that match a certain set of constraints. This week, let's take a look at some techniques and patterns that are made possible because of type constraints, and how they can be used in practice - focusing on some of the new capabilities that were recently introduced with Swift 3.1 & 4.
The basic idea
Let's start with the basics by taking a look at an example. Let's say that we have an array of numbers that we want to sum up. Rather than having to use a sum(_ numbers: [Int])
function, we can define a type constrained extension on Array
when the Element
type (which is the type of the elements of a given array) conforms to the Numeric
protocol, like this:
extension Array where Element: Numeric {
func sum() -> Element {
return reduce(0, +)
}
}
Above, we use the reduce
method on the array (which is a collection operation that continuously mutates an initial value - in this case 0
- by calling a function on all elements). Since we have constrained our extension to the Numeric
protocol, we get access to the +
operator, which we can pass as a function to reduce
. Pretty cool 😎!
The main advantage of structuring APIs like this, as extensions rather than top-level functions, is that it creates a closer association between the API and the type it's being used on. Instead of having to search for which functions that can be used, you have them all available, right there on the type. Compare the call sites for using a top level function and an extension:
let totalPrice = sum(itemPrices)
let totalPrice = itemPrices.sum()
Same type constraints
Before Swift 3.1, type constraints were limited to protocols (like in the above example) and subclasses. While that's powerful enough for most use cases, there are several creative ways of solving many common problems that we can use now that we also are able to add same type constraints.
For example, let's say we want to count all the words in any collection containing strings. We can easily do so by adding an extension on Collection
, constrained by elements that are of the exact type String
, like this:
extension Collection where Element == String {
func countWords() -> Int {
return reduce(0) { count, string in
let components = string.components(separatedBy: .whitespacesAndNewlines)
return count + components.count
}
}
}
Another cool (and not so widely known) side-effect of being able to use same type constrains is that we can actually even use closure types in constraints. For example, we can add an extension on Sequence
that lets us easily call all closures contained in it:
extension Sequence where Element == () -> Void {
func callAll() {
for closure in self {
closure()
}
}
}
Makes for some really nice and compact code, for example when dealing with observers:
observers.callAll()
Type constraints in generic protocols
Another situation in which type constraints are really useful is when defining APIs using protocols. This is in general a good practice for things like testability and separation of concerns, but can sometimes be a bit tricky when you need nested types to be flexible.
Let's say that we want to define a shared API for all objects that are responsible for managing various types of models in our app. To do that, we define a protocol called ModelManager
, which has an associated Model
type, like this:
protocol ModelManager {
associatedtype Model
}
Now let's say that we want to take it a step further, and define an API to query a given model manager for a collection of models. We could do that by using concrete types like String
for the query and [Model]
for the result, like this:
protocol ModelManager {
associatedtype Model
func models(matching query: String) -> [Model]
}
The above works, but is not very flexible and can also be described using a phrase that makes most Swift developers shiver: stringly typed 😱. Instead, let's use generic constraints to leverage the type system to enable a lot more flexibility, and to enable strongly typed queries to be performed on a manager.
To do that, we're going to introduce two new associated types on our protocol, one for a Query
type, which can be anything that an implementor wants to use to express a query - for example an enum. Next, we're going to add a Collection
type, that we type constrain to be able to guerantee that a returned collection's element type matches the manager's model type. The end result looks like this:
protocol ModelManager {
associatedtype Model
associatedtype Collection: Swift.Collection where Collection.Element == Model
associatedtype Query
func models(matching query: Query) -> Collection
}
Now, with the above, we are free to implement model managers that all provide the exact same API - but still can use types and collections that match their needs. For example, to implement a ModelManager
for a User
model, we may choose to use Array
as our collection type and a Query
enum that lets us match users by name or age:
class UserManager: ModelManager {
typealias Model = User
enum Query {
case name(String)
case ageRange(Range<Int>)
}
func models(matching query: Query) -> [User] {
...
}
}
For other models it may be a much better fit to use Dictionary
as the collection type. Here's another manager that keeps track of movies based on their genre, and lets us query for movies matching either a name or a director:
class MovieManager: ModelManager {
typealias Model = (key: Genre, value: Movie)
enum Query {
case name(String)
case director(String)
}
func models(matching query: Query) -> [Genre : Movie] {
...
}
}
By using constrained associated types we can get access to the power of protocol oriented programming and enable easier mocking & testability, while still having lots of flexibility when implementing our concrete types 👍.
Conclusion
Type constraints are more powerful in Swift 4 than they have ever been, and they enable us to use some pretty interesting techniques to take full advantage of the type system even when writing more generic code.
Like always when dealing with generics (and other advanced language features), it's important to exercise some amount of caution and plan a bit up front - so that you don't end up writing too generalized code that is hard to understand. Not everything needs to be abstracted and generalized, and sometimes inlining an implementation in multiple places can actually be a lot simpler and nicer. But for situations when you do want to write something in a more generic way, using type constraints can provide a great way of doing so.
What do you think? Do you use generics and type constraints in your code, or are you planning to try it out? Let me know, along with any other questions, feedback or comments that you might have - on Twitter @johnsundell.
Thanks for reading! 🚀