Menu
Coddy logo textTech

Паттерн состояние

Часть раздела Объектно-ориентированное программирование путешествия по Kotlin на Coddy. Урок 49 из 57.

Паттерн состояние задаёт объекту различное поведение для каждого состояния, в котором он находится. Каждое состояние является отдельным типом, а объект передаёт каждое событие своему текущему состоянию, которое возвращает следующее:

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

Состояние может содержать данные. В запечатанном интерфейсе data object обозначает состояние без данных, класс данных — состояние с данными, а одна функция возвращает следующее состояние для каждого события:

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, допускают невозможные комбинации, например одновременное открытое и заблокированное состояние, и каждый метод должен их проверять. Одно свойство состояния содержит ровно одно допустимое состояние, а конструкция when для запечатанного типа позволяет компилятору проверить, обработано ли каждое состояние:

// flags: 4 комбинации, одна из них невозможна
class DoorWithFlags {
    var isOpen = false
    var isLocked = false        // isOpen && isLocked никогда не должно происходить
}
// state: всегда ровно одно из Open, Closed, Locked
class DoorWithState {
    var state: Door = Closed
        private set
}
challenge icon

Задание

Легко

Торговый автомат находится в одном из заданных состояний: Idle, HasCoins(cents) или SoldOut. Напишите Vending.label(), которая возвращает idle, coins 100 или sold out, а также класс VendingMachine, состояние state которого изначально равно Idle и может изменяться только внутри класса. handle(event) применяет событие и возвращает true либо возвращает false и ничего не изменяет, если событие недопустимо: coin <n> переводит из Idle в HasCoins(n) и добавляет сумму к количеству в HasCoins; buy в состоянии HasCoins при наличии как минимум 150 центов является продажей (она учитывается в sales) и переводит автомат обратно в Idle; refund в состоянии HasCoins переводит автомат в Idle; empty переводит автомат из любого состояния в SoldOut; refill в состоянии SoldOut переводит автомат в 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