オブジェクト・モデリングにおける動的モデルは状態機械で記述するのが基本です。つまり状態機械はオブジェクト・モデリングの重要な構成要素の一つということです。
ScalaでObject-Functional Programming(OFP)を行う場合でも、要求・分析・設計の各アクティビティを経て作成されたオブジェクト・モデル内の状態機械をどのように実装していくのかという実装方式が論点になります。
普通のOOP流の実装はすでに議論しつくされていると思いますが、OFPにおけるFPでの実装方式については、これから整備されていくことになると思います。
注意が必要なのはクラウド・アプリケーション開発をターゲットにする場合、伝統的なFPというよりMonadic Programming(以下MP)を経てFunctional Reactive Programming(以下FRP)がゴールになるということです。
このためFRPとして利用可能な状態機械実装を探っておきたいところです。
課題
状態機械の使い所としては、エンティティの状態遷移を記述することで業務ワークフローの構築に用いたり、プロトコルドライバのフロー制御といったものが考えられます。前者はDBに状態を格納することになりエンタープライズ・アプリケーション的なライフサイクルの長い応用ですし、後者は制御系の応用でメモリ内の制御で完結する形のものです。
色々な応用があるわけですが、どの方面でも使える実現方法を念頭におきつつ、ここではCSVのパーサー処理を状態機械で実装してみることにします。FPとの接続部分に興味を集中させるためできるだけ簡単なものを選んでみました。
具体的には以下の課題になります。
- CSVの1行を「,」区切りのレコードとしてパースし、文字列の列を取得する処理に使用する状態機械
この状態機械は文字列の列の各文字をイベントとして扱い、一連のイベント終了後にパース処理の結果となる「文字列の列」を状態の情報として保持する形になります。
case classで状態機械
状態機械は「状態×イベント→状態」のメカニズムが基本の構成要素です。このメカニズムをcase classで実装します。
いうまでもなくcase classはScalaプログラミングの超重要な構成要素で、OOP的側面とFP的側面を兼ね備えたObject-Functional Programmingの肝となる機構です。
OOP的にはDDDでいうところのvalue objectの実装に適した文法になっています。
FP的には代数的データ型(algebraic data type)として利用することが想定されています。
つまりvalue object兼代数的データ型を実現するための機構がcase classです。
ただしcase classの文法上はvalue objectや代数的データ型に適さない普通のオブジェクトを実装することも可能です。このため、value object兼代数的データ型を実現するための紳士協定を組み込んでおかなければなりません。
この紳士協定は以下の2つです。
- 不変オブジェクト(immutable object)にする
- 基底のトレイトや抽象クラスはsealedにする
Value objectと代数的データ型は共に不変オブジェクトである必要があります。
また代数的データ型の要件としてコンパイル時に全インヘリタンス関係が確定している必要があるので、sealedにすることでこれを担保します。
case classはオブジェクト指向の普通のオブジェクトでもあるので、不変オブジェクトの性質さえ守ればオブジェクトのフルスペックを使用することができます。
package sample
sealed trait ParseState {
def event(c: Char): ParseState
def endEvent(): EndState
}
case object InitState extends ParseState {
def event(c: Char) = c match {
case ',' => InputState(Vector(""), "")
case '\n' => EndState(Nil)
case _ => InputState(Nil, c.toString)
}
def endEvent() = EndState(Nil)
}
case class InputState(
fields: Seq[String],
candidate: String
) extends ParseState {
def event(c: Char) = c match {
case ',' => InputState(fields :+ candidate, "")
case '\n' => EndState(fields :+ candidate)
case _ => InputState(fields, candidate :+ c)
}
def endEvent() = EndState(fields :+ candidate)
}
case class EndState(
row: Seq[String]
) extends ParseState {
def event(c: Char) = this
def endEvent() = this
}
case class FailureState(
row: Seq[String],
message: String
) extends ParseState {
def event(c: Char) = this
def endEvent() = sys.error("failure")
}
状態機械は、「状態×イベント→状態」の情報を記述したマトリックスとして表現できます。このマトリクスを実現するデータ構造と評価戦略は色々考えられますが、ここではOOP的なアプローチで実現しました。
状態
まず状態機械全体を表すトレイトとしてParseStateを定義します。sealedにすることで代数的データ構造の要件の1つを満たします。
ParseStateのサブクラスとして具体的な状態を定義します。情報を持つInitState, InputState, EndState, FailureStateはcase classとして、情報を持たないInitStateはcase objectとして定義しました。
イベント
発生するイベントはメソッドで表現しました。
- event
- 1文字入力イベント発生
- endEvent
- パース終了イベント発生
状態×イベント→状態
状態とイベントの組から新しい状態を決定するアルゴリズムは「発生するイベント」で定義したメソッドの実装になります。
具体的には各case class, case objectのeventメソッド、endEventメソッドの実装を参照して下さい。
オブジェクト版状態機械
case classによる状態機械の表現はimmutableなので、そのままでは状態機械としては動きません。あくまでも「状態×イベント→状態」のメカニズムを提供するまでになります。
状態機械の状態遷移は状態を遷移させるので本質的にはmutableな処理です。
OOP的には、オブジェクトのインスタンス変数でmutableな状態を保持する実現方法になります。
OOP版の状態機械をParserObjectとして実装しました。
package sample
class ParserObject {
var state: ParseState = InitState
def charEvent(c: Char) {
state = state.event(c)
}
def parseEnd(): Seq[String] = {
val end = state.endEvent()
state = end
end.row
}
}
PaserObjectクラスでは、変更可能なインスタンス変数としてstateを定義しています。
イベントの発生をメソッドで受取り、状態とイベントの組合せで新たな状態を計算し、これをstateに設定することで状態遷移を実現しています。
新しい状態の計算ロジックは、ParseStateオブジェクトにカプセル化しています。
代数的データ構造で表現した状態機械のimmutableなオブジェクトを、インスタンス変数で管理することで、OOP的な状態機械が実現できました。
使い方
プログラムを実行するためのSpecは以下になります。
package sample
import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest._
import org.scalatest.prop.GeneratorDrivenPropertyChecks
@RunWith(classOf[JUnitRunner])
class ParserObjectSpec extends WordSpec with Matchers with GivenWhenThen with GeneratorDrivenPropertyChecks {
"ParserObject" should {
"parse" in {
val parser = new ParserObject()
val text = "abc,def,xyz"
for (c <- text) {
parser.charEvent(c) // 一文字づつパーサーに送信
}
val r = parser.parseEnd() // 解析終了のイベントを送信
println(s"ParserObject: $r")
r should be (Vector("abc", "def", "xyz"))
}
}
}
実行
実行結果は以下になります。
$ sbt test-only sample.ParserObjectSpec
ParserObject: List(abc, def, xyz)
[info] ParserObjectSpec:
[info] ParserObject
[info] - should parse
[info] ScalaTest
[info] 36mRun completed in 180 milliseconds.0m
[info] 36mTotal number of tests run: 10m
[info] 36mSuites: completed 1, aborted 00m
[info] 36mTests: succeeded 1, failed 0, canceled 0, ignored 0, pending 00m
[info] 32mAll tests passed.0m
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 0 s, completed 2015/03/21 17:47:46
アクター版状態機械
FRP的な応用を考える場合、並行・並列処理で枠組みの中で状態機械を実現していく必要があります。
この実現方式として有力なのがアクターです。
ScalaではAkkaというアクター基盤を使用することができます。
このアクターは(理論的には色々あるでしょうが)状態を持ったactive objectを実現する方式になっています。
active objectであるアクター間はメッセージボックスでキューイングされたメッセージ通信で協調動作するので、アプリケーションレベルで特別な排他制御を行わなくても並行・並列処理を簡潔に記述することができます。
アクター版のCSVパーサーであるParserActorは以下になります。
package sample
import akka.actor._
case class ParseCharEvent(c: Char)
case object ParseEnd
case class ParseResult(result: Seq[String])
class ParserActor extends Actor {
var state: ParseState = InitState
def receive = {
case ParseCharEvent(c) =>
state = state.event(c)
case ParseEnd =>
val end = state.endEvent()
state = end
sender ! ParseResult(end.row)
}
}
基本的に行っているのは(passive objectである)ParserObjectと同じです。オブジェクトのインスタンス変数でmutableな状態を保持する実現方法になります。
ParserObjectとの違いは、メソッド呼び出しではなくアクター間でのメッセージ通信によって処理が実行されるため、以下のようなメッセージ通信のためのコードを用意しないといけない点です。
- アクター間で送受信されるメッセージの定義
- メッセージを受け取り処理を行うイベントハンドラ
ParserObjectではメソッドとして簡単に定義できる処理ですが、これをメッセージ処理ように仕立て直さないといけないわけです。
アクターはとても簡単に利用できるのですが、少しボイラープレイトのコードを書く必要があります。
使い方
package sample
import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest._
import org.scalatest.prop.GeneratorDrivenPropertyChecks
import akka.actor._
import akka.pattern.ask
import akka.util.Timeout
import scala.concurrent.duration._
@RunWith(classOf[JUnitRunner])
class ParserActorSpec extends WordSpec with Matchers with GivenWhenThen with GeneratorDrivenPropertyChecks {
"ParseActor" should {
"parse" in {
implicit val timeout = Timeout(5.seconds)
val system = ActorSystem("state")
implicit val context = system.dispatcher
val actor = system.actorOf(Props[ParserActor])
val text = "abc,def,xyz"
for (c <- text) {
actor ! ParseCharEvent(c)
}
for (r <- actor ? ParseEnd) yield {
r match {
case ParseResult(r) =>
println(s"ParserActorSpec: $r")
r should be (Vector("abc", "def", "xyz"))
}
}
system.shutdown()
}
}
}
アプリケーションロジックは以下の部分です。
val text = "abc,def,xyz"
for (c <- text) {
actor ! ParseCharEvent(c)
}
for (r <- actor ? ParseEnd) yield {
r match {
case ParseResult(r) =>
println(s"ParserActorSpec: $r")
r should be (Vector("abc", "def", "xyz"))
}
}
アクターを使うために以下のような準備が必要になります。
// タイムアウト値の暗黙知を定義
implicit val timeout = Timeout(5.seconds)
// アクターの実行基盤を作成
val system = ActorSystem("state")
// スレッドの実行文脈を定義
implicit val context = system.dispatcher
// アクターの生成
val actor = system.actorOf(Props[ParserActor])
....
// アクターの実行基盤をシャットダウン
system.shutdown()
アクターはJava流のスレッドによる並行・並列プログラミングと比較するとはるかに楽でバグも出にくいアプローチなのですが、それでも少し込み入ったボイラープレイトが必要になります。
実行
実行結果は以下になります。
$ sbt test-only sample.ParserActorSpec
[info] Compiling 1 Scala source to /Users/asami/src/workspace2015/0317.blog.statemachine/target/scala-2.11/test-classes...
ParserActorSpec: List(abc, def, xyz)
[info] ParserActorSpec:
[info] ParseActor
[info] - should parse
[info] ScalaTest
[info] 36mRun completed in 408 milliseconds.0m
[info] 36mTotal number of tests run: 10m
[info] 36mSuites: completed 1, aborted 00m
[info] 36mTests: succeeded 1, failed 0, canceled 0, ignored 0, pending 00m
[info] 32mAll tests passed.0m
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 2 s, completed 2015/03/21 17:48:09
まとめ
今回は状態機械のベースとなるcase class群(ParseState)を作成した後、通常のOOPのアプローチとして普通のオブジェクト版(passive object)のパーサーとアクター版(active object)のパーサーを作成しました。
ParseStateはOOPのvalue objectであると同時にFPの代数的データ型でもあります。このParseStateを普通のオブジェクト、アクターの両方で普通に利用して状態機械を作成できることが確認できました。
次回は今回のParseStateをベースにMonadic Programming, Functional Reactive Programmingにおける状態機械の実現方法について考えます。
諸元
- Scala 2.11.4
- Scalaz 7.1.0
- Scalaz-stream 0.6a
- Scalatest 2.2.4