How to Use Generics in Swift

Generics allow you to declare a variable which, on execution, may be assigned to a set of types defined by us.

In Swift, an array can hold data of any type. If we need an array of integers, strings, or floats, we can create one with the Swift standard library. The type that the array should hold is defined when it's declared. Arrays are a common example of generics in use. If you were to implement your own collection you'd definitely want to use generics. 

Let's explore generics and what great things they allow us to do.

1. Generic Functions

We begin by creating a simple generic function. Our goal is to make a function to check if any two objects are of the same type. If they are of the same type, then we'll make the second object's value equal to the first object's value. If they are not of the same type, then we'll print "not the same type". Here is an attempt at implementing such a function in Swift.

In a world without generics, we run into a major issue. In the definition of a function we need to specify the type of each argument. As a result, if we want our function to work with every possible type, we would have to write a definition of our function with different parameters for every possible combination of types. That isn't a viable option.

We can avoid this problem by using generics. Take a look a the following example in which we leverage generics.

Here we see the syntax of using generics. The generic types are symbolized by T and E. The types are specified by putting <T,E> in our function's definition, after the function's name. Think of T and E as placeholders for whichever type we use our function with.

There is a major issue with this function though. It won't compile. The compiler with throw an error, indicating that T is not convertible to E. Generics assume that since T and E have different labels, they will also be different types. This is fine, we can still accomplish our goal with two definitions of our function.

There are two cases for our function's arguments:

  • If they are of the same type, the second implementation is called. The value of two is then assigned to one.
  • If they are of different types, the first implementation is called and the string "not same type" is printed to the console. 

We've reduced our function definitions from a potentially infinite number of argument type combinations to just two. Our function now works with any combination of types as arguments.

Generic programming can also be applied to classes and structures. Let's take a look at how that works.

2. Generic Classes and Structures

Consider the situation where we'd like to make our own data type, a binary tree. If we use a traditional approach in which we don't use generics, then we'd be making a binary tree that can hold only one type of data. Luckily, we have generics.

A binary tree consists of nodes that have:

  • two children or branches, which are other nodes
  • a piece of data that is the generic element
  • a parent node that is usually not reference by the node
Binary Tree

Every binary tree has a head node that doesn't have parents. The two children are commonly differentiated as left and right nodes.

Any data in a left child must be less than the parent node. Any data in the right child must be greater than the parent node.

The declaration of the BTree class also declares the generic T, which is constrained by the Comparable protocol. We will discuss protocols and constraints in a bit.

Our tree's data item is specified to be of type T. Any element inserted must also be of type T as specified by the declaration of the insert(_:) method. For a generic class, the type is specified when the object is declared.

In this example, we create a binary tree of integers. Making a generic class is quite simple. All we need to do is include the generic in the declaration and reference it in the body when necessary.

3. Protocols and Constraints

In many situations, we have to manipulate arrays to accomplish a programmatic goal. This could be sorting, searching, etc. We'll take a look at how generics can help us with searching.

The key reason we use a generic function for search is that we want to be able to search an array regardless of what type of objects it holds.

In the above example, the find(array:item:) function accepts an array of the generic type T and searches it for a match to item which is also of type T.

There is a problem though. If you try to compile the above example, the compiler will throw another error. The compiler tells us that the binary operator == cannot be applied to two T operands. The reason is obvious if you think about it. We cannot guarantee that the generic type T supports the == operator. Luckily, Swift has this covered. Take a look at the updated example below.

If we specify that the generic type must conform to the Equatable protocol, then the compiler gives us a pass. In other words, we apply a constraint on what types T can represent. To add a constraint to a generic, you list the protocols between the angle brackets.

But what does it mean for something to be Equatable? It simply means that it supports the comparison operator ==.

Equatable is not the only protocol we can use. Swift has other protocols, such as Hashable and Comparable. We saw Comparable earlier in the binary tree example. If a type conforms to the Comparable protocol, it means the < and > operators are supported. I hope it's clear that you can use any protocol you like and apply it as a constraint.

4. Defining Protocols

Let's use an example of a game to demonstrate constraints and protocols in action. In any game, we will have a number of objects that need to be updated over time. This update could be to the object's position, health, etc. For now let's use the example of the object's health.

In our implementation of the game, we have many different objects with health that could be enemies, allies, neutral, etc. They would not all be the same class as all our different objects could have different functions.

We'd like to create a function called check(_:) to check a given object's health and update its current status. Depending on the object's status we may make changes to its health. We want this function to work on all objects, regardless of their type. This means that we need to make check(_:) a generic function. By doing so, we can iterate through the different objects and call check(_:) on each object.

All these objects must have a variable to represent their health and a function to change their alive status. Let's declare a protocol for this and name it Healthy.

The protocol defines which properties and methods the type that conforms to the protocol needs to implement. For example, the protocol requires that any type conforming to the Healthy protocol implements the mutating setAlive(_:) function. The protocol also requires a property named health.

Let's now revisit the check(_:) function we declared earlier. We specify in the declaration with a constraint that the type T must conform to the Healthy protocol.

We check the object's health property. If it is less than or equal to zero, we call setAlive(_:) on the object, passing in false. Because T is required to conform to the Healthy protocol, we know that the setAlive(_:) function can be called on any object that is passed to the check(_:) function.

5. Associated Types

If you'd like to have further control over your protocols, you can use associated types. Let's revisit the binary tree example. We'd like to create a function to make operations on a binary tree. We need some way to make sure the input argument satisfies what we define as a binary tree. To solve this, we can create a BinaryTree protocol.

This uses an associated type typealias dataType. dataType is similar to a generic. T from earlier, behaves similarly to dataType. We specify that a binary tree must implement the functions insert(_:)  and index(_:)insert(_:) accepts one argument of type dataType. index(_:) returns a dataType object. We also specify that the binary tree must have a property data that is of type dataType.

Thanks to our associated type we know our binary tree will be consistent. We can assume that the type passed to insert(_:), given by index(_:), and held by data is the same for each. If the types were not all the same we'd run into issues.

6. Where Clause

Swift also allows you to use where clauses with generics. Let's see how that works. There are two things where clauses allow us to accomplish with generics:

  • We can enforce that associated types or variables within a protocol are the same type.
  • We can assign a protocol to an associated type.

To show this in action, let's implement a function to manipulate binary trees. The goal is to find the maximum value between two binary trees.

For simplicity's sake, we will add a function to the BinaryTree protocol called inorder(). In-order is one of the three popular depth-first traversal types. It is an ordering of the tree's nodes that travels recursively, left subtree, current node, right subtree.

We expect the inorder() function to return an array of objects of the associated type. We also implement the function twoMax(treeOne:treeTwo:)which accepts two binary trees.

Our declaration is quite long due to the where clause. The first requirement, B.dataType == T.dataType, states that the associated types of the two binary trees should be the same. This means that their data objects should be of the same type.

The second set of requirements, B.dataType: Comparable, T.dataType: Comparable, states that the associated types of both must conform to the Comparable protocol. This way we can check what is the maximum value when performing a comparison.

Interestingly, due to the nature of a binary tree we know that the last element of an in-order will be the maximum element within that tree. This is because in a binary tree the rightmost node is the largest. We only need to look at those two elements to determine the maximum value.

We have three cases:

  1. If tree one contains the maximum value, then its inorder's last element will be greatest and we return it in the first if statement.
  2. If tree two contains the maximum value, then its inorder's last element will be greatest and we return it in the else clause of the first if statement.
  3. If their maximums are equal, then we return the last element in tree two's inorder, which is still the maximum for both.

Conclusion

In this tutorial, we focused on generics in Swift. We've learned about the value of generics and explored how to use generics in functions, classes, and structures. We also made use of generics in protocols and explored associated types and where clauses.

With a good understanding of generics, you can now create more versatile code and you'll be able to deal better with difficult coding problems.

Tags:

Comments

Related Articles