Protocol Inheritance in Swift

Protocol Inheritance in Swift
Protocol Inheritance in Swift - Swift Anytime

Protocol inheritance allows us to define a new protocol that inherits the properties and functions of one or more existing protocols. This feature is essential for building modular, reusable, and organized code. It promotes code consistency, reusability, and ensures that conforming types adhere to a set of common rules and conventions.

Just like classes can inherit from other classes, protocols can inherit from other protocols. When a protocol inherits from another protocol, it gains all the requirements and capabilities of the parent protocol(s).

Here's the basic syntax:

protocol ProtocolA {
    // Protocol A requirements
}

protocol ProtocolB {
    // Protocol B requirements
}

protocol CombinedProtocol: ProtocolA, ProtocolB {
    // Additional requirements specific to CombinedProtocol
}

In the above syntax, we created a single CombinedProtocol protocol that brings them together in one protocol. We don’t need to add anything on top if not required, so we’ll just write open and close braces.

Let's explore a practical example of protocol inheritance by making a hierarchy of protocols and conforming types for a simple multimedia playback system. We'll create protocols for basic media functionality, extend those protocols with more specialized behaviors, and define conforming types.

protocol MediaPlayable {
    var title: String { get }
    func play()
}

protocol AudioPlayable: MediaPlayable {
    var artist: String { get }
    func pause()
}

protocol VideoPlayable: MediaPlayable {
    var duration: TimeInterval { get }
    func stop()
}

We start with a base protocol MediaPlayable, which represents the most fundamental media playback functionality.

We then create two specialized protocols: AudioPlayable and VideoPlayable, each inheriting from MediaPlayable. AudioPlayable adds audio-specific properties and methods, while VideoPlayable adds video-specific properties and methods.

Conforming Type: Song (conforms to AudioPlayable):

struct Song: AudioPlayable {
    let title: String
    let artist: String

    func play() {
        print("Playing song: \(title) by \(artist)")
    }

    func pause() {
        print("Pausing song: \(title)")
    }
}

Usage:

let song = Song(title: "Clash City Rockers", artist: "The Clash")
song.play() // Prints - Playing song: Clash City Rockers by The Clash
song.pause() // Prints - Pausing song: Clash City Rockers

Conforming Type: Movie (conforms to VideoPlayable):

struct Movie: VideoPlayable {
    let title: String
    let duration: TimeInterval

    func play() {
        print("Playing movie: \(title)")
    }

    func stop() {
        print("Stopping movie: \(title)")
    }
}

Usage:

let movie = Movie(title: "Eternal Echoes", duration: 125)
movie.play() // Prints - Playing movie: Eternal Echoes
movie.stop() // Prints - Stopping movie: Eternal Echoes

Conforming Type: Podcast (conforms to AudioPlayable):

struct Podcast: AudioPlayable {
    let title: String
    let artist: String

    func play() {
        print("Playing podcast: \(title) by \(artist)")
    }

    func pause() {
        print("Pausing podcast: \(title)")
    }
}

Usage:

let podcast = Podcast(title: "TechTalk with Sarah Johnson", artist: "Sarah Johnson")
podcast.play() // Prints - Playing podcast: TechTalk with Sarah Johnson by Sarah Johnson
podcast.pause() // Prints - Pausing podcast: TechTalk with Sarah Johnson

We can see how protocol inheritance allows for a hierarchical organization of media playback functionality. We have different conforming types with their specific behaviors, all conforming to the appropriate protocols.

When we create new media-related types in our application, we can easily extend the hierarchy by conforming to the appropriate protocols, ensuring that they meet the expected requirements.

Understanding The Interface Segregation Principle (with protocol)

The Interface Segregation Principle (ISP) is one of the SOLID principles of object-oriented design, and it emphasizes that no type (custom class or struct) should be forced to depend on methods it does not use.

By following the Interface Segregation Principle, we can create more maintainable and flexible code by designing focused protocols that can be used to the specific needs of our conforming types.

Addressing the problem:

Let's say, we are developing a tracking system for our app. This system is responsible for capturing and logging various types of events in the app, such as user engagement events, error events, and user registration events. Initially, we create a single protocol, Tracker, to define the methods for tracking all these events:

protocol Tracker {
    func trackPageView(pageName: String)
    func trackButtonClick(buttonName: String)
    func trackError(errorDescription: String)
    func trackUserRegistration(username: String)
    func trackUserLogin(username: String)
}

The problem with this approach is that all conforming types, such as different tracking services, are forced to implement all the methods defined in the Tracker protocol, even if they only need a subset of these methods.

Types that use the tracking services are tightly coupled to the Tracker protocol, making it challenging to switch between different tracking services or add new ones without affecting existing code.

Implementing the ISP Principle:

To address the problem, we can apply the Interface Segregation Principle by breaking down the Tracker protocol into smaller, more focused protocols, each representing a specific category of tracking events. For example:

// Protocol for tracking user engagement events
protocol UserEngagementTracker {
    func trackPageView(pageName: String)
    func trackButtonClick(buttonName: String)
}

// Protocol for tracking error events
protocol ErrorTracker {
    func trackError(errorDescription: String)
}

// Protocol for tracking user registration events
protocol UserRegistrationTracker {
    func trackUserRegistration(username: String)
    func trackUserLogin(username: String)
}

Now, each protocol defines a specific set of requirements tailored to its category of tracking events. Conforming types, such as tracking services, only need to implement protocols that are relevant to their purpose.

Let's see an example of how this approach solve the problem:

class AnalyticsService: UserEngagementTracker {
    func trackPageView(pageName: String) {
        print("Tracking page view: \(pageName)")
    }
    
    func trackButtonClick(buttonName: String) {
        print("Tracking button click: \(buttonName)")
    }
}

In the above example, the AnalyticsService is a concrete class that implements the UserEngagementTracker protocol. It is responsible for tracking user engagement events in the app.

class ErrorLoggingService: ErrorTracker {
    func trackError(errorDescription: String) {
        print("Logging error: \(errorDescription)")
    }
}

In the above example, the ErrorLoggingService is a concrete class that implements the ErrorTracker protocol. It is responsible for logging error events that occur within the app.

class UserRegistrationService: UserRegistrationTracker {
    func trackUserRegistration(username: String) {
        print("Tracking user registration: \(username)")
    }
    
    func trackUserLogin(username: String) {
        print("Tracking user login: \(username)")
    }
}

In the above example, the UserRegistrationService is a concrete class that implements the UserRegistrationTracker protocol. It handles user registration and login events in the app.

These classes adhere to the Interface Segregation Principle by implementing only the methods related to their specific tracking purposes. This separation of concerns ensures that each class has a clear and distinct responsibility, making the code more maintainable and adaptable.

Protocol Inheritance Vs Class Inheritance

Protocol inheritance and class inheritance are two different approaches to defining relationships between types. They serve different purposes and have specific characteristics. Let's understand the comparison between protocol inheritance and class inheritance.

Purpose:

Protocol Inheritance is defined a set of requirements that conforming types must adhere to. While, class inheritance is used to establish a hierarchical relationship where a subclass inherits properties and behaviors from a superclass.

Multiple Inheritance:

A type can conform to multiple protocols, allowing for multiple inheritance. While, a class can inherit from only one superclass, meaning single inheritance.

Inherited Properties:

Protocols can declare properties, but they are required to be gettable only, and conforming types must provide their implementations. While, subclasses inherit all properties (both stored and computed) from the superclass, with the option to override them.

Default Implementation:

Protocols can provide default implementations for methods and properties, allowing conforming types to opt in or provide custom implementations. While, superclasses can provide method implementations that are inherited by subclasses but can be overridden if needed.

Let's compare protocol inheritance and class inheritance with an example:

Protocol Inheritance:

We can create custom protocols for different aspects of view controller behavior like below:

protocol DataLoader {
    func loadData()
}

protocol Authenticator {
    func authenticateUser()
}

class LoginViewController: UIViewController, DataLoader, Authenticator {
    func loadData() {
        print("Loading data...")
    }
    
    func authenticateUser() {
        print("Authenticating user...")
    }
}

Class Inheritance:

Class inheritance is often used when building a hierarchy of view controllers, such as when creating a base-detail view structure like below:

class BaseViewController: UIViewController {
    func commonSetup() {
        print("Common setup for all view controllers")
    }
}

class DataViewController: BaseViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        commonSetup()
        loadData()
    }

    func loadData() {
        print("Loading data...")
    }
}

Benefits of Protocol Inheritance:

  • Code Reusability: By defining a hierarchy of protocols, we can reuse common behaviors and requirements across different parts of our codebase.
  • Modularity: Protocols and protocol inheritance allow us to break down complex behaviors into smaller, more manageable pieces.
  • Type Safety: Swift's strong typing system ensures that conforming types adhere to the protocols' contracts, reducing runtime errors.

Where to go next?

Congratulations, we have explored the Protocol Inheritance concept and this will let us make our codebase more readable and less redundant. You can check complete guide to Protocol Oriented Programming here or learn about Protocol Extensions and Protocol Composition in order to make full use of POP in Swift.