Look ma, no protocols.

TL;DR: You can move coupled PAT* logic into a generic type and even get rid of generic types at all.

*protocol with associated types

This is a follow up on Protocol-lisions.

That blog post was about a bunch of protocols with associated types. In order to take full advantage of the PAT-Power™ they were coupled in an extension of one of them. And as demonstrated you can add a lot of implicit functionality to your types by going down that road. But. Beware. You also get some non trivial stuff to think about.

error: protocol ‘Fetchable’ can only be used as a generic constraint because it has Self or associated type requirements

There was a great talk by Robert Napier at dotSwift 2016. He explained how he ended up with lots of PATs + extensions + where type constraints and all he wanted was basically a solution. So he moved all the logic of the protocols as functions into a generic type. And of course - that`s a brilliant way! We will do the same today. And maybe we can find some pros and cons, and when to use what. And we try to do a mix of both worlds.

Yo Bro-tocol 🎛

Just a quick reminder:

// 1.
protocol Fetchable {
  // should be associatedtype but syntax hightlighting ...  =)
  typealias FetchedType
  static var all: [FetchedType] { get }
}
// 2.
protocol Changeable { 
  // associatedtype
  typealias ChangeResultType
  static var changeTo: Self -> ChangeResultType { get }
}
// 3.
protocol Mergeable { 
 // associatedtype
  typealias MergeResultType
  static var merge: (MergeResultType, Self) -> MergeResultType { get }
  static var identity: MergeResultType { get }
}

When you have a close look at the protocols, you see that all they provide (mostly, in this case) is a closure to transform from 1 or 2 Types to another ( + a variable called identity). So we extract:

  • static var changeTo: Self -> ChangeResultType { get }
  • static var merge: (MergeResultType, Self) -> MergeResultType { get }
  • static var identity: MergeResultType { get }

and move them straight into a single type - like this:

// The functions in the struct define how the types line up
// - FetchedType is now called Fetched
// - ChangeResultType is now called ChangeTo
// - MergeResultType is now called MergeTo
struct Workflow<Fetched, ChangeTo, MergeTo> {
  // no protocol: just a function
  let change: Fetched -> ChangeTo
  // no protocol: just a function
  let merge: (MergeTo, ChangeTo) -> MergeTo
  // initial value
  let mergeStart: MergeTo
  // provide argument and function returns happily a result
  func run(fetch: [Fetched]) -> MergeTo {
    // this was part of the protocol extension in the previous version
    return fetch.map(change).reduce(mergeStart, combine: merge)
  }
}

Looks like we have way less code. And we gained another advantage. We can take the type Workflow and use it as an type identifier.

// hopefully correct:
// Workflow<String, Int, Int> is a nominal type, 
// -> thus you can use it as type identifer.
let workflow: Workflow<String, Int, Int>? = nil
// The existential type Fetchable has a type hole 
// -> and cannot be used as type identifer.
let fetchable: Fetchable? = nil

Matching Types 🍱

The solution based on the generic struct seems pretty solid. The fundamental difference is:

  • Your types don`t need to conform to a protocol
  • The workflow can take whatever you give to it
  • … at the same time … it feels a little bit vague

Let`s see how it`s working out, when you use it.

// let`s define a closure to get the 
// number of characters in a string
let countCharacters: String -> Int = { $0.characters.count }
// no need to specify the generics as all information
// about them is provided by the arguments
// -> thats why the generics can be inferred on left and right side.
let stringWorkflow = Workflow(change: countCharacters, merge: +, mergeStart: 0)
// who actually provides the info?
//
// Workflow<String, Int, Int>
//            |      |    |
//            |      |    ---> merge func +(Int, Int) as change returns Int
//            |      |
//            ---------> change function

Great - why not use this approach all the times?

It depends.

The thing is that Workflow<String, Int, Int> does not really provide much insight into what it is really doing. It is definitely a great way to flip the PAT stuff into real usable types. At the same time it feels less restricted and therefore i tend to say it might be a little bit vague sometimes. Both ways have their pros and cons.

Even though you have to get used to where constraints the PAT approach feels more robust to me and might be better for many types that have connections between them.

The generic solution feels like a super slick way, while at the same time you loose the safety of the protocol types. It might be a little less obvious what you are actually doing.

To be honest - i guess both ways have their right to exist. It always depends on the context.

No Generics allowed today 🚮

But we don`t stop now. We got rid of those messy PATs - so now we want to kill the generic types at all. Ultimately it might be necessary to handle multiple Workflows with different generics at once. How we gonna free the workflow?

// type erasure struct
// without any generic information
struct AnyWorkflow {
  // we don`t know whats the result
  // so all we can return is Any
  let run: () -> Any
  // in order to initialize with an generic type 
  // we need to make a generic init function.
  // the information about the types is erased 
  // as the struct does not have any generics.
  // we erased them! 
  // all we do is: 
  // storing the type in a function that returns: Any
  // Again:
  // 1. init is generic!
  // 2. the type is not generic!
  init<F, C, M>(workflow: Workflow<F, C, M>, run input: [F]) {
    self.run = {
      workflow.run(input)
    }
  }
}
// actually - this could be anything
// at least the init is typesafe!
let any = AnyWorkflow(workflow: stringWorkflow, run: ["a", "b"])

And now we can put all kinds of workflows in an Array.

// now a totally different workflow
let intWorkflow = Workflow(
  change: { (int: Int) in int.description },
  merge: { [$0, $1].joinWithSeparator(" - ") },
  mergeStart: "da ints"
)
// again we wrap it in AnyWorkflow
let any2 = AnyWorkflow(workflow: intWorkflow, run: [1,2,3,4])
// Type erasure live and in action
// 'any' and 'any2' consumed different workflows 
// but we can put them in a list and run them!
// the underlying workflow in 'any' is of Type: 
// Workflow<String, Int, Int> 
// the underlying workflow in any2 is of Type: 
// Workflow<Int, String, String>
let anyResults = [any, any2].map { $0.run() }
// whats the type of anyResults?
// -> Array<protocol<>>

Ok. Do you really need that? Maybe.

Worst of Both Worlds 🌀

I want to finish by describing a 3rd solution: using both.

  • A generic type that can be used as type identifier
  • While keeping the protocol safety
  • Hmmm … just kidding
/// 1. protocol
protocol Fetchable {
  typealias FetchedType
}
/// 2. protocol
protocol Changeable {
  typealias ChangeResultType
}
/// 3. protocol
protocol Mergeable {
  typealias MergeResultType
}
/// 4. conform to protocol
extension String: Fetchable {
  typealias FetchedType = String
}
/// 5. conform to protocol
extension String: Changeable {
  typealias ChangeResultType = Int
}
/// 6. conform to protocol
extension Int: Mergeable {
  typealias MergeResultType = Int
}
/// the generics struct with protocols, associated types, constraints 
/// and a split personality
struct ProtoWorkflow<F, C, M where
  F: Fetchable,
  C: Changeable,
  M: Mergeable,
  F.FetchedType == C,
  C.ChangeResultType == M
> {
  // protocol-y
  let change: F.FetchedType -> C.ChangeResultType
  // protocol-y
  let merge: (M.MergeResultType, C.ChangeResultType) -> M.MergeResultType
  // protocol-y
  let mergeStart: M.MergeResultType
  // protocol-y
  func run(fetch: [F.FetchedType]) -> M.MergeResultType {
    return fetch.map(change).reduce(mergeStart, combine: merge)
  }
}
/// the generics can not be inferred ... 
/// maybe just another reason to not use it that way
let 🎨 = ProtoWorkflow<String, String, Int>(change: countCharacters, merge: +, mergeStart: 0)

What do you think?

The gist of it 👻

👻 Feel free to hit me on twitter. @elmkretzer