The Power Of Generics
Learning outcomes:
- Understand what are Generic Functions and Generic Types
- Understand what are Type Constraints
- Use generic functions to write cleaner code
Introduction
Generics is one of the most powerful tools in Swift. It enables us to write clean, flexible, and reusable code to avoid duplications. Great, isn’t it? Now the question is: How does this tool help me to write cleaner and flexible code? The answer is not that simple, but I promise you will be using generics with confidence after reading this article. Let’s start by saying that generics covers topics like:
- Generic Functions
- Generic Types
- Type Constraints
- Associated Types
- Generic Where Clauses
In this article, we’ll only cover the first three items above. These three items are the core of Generics.
Now let’s dive in!
Generic Functions
These are functions capable of receiving any parameter type. Their return type is flexible as well. I know that this sounds nonsense in the beginning because once we write a function, it will only be allowed to receive or return certain types, and that’s true for sure, but we can also rewrite our function to become a Generic Function.
Let’s see an example. Imagine that we want to create a function that receives an item and inserts it in an array. We don’t know how to use generics so far, so let’s write a non-generic version of the function before, using an Int
item and an Int
array.
There it is! As you can see we wrote our function to receive only an Int
array and an Int
item. We declared our array
param as an inout
parameter because we don’t want to return anything yet. Now what happens, if during our development process, we find out that we require the same functionality for a String
item and a String
array instead? We could think of creating another inserItem
function changing the types. That’s what Generics helps us to avoid, repeat the same source code with different types.
Let’s rewrite our insertItem
function as a generic function. To do that let’s take another look to the non-generic version. As you can see the only requirement is that both item
and array
type must be the same.
There we have our generic version! But let’s take a closer look to see what’s going on.
As you can see, we changed the types for both item
and array
to T
. T
is a commonly used type on Swift… wait, it doesn’t even exist on Swift. That must throw a compiler error, you may say, but it doesn’t. Why? Because we defined T
as a Placeholder Type Name
. We did that right after the function name, putting T
between angle brackets(<T>
). When the compiler looks this it says: “I know that this type doesn’t exist because it’s a placeholder type name. I will replace it with any other type sent by the developer on each call to this function.”
Now we can call our function either with an Int, String, or Double. Each call will replace the placeholder type name with the specified type.
var intArray = [2, 3]
insertItem(item: 2, in: &intArray)var stringArray = ["a string", "another string"]
insertItem(item: "2", in: &stringArray)
In the previous example, the first call to insertItem
replaced T
with Int
later; in the second call, it replaced T
with String
.
Something to keep in mind is that we can add as many placeholder type names as we want but, we must be careful because we need to use all of them or, the compiler will throw an error such as:
Generic parameter ‘X’ is not used in function signature
To add more placeholder type names, we separate them by commas as follows:
func functionName<T, U, X>() {}
We have covered the use of placeholder type names in parameters so far, but as you remember, we already mentioned that we could also use them as return types. Let’s see an example. Imagine that we are working on our app’s API module and we want to decode the user fetched data in a User
instance.
It works as expected, but suddenly we realized that the same decoding must be done for every fetched data in the app, meaning that we will have to create a decodeMessage
function for our Message
class.
Wouldn’t it be better to have a function capable of return any type? Yes, it would. We will create a placeholder type name for this and, then we will use it as the function’s return type.
Spoiler alert: our decode
function throws a compiler error, but we will fix it a few lines later 🙂.
Done! As you can see, we created a new placeholder, D
and, we replaced the User
usages with it. Now instead of adding a function to decode each one of our types, we could call our decode function as follows:
let user: User? = decode(data: Data())
let message: Message? = decode(data: Data())
When the compiler reaches these lines it says: “The developer is calling a function with a generic return type, and I have to assign its return to a User/Message variable. I’ll replace the placeholder D with User/Message type”.
Type Constraints
Type constraints help us to add some restrictions to our placeholder type names. For example, inherit from a certain class or conform to a certain protocol.
As you remember, we mentioned before that our decode
function threw a compiler error, that error is:
Instance method ‘decode(_:from:)’ requires that ‘D’ conform to ‘Decodable’
We added the line JSONDecoder().decode(D.self, from: data)
in our decode
function. That means that we are trying to decode some data into an instance of type D
. Remember that we are only allowed to decode data into types that conform to the Decodable
protocol. The compiler doesn’t know everything about our D
placeholder type name; therefore, it isn’t sure if it can use D
to decode data and, that’s why it throws the error.
To fix this issue, we must tell the compiler that D
conform to the Decodable
protocol. We do this by adding a type constraint to D
. To add type constraints we put a colon after our placeholder type name and then, we add a class to inherit from or a protocol to conform.
Boom! The error was solved! When we added <D: Decodable>
, we constrained D
to be Decodable
. Now the compiler knows that it can decode data into D
and, we can only assign the decode
returned result to a type that conforms to the Decodable
protocol. If we try to assign decode
returned result to a type that doesn’t conform to the Decodable
protocol, we will receive an error like this:
Function ‘decode(data:)’ requires that ‘YourType’ conform to ‘Decodable’
If we want to add more type constraints, we separate them by &
. For example: <D: Decodable & Equatable>
, meaning that the function’s return type must be Decodable and Equatable.
Generic Types
Generic Types are classes, structs, or enums that can work with any type. To declare generic types, we write our type name, then we put our placeholder type name between angle brackets.
Let’s see an example. Imagine that we want to encapsulate our decode
function into a class. As you remember, we must have the same decode functionality for all our fetched data so, Do we create an UserDataMapper
and a MessageDataMapper
? We could but, we are going to use a generic type instead. Why? Because the functionality will be the same for all our fetched data regardless of the type we decode. We will create a single DataMapper
instead.
Awesome! Now we have a generic type to decode all our app’s data. Notice that now the placeholder type name is declared at a class level, meaning that it will be accessible for any other functions that we may want to add in the future. We added the same type constraint as well (Type constraints apply here as well 🚀).
How can we use our new generic type? Even though the result is the same, we access the functionality differently. Generic types require us to pass them the type(s) to be stored during the instantiation.
let dataMapper = DataMapper<User>()
let user = dataMapper.decode(data: Data(json.utf8))
Done! When we created the dataMapper
object, we passed the User
type(<User>
). Now the compiler understands that this object will always replace its placeholder type name with User
instances, meaning that it isn’t necessary to specify the user
constant type. Additionally, we can only instantiate DataMapper
with decodable types because of the type constraint.
Here we could have as many placeholders as we want as well. We must pass them each time we instantiate an object as follows:
let myObject = GenericType<StoredType, AnotherStoredType>()
In the previous example, we created an instance of GenericType
. We are passing two types to be stored. These types could be a class, a struct, or any other type, that fulfills the type constraints if there are any.