Skip to content

Instantly share code, notes, and snippets.

@jmfayard
Created December 22, 2017 19:11
Show Gist options
  • Save jmfayard/ac6a94df1cc2994ab5b59f510c98133f to your computer and use it in GitHub Desktop.
Save jmfayard/ac6a94df1cc2994ab5b59f510c98133f to your computer and use it in GitHub Desktop.
package statemachine
import debug
import fail
import io.reactivex.Observable
import kotlinx.coroutines.experimental.channels.Channel
import kotlinx.coroutines.experimental.channels.produce
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking
import statemachine.TurnStyle.Event.*
import statemachine.TurnStyle.Command.*
import statemachine.TurnStyle.State.*
import java.lang.Thread.sleep
/***
* Context: I highly recommend andymatuschak's gist
*
* A composable pattern for pure state machines with effects
* https://gist.github.com/andymatuschak/d5f0a8730ad601bcccae97e8398e25b2
*
* It's written in swift but nicely maps to Kotlin as demonstrated here
*
* See the schema of the TurnStyle here
*
* ![TurnStyle](https://camo.githubusercontent.com/a74ea94a7eab348f991fb22d6f70a92c5bef3740/68747470733a2f2f616e64796d617475736368616b2e6f72672f7374617465732f666967757265332e706e67)
***/
fun main(args: Array<String>) {
/*** The functional core of the state machine is suepr trivial to test **/
val events: List<TurnStyle.Event> = listOf(
InsertCoin(20), InsertCoin(20), InsertCoin(10),
AdmitPerson,
InsertCoin(1),
MachineDidFail,
MachineRepairDidComplete)
val expectedStates = listOf(
Locked(0), Locked(20), Locked(40), Unlocked,
Locked(0), Locked(1), Broken(Locked(1)), Locked(0)
)
val expectedCommands: List<TurnStyle.Command?> = listOf(null, null, null, OpenDoors, CloseDoors, null, null, null)
val stateMachine = TurnStyle()
events.forEach { e -> stateMachine.handleEvent(e) }
stateMachine.debug()
stateMachine.statesHistory() shouldBe expectedStates
stateMachine.commandHistory() shouldBe expectedCommands
/** The imperative shell takes care of the side Effects **/
runBlocking {
val controller = runStateMachineWithSideEffects()
controller.customerDidInsertCoin(10)
delay(100)
controller.customerDidInsertCoin(50)
delay(100)
// controller.shitHappens()
delay(2000)
controller.stateMachine.debug()
controller.doorHardwareController.msgs shouldBe listOf("sendControlSignalToOpenDoors", "sendControlSignalToCloseDoors")
}
}
/** Generic State Machine **/
interface StateType
interface StateEvent
interface StateCommand
interface StateMachine<State : StateType, Event : StateEvent, Command : StateCommand> {
fun initialState(): State
fun currentState(): State
fun handleEvent(event: Event): Command?
fun statesHistory(): List<State>
fun commandHistory(): List<Command?>
fun eventsHistory(): List<Event>
// utility functions to model a transition with or without an emitted command
fun State.move(): Pair<State, Command?> = Pair(this, null)
fun State.emit(command: Command?): Pair<State, Command?> = Pair(this, command)
fun debug() {
println("""
Events: ${printList(eventsHistory())}
States: ${printList(statesHistory())}
Commands: ${printList(commandHistory())}
""")
}
}
/***
* Functional Core of our state machine.
*/
class TurnStyle : StateMachine<TurnStyle.State, TurnStyle.Event, TurnStyle.Command> {
override fun initialState(): TurnStyle.State = State.Locked(credit = 0)
override fun currentState(): State = history.last().first
private val history = mutableListOf(initialState() to doNothing)
private val events = mutableListOf<Event>()
override fun statesHistory(): List<State> = history.map { it.first }
override fun commandHistory(): List<Command?> = history.map { it.second }
override fun eventsHistory(): List<Event> = events.toList()
sealed class State(val msg: String? = null) : StateType {
data class Locked(val credit: Int) : State()
object Unlocked : State("Unlocked")
data class Broken(val oldState: State) : State()
override fun toString(): String =
msg ?: super.toString()
}
sealed class Event(val msg: String? = null) : StateEvent {
data class InsertCoin(val value: Int) : Event()
object AdmitPerson : Event("AdmitPerson")
object MachineDidFail : Event("MachineDidFail")
object MachineRepairDidComplete : Event("MachineRepairDidComplete")
override fun toString(): String =
msg ?: super.toString()
}
enum class Command : StateCommand {
SoundAlarm, CloseDoors, OpenDoors
}
override fun handleEvent(event: Event): Command? {
events += event
val currentState = currentState()
val nextMove: Pair<State, Command?>? = when (currentState) {
is Locked -> when (event) {
is Event.InsertCoin -> {
val newCredit = currentState.credit + event.value
if (newCredit >= FARE_PRICE)
Unlocked.emit(OpenDoors)
else
Locked(newCredit).move()
}
AdmitPerson -> currentState.emit(SoundAlarm)
MachineDidFail -> Broken(oldState = currentState).move()
MachineRepairDidComplete -> null
}
Unlocked -> when (event) {
AdmitPerson -> Locked(credit = 0).emit(CloseDoors)
else -> null
}
is Broken -> when (event) {
MachineRepairDidComplete -> Locked(credit = 0).move()
else -> null
}
}
if (nextMove == null) {
fail("Unexpected event $event from state $currentState")
} else {
history.add(nextMove)
return nextMove.second
}
}
companion object {
private val doNothing: Command? = null
const val FARE_PRICE = 50
}
}
private fun printList(list: List<Any?>) = list.joinToString(prefix = "listOf(", postfix = ")")
private infix fun <T> T?.shouldBe(expected: Any?) {
if (this != expected) error("ShouldBe Failed!\nExpected: $expected\nGot: $this")
}
/***
Now, an imperative shell that hides the enums and delegates to actuators.
Note that it has no domain knowledge: it just connects object interfaces.
***/
suspend fun runStateMachineWithSideEffects(): TurnStyleController {
val controller = TurnStyleController(DoorHardwareController(), SpeakerController(), TurnStyle())
launch { controller.consumeEvents() }
return controller
}
class TurnStyleController(
val doorHardwareController: DoorHardwareController,
val speakerController: SpeakerController,
val stateMachine: TurnStyle
) {
private val events = Channel<TurnStyle.Event>(5)
suspend fun consumeEvents() {
for (event in events) {
if (event == MachineDidFail) {
askSomeoneToRepair()
}
val command = stateMachine.handleEvent(event)
val nextEvent = handleCommand(command)
if (nextEvent != null) events.send(nextEvent)
}
stateMachine.debug()
}
suspend fun shitHappens() {
events.send(MachineDidFail)
}
suspend fun askSomeoneToRepair() {
delay(700)
events.send(MachineRepairDidComplete)
}
suspend fun customerDidInsertCoin(value: Int) {
events.send(InsertCoin(value))
}
suspend fun handleCommand(command: TurnStyle.Command?): TurnStyle.Event? {
val nextEvent: TurnStyle.Event? = when (command) {
OpenDoors -> doorHardwareController.sendControlSignalToOpenDoors()
SoundAlarm -> speakerController.soundTheAlarm()
CloseDoors -> doorHardwareController.sendControlSignalToCloseDoors()
null -> null
}
return nextEvent
}
}
class DoorHardwareController() {
val msgs = mutableListOf<String>()
suspend fun sendControlSignalToOpenDoors(): TurnStyle.Event? {
delay(500)
say("sendControlSignalToOpenDoors")
return AdmitPerson
}
suspend fun sendControlSignalToCloseDoors(): TurnStyle.Event? {
delay(100)
say("sendControlSignalToCloseDoors")
return null
}
private fun say(msg: String) {
msgs += msg
println(msg)
}
}
class SpeakerController {
val msgs = mutableListOf<String>()
suspend fun soundTheAlarm(): TurnStyle.Event? {
delay(50)
say("soundTheAlarm")
return MachineRepairDidComplete
}
private fun say(msg: String) {
println(msg)
msgs += msg
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment