Last active
May 17, 2023 12:27
-
-
Save shaps80/fb81ada03c3669a5df66be95ab60de04 to your computer and use it in GitHub Desktop.
A lightweight generic state machine implementation in Swift.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
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] ] | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
StateMachine
is generic over itsDelegate
because this then allows us to make our Delegate type safe as well.Assumptions:
Delegate
is defined already (for e.g. aUIViewController
)State
is an enum as seen in the tests