Введение в программирование с функциональными ZIO эффектами

Перевод статьи "Introduction to Programming with ZIO Functional Effects", автор Jorge Vasquez

Введение

Разработчики во всем мире часто избегают парадигмы функционального программирования (ФП), предпочитая продолжать разработку приложений, используя более известную и более широко используемую парадигму объектно-ориентированного программирования (ООП). Этот отказ от функционального программирования происходит либо из-за простого недостатка знаний, либо потому, что разработчики склонны думать, что ФП - что-то сложное и полезное только в академических областях. Вероятно, это связано с тем, что появилось много новых понятий, которые обычно объясняются принципиальным образом, но полны математического жаргона. Ничто не может быть дальше от истины: функциональное программирование помогает решить многие проблемы, возникающие при разработке программного обеспечения, особенно программ, требующих высокого уровня асинхронности и параллелизма, и если оно правильно объяснено, ФП не должно быть чем-то сложным для понимания.

Язык Scala сочетает в себе лучшее из мира функционального и объектно-ориентированного программирования и обеспечивает полную совместимость с обширной экосистемой Java. Кроме того, в экосистеме Scala есть такие библиотеки, как ZIO, которые позволяют разработчикам стать более эффективными и создавать современные, высокопроизводительные и параллельные приложения, используя возможности функционального программирования — и все это без каких-либо огромных барьеров для входа.

В этой статье я (автор - Jorge Vasquez) объясню принципы функционального программирования, а затем покажу, как с помощью Scala и ZIO можно создавать приложения для решения реальных задач. В качестве наглядного примера реализуем игру «Виселица» (a hangman game).

Функциональное программирование

Что такое функциональное программирование?

Функциональное программирование — это парадигма программирования, в которой программы представляют собой набор чистых функций. Это представляет собой фундаментальное отличие от объектно-ориентированного программирования, где программы представляют собой последовательности операторов, обычно работающих в изменяемом состоянии. Они организованы в так называемые функции, но они не являются функциями в математическом смысле, поскольку не отвечают некоторым фундаментальным характеристикам.

Чистая функция должна быть полной

Во-первых, функция должна быть полной. Это означает, что для каждых входных данных, предоставленных функции, должен быть выдан определенный результат.

Функцией f, определённой на множестве X со значениями в множестве Y, называют «правило» такое, что каждому элементу x из X соответствует элемент f(x), лежащий в Y и притом только один.

Например, следующая функция деления двух целых чисел не является полной:

def divide(a: Int, b: Int): Int = a / b

Чтобы ответить, почему эта функция не является полной, давайте посмотрим, что произойдет, если попытаться поделить на ноль:

divide(5, 0)  // java.lang.ArithmeticException: / by zero

Деление на ноль не определено, поэтому выдается исключение. Это означает, что функция divide не является полной, потому что она не возвращает никакого результата в случае, когда b = 0.

Некоторые важные вещи, которые можно здесь выделить:

Итак, как можно решить эту проблему? Давайте посмотрим на альтернативное определение функции divide:

def divide(a: Int, b: Int): Option[Int] =
  if b != 0 then Some(a / b) else None

В этом случае функция деления возвращает Option[Int] вместо Int, поэтому, когда b = 0, функция возвращает None вместо выдачи исключения. Вот как мы преобразовали частично определенную функцию в полную, и благодаря этому у нас есть некоторые преимущества:

Чистая функция должна быть детерминированной и зависеть только от своих входных данных

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

def generateRandomInt(): Int = (new scala.util.Random).nextInt

Чтобы продемонстрировать, почему эта функция не является детерминированной, давайте рассмотрим, что происходит при первом вызове функции:

generateRandomInt()
// res0: Int = 442458883

И затем, что происходит, когда мы снова вызываем функцию:

generateRandomInt()
// res1: Int = 1432192231

У нас разные результаты! Ясно, что эта функция не является детерминированной, и ее сигнатура снова вводит в заблуждение, поскольку предполагает, что она не зависит ни от каких входных данных для получения выходных данных, тогда как на самом деле существует скрытая зависимость от объекта scala.util.Random. Что может вызвать проблемы, потому что мы не знаем, как будет вести себя функция generateRandomInt, что затрудняет ее тестирование.

Теперь давайте посмотрим на альтернативное определение. Для этого будем использовать собственный генератор случайных чисел, основанный на примере из книги «Функциональное программирование на Scala»:

final case class RNG(seed: Long):
  def nextInt: (Int, RNG) =
    val newSeed = (seed * 0x5deece66dL + 0xbL) & 0xffffffffffffL
    val nextRNG = RNG(newSeed)
    val n = (newSeed >>> 16).toInt
    (n, nextRNG)

def generateRandomInt(random: RNG): (Int, RNG) = random.nextInt

Эта новая версия функции generateRandomInt является детерминированной: независимо от того, сколько раз она вызывается, мы всегда будем получать одинаковый результат для одних и тех же входных данных, а в сигнатуре теперь четко указывается зависимость от RNG. Например:

val random        = RNG(10)
// random: RNG = RNG(seed = 10L)
val (n1, random1) = generateRandomInt(random)
// n1: Int = 3847489
// random1: RNG = RNG(seed = 252149039181L)
val (n2, random2) = generateRandomInt(random) 
// n2: Int = 3847489
// random2: RNG = RNG(seed = 252149039181L)

Если мы хотим сгенерировать новое целое число, то должны предоставить другие входные данные:

val (n3, random3) = generateRandomInt(random2)
// n3: Int = 1334288366
// random3: RNG = RNG(seed = 87443922374356L)

Чистая функция не должна иметь побочных эффектов

Наконец, функция не должна иметь побочных эффектов. Вот некоторые примеры побочных эффектов:

Это означает, что чистая функция может работать только с неизменяемыми значениями и может возвращать для соответствующих входных данных только результат, ничего больше.

Например, следующая функция increment не является чистой, поскольку работает с изменяемой переменной a:

var a = 0
def increment(inc: Int): Int =
  a += inc
  a

И следующая функция тоже не чистая, потому что она выводит сообщение в консоль:

def add(a: Int, b: Int): Int =
  println(s"Adding two integers: $a and $b")
  a + b

В чем разница между ФП и ООП?

В следующей таблице приведены основные различия между этими двумя парадигмами программирования:

ФП ООП
Переменные Неизменяемые Изменяемые
Модель программирования Декларативная Императивная
Фокусируется на... "что" сделать "как" сделать
Параллельное программирование подходит не подходит
Побочные эффекты не могут вызывать побочных эффектов могут вызывать побочные эффекты
Итерации рекурсии циклы (for, while)
Состояние приложения проходит через чистые функции обычно совместно используется несколькими объектами
Ключевые элементы неизменяемые значения и функции объекты и методы

Каковы преимущества функционального программирования?

По разным причинам функциональное программирование до сих пор может многим казаться сложным. Но, если мы более внимательно посмотрим на преимущества, то сможем изменить наш образ мышления.

Во-первых, использование этой парадигмы программирования помогает разбивать каждое приложение на более мелкие, более простые части, которые надежны и легки для понимания. Это связано с тем, что функциональный исходный код часто более лаконичен, предсказуем и его легче тестировать. Но как мы можем это обеспечить?

Поскольку Scala поддерживает парадигму функционального программирования, эти преимущества распространяются и на сам язык. В результате все больше и больше компаний используют Scala, включая таких гигантов, как LinkedIn, Twitter и Netflix.

Но... в реальных приложениях должны быть побочные эффекты!

Теперь мы знаем, что функциональное программирование — это программирование с использованием чистых функций, и что чистые функции не могут иметь побочных эффектов, поэтому возникает несколько логичных вопросов:

Ответ на эти вопросы следующий: Да, можно использовать функциональное программирование в реальных приложениях, а не только в академических условиях. Чтобы наши приложения могли взаимодействовать с внешними сервисами, можно сделать следующее: вместо написания функций, взаимодействующих с внешним миром, мы пишем функции, описывающие взаимодействия с внешним миром, которые выполняются только в определенной точке нашего приложение (обычно называемое "конец света" (end of the world)), например, в main функции.

Если мы хорошенько об этом подумаем, эти описания взаимодействий с внешним миром являются просто неизменяемыми значениями, которые могут служить входными и выходными данными чистых функций, и таким образом мы не нарушаем основной принцип функционального программирования: не производить побочных эффектов.

Что это за вышеупомянутый "конец света"? "Конец света" — это просто конкретная точка в нашем приложении, где заканчивается функциональный мир, и где запускаются описания взаимодействий с внешним миром, обычно как можно позже, желательно на самом краю нашей программы, который является его основной функцией. Таким образом, все наше приложение может быть написано в функциональном стиле, но в то же время способно выполнять полезные задачи.

Теперь возникает новый вопрос: как писать приложения таким образом, чтобы все функции не имели бы побочных эффектов, а только строили описания того, что нужно делать? И вот тут-то и появляется очень мощная библиотека, которая может помочь с этой задачей: представляем библиотеку ZIO.

Введение в библиотеку ZIO

Для чего нужна библиотека ZIO?

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

Почему утверждается, что ZIO позволяет создавать приложения, которые легко понять и протестировать? Потому что ZIO помогает строить приложения любой сложности постепенно, за счет комбинации описаний взаимодействий с внешним миром. Кстати, эти описания называются функциональными эффектами (functional effects).

Почему утверждается, что ZIO позволяет создавать отказоустойчивые приложения? Потому что ZIO в полной мере использует систему типов Scala таким образом, что может обнаруживать больше ошибок во время компиляции, а не во время выполнения. Это здорово, потому что, просто взглянув на сигнатуру функции, можно сказать:

И, наконец, почему мы говорим, что ZIO позволяет создавать асинхронные и параллельные приложения? Потому что ZIO дает нам "сверхвозможности" работать с асинхронным и параллельным программированием, используя модель на основе волокон (fiber-ов), которая намного эффективнее, чем модель на основе потоков. Мы не будем подробно останавливаться на этом конкретном аспекте в этой статье, однако стоит упомянуть, что именно в этой области блистает ZIO, позволяя создавать действительно производительные приложения.

Тип данных ZIO

Самый важный тип данных в библиотеке ZIO (а также основной строительный блок любого приложения, основанного на этой библиотеке) также называется ZIO:

ZIO[-R, +E, +A]

Тип данных ZIO — это функциональный эффект, что означает, что это неизменяемое значение, содержащее описание ряда взаимодействий с внешним миром (запросы к базе данных, вызовы сторонних API и т.д.). Хорошая ментальная модель типа данных ZIO выглядит следующим образом:

R => Either[E, A]

Это означает, что ZIO эффект:

Общие псевдонимы для типа данных ZIO

Стоит отметить, что ZIO предоставляет некоторые псевдонимы типов для типа данных ZIO, которые очень полезны, когда дело доходит до представления некоторых распространенных вариантов использования:

Реализация игры «Виселица» с помощью ZIO

Далее реализуем игру «Виселица» с использованием ZIO в качестве наглядного примера того, как можно разрабатывать чисто функциональные приложения на Scala с помощью этой библиотеки.

Для справки вы можете посмотреть репозиторий Github с полным кодом по этой ссылке на Scala 2 или тот же самый пример для Scala 3.

Дизайн и требования

Игра «Виселица» состоит из случайного выбора слова и предоставления игроку возможности угадывать буквы до тех пор, пока слово не будет полностью угадано. Правила следующие:

Итак, наша реализация игры «Виселица» должна работать следующим образом:

Создание базовой структуры приложения

Определим наше приложение как проект sbt, файл build.sbt будет содержать зависимости нашего проекта:

val scalaVer = "3.3.0"

val zioVersion = "2.0.14"

lazy val compileDependencies = Seq(
  "dev.zio" %% "zio" % zioVersion
) map (_ % Compile)

lazy val settings = Seq(
  name := "zio-hangman",
  version := "2.0.0",
  scalaVersion := scalaVer,
  libraryDependencies ++= compileDependencies
)

lazy val root = (project in file("."))
  .settings(settings)

Как видите, мы будем работать со Scala 3.3.0 и с ZIO 2.0.14.

Создание модели предметной области с использованием функционального стиля

Во-первых, мы должны определить модель предметной области. Для этого определим несколько классов.

В качестве первого шага можно определить класс Name, представляющий имя игрока:

final case class Name(name: String)

Как видите, мы определяем Name как case class, а не просто как class. Использование case class-ов дает много преимуществ.

Кроме того, мы определяем класс как final, чтобы его нельзя было расширить другими классами. С другой стороны, мы видим, что класс Name просто инкапсулирует строку, представляющую имя пользователя. Мы могли бы оставить эту модель как есть, однако есть проблема. Текущая реализация позволяет создавать объекты Name с пустой строкой, что нежелательно. Итак, чтобы предотвратить это, мы можем применить метод функционального проектирования, называемый умным конструктором (smart constructor), который просто состоит в определении метода в сопутствующем объекте класса Name, позволяющий выполнять соответствующие проверки перед созданием экземпляра. Итак, у нас получилось что-то вроде этого:

final case class Name (name: String)

object Name:
  def make(name: String): Option[Name] =
    if name.nonEmpty then Some(Name(name)) else None

Как видите, метод make соответствует нашему умному конструктору и проверяет полученную строку на пустоту. Если имя не пустое, метод возвращает новый Name, но "обернутый" в Some, а если пустое - возвращает None. Здесь важно подчеркнуть, что метод make является чистой функцией, потому что он полный, детерминированный, вывод зависит только от входящего значения String и не вызывает побочных эффектов.

Теперь есть некоторые вещи, которые в нашей реализации можно улучшить. Например, по-прежнему можно напрямую вызывать конструктор Name с пустыми строками. Чтобы избежать этого, мы можем сделать его приватным:

final case class Name private (name: String)

object Name:
  def make(name: String): Option[Name] =
    if name.nonEmpty then Some(Name(name)) else None
Можно пойти дальше и использовать уточняющие типы, но это предмет для отдельной статьи.

Таким образом, мы полностью уверены, что любой объект Name в нашем приложении всегда будет действительным.

Точно так же можно определить case class с именем Guess, который представляет собой любую букву, угаданную игроком:

final case class Guess private (char: Char)

object Guess:
  def make(str: String): Option[Guess] =
    Some(str.toList).collect:
      case c :: Nil if c.isLetter => Guess(c.toLower)

Как видите, мы использовали ту же технику определения умного конструктора, который получает строку, введенную игроком, и проверяет, состоит ли она только из одной буквы. Если это не так (например, когда строка, введенная игроком, состоит из нескольких символов или при вводе числа или небуквенного символа), возвращается None.

Затем мы можем определить другой case class с именем Word, представляющий слово, которое игрок должен угадать:

final case class Word private (word: String):
  val length: Int                   = word.length
  val toList: List[Char]            = word.toList
  val toSet: Set[Char]              = word.toSet
  def contains(char: Char): Boolean = word.contains(char)

object Word:
  def make(word: String): Option[Word] =
    if word.nonEmpty && word.forall(_.isLetter) then
      Some(Word(word.toLowerCase))
    else None

Опять же, у нас есть умный конструктор, который проверяет, что слово не является пустым и содержит только буквы, а не числа или небуквенные символы. Кроме того, этот класс содержит несколько полезных методов (кстати, все они являются чистыми функциями):

Затем мы определяем еще один case class с именем State, который представляет внутреннее состояние приложения, в том числе:

final case class State private (name: Name, guesses: Set[Guess], word: Word):
  lazy val failuresCount: Int  = (guesses.map(_.char) -- word.toSet).size
  lazy val playerLost: Boolean = failuresCount > 5
  lazy val playerWon: Boolean  = (word.toSet -- guesses.map(_.char)).isEmpty

  def addGuess(guess: Guess): State =
    State(name, guesses + guess, word)

object State:
  def initial(name: Name, word: Word): State =
    State(name, Set.empty, word)

Точно так же у нас есть умный конструктор State.initial, позволяющий создавать начальное состояние игры с именем игрока и словом, которое нужно угадать, очевидно, с пустым набором угаданных букв. С другой стороны, State включает несколько полезных элементов:

Наконец, мы определяем модель GuessResult, которая представляет результат, полученный игроком после использования буквы. Этот результат может быть одним из следующих:

Мы можем представить это перечислением следующим образом:

enum GuessResult:
  case Won, Lost, Correct, Incorrect, Unchanged

В этом случае имеет смысл использовать перечисление, так как GuessResult может иметь только одно возможное значение из тех, что указаны в списке опций. Важные детали о enum описаны в соответствующей документации

Создание скелета приложения

Теперь, когда мы определили модель предметной области приложения, можем продолжить с реализацией его самого. Внутри файла Hangman.scala создадим объект, реализующий трейт ZIOAppDefault, например:

import zio.*

object Hangman extends ZIOAppDefault:
  def run = ???

С помощью всего лишь этого небольшого фрагмента кода мы можем узнать несколько вещей:

Трейт ZIOAppDefault требует, чтобы мы реализовали метод run, который является точкой входа приложения. Этот метод должен возвращать функциональный ZIO эффект, который, как мы уже знаем, является лишь описанием того, что должно делать наше приложение. Это описание будет переведено средой выполнения ZIO во время выполнения приложения в реальные взаимодействия с внешним миром, то есть побочные эффекты.

Интересно, что нам, как разработчикам, не нужно беспокоиться о том, как это происходит - ZIO позаботится обо всем этом за нас.

Если предоставленный эффект не работает по какой-либо причине, она будет зарегистрирована, а код выхода приложения будет ненулевым. В противном случае код выхода приложения будет нулевым. Следовательно, метод run станет end of the functional world для нашего приложения. Пока оставим его нереализованным.

Функционал для получения имени игрока через консоль

Теперь, когда у нас есть базовый скелет нашего приложения, во-первых, нужно написать функционал для получения имени игрока с помощью консоли, для этого напишем вспомогательную функцию внутри объекта Hangman, которая позволяет печатать любое сообщение и затем запрашивает текст от игрока:

def getUserInput(message: String): IO[IOException, String] =
  Console.printLine(message)
  Console.readLine

Рассмотрим эту функцию более подробно:

Здесь важно отметить, как ZIO 2.0 работает по сравнению с ZIO 1.0. В ZIO 1.0 подпись типа этого метода была бы такой: def getUserInput(message: String): ZIO[Console, IOException, String]. Это означает, что getUserInput вернул бы эффект ZIO, для которого требовался стандартный модуль консоли, предоставленный ZIO. Это было упрощено в ZIO 2.0, поэтому, когда мы используем только стандартные модули ZIO (такие как Console или Random), они больше не появляются в сигнатуре типа, и это значительно облегчит нам жизнь. Когда мы используем определяемые пользователем модули, они будут отражаться в сигнатуре типа, а поскольку в этом примере мы не определяем собственные модули, тип среды всегда будет Any.

Вот и все! Ну... на самом деле есть небольшая проблема, и она заключается в том, что если бы мы вызывали эту функцию из метода run, сообщение никогда бы в консоли не отобразилось, а шло бы напрямую к запросу текста от пользователя. Чего тогда не хватает, если мы сначала вызовем Console.printLine, а затем Console.readLine? Проблема в том, что и Console.printLine, и Console.readLine возвращают простые описания взаимодействий с внешним миром (называемые функциональными эффектами), и если присмотреться: действительно ли мы что-то делаем с функциональным эффектом, возвращаемым Console.printLine? Ответ — нет, мы просто отбрасываем его, и единственный эффект, возвращаемый нашей функцией getUserInput, — это эффект, возвращаемый Console.readLine. Эта ситуация более или менее похожа на ту, как если бы у нас была вот такая функция:

def foo (): Int =
  4
  3

Мы в этой функции что-то делаем с 4? Ничего! Это как если бы его там не было, и то же самое происходит в нашей функции getUserInput, как если бы вызова Console.printLine не было.

Поэтому мы должны модифицировать функцию getUserInput, чтобы эффект, возвращаемый Console.printLine, действительно использовался:

def getUserInput(message: String): IO[IOException, String] =
  Console.printLine(message).flatMap(_ => Console.readLine)

Что делает эта новая версия, так это: возвращает эффект, последовательно объединяющий эффекты, возвращаемые Console.printLine и Console.readLine, используя оператор ZIO#flatMap, который получает функцию, где:

Таким образом, мы больше не отбрасываем эффект, производимый Console.printLine.

Теперь, поскольку тип данных ZIO также предлагает метод ZIO#map, то можно написать getUserInput с использованием for-comprehension, что поможет визуализировать код так, чтобы он больше походил на типичный императивный код:

def getUserInput(message: String): IO[IOException, String] =
  for
    _     <- Console.printLine(message)
    input <- Console.readLine
  yield input

Эта реализация работает отлично, однако мы можем написать ее по-другому:

def getUserInput(message: String): IO[IOException, String] =
  Console.printLine(message) <*> Console.readLine

В данном случае мы используем другой оператор для последовательного объединения двух эффектов, и это оператор <*> (который, кстати, эквивалентен методу ZIO#zip). Этот оператор, как и ZIO#flatMap, объединяет результаты двух эффектов, с той разницей, что второму эффекту не требуется для выполнения результат первого. Результатом <*> является эффект, возвращаемое значение которого является кортежем, в данном случае это будет (Unit, String). Однако, обратите внимание на следующее: если типом успеха getUserInput является String, почему эта реализация вообще работает? Поскольку тип успеха при вызове оператора <*> будет (Unit, String), код даже не должен компилироваться! Так почему же это работает? Ответ: Это еще одно упрощение ZIO 2.0! В ZIO 1.0 компиляция бы не удалась, и нам нужно было бы сделать что-то вроде этого:

def getUserInput(message: String): ZIO[Console, IOException, String] =
  (Console.printLine(message) <*> Console.readLine).map(_._2)

Нам нужно было вызвать ZIO#map, метод, который позволяет преобразовать результат эффекта, в этом случае мы получили бы второй компонент кортежа, возвращаемый <*>.

Но в ZIO 2.0 у нас есть Compositional Zips, что в основном означает, что если тип успеха операции zip содержит Unit в одном из членов кортежа, Unit автоматически отбрасывается. Тогда вместо (Unit, String) у нас будет просто String.

Важно отметить, что вместо использования оператора <*> мы могли бы также использовать оператор *> (эквивалентный методу ZIO#zipRight), который всегда отбрасывает результат левостороннего вычисления, даже если его результирующий тип не Unit:

def getUserInput(message: String): IO[IOException, String] =
  Console.printLine(message) *> Console.readLine

И, наконец, наиболее сжатая версия getUserInput будет следующей:

def getUserInput(message: String): IO[IOException, String] =  
  Console.readLine(message)

Ну и теперь, когда у нас есть функция для получения текста от игрока, можно реализовать функциональный эффект, чтобы получить его имя:

lazy val getName: IO[IOException, Name] =
  for
    input <- getUserInput("What's your name? ")
    name <- Name.make(input) match
      case Some(name) => ZIO.succeed(name)
      case None =>
        Console.printLine("Invalid input. Please try again...") <*> getName
  yield name

Как видете:

Вот и все!

Однако мы можем написать эквивалентную версию:

lazy val getName: IO[IOException, Name] =
  for
    input <- getUserInput("What's your name?")
    name  <- ZIO.fromOption(Name.make(input)) <> (Console.printLine("Invalid input. Please try again...") <*> getName)
  yield name

В этой версии результат вызова Name.make, который имеет тип Option, преобразуется в эффект ZIO с помощью метода ZIO.fromOption, который возвращает эффект, завершающийся успешно, если данный Option имеет значение Some, и неудавшийся эффект, если данный параметр равен None. Затем мы используем новый оператор <> (эквивалентный методу ZIO#orElse), который также позволяет последовательно комбинировать два эффекта, но несколько иначе, чем <*>. Логика <> следующая:

Как видите, эта новая версия getName несколько более лаконична. Однако мы можем сделать еще одно упрощение:

lazy val getName: IO[IOException, Name] =
  for
    input <- getUserInput("What's your name? ")
    name  <- ZIO.from(Name.make(input)) <> (Console.printLine("Invalid input. Please try again...") <*> getName)
  yield name

Вместо ZIO.fromOption можно использовать ZIO.from, который создает значение ZIO соответствующего типа для указанного ввода, например:

Функциональность для случайного выбора слова из словаря

Давайте теперь посмотрим, как реализовать функцию случайного выбора слова, которое игрок должен угадать. Для этого у нас есть словарь слов, представляющий собой просто список слов.

Реализация выглядит следующим образом:

lazy val chooseWord: UIO[Word] =
  for
    index <- Random.nextIntBounded(words.length)
    word  <- ZIO.from(words.lift(index).flatMap(Word.make)).orDieWith(_ => new Error("Boom!"))
  yield word

Как видите, мы используем функцию Random.nextIntBounded пакета zio. Эта функция возвращает эффект, генерирующий случайные целые числа от 0 до заданного предела, в данном случае пределом является длина словаря. Когда у нас есть случайное целое число, мы получаем соответствующее слово из словаря с выражением words.lift(index).flatMap(Word.make), которое возвращает Option[Word], преобразующийся в эффект ZIO с помощью метода ZIO.from, этот эффект:

Если хорошенько подумать, может ли случиться, когда не удается получить слово из словаря? Это нереалистично, потому что эффект chooseWord никогда не будет пытаться получить слово, индекс которого находится за пределами диапазона длины словаря, а с другой стороны, все слова в словаре предопределены и не пусты. Таким образом, мы можем без проблем исключить неверный вариант, используя метод ZIO#orDieWith, возвращающий новый эффект, который не может дать сбой, а если он сработает, то это будет означать наличие серьезного, неисправимого дефекта в нашей программе (например, что некоторые из предопределенных слов пусты, хотя не должны), и поэтому приложение должно немедленно завершиться ошибкой с предоставленным исключением.

В конце концов, chooseWord — это эффект с типом UIO[Word], что означает:

Функциональность для отображения состояния игры на консоли

Следующее, что нам нужно сделать, это реализовать функциональность для отображения состояния игры на консоли:

def renderState(state: State): IO[IOException, Unit] =

  /*
      --------
      |      |
      |      0
      |     \|/
      |      |
      |     / \
      -

      f     n  c  t  o
      -  -  -  -  -  -  -
      Guesses: a, z, y, x
   */
  val hangman = ZIO.attempt(hangmanStages(state.failuresCount)).orDie
  val word =
    state.word.toList
      .map(c => if (state.guesses.map(_.char).contains(c)) s" $c " else "   ")
      .mkString

  val line    = List.fill(state.word.length)(" - ").mkString
  val guesses = s" Guesses: ${state.guesses.map(_.char).mkString(", ")}"

  hangman.flatMap: hangman =>
    Console.printLine:
      s"""
         #$hangman
         #
         #$word
         #$line
         #
         #$guesses
         #
         #""".stripMargin('#')

Мы не будем вдаваться в подробности того, как реализована эта функция, а сосредоточимся на одной строчке, пожалуй, самой интересной:

val hangman = ZIO.attempt(hangmanStages(state.failuresCount)).orDie

Мы можем видеть, что эта строка отвечает, прежде всего, за выбор того, какая фигура должна быть показана для представления "виселицы", зависимая от количества неудач, которые были у игрока (переменная hangmanStates, состоит из списка с шестью возможными вариантами). Например:

Выражение hangmanStages(state.failuresCount) не является чисто функциональным, потому что оно может вызвать исключение, если state.failuresCount больше 6. Итак, поскольку мы работаем с функциональным программированием, то не можем допустить, чтобы наш код имел побочные эффекты, такие как генерирование исключений, поэтому мы инкапсулировали предыдущее выражение в ZIO.attempt, что позволяет создавать функциональный эффект из выражения, которое может генерировать исключения. Между прочим, результатом вызова ZIO.attempt является эффект, который может дать сбой с помощью Throwable, но в соответствии с дизайном нашего приложения мы надеемся, что на самом деле никогда не будет сбоя при попытке получить рисунок повешенного (поскольку переменная failuresCount никогда не должна быть больше 6). Поэтому можно исключить неверный вариант, вызвав метод ZIO#orDie, и наше приложение должно немедленно завершиться ошибкой). Кстати, ZIO#orDie очень похож на ZIO#orDieWith, но последний можно использовать только с эффектами, которые не работают с Throwable.

Функционал для получения письма от игрока

Функционал для получения попытки угадать букву от игрока очень похож на то, как мы получаем имя, поэтому не будем вдаваться в подробности:

lazy val getGuess: IO[IOException, Guess] =
  for
    input <- getUserInput("What's your next guess? ")
    guess <- ZIO.from(Guess.make(input)) <> (Console.printLine("Invalid input. Please try again...") <*> getGuess)
  yield guess

Функционал для анализа буквы, введенной игроком

Эта функциональность очень проста: все, что она делает, это анализирует предыдущее состояние и состояние после попытки игрока, чтобы увидеть, выиграл ли игрок, проиграл, правильно угадал букву, но еще не выиграл игру, неправильно угадал букву, но еще не проиграл игру или если он повторил букву, уже опробованную ранее:

def analyzeNewGuess(oldState: State, newState: State, guess: Guess): GuessResult =
  if oldState.guesses.contains(guess) then GuessResult.Unchanged
  else if newState.playerWon then GuessResult.Won
  else if newState.playerLost then GuessResult.Lost
  else if oldState.word.contains(guess.char) then GuessResult.Correct
  else GuessResult.Incorrect

Реализация игрового цикла

Реализация игрового цикла использует функции, которые мы определили ранее:

def gameLoop(oldState: State): IO[IOException, Unit] =
  for
    guess <- renderState(oldState) <*> getGuess
    newState    = oldState.addGuess(guess)
    guessResult = analyzeNewGuess(oldState, newState, guess)
    _ <- guessResult match
      case GuessResult.Won =>
        Console.printLine(s"Congratulations ${newState.name.name}! You won!") <*> renderState(newState)
      case GuessResult.Lost =>
        Console.printLine(s"Sorry ${newState.name.name}! You Lost! Word was: ${newState.word.word}") <*> renderState(newState)
      case GuessResult.Correct =>
        Console.printLine(s"Good guess, ${newState.name.name}!") <*> gameLoop(newState)
      case GuessResult.Incorrect =>
        Console.printLine(s"Bad guess, ${newState.name.name}!") <*> gameLoop(newState)
      case GuessResult.Unchanged =>
        Console.printLine(s"${newState.name.name}, You've already tried that letter!") <*> gameLoop(newState)
  yield ()

Как видите, функция gameLoop делает следующее:

В конце gameLoop возвращает ZIO эффект, который не зависит ни от какого пользовательского модуля, он может завершиться ошибкой с IOException или успешно завершиться со значением Unit.

Собираем все части вместе

Наконец, у нас есть все части нашего приложения, и теперь остается только собрать их воедино и вызвать из метода run, который, как уже упоминалось, является точкой входа любого ZIO приложения:

val run: IO[IOException, Unit] =
  for
    name <- Console.printLine("Welcome to ZIO Hangman!") <*> getName
    word <- chooseWord
    _    <- gameLoop(State.initial(name, word))
  yield ()

Мы видим, что логика очень проста:

Вот и все! Мы закончили реализацию игры "Виселица", используя чисто функциональный стиль программирования, с помощью библиотеки ZIO.

Выводы

В этой статье мы смогли увидеть, как благодаря таким библиотекам, как ZIO, можно реализовывать полноценные приложения, используя парадигму функционального программирования, просто написав описания взаимодействий с внешним миром (называемые функциональными эффектами), которые можно комбинировать с другими эффектами для формирования более сложных описаний. Мы видели, что ZIO позволяет создавать, комбинировать и преобразовывать функциональные эффекты друг с другом различными способами, хотя мы, конечно, не видели всех возможностей, которые предлагает ZIO. Тем не менее, надеюсь, что это послужит толчком для дальнейшего изучения увлекательной экосистемы, которая строится вокруг этой библиотеки.

То важное, чего мы не сделали в этой статье, — это написание модульных тестов для нашего приложения. Поэтому, если вы хотите знать, как писать модульные тесты чисто функциональным способом с использованием библиотеки ZIO Test, то можете взглянуть на следующую статью в блоге Scalac.

Наконец, если вы хотите узнать о ZIO больше, то можете ознакомиться со следующими ресурсами:


Ссылки: