In our previous lessons, we learned how to define protocols that specify properties and requirements for methods. In this lesson, we will explore how protocols can include method signatures. We will also see how to provide default implementations for these methods using extensions. This approach not only promotes code reusability but also demonstrates polymorphism in Swift.

Polymorphism is a concept in object-oriented programming that means objects of different types can be treated as objects of a common type.

This means that a single interface can be used to represent different underlying forms (data types). By leveraging polymorphism, developers can write more general and reusable code, where functions and methods can operate on objects of various structs without knowing their exact type.

In fact, we've already seen this in the previous lesson where we could treat Book, Magazine, DVD, and Audiobook in the same way — they or their protocols conform to CatalogueItem.

For instance, by using polymorphism to define a common protocol, such as an Animal protocol with a cry() method, you can extend the program to include new animal types without modifying the code that interacts with these objects — code that is able to work with Animal can work just as well with Dogs, Frogs, Pigs, and Chickens.

Polymorphism helps you build robust software by facilitating code reuse and reducing redundancy. When objects share a common protocol, they can be interchanged effortlessly, making it easier to test, debug, and enhance parts of the application. This not only streamlines development but also minimizes the likelihood of errors, as a well-defined contract (protocol) ensures that all objects conform to expected behavior.

We've already made protocols that define the data that objects should contain. Let's use protocols to define methods that objects should implement.

Let’s start by creating a protocol named Animal with one method requirement: cry(). This method will not take any parameters and will print the animal’s cry.

Example: defining the Animal protocol.

protocol Animal {
    // Method signature for cry, which prints the animal's cry.
    func cry()
}

We will now create two structs: Dog and Cat. Both will conform to the Animal protocol and provide their own implementations of the cry() method. The Dog will print “Woof” while the Cat will print “Meow”.

Example: Dog and Cat conforming to Animal.

struct Dog: Animal {
    func cry() {
        print("Woof")
    }
}

struct Cat: Animal {
    func cry() {
        print("Meow")
    }
}

let dog = Dog()
let cat = Cat()

dog.cry()  // Woof
cat.cry()  // Meow

Often, multiple types may share the same implementation for a protocol’s method. Instead of repeating the same code, we can create an extension that provides a default implementation.

Imagine we have three additional structs: Piwakawaka, Tui, and Kakapo. Initially, all of these birds will have the same cry: “Tweet”. We could write out their implementations like this:

struct Piwakawaka : Animal {
    func cry() {
        print("Tweet")
    }
}

struct Tui : Animal {
    func cry() {
        print("Tweet")
    }
}

struct Kakapo : Animal {
    func cry() {
        print("Tweet")
    }
}

However, this is needless repetition. This means more work for you and more potential for errors. What if you made a typo in one of the strings?


Next, let's add a fly() method so that the birds can soar into the skies. This method will specify a spot towards which the bird will fly. To do this, we will:

  • add a new variable called location, a mutable String
  • add a new method called fly(to:)
  • copy-paste that into each bird
struct Piwakawaka : Animal {
    var location: String

    func cry() {
        print("Tweet")
    }

    func fly(to location: String) {
        print("I'm flying to \(location)!")
        self.location = location
    }
}

struct Tui : Animal {
    func cry() {
        print("Tweet")
    }

    func fly(to location: String) {
        print("I'm flying to \(location)!")
        self.location = location
    }
}

struct Kakapo : Animal {
    func cry() {
        print("Tweet")
    }

    func fly(to location: String) {
        print("I'm flying to \(location)!")
        self.location = location
    }
}

To minimise the unnecessary copying, let's create a new protocol that can do the tweeting and flying for us.

  • Create a new Bird protocol that conforms to Animal.
  • Add the location mutable String.
  • Then, provide a default implementation for cry() and fly(to:) in an extension of Bird.

Example: Creating the Bird protocol and providing a default cry() and fly(to:)

protocol Bird: Animal {
    var location: String { get set }
}

extension Bird {
    // Default implementation of tweet for ALL birds.
    func cry() {
        print("Tweet")
    }

    // Default implentation of fly for ALL birds.
    func fly(to location: String) {
        print("I'm flying to \(location)!")
        self.location = location
    }
}

Now, we define the structs for Piwakawaka, Tui, and Kakapo to conform to Bird. They will automatically inherit the default cry() and fly(to:) implementations from the extension.

struct Piwakawaka: Bird { }
struct Tui: Bird { }
struct Kakapo: Bird { }

let piwakawaka = Piwakawaka()
let tui = Tui()
let kakapo = Kakapo()

piwakawaka.cry()  // Tweet
tui.cry()         // Tweet
kakapo.cry()      // Tweet

Sometimes, you may want a specific type to have its own behaviour.

In our example, we want the Kakapo to override the default cry() behavior. Instead of “Tweet”, it should print “Squawk”; it's a parrot, after all! 🦜

Example: Overriding the cry() method in Kakapo.

struct Kakapo: Bird {
    // Override the default implementation for Kakapo.
    func cry() {
        print("Squawk")
    }
}

let kakapo = Kakapo()
kakapo.cry()  // Squawk

We might also want to add a Kiwi struct; famously, they don't fly. We have two options:

  • using overrides:
    • override fly(to:) in the Kiwi struct to not update the location and print that the kiwi hasn't moved
  • using protocols:
    • move fly(to:) to a separate FlightCapable protocol
    • create a new FlyingBird protocol that conforms to Bird (containing cry()) and FlightCapable; then, make Piwakawaka, Tui, and Kakapo conform to FlyingBird
    • make Kiwi conform only to Bird (i.e. doesn't conform to FlightCapable)

Which do you think is easier? Which do you think is more flexible and robust?

You are developing software for a space mission control system that manages different types of spacecraft and their functionalities.

  1. Create protocols:
    • Navigable: Requires conforming types to have a location and move to a new location.
    • Communicable: Requires conforming types to send messages.
    • Operatable: Requires a status property and a method to perform an operation.
  2. More protocols with conformance:
    • Spacecraft: Inherits from Navigable, Communicable, and Operatable. Provides a launch() function.
    • AutonomousSystem: Inherits from Operatable, but not Navigable (it stays in one place but still operates).
  3. Structs with conformance:
    • Orbiter: Conforms to Spacecraft, moves in orbit.
    • Lander: Conforms to Spacecraft, lands on a planet.
    • Satellite: Conforms to AutonomousSystem, stays in a fixed orbit but can transmit data.
    • Rover: Conforms to Spacecraft, moves on a planetary surface.
  4. Protocol extensions with default methods:
    • Add a default implementation of sendMessage() in Communicable, which simply prints a generic message.
    • Some structs override the default sendMessage() method, adding their own unique text to the message before sending it.
  5. Create the following objects for each struct
Object Conforms to Details
voyager1 Orbiter Drifting in interstellar space
juno Orbiter Collecting data from Jupiter
perseverance Lander Inactive, on Mars
venera9 Lander Destroyed on Venus
hubble Satellite Observing deep space
jamesWebb Satellite Deploying mirror array
curiosity Rover Analysing soil in the Gale Crater, Mars
lunokhod1 Rover Inactive, on the Moon
  1. Test their expected behaviours by calling the appropriate methods.

In some cases, you will need to use print(_:) to determine whether the object's values have been set correctly (i.e. when checking the result of operate()).

Object Behaviour Expected output
voyager1 .launch() "Orbiter is launching from Earth."
.move(to: "Heliopause") "Orbiter is now orbiting Heliopause."
.operate() status == "Monitoring planetary surface."
.sendMessage() "Sending a standard transmission."
juno .move(to: "Jupiter's Magnetosphere") "Orbiter is now orbiting Jupiter's Magnetosphere."
.sendMessage() "Sending a standard transmission."
perseverance .launch() "Lander is launching towards its target planet."
.move(to: "Jezero Crater") "Lander has landed on Jezero Crater."
.operate() status == "Collecting soil samples."
.sendMessage() "Lander: Sending geological data from Jezero Crater."
venera9 .move(to: "Venus Surface") "Lander has landed on Venus Surface."
.sendMessage() "Lander: Sending geological data from Venus Surface."
hubble .operate() status == "Transmitting signals to Earth."
.sendMessage() "Satellite: Relaying signals from orbit."
jamesWebb .operate() status == "Transmitting signals to Earth."
.sendMessage() "Satellite: Relaying signals from orbit."
curiosity .launch() "Rover is deploying to the surface."
.move(to: "Mount Sharp") "Rover has moved to Mount Sharp."
.operate() status == "Exploring terrain."
.sendMessage() "Rover: Transmitting images from Mount Sharp."
lunokhod1 .move(to: "Sea of Rains") "Rover has moved to Sea of Rains."
.sendMessage() "Rover: Transmitting images from Sea of Rains."