Episode #486

Intro to Protocol Witnesses

Series: Codable Witnesses

27 minutes
Published on April 22, 2021
In the next few episodes we will explore the concept of Protocol Witnesses. This is an advanced topic that can be somewhat hard to approach, but in learning about Protocol Witnesses you will see how we can leverage the Swift language and functional programming to do some really cool things.

Links

A Bit of Background

One of the limitations of protocols is that you can only have one conformance. Take this example where we have a type that is Discountable and it implements a method to return the amount of the discount:

protocol Discountable {
    func discounted() -> Double
}

struct Purchase {
    var amount: Double
    var shippingAmount: Double

    func discounted() -> Double {
        amount * 0.9
    }
}

func printDiscount<D>(_ discountable: Discountable) -> String {
    let discount = discountable.discounted()
    return "Discount: \(discount)"
}

So if we wanted to offer a different discount we are stuck. This admittedly contrived example is enough to show how we could transform a protocol into a protocol witness.

Converting to a Witness

First we look a the shape of the protocol functions. It appears we have a method that takes nothing (Void) and returns a Double. But this will be an instance method, so there's an implied instance that is available to use when we adopt it. So we could think of this function as (Self) -> Double.

Next we create a struct that is generic over some type A. We'll create a closure property on this type that matches the shape of the protocol functions.

struct Discounting<A> {
    let discounted: (A) -> Double
}

To replicate the behavior we previously had on our Purchase type, we can now create an instance of this Discounting type:

let purchaseDiscount = Discounting<Purchase> { purchase in 
        return purchase.amount * 0.9
}

This is what is called "a witness to the Discounting type". Even though Discounting is a struct, this is essentially what protocols boil down to in the Swift compiler.

Using a Witness

We had a printDiscount function above that used the discount. It was generic over the original protocol, but now we want to change it to use a witness.

func printDiscount<A>(_ item: A, with discount: Discounting<A>) -> String {
    // ...
}

Notice that we no longer care about what the instance A is, so long as you can provide a discount witness (or strategy) for this same type. The body of the function is changed only slightly, this type calling the discounted block with the instance item.

        let discount = discount.discounted(item)
    return "Discount: \(discount)"

The call-site looks like this:

printDiscount(purchase, with: purchaseDiscount)

Witness as Extensions

One of the things that is not great is that we just have this free variable purchaseDiscount hanging around. We can clean this up by moving it to an extension on Discounting for the type that it operates on:

extension Discounting where A == Purchase {
    static let tenPercentOff = Discounting<Purchase> { purchase in
        purchase.amount * 0.9
    }
}

Now that we have this, the call-site can be changed to:

printDiscount(purchase, with: .tenPercentOff)

Because the compiler expects the 2nd argument here to be a Discounting<Purchase> we can get nice autocompletion for this parameter just by pressing "dot".

This is great if you have more than one witness:

extension Discounting where A == Purchase {
    static let tenPercentOff = Discounting<Purchase> { purchase in
        purchase.amount * 0.9
    }

    static let fiveDollarsOff = Discounting<Purchase> { p in
        p.amount - 5
    }
}

Now we can use a different strategy if we want.

printDiscount(purchase, with: .fiveDollarsOff)

Now we're starting to see the power of this idea.

Pullback?!

Bare with me on the name. It will eventually make sense!

One thing we notice here is that both of our discounts so far don't really deal with a Purchase they deal with amount which is a Double. What if we moved these to be witnesses on the Double type instead.

extension Discounting where A == Double {
    static let tenPercentOff = Self { amount in
        amount * 0.9
    }

    static let fiveDollarsOff = Self { amount in
        amount - 5
    }
}

Next we want to utilize these when defining our Purchase witnesses. But how can we do this? There's a concept from the functional programming world that is sometimes called contramap, as it somewhat like an inverted map.

The shape looks like this:

// MAP
// given Something<A>, and a function A -> B and produces Something<B>

// CONTRAMAP
// given Something<A>, and a function B -> A and produces Something<B>

Unfortunately contramap is just not a great name when you're using it. Another name for this is pullback, and this tends to read better at the call-site, so we'll use it.

Defining a pullback function is not too difficult. Since we are doing this as an extension on the Discounting type, we have access to a the block that takes an A , and when we return a new Discounting<B> we define the block that yields a B to us, so we can use the provided function to convert between the two.

extension Discounting {
    func pullback<B>(_ f: @escaping (B) -> A) -> Discounting<B> {
        .init { other -> Double in
            self.discounted(f(other))
        }
    }
}

Once we have this we can use it to transform one witness into another.

extension Discounting where A == Purchase {
    static let tenPercentOff: Self = Discounting<Double>
        .tenPercentOff
        .pullback(\.amount)

    static let tenPercentOffShipping: Self = Discounting<Double>
            .tenPercentOff
            .pullback(\.shippingAmount)
}

This concept of reusing functions and composing them is really powerful. So while this example is contrived, we can push this concept even farther to get more mileage out of our work.

This episode uses Swift 5.3, Xcode 12.4.