Mastering Swift Protocols

Understand Swift Protocols and use them correctly

Serge Mata M
18 min readMay 8, 2020

In everyday jargon, we talk about protocols to refer to the set procedures or system of rules governing events. Whenever you are taking part in an event, you need to adhere to the protocol.

Comprehensive definition

Swift protocols are not different from events protocols, they too provide a means of defining a no result-guaranteed group of actions that a set of objects can perform or respond to.

Let’s unpack the key elements of the previous statement, and see how they can help understand Apple’s official definition of protocols: “A protocol defines a blueprint of methods, properties, and other requirements that suit a particular task or piece of functionality.

  1. Perform or Respond to:
protocol OpenProtocol {
var debugDescription: String { get }
func doSomething()
}

A protocol may require:

  • instance methods which are the actions that the adopting entities must all be able to perform.
  • computed properties which are in essence, disguised getters. Hence they fall within the actions that all adopting entities must be able to respond to.

Note: A protocol can be defined as conforming to an existing protocol, in that case, all the requirements of the parent protocol are ipso facto the requirements of the child protocol, and conformance to child protocol guarantees conformance to the parent protocol. This is referred to as protocol inheritance, just like classes, protocols can also be composed in that case we speak about protocol composition.

protocol OpenProtocol1: OpenProtocol { // inheritance
func doTheFirstThing()
}
protocol OpenProtocol3: OpenProtocol { // inheritance
func doTheSecondThing()
}
protocol ComposedProtocol: OpenProtocol, OpenProtocol3 {
// composition
}

2. No result-guaranteed

An object that conforms to a protocol promises that it will implement all non-optional methods of that protocol, but never makes any promise on the results.

Furthermore, an object that conforms to a protocol promises that it will provide all computed properties of the protocol, but never makes any promise on the value returned.

2. Set of objects

When a protocol is defined, it always restricts the kind of entities ie. types that can conform to it.

  • Without any specification, the protocol is open and any type (class, struct, enum, etc.) can conform to it:
protocol OpenProtocol { 
// can be adopted by any type
var debugDescription: String { get }
func doSomething()
}

A protocol can be class-bounded, to indicate that only class types can conform to it. Note that theclass keyword used here is just a typealias for AnyObject.

protocol ClassBoundedProtocol: class { 
// can only be adopted by classes
var debugDescription: String { get }
func doSomething()
}

A protocol can be bounded to a specific class, to indicate that only classes subclassing the specific class can conform to it.

protocol ErrorPresenter: UIViewController { 
// can only be adopted by uiviewcontroller types
func hideAllErrors()
func show(error: Error)
func hide(error: Error)
}

It is important to mention that a protocol is adopted on the type level and not on the instance level. The latter makes it justifiable to allow for static requirements on a protocol, hence we can define a protocol by:

protocol StaticRequirementContainingProtocol: OpenProtocol {
// MARK: - Static Requirements
static var typeName: String { get }
static func isTypeNameEquals(to comparingToTypeName: String) -> Bool
}

It is important to mention, when dealing with static requirements, that we need to use safe type accessors such as type(of: T) when we wish to perform static protocol requirements calls on given objects of any conforming type.

func validateType(aProtocol: StaticRequirementContainingProtocol) -> Bool {
type(of: aProtocol).isTypeNameEquals(to: "")
}

Naming Convention

The complete Swift naming guidelines can be found here.

  • Protocols names areUpperCamelCase.
  • Protocols that describe what something is should read as nouns (e.g. Collection).
  • Protocols that describe a capability should be named using the suffixes able, ible, or ing (e.g. Equatable, ProgressReporting).
  • If an existing type is so tightly bound to a protocol’s role, avoid collision by appending Protocol to the protocol name. (e.g. CarEngineProtocol)

Opaque Types

A function can return an opaque when there is a need to hide type identity. An opaque type is defined as some Protocol where Protocol is the protocol behind which we wish to hide the type identity. A function returning an opaque is defined with the following signature:

func object(forKey key: String) -> some OpenProtocol

The key achievement of the above is that at the call site the returned variable is just anOpenProtocol object. However, the compiler and the implementing class have full knowledge of the underlying concrete type of the object returned.

An opaque type can be seen as the reverse of a generic type. The types to which the generic types are mapped are dictated by the call site, and the function implementation is based on the abstract footprint of acceptable types. Those roles are reversed for a function with an opaque return type. An opaque type lets the function implementation pick the type for the value it returns in a way that is abstracted away from the call site.

Some possible applications of opaque types can bepublic contracts orAPIs that allow intercommunications between modules, frameworks. The public interface (public contract) may consist of operations like passing commands or requesting data; those operations can be restricted to return some Protocol. Hiding type information is useful at the APIs which constitute the boundaries between the various modules because the underlying type of the return value can remain private.

public class AGivenModule {  private struct AStruct: OpenProtocol { //private type
var debugDescription: String
func doSomething() {}
}
private struct AnotherStruct: OpenProtocol { //private type
var debugDescription: String
func doSomething() {}
}
private lazy var aProperty = AStruct(debugDescription: "Debug")
private lazy var anotherProperty = AnotherStruct(debugDescription: "Other")
// Public API
public func object(forKey key: String) -> some OpenProtocol {
if aProperty.debugDescription==key {
return aProperty
} else if anotherProperty.debugDescription==key {
return anotherProperty
}
return AStruct(debugDescription: "")
}
}

Protocol requirements

Properties

Properties requirements can be specified as get only or both get and set . You cannot have a set only property requirement since you can only set something that you should be able to read.

Properties can be defined on an instance or a type level. The latter is achieved by the use of the static qualifier. The class cannot be used in protocol requirements since the protocol definition does not tie it to a particular class.

protocol PropertiesProtocol: class {
var aGetOnlyProperty: String { get }
var aGetAndSetProperty: String { get set }
static var aTypeLevelProperty: String { get set } // class var aClassLevelProperty: String { set } // Not possible
// var aSetOnlyProperty: String { set } // Not possible
}

Methods

Instance and type methods can be required; those protocol methods cannot provide default parameters in the protocol definition. Variadic and generic parameters are allowed. Protocol methods cannot return opaque types. Similarly, with type property requirements, the only keyword allowed to indicate a type method is static.

Mutating methods are allowed in non-class bounded protocols and should be used when the method might modify the value of any of the properties of the adopting objects.

protocol SomeCache {
static func setTime(lifeTime: TimeInterval) // type method
func registerForMemoryWarning() // intance method mutating func clear() // mutating instance method,
// only allowed in non-class bounded protocol
func savedObject<T>() -> T? // Generic method
func store(value: Any...) // Method with variadic parameters
}

Initializers

Initializer requirements, which can be failable or not, should be defined without the func keyword and must be named init.

protocol SomeCollectionProcol {
init() // non failable init
}
protocol SomeCollectionProcol {
init?(collectionLiteral: String) // failable init
}

Even though Swift allows for initializers requirements in Protocols, my personal take on this is that they should be avoided as much as possible. My argument is that abstractions, from the call site perspective, should ideally be post-existence and not pre-existence tools. The conforming objects must exist prior to applying any logic driven by the abstraction requirements onto them. By providing an init requirement, we are implicitely saying that we could create conforming objects using the protocol requirements. Given the fact that an init is a type and not instance method, this implies that we either know the type beforehand or we retrieve its underlying type via type accessors.

From the previous statement, arise the following questions:

  • If we know the underlying concrete type, why bother to see it as an abstraction behindprotocol type, especially if it is an object that is internally stored and used?
  • If we have a conforming object already, why bother getting its type just for the sake of creating another conforming object?
  • Knowing that abstractions should not depend on details; are we not breaking dependency inversion principle by providing an init that receives concrete type details as some of its arguments?

If and only if, you can provide valid answers to the above questions, you can then consider having initializer requirements in your protocol definition.

Note: You will need to use type accessors and not the concrete type names to create protocol instances.

Protocol operations

  1. is operator

Theis operator is used for checking for conformance against a Protocol. It is used with the following syntax:

let doesConformToProtocol = anObject is SomeCache

The result is a boolean, evaluated to true if anObject conforms to SomeCache and false otherwise.

  1. as operator

The as operator is used for casting a given object into Protocol object. The casting can be performed forcefully using as! (non-failable) or gracefully as? (failable).

if let cache = anObject as? SomeCache {
cache.registerForMemoryWarning() // non optional
}
// or(anObject as? SomeCache)?.registerForMemoryWarning()// orlet cache = anObject as? SomeCache
cache?.registerForMemoryWarning()

Note: While the as? can be used at any point without any prior checks, as! must only be used when the result is guaranteed to succeed. It should ideally be protected by a prior check against is operator, which must have been evaluated to true.

if anObject is SomeCache { 
registerForMemoryWarning(anObject)
}
fun registerForMemory(_ anObject: AnyObject) {
let cache = anObject as! SomeCache
cache.registerForMemoryWarning()
}
  1. & operator

When an instance property or a method argument should conform to multiple Protocols, one option is to represent its type by a new Protocol that can be defined using protocol composition. Let’s assume two protocolsPrototolA and ProtocolB; we can build a third one,ProtocolC as follows:

protocol ProtocolA {}
protocol ProtocolB {}
protocol ProtocolC: ProtocolA, ProtocolB {} // protocol composition
class A {
var aProperty: ProtocolC
}
func accept(protocol: ProtocolC) {}

However, protocol composition only makes sense when there is an empirical meaning of ProtocolC. Whenever the latter does not have any essential meaning or does not establish a certain correlation between PrototolA and ProtocolB, it is errosnoues to use composition. To our rescue comes the & operator, best suited to indicate the mere fact that a given object must simultaneously conform to multiple protocols and/or be of a certain concrete class-type.

class A {
var aProperty: ProtocolA & ProtocolB
}
func accept(protocol: ProtocolA & ProtocolB) {}

The & operator can be applied between multiple types provided that at least one the operands is aProtocol and no more than one operand is of a class-type. When the need arise to reduce the verbosity, it can be achieved by the use oftypealias:

typealias PropertyType = ProtocolA & ProtocolB & ProtocolB & AClass

Protocol conformance

It is important to mention that a Protocol does not impose the storage mechanism of the properties on adopting types. The protocol get only requirements can be implemented as stored (let and var) or computed properties. Similarly, the get and set requirements can either be implemented as stored properties(var only) or as computed properties providing respectively providing both a set and a get.

Even if default parameters are not allowed in the protocol methods definitions, the conforming types can implement the required methods with signatures providing default parameters. Though the compiler will validate the conformance to the protocol, at the call site of any of these methods on the protocol objects, one will still need to pass all parameters. The only advantage in implementing a protocol method with default parameters is to allow for the omission of those parameters when the said method is called on the object, seen as the representation of its concrete type but not a protocol object.

To illustrate the points in the two preceding paragraphs, let’s consider a protocol SomeCache defined as follows:

protocol SomeCache {
var lifetime: String { get } // get only property
var storageCount: Double { get } // get only property
var settableProperty: String { get set } // set and get property
var anotherSettableProperty: String { get set } // set and get property
func invalidate(keepingStoredData shouldKeepStoreData: Bool)
}

and consider a struct conforming to it:

struct MyStruct: SomeCache {
let storage = NSMutableSet()
private(set) let lifeTime: TimeInterval = 1000 // get only
// property requirement implemented as stored let property

var storageCount: Int { storage.count } // get only property
// implemented as computed property
var settableProperty: String { // get and set
// property requirement implemented
// as computed properties providing getter and setter

get { "" }
set { /* perform the set operation */ }
}
var anotherSettableProperty: String = "Test" // get and set
// property requirement implemented as stored var property

// method requirement implemented with default parameter
func invalidate(keepingStoredData shouldKeepStoreData: Bool = true) { }}let concreteTypeObject: MyStruct = MyStruct()
concreteTypeObject.invalidate() // can be called considering default param
let protocolObject: SomeCache = MyStruct()
protocolObject.invalidate(keepingStoredData: false) // cannot be called considering default param

Default Implementation

To provide a default implementation of a given requirement (property or method) ie. one that is conferred to any adopting types, you will have to use extensions. If you provide the implementations for all requirements, any qualifying type can adopt the protocol at no extra code addition cost, just mark the type as conforming to the protocol and voila. A type-specific implementation of a required method or property supersedes the default implementation.

protocol ProtocolWithExtensionProvingSomeRequirement {
var property: String { get }
var anotherProperty: Double { get }
}
extension ProtocolWithExtensionProvingSomeRequirement {
var property: String { "A given string" }
}
struct StructNotProvingOwnImplementation: ProtocolWithExtensionProvingSomeRequirement {
// needs to provide implementation for anotherProperty
var anotherProperty: Double { 0.0 }}struct StructProvidingOwnImplementation: ProtocolWithExtensionProvingSomeRequirement {
// needs to provide implementation for anotherProperty
var property: String { "This will be used instead " } // will be returned instead of the default implementation return value var anotherProperty: Double { 0.0 }}protocol ProtocolWithExtensionProvidingAllsRequirements {
var property: String { get }
}
extension ProtocolWithExtensionProvidingAllsRequirements {
var property: String { "A given string" }
}
struct StructProviding: ProtocolWithExtensionProvidingAllsRequirements {
// no need to provide any implementation
}

When you find yourself writing a default implementation, especially of a method requirement with no code or with dummy code such asprint, this is usually symptomatic of an incorrect interface segregation. Indeed, default implementation as the words stipulate are meant to be implementation and not no-implementation. The latter means that not all of the anticipated conforming types do not need that method, you should therefore consider breaking the protocols into many and using protocol composition to defined suitable protocols for those conforming types that need to provide all the methods and properties of the composed protocols.

Note: We will discuss optional methods in Objc Swift protocols that could help address the above issue.

Conformance for third-party types

It is only possible to mark a type as conforming to a protocol at its definition when one owns it, i.e it is defined in the current module. However, for types that are part of Swift, Objc, third-party library, framework, or an external module, that one doest not own, the conformance has to be done using type extension. The latter will be responsible for providing all requirements not already provided by the original implementation of the unowned type.

Let’s assume the type String that is defined in Swift. If we wish to throw string objects as DisplayableError objects, we will have to do so using extensions as follows:

protocol DisplayableError: Error {
var errorInfo: String { get }
mutating func invalide()
}
extension String: DisplayableError {
var errorInfo: String { self }
func invalide() { self = "" }
}

The above type of conformance is said to be unconditional since it will be adopted by all String types. We speak about conditional conformance when there is a restriction on types to which the protocol requirements implementation is conferred. Assume, the protocol BoundedSummableCollection defined as follows:

protocol BoundedSummableCollection {
var min: Double? { get }
var max: Double? { get }
func sum() -> Double?
}

If our intention is that only collections holding floating values should be able to conform to this protocol, we can provide the following conditional conformance:

extension Array:BoundedSummableCollection where Element: BinaryFloatingPoint {  var max: Double? {
guard let maxElement = (sorted { (lhs, rhs) -> Bool in
lhs.magnitude > rhs.magnitude
}).first else
return nil
}
return Double(maxElement)
}
var min: Double? {
guard let minElement = (sorted { (lhs, rhs) -> Bool in
lhs.magnitude < rhs.magnitude
}).first else {
return nil
}
return Double(minElement)
}
func sum() -> Double {
let elementsSum = reduce(0,+)
return Double(elementsSum)
}
}

Note: We could have achieved the above using default implementations as follows:

extension BoundedSummableCollection where Self == Array<Double> { }     
// constraint is a type, use '=='
orextension BoundedSummableCollection where Element is Numberic {}
// constraint is a protocol, use 'is'

Note: As highlighted in the example, when the constraint is a concrete type, we use == and when the constraint is a protocol, we have to use is.

In those extensions, we can respectively use self as an object of type Array<Double> or an object conforming to Numeric, which allows performing all manipulations needed to similarly achieve what we did using the conditional conformance. Just because a given type or its extension provides all protocol requirements, doesn’t make it protocol-conforming; protocol conformance is only guaranteed by an explicit adoption and is never imply. We need to explicitly declare that Array conforms to BoundedSummableCollection in order to “activate” the conformance.

extension Array: BoundedSummableCollection where Double == Int {}

Providing initializer requirements

Initializer requirements can be implemented either as designated or convenience initializers, with a compulsory need for therequired modifier except for final classes because those can’t be subclassed.

protocol SomeCollectionProcol {
init?(collectionLiteral: String) // failable init
}
class ACollectionClass: SomeCollectionProcol {
required init?(collectionLiteral: String) { }
}

required is used to force all subclasses to provide an explicit or inherited implementation of the initializer requirement so that conformance is guaranteed for subclasses. If a subclass overrides a designated initializer from a superclass, and also implements a matching initializer requirement from a protocol, mark the initializer implementation with both the required and override modifiers.

Associated Type Protocols

The BoundedSummableCollection protocol has one major drawback. If its requirements apply to any Numeric collections, the value types demanded by them on the other side, are not consistent with various Numerics. Indeed, the maximum, the minimum and the sum of integers, unsigned integers, floats, must all be respectively integers, unsigned integers, floats, double. Currently, to apply BoundedSummableCollection to any collections holding non-double type elements, we will have to go through various back-and-forth casting exercises. Associated types provide a means for allowing the adopting types to specify the dynamic types returned or accepted by a protocol’s requirements. The dynamic types are defined using the keyword associatedtype, a given protocol can have many associated types, and associated types can have class-type or protocol constraints.

protocol BoundedSummableCollection {
associatedtype T: Numeric
// associated type with protocol constraint
associatedtype MappingObjcContainerClass: NSObject
// associated type with type constraint
// must be a class type
var min: T? { get }
var max: T? { get }
func sum() -> T?
}

With this improved definition of BoundedSummableCollection, adopting collections must provide the type associated with T which is constrained to be a Numeric and the one associated to MappingObjcContainerClass which is constrained to be an NSObject. Once the associated types are defined, the requirements must be implemented in terms of the specified types and not the generic types used in the protocol definition.

extension Array: BoundedSummableCollection where Element == Int {
typealias T = Int
typealias MappingObjcContainerClass = NSArray
var max: Int? { // use Int? instead of T?
return (sorted { (lhs, rhs) -> Bool in
lhs.magnitude > rhs.magnitude
}).first
}
var min: Int? { // use Int? instead of T?
return (sorted { (lhs, rhs) -> Bool in
lhs.magnitude < rhs.magnitude
}).first
}
func sum() -> Int { reduce(0,+) } // // use Int instead of T
}

Let’s assume that we store a collection ofBoundedSummableCollections

let boundedSummables: [BoundedSummableCollection]

The compiler will throw the following error :

protocol BoundedSummableCollection can only be used as a generic constraint because it has Self or associated type requirements.

This problem is caused by the fact that Swift generics are not covariant, which would have allowed the compiler to use Any for the collection element type; type erasure is a technique used to circumvent this problem. A comprehensive document about type erasure can be found here.

ObjC Swift Protocols

For a Swift protocol to be used in the ObjC realm, it needs to be marked as @objc. The immediate downside of marking a protocol as @objc is that it can only be adopted only by class-types that inherit from NSObject or @objc class-types, excluding therefore a possible adoption by structures or enumerations. Conversely, one of the benefits of an@objc marked Swift protocol is that it can contain optional requirements, which can be left unimplemented by conforming types. Requirements are made optional by preceding them with the optional qualifier in the protocol’s definition, all optional requirements must be @objc.

@objc protocol ObjcProtocol {
@objc optional var property: String { get }
var anotherProperty: Double { get }
@objc optional func doSomething()
}
class AdoptingClass: ObjcProtocol {
// doesn't have to implement `property` & `doSomething` requirements
var anotherProperty: Double { 0.0 }
}
let protocolObject: ObjcProtocol = AdoptingClass()let property = protocolObject.property //Accessing optional property
protocolObject.doSomething?() // Calling optional method

None of the requirements of @objc protocol can accept or return a non-objc types. Return types of optional properties are made optional by the compiler, optional method are called with a ? after the method name.

Applications for protocols

When a feature is present in a language, it does not suffice to understand it, it is also required to identify where its usage is best suited. In other terms, adding protocols in a codebase for the sake of doing so, is not commendable. The protocol-driven development trend has led to the widespread misuse of protocols. I have tried to identify some of the use cases where I believe, protocols provide the greatest value at the lowest cost.

  1. Abstraction layers

Delegation is a design pattern that allows for an executing object(the delegating object) to make another object (the delegate) responsible for handling the results of some of its actions and/or observations. The formal Apple definition can be found here. The delegating and the delegate objects will be, one the low-level module and the other the high-level module. They should communicate via an abstraction according to the dependency Inversion Principle.

Use protocols to define the abstractions under which the delegate objects will be stored in delegating objects; make sure to class-bound delegate protocol whenever you need to store the delegates weakly. For a deep understanding of Swift weak referencing please read this article. Delegate protocols should be named with the “Delegate” suffix.

struct Contact {
let name: String
let surname: String
let cellPhoneNumber: String
let officeNumber: String
}
class ContactRepository: ContactFetcherDelegate {
// delegate type, low level-module
func contactController(_ contactController: ContactController,
didAddContact: Contact) {
/* update contact list storage */
}
func contactController(_ contactController: ContactController,
didDelete: Contact) {
/* update contact list storage */
}
}
struct ContactController { // delegating type, high-level module
public weak var delegate: ContactFetcherDelegate?
}
protocol ContactFetcherDelegate: class {
// Abstraction layer between low and high-level module
func contactController(_ contactController: ContactController,
didAddContact: Contact)
// contactController uniquely identifies a ContactController
func contactController(_ contactController: ContactController,
didDelete: Contact)
}

It is important, and even required, that all delegate requirements convey information that uniquely identifies the delegating object since a single object can be the delegate of multiple delegating objects. The delegate can use that information as a differentiator.

In general, we can use Protocolsfor any valid abstractions needed. You should be able to adequately define a suitable protocol if you can correctly answer the following :

  • Is the abstraction needed?
  • What should the requirements of the abstraction?
  • What should the constraints of the abstraction?
  • Does it need dynamic behavior?
  • Can we identify generic behavior for conforming types and provide default implementations?

2. Heterogeneous collections of types with commonalities

To store objects of different types in a type-constraint collection one can use Any as the element type. However, collections are often created because of existing commonalities amongst the collection objects, as few as those could be. So, even if the collection objects are of different types, it is usually possible to identify the commonalities and to extract them into a protocol. Using a Protocol rather than Any as the element type of a collection offers the great advantage of reduced casting prior to acting on objects and increased predictability of the objects’ behavior.

let objets: [AnyObject] // not advisable, if possible identify 
// common properties in any possible
// object of that can go into the collection
protocol CollectionElement { // extract commonalities into a
// protocol

}
let objets: [CollectionElement] // replace colletion element type
// with protocol

3. Type exclusion

When we need to restrict thetypes that can be returned or accepted by a method, Protocols come in handy. The most obvious example is Error protocol defined by Apple, it is defined as a no-requirement protocol, which appears to be odd at first glance. However, it plays the crucial role of restricting throwable only to objects only conforming to Error, hence prevents random unintentional type throws.

Assume we want a Swift atomic type for primitives, we will need to make sure that an Atomic object can only be created with a type we know to be a primitive. We can define the Primitive and Atomic types as follows:

protocol Primitive {} // primitive protocol
extension Int: Primitive {}
extension Bool: Primitive {}
extension: UInt: Primivite {}
struct Atomic<Primitive> {
private var primitive: Primitive
}

4. Access restriction

Assume we want to ship a codebase that imports third-party modules, and that some of the pre-existing models in the said modules don’t restrict access to their properties. If we wish to prevent erroneous updates of these models, we can hide them behind a protocol that specifies requirements with stricter access policies. In doing so, one must avoid the need for any additional lines of code by defining in such a way that conformance is guaranteed by the existing implementations of those pre-existing models.

class Person { // existing model that allows for modification of properties, that can be modified  var name: String = ""
var surname: String = ""
var cellPhoneNumber: String = ""
var officeNumber: String = ""
init() {}
}
public protocol ContactDetailsProvider {
// restricting protocol, only provide get access
var name: String { get }
var surname: String { get }
var cellPhoneNumber: String { get }
var officeNumber: String { get }
}
extension Person: ContactDetailsProvider { } // No code-guaranteed conformance protocol ContactFetcherDelegate: class { // Abstraction layer
// between low and high-level module
func contactController(_ contactController: ContactController,
didAddContact: Person) // hide Person
// object behind protocol with restricted access
func contactController(_ contactController: ContactController,
didDelete: Person) // hide Person
// object behind protocol with restricted access
}

Additional documentation

Follow me on Twitter @SergeMbamba.

--

--