Swift State Machines, Part 3: Follow Up

Alexis Gallagher writes in a comment to my last post:

I’m wondering if there’s an alternative design … where the transition and effect functions are simply required as methods on the enum type?

A fascinating idea! And one that casts doubt on my previous assertion that “A delegate with value semantics doesn’t really make any sense.”

Part of this is a huge win, right off the bat. Something I’m constantly combating is my desire to insert side effects into shouldTransitionFrom(to:)->Bool, for example.

func shouldTransitionFrom(from:State, to: State) -> Bool{
  switch (from, to){
  case (.Ready, .Fetch):
    kickOffTheFetch() //Bad!
    return true
  case (.Fetch, .Error(let error))
    presentTheError(error) //Bad!!
    return true
  }
}

shouldTransition... has one simple responsibility: to define the legal transitions through our states. Side effects are what didTransition... is for. The moment we start adding functionality in shouldTransition... or transitions in didTransition..., they become tangled with each other and much harder to reason about1.

enums are inert value types. They can’t hold stored properties. They can’t get references to enclosing types. There’s simply no way for us to (side-)effect an enclosing class or type from within an enum’s method. This makes it an ideal place to put shouldTransition...:

protocol StateMachineDataSource{
  func shouldTransitionFrom(from:Self, to:Self)->Bool
}

protocol StateMachineDelegate: class{
  typealias StateType:StateMachineDataSource
  func didTransitionFrom(from:StateType, to:StateType)
}

class StateMachine<P:StateMachineDelegate>{
  var state:P.StateType{
    //...
    set{
      if _state.shouldTransitionFrom(_state, to:newValue){
        _state = newValue
      }
    }
  }
  //...
}

class MyClass:StateMachineDelegate{
  enum State:StateMachineDataSource{
    case Ready, Success, Fail

    func shouldTransitionFrom(from:State, to: State) -> Bool{
      switch (from, to){
      case (.Ready, .Success):
        return true
      default:
        return false
      }
    }
  }
}

This is awesome. I love this. So obviously the next step is to move didTransition... into our enum as well, right?

Maybe not. The same thing that makes the enum such a great home for shouldTransition... makes it a difficult one for didTransition.... Remember, we want didTransition... to actually do stuff in response to changes in our state. Kick off a network request. Change the background color of a view. That sort of thing.

And as we just discussed, we can’t hold a reference to outside things in our enum. That means we’d have to pass it in as a parameter:

class MyClass{
  enum State:StateMachineDataSource{
    func didTransitionFrom(from:State, to:State, on:MyClass){
      //Do something with "on"...
    }
  }
}

Which means we’d have to pass our delegate to our state machine just so it could hold a reference to it to pass it back to our enum’s method when the state changes so that the enum can call methods on the delegate that defined it… my eyes have crossed just writing that.

But more importantly, remember what we said above2 about tangling responsibilities? Coupling state (the enum’s cases) and transitions (shouldTransition...) makes sense. The two can’t exist apart from one another. If we define new states, we have to define new transitions. If we remove states, we have to remove transitions. By putting them together in the same enum, we’ve made a nice, reusable bundle that carries both.

But didTransition... is a whole separate concern. Our behavior in response to state change isn’t a factor of the of states we have. Adding states won’t alter our implementation of didTransition... at all. If it weren’t for type safety, changing or removing them wouldn’t either.

Instead, our response to state change is a factor of our interface (this view should be green while state is “Ready”, this request should be ignored if state is “Fail”). When our interface changes, we need to alter our responses. Our responses are thus tightly coupled with our delegate. Mixing our state together with them would only couple it to our delegate as well, killing its reusability.

So, keeping our state and our behavior apart from each other is a Good Thing, and I’m pretty happy with where we’ve ended up here!

Almost. There’s one more change we can make that will help us further detangle our transitions from behavior. And that will be the topic of tomorrow’s (final?) State Machine post.


1: State is already hard enough to reason about, yeah?↩︎

2: And by “above” I mean, “Every other post on this blog."↩︎