Construire des CaseMachines
Suite de mon ancien article sur Machines d’état dans Swiftj’ai apporté quelques améliorations qui m’ont même conduit à écrire un petit forfait.
Dans l’ancien article, j’ai proposé d’ajouter les transitions possibles pour chaque état au cas enum représentant cet état, comme ceci :
enum MyMachine {
case state1(goto2: () -> Void)
case state2(goTo1: () -> Void)
}
L’inconvénient évident est que vous devez écrire un contrôleur personnalisé et transmettre ses méthodes privées à l’énumération :
import Combineclass Controller : ObservableObject {
@Published private(set) var state: MyMachine!
init() {
state = .state1(goto2: self.goto2)
}
private func goto2() {
guard case .state1 = state else {return}
//change state and provide implementation
state = .state2 {[unowned self] in self.goto1()}
}
private func goto1() {
guard case .state2 = state else {return}
//change state and provide implementation
state = .state1 {[unowned self] in self.goto2()}
}
}
Notez également que nous devons toujours vérifier la casse, car différents acteurs peuvent appeler simultanément les méthodes échappées et nous ne voulons répondre que lorsque nous sommes dans le bon état.
Une amélioration évidente serait si nous pouvions simplement avoir un type spécifique pour chaque cas qui expose ses transitions en tant que méthodes d’instance. Pour améliorer la prévisibilité de notre code, nous aimerions que ces méthodes d’instance soient pures et déclarent simplement dans quel état aller.
enum MyMachine {
case state1(State1)
case state2(State2)
}struct State1 {
func goto2() -> State2 {State2()}
}
struct State2 {
func goto1() -> State1 {State1()}
}
Mais jusqu’à présent, nous n’avons que des types avec des méthodes d’instance qui correspondent à d’autres types qui sont des cas de la même énumération.
Si nous voulons utiliser ces méthodes de manière structurée, nous devons avoir un protocole unifiant les cas.
protocol Case {
static func extract(from whole: MyMachine) -> Self?
func embed() -> MyMachine
}// obvious implementations
// skip if you can tell from the protocol what's going on
extension State1 : Case {
static func extract(from whole: MyMachine) -> Self? {
guard case .state1(let state) = whole else {return nil}
return state
}
func embed() -> MyMachine {.state1(self)}
}
extension State2 : Case {
static func extract(from whole: MyMachine) -> Self? {
guard case .state2(let state) = whole else {return nil}
return state
}
func embed() -> MyMachine {.state2(self)}
}
Maintenant, nous pouvons écrire un contrôleur super soigné comme celui-ci :
import Combineclass Controller : ObservableObject {
@Published private(set) var state = MyMachine.state1(State1())
func transition<From : Case, To : Case>(from: From.Type,
_ closure: (From) -> To) {
guard let from = From.extract(from: state) else {return}
state = closure(from).embed()
}
}
// example usage
func doSomething() {
let controller = Controller()
controller.transition(from: State1.self) {state in state.goto2()}
}
Pour les machines avec des états triviaux, c’est tout ce dont vous aurez besoin. Et si nos états disposaient de données internes modifiables ? Eh bien, notre approche fonctionnera toujours, mais ce n’est peut-être pas la plus efficace. Si votre état prend en charge la copie sur écriture (comme les tableaux), vous souhaiterez peut-être le modifier sur place.
Modifions notre protocole de cas :
protocol Case {
static func extract(from whole: MyMachine) -> Self?
func embed() -> MyMachine
// new
static func tryModify(_ whole: inout MyMachine,
using closure: (inout Self) -> Void)
}//default implementation - no copy on write
extension MyCase {
static func tryModify(_ whole: inout MyMachine,
using closure: (inout Self) -> Void) {
guard var this = extract(from: whole) else {return}
defer {whole = this.embed()}
closure(&this)
}
}
Si vous en avez besoin, vous avez maintenant un moyen de personnaliser tryModify
pour obtenir un accès exclusif lors de la modification de votre cas d’énumération. Nous pouvons maintenant continuer et écrire des méthodes d’instance mutantes :
extension State1 {
mutating func whatever() {...}
}
Mais si nous essayons maintenant de l’appeler depuis le Controller
nous aurons des erreurs !
func doSomething() {let controller = Controller()
controller.transition(from: State1.self) {state in state.whatever()}
// Cannot use mutating member on immutable value: 'state' is a 'let' constant
// Type '()' cannot conform to 'Case'
}
Pour résoudre ce problème, nous devrions donner au contrôleur une autre méthode qui accepte les fermetures avec inout
cas. Mais peut-être existe-t-il une solution plus structurée.
Au lieu d’écrire des méthodes, écrivons des types conformes à un protocole spécifique.
protocol Method {
func apply(to whole: inout MyMachine)
}// mutating methods
protocol StateMethod : Method {
associatedtype This : Case
func applyToCase(_ this: inout This)
}
extension StateMethod {
func apply(to whole: inout MyMachine) {
This.tryModify(&whole) {applyToCase(&$0)}
}
}
// transitions between states
protocol StateChange : Method {
associatedtype From : Case
associatedtype To : Case
func move(from: From) -> To
}
extension StateChange {
func apply(to whole: inout MyMachine) {
guard let from = From.extract(from: whole) else {return}
whole = move(from: from).embed()
}
}
// examples
extension State1 {
struct GoTo2 : StateChange {
func move(from: State1) -> State2 {
State2()
}
}
}
extension State2 {
struct GoTo1 : StateChange {
func move(from: State2) -> State1 {
State1()
}
}
}
Maintenant le contrôleur peut rester trivial :
import Combineclass Controller : ObservableObject {
@Published private(set) var state = MyMachine.state1(State1())
func transition<M : Method>(_ method: M) {
method.apply(to: &state)
}
}
C’est une grande amélioration ! Les méthodes ont maintenant une interface unifiée et toute la complexité de la façon dont l’interface est exposée est supprimée. Le seul inconvénient de cette approche est que nous exposons à nouveau nos méthodes.
Mais si nous sommes honnêtes, cela a toujours été le cas car on peut simplement échapper aux fermetures que nous avons initialement mises dans nos cas d’énumération. Nous les avons encore nommés maintenant et ils sont plutôt faciles à écrire et à lire.
Allons fous maintenant. Supposons que nous voulions que certains effets se produisent chaque fois que nous exécutons une transition. Changeons tous nos protocoles :
protocol Case {
static func extract(from whole: MyMachine) -> Self?
func embed() -> MyMachine
// now returns something
static func tryModify<T>(_ whole: inout MyMachine,
using closure: (inout Self) -> T?) -> T?
}// default implementation needs to change so it can return something
extension MyCase {
static func tryModify<T>(_ whole: inout MyMachine,
using closure: (inout Self) -> T?) -> T? {
guard var this = extract(from: whole) else {return nil}
defer {whole = this.embed()}
return closure(&this)
}
}
// some dummy Effect type
enum Effect {
case print(String)
}
// Methods will now optionally return effects
protocol Method {
func apply(to whole: inout MyMachine) -> Effect?
}
// implementations have to change
// feel free to skip if this feels just bureaucratic
protocol StateMethod : Method {
associatedtype This : MyCase
func applyToCase(_ this: inout This) -> Effect?
}
extension StateMethod {
func apply(to whole: inout MyMachine) -> Effect? {
This.tryModify(&whole) {applyToCase(&$0)}
}
}
protocol StateChange : Method {
associatedtype From : MyCase
associatedtype To : MyCase
func move(from: From) -> (To, Effect?)
}
extension StateChange {
func apply(to whole: inout MyMachine) -> Effect? {
guard let from = From.extract(from: whole) else {return nil}
let (to, eff) = move(from: from)
whole = to.embed()
return eff
}
}
// example Methods
extension State1 {
struct GoTo2 : StateChange {
func move(from: State1) -> (State2, Effect?) {
(State2(), .print("Go to State2"))
}
}
}
extension State2 {
struct GoTo1 : StateChange {
func move(from: State2) -> (State1, Effect?) {
(State1(), .print("Go to State1"))
}
}
}
// controller remains almost unchanged
import Combine
class Controller : ObservableObject {
@Published private(set) var state = MyMachine.state1(State1())
func transition<M : Method>(_ method: M) {
if let eff = method.apply(to: &state) {
// do something with effect here
switch eff {
case .print(let string):
print(string)
}
}
}
}
// example usage
func doSomething() {
let controller = Controller()
controller.transition(State1.GoTo2())
}
Nous venons de fournir un moyen pour que nos méthodes restent pures même si nous voulons que des effets secondaires se produisent à chaque fois qu’elles sont exécutées. En principe, nous pouvons même exécuter des effets à l’intérieur d’une méthode tout en restant purs, bien que nous devions alors faire confiance au contrôleur pour répondre à nos attentes :
enum Effect {case print(String, then: Method? = nil)
case readLine((String) -> Method)
}
import Combine
class Controller : ObservableObject {
@Published private(set) var state = MyMachine.state1(State1())
func transition<M : Method>(_ method: M) {
if let eff = method.apply(to: &state) {
// do something with effect here
switch eff {
case .print(let string, let then):
print(string)
if let then {
transition(then)
}
case .readLine(let onRead):
transition(onRead(readLine() ?? "No data"))
}
}
}
}
// example
extension State1 {
struct Greet : StateMethod {
func applyToCase(_ this: inout State1) -> Effect? {
.readLine{line in
Write(message: "hello, " + line)
}
}
}
struct Write : StateChange {
let message : String
func move(from: State1) -> (State2, Effect?) {
(State2(), .print(message))
}
}
}
Avec ce modèle de « style de passage continu », vous pouvez garder votre contrôleur inconscient des méthodes qui existent même et vous concentrer uniquement sur l’exécution des transitions et la gestion des effets.
J’espère vous avoir donné une idée de la direction dans laquelle cette approche des machines à états s’est développée. Pour le reste de cet article, je vais ignorer les détails de mise en œuvre et souligner uniquement certains autres aspects que j’ai incorporés dans le dépôt J’ai mentionné au début.
Une abstraction évidente consistait à écrire le protocole Case de manière à ce que l’énumération englobante soit un type associé. De plus, je l’ai renommé « State » et j’ai réservé le nom « Case » pour un protocole enfant où vous fournissez un statique chemin de cas.
public protocol State {associatedtype Whole : CaseMachine
associatedtype Effect = Whole.Effect
static func extract(from whole: Whole) -> Self?
func embed(into whole: inout Whole)
// default implementation exists
static func tryModify(_ state: inout Whole, using closure: (inout Self) -> Effect?) -> Whole.Effect?
// default implementation exists if Effect == Whole.Effect
static func embed(_ effect: Effect) -> Whole.Effect
}
public protocol Case : State where Whole: CaseMachine {
associatedtype Whole
static var casePath : CasePath<Whole, Self> {get}
}
La conformité au protocole Case implémentera automatiquement l’extraction et l’intégration pour vous. Notez également qu’il est possible (mais pas obligatoire) de limiter les méthodes de ce cas à des effets spécialisés.
Le prochain sujet important est que vous voudrez peut-être jouer certains effets chaque fois que vous entrez ou sortez d’un certain état, quelle que soit la façon dont vous y êtes arrivé. Pour y parvenir, deux ingrédients sont nécessaires. Le premier ingrédient est que nous limitons notre énumération englobante à un protocole – appelé CaseMachine – qui vous permet en fait de fournir des effets onEnter et onLeave personnalisés.
public protocol CaseMachine : StateChart where Effect == Whole.Effect {
associatedtype Whole : StateChart = Self
associatedtype Effect = Whole.Effect
//both are nil by default
var onEnter : Effect? {get}
var onLeave : Effect? {get}
}
Le deuxième ingrédient consistait à faire en sorte que les méthodes (appelées morphismes dans le dépôt) renvoient un type de données plus détaillé qu’un simple effet :
public struct Effects<Chart : StateChart> {
public let onLeave : [Chart.Effect]
public let onEnter : [Chart.Effect]
public let onTransition : [Chart.Effect]
}public protocol Morphism<Whole> {
associatedtype Whole : StateChart
func execute(_ state: inout Whole) -> Effects<Whole>
}
Les protocoles enfants de Morphism
s’occuper d’assigner onLeave
, onEnter
et onTransition
correctement, alors que la classe ouverte MachineController
prend soin de l’ordre dans lequel ils seront appelés (onLeave
— onEnter
— onTransition
).
Vous vous demandez peut-être maintenant : pourquoi toutes ces propriétés sont-elles des tableaux d’effets plutôt que de simples effets ? Et qu’est-ce que c’est que ça StateChart
taper?
L’idée principale de StateCharts
est que vous avez un tas de machines d’état qui peuvent communiquer entre elles. Le protocole lui-même est assez trivial :
public protocol StateChart {
associatedtype Effect = Void
// nil by default, onEnter if you conform to CaseMachine
var onInit : Effect? {get}
}
L’idée est que vous venez de mettre un tas de CaseMachines
qui partagent un type d’effet en un seul StateChart
par exemple:
struct Chart : StateChart {
var machine1 : Machine1
var machine2 : Machine2
}
…où Machine1
et Machine2
sommes CaseMachines
où Entier == Graphique.
Comment les morphismes (qui sont génériques sur StateCharts
ne pas CaseMachines
!) sais quoi CaseMachine
ils s’appliquent à?
Fondamentalement, j’ai besoin d’implémentations spécifiques pour fournir un WritableKeyPath
du graphique à la machine d’état. En faisant de ce chemin de clé un membre d’instance, des scénarios plus dynamiques avec des tableaux de machines d’état deviennent envisageables.
L’idée originale de la façon dont les machines communiquent — remonte à quelqu’un appelé Harel — était en diffusant des événements. C’est quelque chose que nous pouvons réellement faire en utilisant nos effets.
Cependant, il existe une solution encore plus soignée vaguement inspirée par Réseaux de Pétri: Il existe un type spécial de morphisme, le MultiArrow, qui encapsule les transitions pour plusieurs machines à états à la fois et qui ne se déclenchera que si toutes les machines à états participantes sont dans un état où elles peuvent gérer la transition. Remarque : il ne s’agit pas d’une exécution séquentielle de morphismes sur une machine à états.
Les MultiArrows sont également la principale raison pour laquelle onLeave
, onEnter
et onTransition
sont des tableaux : de cette façon, nous pouvons jouer des effets pour chaque CaseMachine
.
Pour conclure l’article, voyons maintenant comment implémenter l’exemple producteur/consommateur de la vidéo sur les réseaux de Petri en utilisant CaseMachines
:
@testable import CaseMachines
import CasePathsstruct ProducerConsumer : StateChart {
// our three machines
var producer = Producer.idle(IdleProducer())
var buffer = Buffer.empty(EmptyBuffer())
var consumer = Consumer.idle(IdleConsumer())
// definition of each machine
enum Producer : CaseMachine {
typealias Whole = ProducerConsumer
case idle(IdleProducer)
case producing(Producing)
}
enum Buffer : CaseMachine {
typealias Whole = ProducerConsumer
case empty(EmptyBuffer)
case goodsAvailable(Goods)
}
enum Consumer : CaseMachine {
typealias Whole = ProducerConsumer
case idle(IdleConsumer)
case consuming(Consuming)
}
}
// defining Producer's states
extension ProducerConsumer {
struct IdleProducer : Case {
typealias Whole = Producer
static let casePath = /Producer.idle
// if the producer is idle, it can start producing
struct Produce : GoTo {
typealias From = IdleProducer
let keyPath = \ProducerConsumer.producer
let newValue = Producing()
}
}
struct Producing : Case {
typealias Whole = Producer
static let casePath = /Producer.producing
// finishing production means that
// the producer goes back to idle
// and puts goods into an empty buffer
// this transition must only run if the buffer is empty!
struct Finish : MultiArrow {
typealias Whole = ProducerConsumer
@ArrowBuilder
var arrows: some GuardedMorphism<ProducerConsumer> {
DoFinish()
EmptyBuffer.Fill()
}
// internal transition of the producer back to idle
// private, because it needs to be coordinated with
// EmptyBuffer.Fill
private struct DoFinish : GoTo {
let keyPath = \ProducerConsumer.producer
typealias From = Producing
let newValue = IdleProducer()
}
}
}
}
// defining Buffer's states
extension ProducerConsumer {
struct EmptyBuffer : Case {
typealias Whole = Buffer
static let casePath = /Buffer.empty
// if the buffer is empty, we can fill it
struct Fill : GoTo {
let keyPath = \ProducerConsumer.buffer
typealias From = EmptyBuffer
let newValue = Goods()
}
}
struct Goods : Case {
typealias Whole = Buffer
static let casePath = /Buffer.goodsAvailable
// if we use the goods, the buffer will be empty again
struct Use : GoTo {
let keyPath = \ProducerConsumer.buffer
typealias From = Goods
let newValue = EmptyBuffer()
}
}
}
// defining Consumer's states
extension ProducerConsumer {
struct IdleConsumer : Case {
typealias Whole = Consumer
static let casePath = /Consumer.idle
// consuming means that
// the consumer goes to consuming
// and takes the goods out of the buffer
// this transition must only run if the buffer is NOT empty!
struct Consume : MultiArrow {
typealias Whole = ProducerConsumer
@ArrowBuilder
var arrows : some GuardedMorphism<ProducerConsumer> {
DoConsume()
Goods.Use()
}
// internal transition of the consumer to consuming
// private, because it needs to be coordinated with
// Goods.Use
private struct DoConsume : GoTo {
let keyPath = \ProducerConsumer.consumer
typealias From = IdleConsumer
let newValue = Consuming()
}
}
}
struct Consuming : Case {
typealias Whole = Consumer
static let casePath = /Consumer.consuming
// all we can do when we're done consuming
// is to go back to idle
struct Finish : GoTo {
let keyPath = \ProducerConsumer.consumer
typealias From = Consuming
let newValue = IdleConsumer()
}
}
}
// And this is how it behaves...
import XCTest
final class CaseMachineTests: XCTestCase {
@MainActor
func testProduceConsume() {
let controller = MachineController(state: ProducerConsumer())
// this one doesn't do anything yet,
// because the buffer is initially empty!
controller.send(ProducerConsumer.IdleConsumer.Consume())
guard
case .idle = controller.state.producer, // producer hasn't done anything yet
case .idle = controller.state.consumer, // nothing to consume yet
case .empty = controller.state.buffer else { // buffer still empty
return XCTFail()
}
controller.send(ProducerConsumer.IdleProducer.Produce())
guard
case .producing = controller.state.producer, // producer started production
case .idle = controller.state.consumer, // nothing to consume yet
case .empty = controller.state.buffer else { // buffer still empty
return XCTFail()
}
controller.send(ProducerConsumer.Producing.Finish())
guard
case .idle = controller.state.producer, // producer is idle again
case .idle = controller.state.consumer, // nothing to consume yet
case .goodsAvailable = controller.state.buffer else { // producer has filled buffer
return XCTFail()
}
controller.send(ProducerConsumer.IdleConsumer.Consume())
guard
case .idle = controller.state.producer, // producer is idle again
case .consuming = controller.state.consumer, // finally we get to consume
case .empty = controller.state.buffer else { // buffer empty again
return XCTFail()
}
controller.send(ProducerConsumer.Consuming.Finish())
guard
case .idle = controller.state.producer, // producer is idle again
case .idle = controller.state.consumer, // done consuming
case .empty = controller.state.buffer else { // buffer empty again
return XCTFail()
}
}
}