Функциональное состояние

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

Резюме
Пример

Рассмотрим упрощенный пример машины по раздаче конфет.

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

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)

Ссылки: