Polymorphism: Theory and Techniques in Swift
In any object oriented system, we will find ourselves working with instances of different types that have shared characteristics. This could be the result of different classes inheriting from the same ancestor, or conforming to the same protocol. In either case, it is common to want to work with these different types through a shared interface - a process which is called polymorphism.
The aim of this article is to examine techniques for grouping various types and accessing the available interfaces at different levels of our entity hierarchy. Let’s begin!
Our Entity Hierarchy
Our entity hierarchy has been designed so that there are three levels of protocols with each subsequent one inheriting from the former. There are also three structs, with each adopting one of the protocols and implementing a method of its own.
This type of hierarchal design is called composition. I recently examined the benefits of compositional design over class inheritance, which you can read about here.
With the hierarchy complete, let’s build an instance of each struct type. For illustrative purposes, we will also call all of the accessible methods on the levelTwo instance.
As StructLevelTwo conformed to ProtocolLevelTwo, and ProtocolLevelTwo inherits ProtocolLevelOne we can see there are two protocol methods available. We can also see the instance method implemented in Struct LevelTwo. The important thing to recognize at this point is that we can access both protocol methods and an instance method for each of our three instances.
A common goal of polymorphism is to group our different types together into some form of collection. To do this we need to upcast to a common type. The awesome thing about Swift protocols is that they are a fully-fledged type, so we can upcast to a protocol type. It is possible to explicitly upcast using as:
In this example we take our levelTwo instance which conforms to ProtocolLevelTwo and upcast it as type ProtocolLevelOne. When we compare the available methods to the previous snippet we can see that the instance method and the protocol method from ProtocolLevelTwo are now no longer available to our instance. Notice also that if we look at the type of upcastToOne we can see that it is now of type ProtocolLevelOne:
You can imagine how laborious it would be to explicitly upcast several instances to a common type before grouping them in a collection. Thankfully, when we declare a collection type and then append different types from further down the hierarchy, Swift is able to implicitly upcast to the correct type. This works for both protocol types and class types.
Notice in the example, that no explicit upcasting is used in the append methods. Now all three of our elements are able to access the method available in ProtocolLevelOne and no others.
Performing Type Checks and Downcasting
As we saw in the previous example, its very easy to work with the instances in our collection by accessing their shared interface. However, if we want only some of the objects to perform a task, or if we want to access an interface that is further down our hierarchy, we need to do a little more work. Here are some useful techniques.
Checking type (lines 2–4)
we can use the is keyword to check that an instance is a certain type. In this case, notice that we are checking if the instance conforms to ProtocolLevelTwo. You may remember that we have two instances that do this, levelTwo and levelThree, which is why we have the print out reveals the method being called twice. It is important to emphasize that is only performs a check not a downcast, so we can still only access the same protocolMethodOne that we had available in the previous example.
Downcast to protocol type (lines 13–16)
for case let is a useful structure when we want to downcast an element and bind it to a constant within a loop, and the next two examples use it to good effect. In this case we are downcasting to our ProtocolLevelTwo type. As you have probably guessed this means that we can only access our protocol methods from level one and two. In our example, we have two instances that conform to this protocol: levelTwo which adopts it directly, and levelThree which adopts it through protocol inheritance.
Downcast to a struct/class type (lines 17–31)
While StructLevelTwo adopts ProtocolLevelTwo, we can clearly see that the results of a class/struct downcast are very different. Whereas two instances performed method calls in the previous example, this time we only have one instance of type StructLevelTwo. Notice also that instead of only accessing protocol methods, we can now access our levelTwo instance method.
Working with functions and methods
Polymorphism is also commonly required when we want to pass different types as an argument into a function or method. The first example (lines 1–4), simply declares the input parameter as a protocol or class type that is the common ancestor. Another way is to create a generic function (lines 6–8) where we can indicate the protocol or class type in the placeholder syntax <>. In a simple example like this, there is effectively no difference, but if you wanted to perform additional checks on your instance before entering the function/method, it is possible in the second example with a where statement. Here’s a link where you can read more about checking conditions on generic instances.
Downcasting with as? or as!
When we want to downcast a single instance outside of a loop, we need to use an optional downcast (as?) or force downcast (as!) operator. Optional downcasting results in either the desired type or nil, whereas forced downcasting will trigger a runtime error if it doesn’t succeed. In most cases you should avoid force downcasting, and instead unwrap your optional downcast in an if let or guard let statement.
In this example above, we are optionally downcasting our input argument to our StructLevelTwo type. Sure enough, we now have access to its two protocol methods and single instance method.
And that’s it! Hopefully this article has shown that working with polymorphism isn’t nearly as difficult as it sounds. To recap our main points:
- Upcasting can be done explicitly with the as operator, although such an operation isn’t even required when passing a type into a collection or function/method as Swift is able to implicitly upcast when you declare the desired type.
- Downcasting to a protocol type or an struct/class type can have very different results in terms of accessible methods and properties. Consider which you need carefully.
- Downcasting in a loop can be done with for case let _ as _
- Outside a loop, you can perform optional downcasting with as? or forced downcasting with as! Forced downcasting should be actively avoided and only used when you know with certainty that it will succeed.
- It is possible to use generic function syntax to perform an upcast and additional checks on your type before entering the body.
Thanks for reading!