Menu
Coddy logo textTech

状態パターン

CoddyのKotlinジャーニー「オブジェクト指向プログラミング」セクションの一部。レッスン 49/57。

state パターンは、オブジェクトが現在いる状態ごとに異なる振る舞いを持つようにします。それぞれの状態は独自の型であり、オブジェクトはすべてのイベントを現在の状態に渡し、現在の状態が次の状態を返します:

interface LampState {
    val name: String
    fun press(): LampState
}
object Off : LampState {
    override val name = "off"
    override fun press() = Dim
}
object Dim : LampState {
    override val name = "dim"
    override fun press() = Bright
}
object Bright : LampState {
    override val name = "bright"
    override fun press() = Off
}

class Lamp {
    var state: LampState = Off
        private set
    fun press() { state = state.press() }
}

main の中で:

val lamp = Lamp()
repeat(4) {
    lamp.press()
    println(lamp.state.name)
}

出力:

dim
bright
off
dim

状態はデータを保持できます。sealed interface を使うと、データを持たない状態は data object で、データを持つ状態は data class で表し、1つの関数がすべてのイベントに対する次の状態を返します。

sealed interface Parking
data object Free : Parking
data class Taken(val plate: String, val since: Int) : Parking

fun Parking.arrive(plate: String, time: Int): Parking = when (this) {
    Free -> Taken(plate, time)
    is Taken -> this
}

fun Parking.leave(time: Int): Pair<Parking, Int> = when (this) {
    Free -> this to 0
    is Taken -> Free to (time - since) * 2
}

main の中で:

var spot: Parking = Free
spot = spot.arrive("AB12", 9)
println(spot)
val (next, fee) = spot.leave(12)
println("$next, fee $fee")

出力:

Taken(plate=AB12, since=9)
Free, fee 6

一部の状態では、許可されないイベントがあります。その場合、遷移は null を返すことができ、オブジェクトは現在の状態にとどまり、拒否されたことを報告します。

sealed interface Door
data object Open : Door
data object Closed : Door
data object Locked : Door

fun next(door: Door, event: String): Door? = when (door) {
    Open -> if (event == "close") Closed else null
    Closed -> when (event) { "open" -> Open; "lock" -> Locked; else -> null }
    Locked -> if (event == "unlock") Closed else null
}

main の中では:

var door: Door = Open
for (e in listOf("lock", "close", "lock", "open", "unlock")) {
    val n = next(door, e)
    if (n == null) println("$e refused while $door") else door = n
}
println("final: $door")

出力:

lock refused while Open
open refused while Locked
final: Closed

Boolean フラグをいくつか使うのはどうでしょうか? isOpen や isLocked のようなフラグでは、開いていて同時にロックされているなど、不可能な組み合わせが許されてしまい、すべてのメソッドでそれらをチェックする必要があります。1つの状態プロパティで常に有効な状態を1つだけ保持でき、sealed 型に対する when によって、すべての状態が処理されているかをコンパイラーがチェックできます。

// flags: 4つの組み合わせ、そのうち1つは不可能
class DoorWithFlags {
    var isOpen = false
    var isLocked = false        // isOpen && isLocked は決して起こってはならない
}
// state: 常に Open、Closed、Locked のいずれかちょうど1つ
class DoorWithState {
    var state: Door = Closed
        private set
}
challenge icon

チャレンジ

簡単

自動販売機は、与えられた状態 Idle、HasCoins(cents)、または SoldOut のいずれかです。idle、coins 100、または sold out を返す Vending.label() と、state が Idle として開始され、内部でのみ変更できるクラス VendingMachine を作成してください。handle(event) はイベントを適用して true を返します。イベントが許可されていない場合は false を返し、何も変更しません。coin <n> は Idle から HasCoins(n) へ移行し、HasCoins の金額に加算します。HasCoins で少なくとも 150 セントある場合の buy は販売となり(sales に加算されます)、Idle に戻ります。HasCoins での refund は Idle に移行します。empty はどの状態からでも SoldOut に移行します。SoldOut での refill は Idle に移行します。

提供されたコードはイベントを順番に適用し、それぞれの後に状態のラベルを出力します。イベントが許可されていない場合は not allowed: <event> を出力し、最後に sales: 1 を出力します。

コードは VendingMachine.kt、Label.kt、Vending.kt、Idle.kt、HasCoins.kt、および SoldOut.kt に記述します。Main.kt には提供された入出力コードが含まれており、編集できません。

自分で試してみよう

fun main() {
    // 提供された入出力コード: そのままにしておく
    val input = generateSequence(::readLine).toList()
    val machine = VendingMachine()
    for (event in input) {
        if (machine.handle(event)) println(machine.state.label()) else println("not allowed: $event")
    }
    println("sales: ${machine.sales}")
}
quiz icon腕試し

このレッスンには短いクイズがあります。レッスンを始めて解答し、進捗を記録しましょう。

オブジェクト指向プログラミングのすべてのレッスン

自分で練習してみよう: Kotlinプレイグラウンド