scalabook
Функциональное состояние
Базовый шаблон, как сделать любой API с отслеживанием состояния чисто функциональным, выглядит так:
opaque type State[S, +A] = S => (A, S)
object State: extension [S, A](underlying: State[S, A]) def run(s: S): (A, S) = underlying(s)
def apply[S, A](f: S => (A, S)): State[S, A] = f
Здесь State
— это сокращение от вычисления, которое переносит какое-то состояние, действие состояния,
переход состояния или даже оператор.
Помимо определения непрозрачного типа,
предоставляется метод расширения run
, позволяющий вызывать базовую функцию,
а также метод apply
для сопутствующего объекта, позволяющий создавать значение State
из функции.
В обоих случаях известен тот факт, что State[S, A]
эквивалентно S ⇒ (A, S)
,
что делает эти две операции простыми и кажущимися избыточными.
Однако за пределами определяющей области видимости — например, в другом пакете — эта эквивалентность неизвестна,
и, следовательно, нужны такие преобразования.
Резюме
- API с отслеживанием состояния можно смоделировать как чистые функции, которые преобразуют входное состояние в выходное при вычислении значения.
- Тип данных
State
упрощает работу с API с отслеживанием состояния, устраняя необходимость вручную передавать состояния ввода и вывода во время вычислений.
Пример
Рассмотрим упрощенный пример машины по раздаче конфет.
case class SimpleMachine(candies: Int, coins: Int)
Если в машине остались конфеты и ей заплатить монету, то она выдаст конфету. Количество конфет уменьшиться на 1, а количество монет увеличится на 1. В остальных случаях состояние машины не изменится.
Опишем это функцией вычисления следующего состояния машины:
case class SimpleMachine(candies: Int, coins: Int): self =>
lazy val next: SimpleMachine = if candies <= 0 then self else SimpleMachine(candies - 1, coins + 1)
Определим State
, вычисляющий значение - количество заработанных монет:
val state: State[SimpleMachine, Int] = State(sm => (sm.coins + 1, sm.next))
Здесь используется метод apply
, создающий значение State
из функции.
Запустить вычисление значения и следующего состояния можно, вызвав run
:
val initial = SimpleMachine(100, 0)state.run(initial)// (1, SimpleMachine(99, 1))
Функции общего назначения
Функции общего назначения, описывающие шаблоны программ с отслеживанием состояния.
map
map
позволяет при наличии базового шаблона State[S, A]
и функции преобразования значения из типа A
в тип B
получать State[S, B]
- преобразование входного состояния в выходное с вычислением значения типа B
.
object State: extension [S, A](underlying: State[S, A]) def map[B](f: A => B): State[S, B] = s => val (a, s1) = run(s) (f(a), s1)
Пример:
val stateB = state.map(coins => s"Заработано $coins монет")stateB.run(initial)// (Заработано 1 монет, SimpleMachine(99, 1))
map2
map2
позволяет объединять два State
в один при наличии функции объединения входящих значений.
object State: extension [S, A](underlying: State[S, A]) def map2[B, C](sb: State[S, B])(f: (A, B) => C): State[S, C] = s0 => val (a, s1) = run(s0) val (b, s2) = sb.run(s1) (f(a, b), s2)
Пример:
val stateA = State[SimpleMachine, String](sm => ("Первое изменение", sm.next))val stateB = State[SimpleMachine, String](sm => (", Второе изменение", sm.next))val stateC = stateA.map2(stateB)(_ + _)
stateC.run(initial)// (Первое изменение, Второе изменение, SimpleMachine(98, 2))
flatMap
flatMap
позволяет при наличии функции преобразования значения в новый State
получать этот State
:
object State: extension [S, A](underlying: State[S, A]) def flatMap[B](f: A => State[S, B]): State[S, B] = s0 => val (a, s1) = run(s0) f(a)(s1)
Пример:
val stateA = State[SimpleMachine, Int](sm => (sm.candies - 1, sm.next))val f: Int => State[SimpleMachine, String] = candies => if candies <= 0 then State(sm => ("Конфеты кончились", sm)) else State(sm => ("Конфеты ещё есть", sm))
val stateB = stateA.flatMap(f)
stateB.run(initial)// (Конфеты ещё есть, SimpleMachine(99, 1))
for comprehension
При наличии у State
методов map
и flatMap
можно использовать синтаксический сахар:
val stateB = for candies <- stateA message <- f(candies) yield message
stateB.run(initial)// (Конфеты ещё есть, SimpleMachine(99, 1))
unit
unit
оборачивает любое значение в очень простой State
, не изменяющий состояние:
object State: def unit[S, A](a: A): State[S, A] = s => (a, s)
Пример:
val stateA = State.unit[SimpleMachine, String]("Сообщение")stateA.run(initial)// (Сообщение, SimpleMachine(100, 0))
get, set и modify
get
нужен для получения текущего состоянияset
- для замены старого состояния новым- а
modify
- для изменения состояния (основан на двух предыдущих)
object State: def get[S]: State[S, S] = s => (s, s)
def set[S](s: S): State[S, Unit] = _ => ((), s)
def modify[S](f: S => S): State[S, Unit] = for s <- get _ <- set(f(s)) yield ()
Пример:
val initial = SimpleMachine(100, 0)val other = SimpleMachine(0, 100)
State.get[SimpleMachine].run(initial)// (SimpleMachine(100, 0), SimpleMachine(100, 0))
State.set(other).run(initial)// ((), SimpleMachine(0, 100))
State.modify[SimpleMachine](_.next).run(initial)// ((), SimpleMachine(99, 1))
traverse и sequence
traverse
и sequence
необходимы для обработки коллекции операций с состоянием.
object State: def sequence[S, A](list: List[State[S, A]]): State[S, List[A]] = traverse(list)(identity)
def traverse[S, A, B](as: List[A])(f: A => State[S, B]): State[S, List[B]] = as.foldRight(unit[S, List[B]](Nil))((a, acc) => f(a).map2(acc)(_ :: _))
Автомат, моделирующий раздачу конфет
Давайте реализуем более сложный автомат, моделирующий простой раздатчик конфет. Автомат имеет два типа входа: можно вставить монету или повернуть ручку, чтобы выдать конфету. Он может находиться в одном из двух состояний: заблокировано или разблокировано. Он также отслеживает, сколько конфет осталось и сколько монет в нем содержится:
enum Input: case Coin, Turn case class Machine(locked: Boolean, candies: Int, coins: Int)
Правила работы машины следующие:
- (а) Если вставить монету в заблокированный автомат, он разблокируется, если в нем остались конфеты.
- (б) Если повернуть ручку на разблокированном автомате, он выдаст конфеты и заблокируется.
- (в) Поворот ручки на заблокированном автомате или вставка монеты в разблокированный автомат ничего не дает.
- (г) Машина, в которой закончились конфеты, игнорирует все входные данные.
Для начала создадим функцию, которая реализует правила выше и
по переданному Input
-у возвращает функцию изменения состояния машины:
object Candy: private val updateMachine: Input => Machine => Machine = (i: Input) => (s: Machine) => (i, s) match case (_, Machine(_, 0, _)) => s // (г) case (Input.Coin, Machine(false, _, _)) => s // (в) case (Input.Turn, Machine(true, _, _)) => s // (в) case (Input.Coin, Machine(true, candy, coin)) => // (а) Machine(false, candy, coin + 1) case (Input.Turn, Machine(false, candy, coin)) => // (б) Machine(true, candy - 1, coin)
Теперь создадим метод simulateMachine
, который управляет машиной на основе списка входных данных
и возвращает количество монет и конфет, оставшихся в машине в конце:
object Candy: def simulateMachine(inputs: List[Input]): State[Machine, (Int, Int)] = for _ <- State.traverse(inputs)(input => State.modify(updateMachine(input))) s <- State.get yield (s.coins, s.candies)
Первая строка State.traverse(inputs)(input => State.modify(updateMachine(input)))
возвращает State
,
преобразующий входное состояние машины в выходное с вычислением значения типа List[Unit]
,
которое нас не интересует.
Вторая строка возвращает выходное состояние в качестве значения.
Из выходного состояния мы можем получить итоговое количество монет и конфет после всех преобразований.
Итого
Метод simulateMachine
по списку операций (вставить монету или повернуть ручку) возвращает State[Machine, (Int, Int)]
-
по своей сути функцию с типом Machine => ((Int, Int), Machine)
.
Осталось только её запустить.
Например, если входные данные Machine
содержат 10 монет и 5 конфет
и всего успешно куплено 4 конфеты, то получим следующие выходные данные (14, 1)
:
val initialMachine = Machine(true, 5, 10)
val inputs = List(Coin, Turn, Coin, Coin, Coin, Turn, Turn, Turn, Coin, Turn, Coin, Turn)
val runner = Candy.simulateMachine(inputs)runner.run(initialMachine)// ((14, 1), Machine(true, 1, 14))
4 и 5 операции - повторная вставка монеты, а также 7 и 8 - поворачивание ручки на заблокированном автомате, не изменяют состояния, поэтому на выходе получаем результат успешной покупки 4 конфет.
Реализация
Реализация в Cats
import cats.data.State
val step1 = State[Int, String]{ num => val ans = num + 1 (ans, s"Result of step1: $ans")}
val step2 = State[Int, String]{ num => val ans = num * 2 (ans, s"Result of step2: $ans")}
val both = for { a <- step1 b <- step2} yield (a, b)
val (state, result) = both.run(20).value// val state: Int = 42// val result: (String, String) = (Result of step1: 21,Result of step2: 42)
Ссылки: