Protocol-oriented Programming in Swift

Protocol-oriented Programming in Swift

Protocols are used to define a “blueprint of methods, properties, and other requirements that suit a particular task or piece of functionality.”

Swift checks for protocol conformity issues at compile-time, allowing developers to discover some fatal bugs in the code even before running the program. Protocols allow developers to write flexible and extensible code in Swift without having to compromise the language’s expressiveness.

Swift takes the convenience of using protocols a step further by providing workarounds to some of the most common quirks and limitations of interfaces that plague many other programming languages.

What is a protocol?

In its simplest form, a protocol is an interface that describes some properties and methods. Any type that conforms to a protocol should fill in the specific properties defined in the protocol with appropriate values and implement its requisite methods. For instance:

protocol Queue {
    var count: Int { get }
    mutating func push(_ element: Int) 
    mutating func pop() -> Int
}

The Queue protocol describes a queue, that contains integer items. The syntax is quite straightforward.

Inside the protocol block, when we describe a property, we must specify whether the property is only gettable { get } or both gettable and settable { get set }. In our case, the variable Count (of type Int) is gettable only.

If a protocol requires a property to be gettable and settable, that requirement cannot be fulfilled by a constant stored property or a read-only computed property.

If the protocol only requires a property to be gettable, the requirement can be satisfied by any kind of property, and it is valid for the property to also be settable, if this is useful for your own code.

For functions defined in a protocol, it is important to indicate if the function will change the contents with the mutating keyword. Other than that, the signature of a function suffices as the definition.

To conform to a protocol, a type must provide all instance properties and implement all methods described in the protocol. Below, for example, is a struct Container that conforms to our Queueprotocol. The struct essentially stores pushed Ints in a private array items.

struct Container: Queue {
    private var items: [Int] = []
    
    var count: Int {
        return items.count
    }
    
    mutating func push(_ element: Int) {
        items.append(element)
    }
    
    mutating func pop() -> Int {
        return items.removeFirst()
    }
}

Our current Queue protocol, however, has a major disadvantage.

Only containers that deal with Ints can conform to this protocol.

We can remove this limitation by using the “associated types” feature. Associated types work like generics. To demonstrate, let’s change the Queue protocol to utilize associated types:

protocol Queue {
    associatedtype ItemType
    var count: Int { get }
    func push(_ element: ItemType) 
    func pop() -> ItemType
}

Now the Queue protocol allows the storage of any type of items.

In the implementation of the Container structure, the compiler determines the associated type from the context (i.e., method return type and parameter types). This approach allows us to create a Container structure with a generic items type. For example:

class Container<Item>: Queue {
    private var items: [Item] = []
    
    var count: Int {
        return items.count
    }
    
    func push(_ element: Item) {
        items.append(element)
    }
    
    func pop() -> Item {
        return items.removeFirst()
    }
}

Using protocols simplifies writing code in many cases.

For instance, any object that represents an error can conform to the Error (or LocalizedError, in case we want to provide localized descriptions) protocol.

The same error handling logic can then be applied to any of these error objects throughout your code. Consequently, you don’t need to use any specific object (like NSError in Objective-C) to represent errors, you can use any type that conforms to the Error or LocalizedError protocols.

You can even extend the String type to make it conform with the LocalizedError protocol and throw strings as errors.

extension String: LocalizedError {
    public var errorDescription: String? {
          Return NSLocalizedString(self, comment:””)
    }
}


throwUnfortunately something went wrong”


func handle(error: Error) {
    print(error.localizedDescription)
}

Protocol Extensions

Protocol extensions build on the awesomeness of protocols. They allow us to:

  1. Provide default implementation of protocol methods and default values of protocol properties, thereby making them “optional”. Types that conform to a protocol can provide their own implementations or use the default ones.
  2. Add implementation of additional methods not described in the protocol and “decorate” any types that conform to the protocol with these additional methods. This feature allows us to add specific methods to multiple types that already conform to the protocol without having to modify each type individually.

Default Method Implementation

Let’s create one more protocol:

protocol ErrorHandler {
    func handle(error: Error)
}

This protocol describes objects that are in charge of handling errors that occur in an application. For example:

struct Handler: ErrorHandler {
    func handle(error: Error) {
        print(error.localizedDescription)
    }
}

Here we just print the localized description of the error. With protocol extension we are able to make this implementation be the default.

extension ErrorHandler {
    func handle(error: Error) {
        print(error.localizedDescription)
    }
}

Doing this makes the handle method optional by providing a default implementation.

The ability to extend an existing protocol with default behaviors is quite powerful, allowing protocols to grow and be extended without having to worry about breaking compatibility of existing code.

Conditional Extensions

So we’ve provided a default implementation of the handle method, but printing to the console is not terribly helpful to the end user.

We’d probably prefer to show them some sort of alert view with a localized description in cases where the error handler is a view controller. To do this, we can extend the ErrorHandler protocol, but can limit the extension to only apply for certain cases (i.e., when the type is a view controller).

Swift allows us to add such conditions to protocol extensions using the where keyword.

extension ErrorHandler where Self: UIViewController {
    func handle(error: Error) {
        let alert = UIAlertController(title: nil, message: error.localizedDescription, preferredStyle: .alert)
        let action = UIAlertAction(title: "OK", style: .cancel, handler: nil)
        alert.addAction(action)
        present(alert, animated: true, completion: nil)
    }
}

Self (with capital “S”) in the code snippet above refers to the type (structure, class or enum). By specifying that we only extend the protocol for types that inherit from UIViewController, we are able to use UIViewController specific methods (such as present(viewControllerToPresnt: animated: completion)).

Now, any view controllers that conform to the ErrorHandler protocol have their own default implementation of the handle method that shows an alert view with a localized description.

Ambiguous Method Implementations

Let’s assume that there are two protocols, both of which have a method with the same signature.

protocol P1 {
    func method()
    //some other methods
}




protocol P2 {
    func method()
    //some other methods
}

Both protocols have an extension with a default implementation of this method.

extension P1 {
    func method() {
        print("Method P1")
    }
}




extension P2 {
    func method() {
        print("Method P2")
    }
}

Now let’s assume that there is a type, that conforms to both protocols.

struct S: P1, P2 {
    
}

In this case, we have an issue with ambiguous method implementation. The type doesn’t indicate clearly which implementation of the method it should use. As a result, we get a compilation error. To fix this, we have to add the implementation of the method to the type.

struct S: P1, P2 {
    func method() {
        print("Method S")
    }
}

Many object-oriented programming languages are plagued with limitations surrounding the resolution of ambiguous extension definitions. Swift handles this quite elegantly through protocol extensions by allowing the programmer to take control where the compiler falls short.

Adding New Methods

Let’s take a look at the Queue protocol one more time.

protocol Queue {
    associatedtype ItemType
    var count: Int { get }
    func push(_ element: ItemType) 
    func pop() -> ItemType
}

Each type that conform to the Queue protocol has a count instance property that defines the number of stored items. This enables us, among other things, to compare such types to decide which one is bigger. We can add this method through protocol extension.

extension Queue {
    func compare<Q>(queue: Q) -> ComparisonResult where Q: Queue  {
        if count < queue.count { return .orderedDescending }
        if count > queue.count { return .orderedAscending }
        return .orderedSame
    }
}

This method is not described in the Queue protocol itself because it is not related to queue functionality.

It is therefore not a default implementation of the protocol method, but rather is a new method implementation that “decorates” all types that conform to the Queue protocol. Without protocol extensions we would have to add this method to each type separately.


Protocol Extensions vs. Base Classes

Protocol extensions may seem quite similar to using a base class, but there are several benefits of using protocol extensions. These include, but are not necessarily limited to:

  1. Since classes, structures and, enums can conform to more than one protocol, they can take the default implementation of multiple protocols. This is conceptually similar to multiple inheritance in other languages.
  2. Protocols can be adopted by classes, structures, and enums, whereas base classes and inheritance are available for classes only.

Swift Standard Library Extensions

In addition to extending your own protocols, you can extend protocols from the Swift standard library. For instance, if we want to find the average size of the collection of queues, we can do so by extending the standard Collection protocol.

Sequence data structures provided by Swift’s standard library, whose elements can be traversed and accessed through indexed subscript, usually conform to the Collection protocol. Through protocol extension, it is possible to extend all such standard library data structures or extend a few of them selectively.


Note: The protocol formerly known as CollectionType in Swift 2.x was renamed to Collection in Swift 3.
extension Collection where Iterator.Element: Queue {
    func avgSize() -> Int {
        let size = map { $0.count }.reduce(0, +)
        return Int(round(Double(size) / Double(count.toIntMax())))
    }
}

Now we can calculate the average size of any collection of queues (Array, Set, etc.). Without protocol extensions, we would have needed to add this method to each collection type separately.

In the Swift standard library, protocol extensions are used to implement, for instance, such methods as map, filter, reduce, etc.

extension Collection {
    public func map<T>(_ transform: (Self.Iterator.Element) throws -> T) rethrows -> [T] {




    }
}

Protocol Extensions and Polymorphism

As I said earlier, protocol extensions allow us to add default implementations of some methods and add new method implementations as well. But what is the difference between these two features? Let’s go back to the error handler, and find out.

protocol ErrorHandler {
    func handle(error: Error)
}


extension ErrorHandler {
    func handle(error: Error) {
        print(error.localizedDescription)
    }
}


struct Handler: ErrorHandler {
    func handle(error: Error) {
        fatalError("Unexpected error occurred")
    }
}


enum ApplicationError: Error {
    case other
}


let handler: Handler = Handler()
handler.handle(error: ApplicationError.other)

The result is a fatal error.

Now remove the handle(error: Error) method declaration from the protocol.

protocol ErrorHandler {
    
}

The result is the same: a fatal error.

Does it mean that there is no difference between adding a default implementation of the protocol method and adding a new method implementation to the protocol?

No! A difference does exist, and you can see it by changing the type of the variable handler from Handler to ErrorHandler.

let handler: ErrorHandler = Handler()

Now the output to the console is: The operation couldn’t be completed. (ApplicationError error 0.)

But if we return the declaration of the handle(error: Error) method to the protocol, the result will change back to the fatal error.

protocol ErrorHandler {
    func handle(error: Error)
}

Let’s look at the order of what happens in each case.

When method declaration exists in the protocol:

The protocol declares the handle(error: Error) method and provides a default implementation. The method is overridden in the Handler implementation. So, the correct implementation of the method is invoked at runtime, regardless of the type of the variable.

When method declaration doesn’t exist in the protocol:

Because the method is not declared in the protocol, the type is not able to override it. That is why the implementation of a called method depends on the type of the variable.

If the variable is of type Handler, the method implementation from the type is invoked. In case the variable is of type ErrorHandler, the method implementation from the protocol extension is invoked.


Protocol-oriented Code: Safe yet Expressive

In this article, we demonstrated some of the power of protocol extensions in Swift.

Unlike other programming languages with interfaces, Swift doesn’t restrict protocols with unnecessary limitations. Swift works around common quirks of those programming languages by allowing the developer to resolve ambiguity as necessary.

With Swift protocols and protocol extensions the code you write can be as expressive as most dynamic programming languages and still be type-safe at compilation time. This allows you to ensure reusability and maintainability of your code and to make changes to your Swift app codebase with more confidence.






Pavan Shisode

Senior Specialist - iOS | Swift | SwiftUI | Combine | MQTT | IoT | CI/CD

7 年

Thanks for sharing.

要查看或添加评论,请登录

社区洞察

其他会员也浏览了