UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Controlling extension points in protocols

Why the two-stage creation of protocol extensions exists

Paul Hudson       @twostraws

Regular readers will know that I consider protocol-oriented programming to be one of three components that make natural Swift, alongside functional programming and preferring value types over reference types. (And if you didn’t already know that, click here to download a free video explaining the benefits these three provide!)

But POP comes with a particular quirk that will undoubtedly catch out new users: declaring a method in a protocol creates extension points. I want to demonstrate this with a real example worked up from basics, so you can see how it works, why it exists, and how you can use it to your own advantage.

We’re going to recreate a little piece of combat from Star Wars, by creating X-Wings and TIE Fighters. These are going to be implemented using protocols that lets them be targeted by other craft, shoot at other craft, and eject if they are destroyed.

So, create an empty playground and give it these three protocols:

protocol Targetable {
    func takeDamage()
}

protocol Firing {
    func shootAt(target: Targetable)
}

protocol Ejecting {
    func eject()
}

All spacecraft in our code will need to conform to those three, so you can combine them together either using a typealias:

typealias Fighter = Firing & Targetable & Ejecting

Or using plain protocol composition:

protocol Fighter: Firing, Targetable, Ejecting { }

Next we’re going to make two structs: one for X-Wings and one for TIE Fighters, both of which will conform to Fighter:

struct XWing: Fighter {
}

struct TIEFighter: Fighter {
}

That code won’t compile because those two structs don’t conform to their protocols. Rather than implement each method several times, we’re going to use protocol extensions to provide a default implementation:

extension Targetable {
    func takeDamage() {
        print("I'm hit!")
    }
}

extension Firing {
    func shootAt(target: Targetable) {
        print("I'm firing!")
        target.takeDamage()
    }
}

extension Ejecting {
    func eject() {
        print("I ejected")
    }
}

This code demonstrates a feature of protocol-oriented programming that you’ll see a lot: it has a two-step process where you declare methods in the protocol then create them in an extension.

This two-step process matters, because it affects the way default method implementations are used. We’ve provided a default eject() method to both our X-Wings and our TIE Fights, but that can be overridden to provide custom functionality.

For example, we might want X-Wings to fight to the death, while TIE Fighters are a bit more interested in self-preservation:

struct XWing: Fighter {
    func eject() {
        print("Eject? I'd rather crash into the Death Star!")
    }
}

struct TIEFighter: Fighter {
    func eject() {
        print("I'm totes out of here.")
    }
}

That replaces our previously empty structs with structs that contain implementations of the eject() method.

Now that we have a default implementation of eject() as well as two overridden implementations, Swift is faced with a choice: which eject() should it call?

The answer is “it depends”, and what it depends on is whether you use the two-stage process with your protocols and whether you use concrete types or rely on polymorphism.

POP uses polymorphism just like OOP does, so we can create an array of XWing and TIEFighter instances then ask them to eject():

let fighters: [Fighter] = [XWing(), XWing(), TIEFighter()]
fighters.forEach { $0.eject() }

That will print “Eject? I'd rather crash into the Death Star!” twice, then “I'm totes out of here,” as you’d expect.

Note: We need to be specific about the data type of the array because Swift doesn’t know whether it should be an array of Targetable, Ejecting, etc.

So far this seems pretty easy, but here’s where it gets confusing: we have a protocol requirement for eject(), we provide a default implementation in the protocol extension, then we provide a custom version inside XWing and TIEFighter.

However, try changing your Ejecting protocol extension to this:

protocol Ejecting {
//    func eject()
}

All that does is comment out the protocol requirement for an eject() method, but the difference is big: your playground will now print “I ejected” three times.

This is confusing behavior at first, and is likely to catch you out on occasion. It’s made even more confusing by the fact that the declared type of the array matters – these two arrays will work differently, with the [XWing] option using the overridden eject() method and the [Fighter] option using the protocol extension.

let fighters1: [XWing] = [XWing(), XWing(), XWing()]
let fighters2: [Fighter] = [XWing(), XWing(), XWing()]

Apple describes this as being about “creating customization points” – they consider it a feature. By declaring a method in the protocol you’re allowing conforming types to override it; if you don’t, they can’t unless they have a specific type.

Once you’re over the initial surprise of this feature, I want to demonstrate why it might be useful in your own projects.

Here’s a trivial protocol that handles things in a store that can be purchased:

protocol Purchaseable {
    func buy()
}

It requires only one method, buy(), which will perform a number of actions:

  • Checking the user is authenticated and allowed to make purchases.
  • Checking the product is still available for sale in the user’s region.
  • Performing the actual purchase transaction, perhaps with Apple’s servers.
  • Saving the transaction receipt somewhere for safety.
  • Update the user interface to reflect the completed purchase.

In code we could condense all that functionality into a protocol extension, like this:

extension Purchaseable {
    func buy() {
        // check user
        // check product
        // perform transaction
        // save receipt
        // update UI
    }
}

That protocol extension does all the work required to implement buy() fully, which means we can make any product conform like this:

struct Book: Purchaseable { }
struct Song: Purchaseable { }
struct Movie: Purchaseable { }

However, there’s a problem: we used the two-step process for our protocol, which means buy() is declared inside the protocol and created inside the extension. As a result, we’ve created an extension point – a place in our protocol that we’re happy for conforming types to override.

As a result, someone could implement an Audiobook struct like this:

struct Audiobook: Purchaseable {
    func buy() {
        // check user
        // check product
        // perform transaction
        // update UI
    }
}

You might have notice that excludes the // save receipt comment, which means someone testing this code might find that the product is bought and the UI is updated, but silently the receipt is missing – this will cause problems later on.

The two-stage process of protocol extensions allows us to control this behavior precisely. If you want conforming types to be able to override a method then include it in your protocol definition, but if you don’t want that – if you want your protocol version to be used all the time – then you should exclude the method from your protocol definition.

In this case, removing buy() from the Purchaseable protocol will stop the Audiobook problem from happening:

protocol Purchaseable { }

The key here is coding with intent: understanding and applying the difference between the two approaches means your code communicates your intent: “this thing can be overridden, but that thing can’t.” On the other hand, always including method definitions in your protocols means it’s harder to understand what you mean to happen.

TAKE YOUR SKILLS TO THE NEXT LEVEL If you like Hacking with Swift, you'll love Hacking with Swift+ – it's my premium service where you can learn advanced Swift and SwiftUI, functional programming, algorithms, and more. Plus it comes with stacks of benefits, including monthly live streams, downloadable projects, a 20% discount on all books, and free gifts!

Find out more

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 5.0/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.