Tuesday, 27 March 2018

Caveats of Swift default protocol extensions

TL;DR - Think carefully whether or not you need to add a default implementation to a property defined on a protocol -
Be prepared to avoid wasting a day of painful debugging caused by an obscure scenario in Swift! Swift 2 introduced the ability to add extensions to protocols, for example: 
protocol SomeProtocol {
    var property: String { get }
}

extension SomeProtocol {
    var property: String {
        return "Default value"
    }
}
This is a great language feature but it introduces the potential for subtle bugs when modifying the protocol. In this post I'll cover 2 cases: renaming a property, and changing the type of a property.

Case 1: Renaming a property defined in a protocol

Protocols can define properties to be implemented by the conforming type. For example, an http request can be modelled by the following:
enum HTTPMethod {
    case get
    case post
}


protocol HTTPRequest {
    var httpMethod: HTTPMethod { get }
}
In the majority of cases the http method used by iOS apps will be a GET request, so it's sensible to define that all http requests default to .get:
extension HTTPRequest {
    var httpMethod: HTTPMethod {
        return .get
    }
}
A POST http request could be defined like this:
struct ExampleHTTPRequest: HTTPRequest {
    var httpMethod: HTTPMethod = .post
}
An HTTPClient can then make http requests using the httpMethod defined in the HTTPRequest protocol:
struct HTTPClient {
    func make(httpRequest: HTTPRequest) {
        // some implementation using the http method
    }
}

let client = HTTPClient()
client.make(httpRequest: ExampleHTTPRequest()) -> uses a POST
Imagine that some time later the httpMethod gets renamed to method:
protocol HTTPRequest {
    var method: HTTPMethod { get }
}

extension HTTPRequest {
    var method: HTTPMethod {
        return .get
    }
}
After this change, when the http client makes the HTTPRequest for the original ExampleHTTPRequest the HTTPMethod is now get because it is using the default implementation of HTTPRequest defined in it's extension:
let client = HTTPClient()
client.make(httpRequest: ExampleHTTPRequest()) -> uses a GET!
Note that the ExampleHTTPRequest didn't change but the behaviour when used in the HTTPClient is different. The ExampleHTTPRequest still defines var httpMethod: HTTPMethod = .post making it hard and confusing to debug.

Case 2: Changing the type of a property defined in a protocol

In this second case instead of renaming the property we'll change the type. Let's start by telling the story of JohnDoe and JaneDoe, 2 devoted athletes. The first thing they learnt as a Person was to Walk:
protocol Walking {}

protocol Person {
    var moveAction: Walking? { get }
}

struct Walk: Walking {}
Since not every Person can walk, the moveAction is optional with a nil default:
extension Person {
    var moveAction: Walking? {
        return nil
    }
}
Once JohnDoe and JaneDoe learned how to Walk they looked like this:
struct JohnDoe: Person {
    var moveAction: Walking? = Walk()
}

struct JaneDoe: Person {
    var moveAction: Walking? = Walk()
}
After learning how to Walk they figured it was time to have a Race:
struct Race {
    var people: [Person]

    func start() {
        people.forEach { person in
            // use person's moveAction
        }
    }
}

let firstRace = Race(people: [JohnDoe(), JaneDoe()])
firstRace.start()
Both of them managed to Walk in this Race:
firstRace.people[0].moveAction // returns Walk
firstRace.people[1].moveAction // returns Walk
After JohnDoe won the Race by a great distance, the next step was to learn how to run:
protocol Running {}
Person could now run:
protocol Person {
    var moveAction: Running? { get }
}

extension Person {
    var moveAction: Running? {
        return nil
    }
}
Lazy JohnDoe was confident that his Walk was good enough to win the next Race and didn't learn how to Run. However, JaneDoe who was much more clever than JohnDoe (and knew a thing or two about Swift) updated her moveAction to include Running so she could Run:
struct JaneDoe: Person {
    var moveAction: Running? = Run()
}
The 2nd Race was on! The whistle went and the race started:
let secondRace = Race(people: [JohnDoe(), JaneDoe()])
secondRace.start()
To JohnDoes surprise he had suddenly forgotten how to Walk as a Person whereas JaneDoe Run like the wind:
secondRace.people[0].moveAction // returns nil for JohnDoe
secondRace.people[1].moveAction // returns Run for JaneDoe
JohnDoe couldn't move and lost the race without knowing what hit him. There wasn't even a compiler error/warning to tell him what happened. Some people have gone as far as saying he couldn't take the pressure and choked...
However, a careful inspection of the Swift code tells us that poor JohnDoe was stripped of his ability to Walk as a Person when the Person protocol's moveAction was modified to Running. This is due to the fact that our default extension is now returning nil for JohnDoe when he is considered a Person. When accessing a property the reference type of the variable matters. When the instance of JohnDoe is considered a Person, the moveAction property from the Person default extension is used, i.e. Running?. Whereas when the instance is refered as JohnDoe the property used is the one defined by JohnDoe, i.e. Walking?:
(secondRace.people[0] as Person).moveAction // returns nil
(secondRace.people[0] as! JohnDoe).moveAction // returns Walk
The type for moveAction defined by JohnDoe is Walking? compared to Running? for Person. This subtle changes means JohnDoe no longer can Walk as Person

Conclusions

If you're thinking that deprecating the old property is the way to go, it does not work. Deprecations don't have any effect whatsoever on the conforming type as that type can define it's own properties overriding the protocol ones:
protocol ExampleProtocol {
    @available(*, deprecated, renamed: "newProperty")
    var oldProperty: String { get }

    var newProperty: String { get }
}

extension ExampleProtocol {
    @available(*, deprecated, renamed: "newProperty")
    var oldProperty: String {
        return "Default value"
    }

    var newProperty: String {
        return "Default value"
    }
}
The following ExampleStruct has no warning or errors:
struct ExampleStruct: ExampleProtocol {
    var oldProperty: String
}
Neither changing it's type produces an error/warning:
struct ExampleStruct: ExampleProtocol {
    var oldProperty: Int
}
Therefore, I suggest that you think carefully when adding default implementations in extensions. If you absolutely must change a property that has default defined I suggest 2 approaches: 
  1. Have complete confidence that you can propagate the change to every consuming type of your protocol
  2. Remove the default implementation to force all consumers to implement it
This is specially important when modifying public interfaces since it's likely that you'll have no visibility of the projects that make use of that protocol. All in all, these solutions are not great and it's probably best to avoid having public extensions adding defaults altogether.
I’d like to thank Nahuel MarisiDaniel Haight and Neil Horton for reviewing this article.

2 comments:

  1. Really great observations and I love the analogies! ��

    ReplyDelete
  2. Nice article! Can't really say if it was more useful or fan :P

    ReplyDelete