Before we go on, here are some tips for users of other programming languages this year:

  • Rust uses traits, which are similar to Swift protocols, to define shared behavior.
  • Java and C# use interfaces that serve a similar purpose to protocols in Swift.
  • C++ uses abstract classes or pure virtual functions to establish a contract, a concept comparable to composing behaviors with protocols in Swift.

Previously, we learned how to group related data and behavior into objects. Now we’re taking another step forward by exploring composition using Swift protocols. Protocols in Swift define a blueprint for methods, properties, and other requirements. We can mix and match functionality from different protocols to build more flexible and modular types.

If we think of structs as a blueprint for an object, a protocol is a blueprint for a struct.

By conforming to a protocol, a type promises to implement the protocol's requirements. This allows you to create small, reusable pieces of behavior that can be composed together, making your code more robust and easier to maintain.

Let's extend our library catalogue system using built-in Swift protocols. In this example, we’ll use protocols like CustomStringConvertible and Equatable to ensure that every library item can be easily described and compared. This approach lets our catalogue handle different kinds of library materials in a uniform way.

The CustomStringConvertible protocol allows you to print an object (i.e. 'print(book)`) showing a well-formatted output. Without this, printing an object shows the internal Swift representation.

To conform to CustomStringConvertible, you must add a computed String variable called description.

Example: a struct that conforms to CustomStringConvertible.

struct Student : CustomStringConvertible {
    let id: Int
    var name: String
    var age: Int

    // Conformation to CustomStringConvertible.
    var description: String {
        return "\(name), \(age) years old."
    }
}

let anahera = Student(id: 1234, name: "Anahera", age: 16)
print(anahera)  // Anahera, 16 years old.

The Equatable protocol allows you to compare two objects of the same type. Without this, you would need to compare the objects' attributes individually.

To conform to Equatable, you must add a static method called ==.

Example: extending the struct above to conform to Equatable, comparing the ID, name, and age.

struct Student : CustomStringConvertible, Equatable {
    let id: Int
    var name: String
    var age: Int

    var description: String {
        return "\(name), \(age) years old."
    }

    // Conformation to Equatable.
    static func ==(lhs: Student, rhs: Student) -> Bool {
        return lhs.id == rhs.id
    }
}

let anahera = Student(id: 1234, name: "Anahera", age: 16)
let jane = Student(id: 1235, name: "Jane", age: 17)
let another_anahera = Student(id: 123, name: "Anahera", age: 16)

print(anahera == jane)  // false
print(anahera == another_anahera)  // true

We can also create our own protocols. Protocols can, themselves, also conform to protocols!

Let's keep our protocols simple. For now, they will only define what data that any conforming structs should contain. To do this, they are defined as variables. Each variable has a name, a type, and a statement of mutability — whether that attribute can be modified or not.

Data that should be immutable (in other words, a constant) is defined with { get } (i.e. you can only 'get' this information). On the other hand, if the data should be mutable (a variable), it is defined with { get set }.

Example: a protocol to describe the basic characteristics of a person. Student will conform to Person rather than CustomStringConvertible and Equatable directly.

protocol Person: CustomStringConvertible, Equatable {
    var id: Int { get }
    var name: String { get set }
    var age: Int { get set }
}

struct Student : Person { ... }

Now, we adjust our Student struct to conform to the Person protocol. Notice how we also add extra properties such as their course codes and their dean — these are not a part of the protocol, they are part of the struct.

struct Student : Person {
    let id: Int
    var name: String
    var age: Int
    var courseCodes: [String]
    var dean: String?

    ...
}

The Person protocol currently only has one conformant object. The beauty of composition is that multiple objects can conform to the same protocol and thus be treated similarly by the rest of the program.

Let's create a second conformant struct called Teacher.

struct Teacher : Person {
    // These are required by the Person protocol.
    let id: Int
    var name: String
    var age: Int

    // This is additional information.
    var courseCodes: [String]

    // Three letter code for the teacher.
    // Since the cypher might change (i.e. when a teacher marries),
    // this is separate from their ID.
    var cypher: String

    // Mr, Ms, Miss, Mrs, Mx, Matua, Whaea, Kōkā
    var salutation: String

    var description: String {
        // Don't show the age, it's not necessary for teachers.
        return "\(salutation) \(name) (\(cypher))"
    }

    static func ==(lhs: Teacher, rhs: Teacher) -> Bool {
        return lhs.id == rhs.id
    }
}

We will create a sort of 'catalogue' of people who work at a school. It will be generic and accept any object that conforms to Person; that means it will hold both Student and Teacher objects. Let's call the catalogue RAMAK.

/// A catalogue for people.
struct RAMAK {
    var people: [any Person]

    /// Add a new person to the catalogue.
    /// This uses a feature called generics. Check the glossary for details.
    mutating func add<T: Person>(_ newPerson: T) {
        // Ensure no duplicate uniqueID is added.
        guard !(people.contains { person in (person as? T) == newPerson }) else {
            print("Person with ID \(newPerson.id) already exists!")
            return
        }
        people.append(newPerson)
    }

    /// Search people by name.
    func search(byName name: String) -> [any Person] {
        return people.filter { $0.name.lowercased() == name.lowercased() }
    }
}

As you can see, the people attribute, the newPerson parameter in the add(_:) method, and the byName name parameter in the search(byName:) method have a new word in their type hint: any.

This is different from the type Any (with a capital A) which specifies any type at all. On the other hand, any Person means that any type that conforms to the Person protocol can be used in these places.

Create a program that allows you to add and look up details of library items using composition with Swift protocols.

To do this:

  • Extend the existing library catalogue system by using built-in Swift protocols:
  • Create a protocol called CatalogueItem to define a common interface for all library items. It should have the same information as now, minus the ISBN
  • Create another protocol named PrintItem, conforming to CatalogueItem, that includes the ISBN
  • Create another protocol named MediaItem, conforming to CatalogueItem, that includes properties such as format (String) and runtime (Double)
  • Add new types like Magazine, DVD, and Audiobook that conform to 'PrintItemorMediaItem` as appropriate
    • In fact, Audiobook should conform to both PrintedItem and MediaItem as audiobooks have ISBN numbers
  • Change the books variable to items, and change the type from [Book] to [CatalogueItem]