The objective of this post is to compare some of the differences between object-oriented programming and protocol-oriented programming while modeling the same functionality using both approaches. Object-oriented programming has been around since the 1970s while protocol-oriented programming has only gotten popular in the last few years, even though protocols/interfaces have been around for a while. The goal of this post is not to pick a winner between the architectures, but to try to help you out choose the best solution for the type of project you’ll be working on.

Object-Oriented Programming

Object-oriented programming is a paradigm based on the concept of an “object”, which in most programming languages is defined by a data structure (e.g. Class) that contains information about attributes in the form of properties, and the actions performed by or to an object in the form of methods. This paradigm allows us to encapsulate the properties and actions of an object into a single type, the entity we are trying to represent in our code.

Let's create a basic class diagram that shows how we could design an animal class hierarchy for an object-oriented design.

This diagram shows that we have one superclass named Animal and three subclasses named Cat, Bird and Fish, which will inherit all of the internal and public properties and methods from the superclass.

Let’s assume that we’re developing a Tamagotchi type of game where we can create animals using the following requirements:

  • We have three different animal categories (land, air and water).
  • An animal can be a member of multiple categories.

Let’s start our object-oriented design by:

  • Creating an AnimalEnvironment enumeration that will be used to identify the places where an animal can survive.
  • Creating an Animal superclass, which will contain all the animal’s attributes. The first three properties are an array of AnimalEnvironment, the name and the age of the animal. The other three properties will contain the speed for each environment an animal could be.
  • Creating methods, the first three methods are gonna be used to make our animals exercise and burn some calories, and the other three to let us know what the animal can do.
enum AnimalEnvironment {
    case land
    case air
    case water
}

class Animal {

    let environments: [AnimalEnvironment]
    let name: String
    let age: String
    let landSpeed: Int
    let airSpeed: Int
    let waterSpeed: Int

    init(environments: [AnimalEnvironment],
         name: String,
         age: String,
         landSpeed: Int,
         airSpeed: Int,
         waterSpeed: Int) {
        self.environments = environments
        self.name = name
        self.age = age
        self.landSpeed = landSpeed
        self.airSpeed = airSpeed
        self.waterSpeed = waterSpeed
    }

    func run() {}

    func fly() {}

    func swim() {}

    func canRun() -> Bool { environments.contains(.land) }

    func canFly() -> Bool { environments.contains(.air) }

    func canSwim() -> Bool { environments.contains(.water) }
}

Now, let's take a look at how we would subclass the Animal class by creating the Cat, Bird, and Fish classes as defined in our diagram.

final class Cat: Animal {

    init(name: String, age: String, landSpeed: Int) {
        super.init(environments: [.land], name: name, age: age, landSpeed: landSpeed, airSpeed: 0, waterSpeed: 0)
    }

    override func run() {
        print("Run")
    }
}

final class Bird: Animal {

    init(name: String, age: String, landSpeed: Int, airSpeed: Int) {
        super.init(environments: [.air, .land], name: name, age: age, landSpeed: landSpeed, airSpeed: airSpeed, waterSpeed: 0)
    }

    override func run() {
        print("Run")
    }

    override func fly() {
        print("Fly")
    }
}

final class Fish: Animal {

    init(name: String, age: String, waterSpeed: Int) {
        super.init(environments: [.water], name: name, age: age, landSpeed: 0, airSpeed: 0, waterSpeed: waterSpeed)
    }

    override func swim() {
        print("Swim")
    }
}

The Cat, Bird and Fish classes are subclasses of the Animal class, and we begin these classes by creating an initializer for each of them. For the Cat subclass for instance, we specify that the animal environment is .land, and airSpeed and waterSpeed is zero since a cat couldn’t survive in those environments. We also override the functions that belong to each animal and add its implementation.

As you can see, all the information from the superclass is exposed to the subclasses even though not all subclasses need all the superclass information. It’s easy to picture a scenario where more code starts being added to the superclass and it becomes more difficult to maintain. Even for the most experienced developer, it is very easy to enter the wrong value into the array or speed properties causing unexpected behavior.

Benefits

  • Reusability of code through inheritance.
  • Polymorphism flexibility.
  • Encapsulation.

Drawbacks

  • Swift is a single inheritance language, and we can have only one superclass for the animal classes, that superclass will need to contain the code required for each of the three categories.
  • Inheritance of functionalities that a type does not need. It can lead to bloated superclasses because we may need to include functionality that is needed by only a few of the subclasses.
  • We can’t create constants in our superclass that can be set by the subclasses.
  • In Swift, Value types cannot use inheritance, only Reference types.
  • Chances of breaking the LSP (Liskov Substitution Principle) if not designed well.

Protocol-Oriented Programming

As we did with the object-oriented design, let's create a diagram that shows how we could design the animal types in a protocol-oriented way.

As we can see, the protocol-oriented design focuses on implementing a protocol that’s concerned about the requirements other than the details as designed in the objected-oriented design. It’s a good example of ISP(Interface Segregation Principle), where the clients don’t need to know about properties and methods they don’t use, and DIP(Dependency Inversion Principle) where the design relies on abstractions instead of concretions.

Let’s start our protocol-oriented design by:

  • Creating an Animal protocol that will contain shared requirements that other protocols could take advantage of.
  • Creating a LandAnimal, AirAnimal and WaterAnimal protocols, which will all inherit from the Animal protocol and add a speed property for each of them.
protocol Animal {
    var name: String { get }
    var age: String { get }
}

protocol LandAnimal: Animal {

    var landSpeed: Int { get }
    func run()
}

protocol AirAnimal: Animal {

    var airSpeed: Int { get }
    func fly()
}

protocol WaterAnimal: Animal {

    var waterSpeed: Int { get }
    func swim()
}

The diagram shows us two techniques used with protocols:

Protocol inheritance: When a protocol can inherit the requirements from one or more protocols. This is similar to class inheritance in object-oriented programming but instead of inheriting functionality, it inherits requirements.

Protocol composition: It allows types to conform to more than one protocol, we can see that happening with the Bird structure. This is also achievable in object-oriented programming where a class can have a single or none inheritance but conform to multiple protocols.

It’s important to note that any type that conforms to the Animal protocol, or any type that conforms to a protocol that inherits from the Animal protocol, will automatically have access to the properties and methods.

Now, let's take a look at how we would implement the Cat, Bird, and Fish structures as defined in our diagram. As we can see, the protocol-oriented design exposes only the necessary requirements for each animal, it’s a good example of separation of concerns. Also, there is no need to create a custom initializer since here we’re working with a struct instead of a class.

struct Cat: LandAnimal {

    let name: String
    let age: String
    let landSpeed: Int

    func run() {
        print("Run")
    }
}

struct Bird: LandAnimal, AirAnimal {

    let name: String
    let age: String
    let landSpeed: Int
    let airSpeed: Int

    func run() {
        print("Run")
    }

    func fly() {
        print("Fly")
    }
}

struct Fish: WaterAnimal {

    let name: String
    let age: String
    let waterSpeed: Int

    func swim() {
        print("Swim")
    }
}

Let’s also implement the missing methods canRun, canFly and canSwim as we had in our object-oriented design but this time let’s extend the Animal protocol providing the methods that gives us the ability to provide common implementations to all the conforming types.

extension Animal {

    func canRun() -> Bool { self is LandAnimal }

    func canFly() -> Bool { self is AirAnimal }

    func canSwim() -> Bool { self is WaterAnimal }
}

Note that in Swift, we use the is keyword to check whether an instance is of a specific type and the as keyword to treat an instance as a specific type. As you can see, our Cat structure had access to all the methods.

let cat = Cat(name: "Meow", age: "4", landSpeed: 12)

let canRun: Bool = cat.canRun()
let canFly: Bool = cat.canFly()
let canSwim: Bool = cat.canSwim()

Both objected-oriented and protocol-oriented designs offer us the ability to work with Polymorphism, which is a single interface for multiple types. Let’s see how it looks like in both designs.

let cat = Cat(name: "Meow", age: "4", landSpeed: 12)
let bird = Bird(name: "Tiki", age: "2", landSpeed: 3, airSpeed: 20)
let fish = Fish(name: "Bubbles", age: "1", waterSpeed: 8)

let animals: [Animal] = [cat, bird, fish]

As you can see, working with polymorphism looks exactly the same for both approaches, while in the object-oriented design Animal is a superclass and in the protocol-oriented design Animal is a protocol.

Benefits

  • Protocol-oriented design allows us to work with classes, structures and enumerations.
  • We can use protocol extensions to add functionality to types that conform to our protocols.
  • Ability to define any of the properties as constants.
  • Protocol composition allows a data structure to implement multiple requirements.
  • Protocols become very powerful when combined with Generics.
  • Cleaner code.
  • Easier to find errors.

Drawbacks

  • Abuse of protocol inheritance and protocol extensions could lead to a complex system.

Conclusion

As you can see, we're able to achieve the same goal using both paradigms (object-oriented programming and protocol-oriented programming). At the end of the day, designing your architecture properly is what will give you a solid and stable system, independently of what approach you choose to use. Try to gather all the requirements for the project you'll be working on, design it on paper, and then think about what paradigm will suit you better.