Skip to content

Instantly share code, notes, and snippets.

@shaps80
Last active May 17, 2023 12:27
Show Gist options
  • Select an option

  • Save shaps80/fb81ada03c3669a5df66be95ab60de04 to your computer and use it in GitHub Desktop.

Select an option

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
Copy Markdown
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