Skip to content

Instantly share code, notes, and snippets.

@shaps80
Last active May 17, 2023 12:27
Show Gist options
  • Save shaps80/fb81ada03c3669a5df66be95ab60de04 to your computer and use it in GitHub Desktop.
Save shaps80/fb81ada03c3669a5df66be95ab60de04 to your computer and use it in GitHub Desktop.
A lightweight generic state machine implementation in Swift.
import Foundation
import os.log
public protocol StateMachineDelegate: class {
associatedtype StateType: Hashable
/// Invoked before a transition is about to occur, allowing you to reject even a valid transition. Defaults to true
///
/// - Parameters:
/// - source: The state before the transition
/// - destination: The state after the transition
/// - Returns: True if the transition is allowed, false otherwise
func shouldTransition(from source: StateType, to destination: StateType) -> Bool
/// Invoked before the state machine transitions from `source` to `destination`
///
/// - Parameters:
/// - source: The state before the transition
/// - destination: The state after the transition
func willTransition(from source: StateType, to destination: StateType)
/// Invoked after the state machine transitions from `source` to `destination`
///
/// - Parameters:
/// - source: The state before the transition
/// - destination: The state after the transition
func didTransition(from source: StateType, to destination: StateType)
/// Invoked if the transition was invalid or rejected. By default this always throws
///
/// - Parameters:
/// - source: The state before the transition
/// - destination: The state after the transition
/// - Throws: `illegalTransition` if the transition was invalid or rejected
func missingTransition(from source: StateType, to destination: StateType) throws
}
public extension StateMachineDelegate {
func shouldTransition(from source: StateType, to destination: StateType) -> Bool { return true }
func willTransition(from source: StateType, to destination: StateType) { }
func didTransition(from source: StateType, to destination: StateType) { }
func missingTransition(from source: StateType, to destination: StateType) throws { throw StateMachineError.illegalTransition }
}
/// Defines the errors that can be thrown from a state machine
///
/// - illegalTransition: An illegal transition attempt was made
public enum StateMachineError: Error {
/// An illegal transition attempt was made
case illegalTransition
}
/**
A generic state machine implementation. It is generally not necessary to subclass. Instead, set the delegate property and implement state transition methods as appropriate.
Example:
enum State {
case initial
case ready
static var validTransitions: [State: [State]] {
return [.initial: [.ready]]
}
}
class StateDelegate {
associatedtype StateType = State
}
let machine = StateMachine<StateDelegate>(initial: .initial, validTransitions: State.validTransitions)
*/
open class StateMachine<Delegate> where Delegate: StateMachineDelegate {
/**
If specified, the state machine invokes transition methods on this delegate instead of itself.
*/
public weak var delegate: Delegate?
/**
Uses OSLog to output state transitions; useful for debugging, but can be noisy.
Defaults to true for DEBUG builds. False otherwise
*/
public var isLoggingEnabled: Bool = false
/**
The current state of the state machine
*/
public private(set) var currentState: Delegate.StateType {
get {
lock.lock()
let state = _currentState
lock.unlock()
return state
} set {
lock.lock()
_currentState = newValue
lock.unlock()
}
}
/**
Definition of the valid transitions for this state machine. This is a dictionary where the keys are the state and the value for each key is an array of the valid next state.
*/
public let validTransitions: [Delegate.StateType: [Delegate.StateType]]
private let lock: SpinLock
private var _currentState: Delegate.StateType
/**
Makes a new state machine.
- Parameters:
- initial: The initial state of this machine
- validTransitions: A dictionary of valid transitions that can be performed
Example:
StateMachine<StateDelegate>(initial: .initial, validTransitions: [
.initial: [.preparing],
.preparing: [.ready, .empty, .error],
.ready: [.refreshing],
.refreshing: [.ready, .empty, .error]
])
*/
public init(initial: Delegate.StateType, validTransitions: [Delegate.StateType: [Delegate.StateType]]) {
self._currentState = initial
self.validTransitions = validTransitions
self.lock = SpinLock()
#if DEBUG
isLoggingEnabled = true
#endif
}
/**
Attempts to transitions to the specified state.
This does not bypass `missingTransition(from:to:)` – if you invoke this with an invalid transition an illegal error will be thrown
*/
public func transition(to state: Delegate.StateType) throws {
let fromState = currentState
let toState = state
log(.request, from: fromState, to: toState)
try validateTransition(from: fromState, to: toState)
log(.will, from: fromState, to: toState)
delegate?.willTransition(from: fromState, to: toState)
currentState = toState
log(.did, from: fromState, to: toState)
delegate?.didTransition(from: fromState, to: toState)
}
/// Validates the transition between two states
///
/// Transitioning to the same state is always allowed. If its explicity defined as a valid transition, the standard methods calls will be invoked, otherwise if will succeed silently.
///
/// - Parameters:
/// - fromState: The state to transition from
/// - toState: The state to transition to
/// - Throws: An `illegalTransition` error is thrown if the transition is invalid or rejected
open func validateTransition(from fromState: Delegate.StateType, to toState: Delegate.StateType) throws {
let isValid = validTransitions[fromState]?.contains(toState) == true
guard isValid else {
if fromState == toState {
log(.ignore, from: fromState, to: toState)
return
}
log(.rejected, from: fromState, to: toState)
guard let delegate = delegate else {
throw StateMachineError.illegalTransition
}
try delegate.missingTransition(from: fromState, to: toState)
return
}
guard delegate?.shouldTransition(from: fromState, to: toState) == true else {
log(.rejected, from: fromState, to: toState)
try delegate?.missingTransition(from: fromState, to: toState)
return
}
}
}
private extension OSLog {
static let state = OSLog(subsystem: "com.152percent", category: "state-machine")
}
private extension StateMachine {
enum Log: String {
case request = "Request"
case will = "Will"
case did = "Did"
case ignore = "Ignoring"
case rejected = "Rejected"
case illegal = "Illegal"
}
func log(_ kind: Log, from: Delegate.StateType, to: Delegate.StateType) {
guard isLoggingEnabled else { return }
if #available(iOS 12.0, *) {
os_log(.debug, log: .state, "%{public}@ transition from %{public}@ to %{public}@",
kind.rawValue, String(describing: from), String(describing: to))
} else {
NSLog("%@ transition from %@ to %@",
kind.rawValue, String(describing: from), String(describing: to))
}
}
}
// MARK: - Locks
public protocol Lock {
func lock()
func unlock()
}
public final class SpinLock: Lock {
private var unfairLock = os_unfair_lock_s()
public init() { }
public func lock() {
os_unfair_lock_lock(&unfairLock)
}
public func unlock() {
os_unfair_lock_unlock(&unfairLock)
}
}
/**
Depends on Quick & Nimble
*/
import Quick
import Nimble
@testable import DataController
final class StateMachine_Spec: QuickSpec, StateMachineDelegate {
typealias StateType = State
override func spec() {
var machine: StateMachine<StateMachine_Spec>!
context("Given a state machine") {
beforeEach {
machine = StateMachine<StateMachine_Spec>(initial: .initial, validTransitions: State.validTransitions)
}
it("it should succeed when performing an valid transition") {
expect { try machine.transition(to: .preparing) }
.toNot( throwError(StateMachineError.illegalTransition) )
expect(machine.currentState).to(equal(.preparing))
}
it("it should throw when performing an invalid transition") {
expect { try machine.transition(to: .refreshing) }
.to( throwError(StateMachineError.illegalTransition) )
expect(machine.currentState).to(equal(.initial))
}
it("it should throw when performing a valid transition that was rejected") {
machine.delegate = self
expect { try machine.transition(to: .preparing) }
.to( throwError(StateMachineError.illegalTransition) )
expect(machine.currentState).to(equal(.initial))
}
it("it should succeed when transitioning to the same state") {
expect { try machine.transition(to: .initial) }
.toNot( throwError() )
expect(machine.currentState).to(equal(.initial))
}
}
}
func shouldTransition(from source: StateMachine_Spec.State, to destination: StateMachine_Spec.State) -> Bool {
return false
}
}
extension StateMachine_Spec {
enum State: String {
case initial
case preparing
case refreshing
static var validTransitions: [StateMachine_Spec.State: [StateMachine_Spec.State]] {
return [ .initial: [.preparing] ]
}
}
}
@shaps80
Copy link
Author

shaps80 commented Aug 18, 2018

StateMachine is generic over its Delegate because this then allows us to make our Delegate type safe as well.

Assumptions:

  • Delegate is defined already (for e.g. a UIViewController)
  • State is an enum as seen in the tests
let machine = StateMachine<Delegate>(initial: .initial, validTransitions: State.validTransitions)
try machine.transition(to: .preparing)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment