Protocols

Protocols are a very powerful tool that guarantees and sometimes augments the functionality of data types that conform to it. A protocol lays out a set of fields and methods. Then, for a data type to conform to said protocol, it must implement the fields and methods set forth in the protocol. Hence, when a data type conforms to a protocol, it guarantees the functionality that the protocol describes. Consequently, we can manipulate these data types using these behaviors without worry of how they're implemented (unless of course, you do not trust the implementer).

Protocols can get very complex. But ultimately, all you need to understand if that we use protocols to guarantee that certain data types have a shared basic minimum functionality and also their declarations. Features like associated types, mutating, required, etc. are lovely bells and whistles that Swift has, but are not that important when building UI unless you are doing some very cool stuff.

Conformance

Conformance is written exactly like inheritance declaration : SomeType, Maybe another type... Protocols, Structs, and Classes can all conform to a set of protocols. For classes, if the class inherits from a superclass, the superclass must precede any protocols declaration: superclass, protocol, another protocol.

Fields

To declare a field, write the type declaration of a variable: var someName: someType. And yes, this has to be variable, not a let constant. But, variable properties must follow every field. Properties must either be { get set } or { get } for typical variables or get-only variables respectively (essentially let constants).

Methods

Declaring methods and static methods work the same as ever–there just cannot be any implementations. Any function declared in the body of a protocol are functions that had should be callable, but their implementation is invisible and unimportant to the writer of the protocol–they only care that the job gets done. Now that certainly does not the protocol writer from writing documentation on how they want it to behave. If you want to have an implementation for every type that conforms to a protocol, in a manner similar to inheritance, look at default functions below.

One special note, if a method changes the fields within an instance of a data type (usually, but not only, functions that have a void return type) this function is said to mutate that instance. This does not matter for classes, but any time a struct implements a mutating function, it must be tagged with mutating mutating func someFunction(...) -> .... Its best practice to keep this in a protocol, so the implementer can choose a struct or a class.

Example

Below I am going to implement a type IntegerSet. It's not a particularly useful protocol, as it doesn't lend itself to very many implementations–it's only integers. But it's a good tool to see how to write protocols. If you want more complex protocols that change its type–think generics with protocols–see Protocols with Associated Types below.

protocol IntegerSet {
    
    /**
    Number of unique elements in the set
     */
    var length: Int { get }
    
    /**
    Unique elements in the set
     */
    var values: [Int] { get set }
    
    /**
    Adds `element` to the set.
     */
    mutating func add(_ element: Int)
    
    /**
    - returns:
     whether a set contains an element
     */
    func contains(_ element: Int) -> Bool
    
}

Implementation

// Class Integer Set
class CISet: IntegerSet {

    private var _values: [Int]

    var values: [Int] {
        get { _values }
        set { _values = sortUnique(newValue) }
    }
    var length: Int { _values.count }

    func add(_ element: Int) {
        if !contains(element) {
            _values.append(element)
        }
    }

    func contains(_ element: Int) -> Bool {
        _values.contains(element)
    }

    func sortUnique(_ values: [Int]) -> [Int] {
        var sortedValues = [Int]()
        for (index, value) in values.enumerated() {
            if index == values.count - 1 { sortedValues.append(value); continue }
            if !values[index + 1..<values.count - 1].contains(value) {
                sortedValues.append(value)
            }
        }
        return sortedValues
    }
    
// Note: the required is required in classes but is not allowed in structs
// because the init is described in the protocol
    required init(values: [Int]) {
        self._values = values
    }

}
// Struct Integer Set
struct SISet: IntegerSet {
    
    private var _values: [Int]
    
    var values: [Int] {
        get { _values }
        set { _values = sortUnique(newValue) }
    }
    var length: Int { _values.count }
    
    mutating func add(_ element: Int) {
        if !contains(element) {
            _values.append(element)
        }
    }
    
    func contains(_ element: Int) -> Bool {
        _values.contains(element)
    }
    
    mutating func sortUnique(_ values: [Int]) -> [Int] {
        var sortedValues = [Int]()
        for (index, value) in values.enumerated() {
            if index == values.count - 1 { sortedValues.append(value); continue }
            if !values[index + 1..<values.count - 1].contains(value) {
                sortedValues.append(value)
            }
        }
        return sortedValues
    }
    
    init(values: [Int]) {
        self._values = values
    }
    
    
}

Encapsulation

In both implementations of Integer Set above, I use the function sortUnique(_:). This function is not described in the protocol, which is important. For front-end development, one of the most common uses of protocols is delegation–we're not going to be defining complex data types very often. Delegation hides the more complex pieces of a class's implementation so that children object can communicate with their parents without messing up unrelated components–think of this as another of encapsulation, so other devs only work with what they need.

let normalSet: CISet = CISet(values: [1, 2, 3])
let encapsulatedSet: IntegerSet = CISet(values: [1, 2, 3])

Both normalSet and encapsulatedSet store the same information, but since normal encapsulatedSet is stored as an IntegerSet and not a CISet, it can only guarantee the features of the original protocol. Thus, encapsulatedSet's extra features are sealed off. Of course, one can get around this with typecasting, but that's dangerous and can be very bad programming practice if the system is not carefully designed.

Default Functions

After defining fields and methods, as shown above, we can now assume a minimum basic functionality for all data types that conform to a protocol. We can imagine that given this basic functionality, it may make sense to synthesize some new functionality using the shared properties–create functions that leverage the implementations of required fields and methods to add functionality to conformant types.

This is where default functions come in. As the name "default function" implies, they can be overridden–think of these likes functions of a superclass. They exist by default, but we can override them (no override tag necessary here though).

Okay great, this sounds pretty useful! How do I do it? As stated before, anything that goes in the actual protocol declaration is something the implementer of a conforming type must guarantee to any user of their code, but we don't actually need or even want the implementer of these types to touch a default function because we are trying to save them from that work in the first place. Hence, instead of writing the implementation in the actual protocol declaration, we write these in an extension of the protocol.

extension IntegerSet {
    
    mutating func union(_ set: IntegerSet) {
        for element in set.values {
            self.add(element)
        }
    }
    
}

Now both implementations of Integer set, can use union with each other to combine into one set!

Associated Types

Be warned this one of the hardest, most frustrating, and rare Swift features. You'll notice that in the above implementation of sets, it only works for integers. The solution to this is associated types. For the vast majority of cases like this, you will want to use a generic type–it's just so much easier. If you simply cannot fully implement it in a generic, almost as if you were using an abstract class in java, then you may need to use associated types. But again, this is very rare. There is evidence that this feature is currently being worked on by the lovely developers on the Swift Team, but for now, this is quite the topic.

We can parameterize a protocol with the keyword associatedType. The associatedType keyword should be thought of like the typealias keyword–it gives a type another name. After naming this type, we can use it anywhere in our protocol. Of course, since this is a protocol, we don't actually say what that type is, we just give it a name and let the implementer figure out what they want.

** Yet to be finished **

Last updated