Введение в программирование с функциональными 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
, то поймем, что она лукавит: сигнатура утверждает, что для каждой пары целых чиселa
иb
эта функция всегда будет возвращать другое целое число, что, как мы уже видели, верно не для всех случаев. - Это означает, что при каждом вызове функции
divide
в какой-то части нашего приложения, нужно быть очень осторожными, поскольку мы не можем быть полностью уверены, что функция вернетInt
. - Если мы забудем учесть необработанный случай при вызове
divide
, то во время выполнения наше приложение будет генерировать исключения и перестанет работать должным образом. К сожалению, компилятор ничего не может сделать, чтобы помочь нам избежать подобных ситуаций, этот код скомпилируется, и мы увидим проблемы только во время выполнения.
Итак, как можно решить эту проблему?
Давайте посмотрим на альтернативное определение функции divide
:
def divide(a: Int, b: Int): Option[Int] =
if b != 0 then Some(a / b) else None
В этом случае функция деления возвращает Option[Int]
вместо Int
,
поэтому, когда b = 0
, функция возвращает None
вместо выдачи исключения.
Вот как мы преобразовали частично определенную функцию в полную,
и благодаря этому у нас есть некоторые преимущества:
- Сигнатура функции ясно сообщает, что некоторые входные данные не обрабатываются, поскольку она возвращает
Option[Int]
. - Когда мы вызываем функцию
divide
в какой-то части нашего приложения, компилятор заставит нас рассмотреть случай, в котором результат не определен. Если мы этого не сделаем, будет выдана ошибка времени компиляции, что означает, что компилятор может помочь нам избежать многих ошибок, и они не появятся во время выполнения.
Чистая функция должна быть детерминированной и зависеть только от своих входных данных
Вторая характеристика функции состоит в том, что она должна быть детерминированной и зависеть только от входных данных. Это означает, что для каждого вызова функции с одними и теми же входными данными должен быть возвращен один и тот же результат, независимо от того, сколько раз вызывалась функция. Например, следующая функция генерации случайных целых чисел не является детерминированной:
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)
Чистая функция не должна иметь побочных эффектов
Наконец, функция не должна иметь побочных эффектов. Вот некоторые примеры побочных эффектов:
- изменение памяти,
-
взаимодействие с внешним миром, например:
- вывод сообщений в консоль,
- вызов внешнего API,
- запрос к базе данных.
Это означает, что чистая функция может работать только с неизменяемыми значениями и может возвращать для соответствующих входных данных только результат, ничего больше.
Например, следующая функция 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) |
Состояние приложения | проходит через чистые функции | обычно совместно используется несколькими объектами |
Ключевые элементы | неизменяемые значения и функции | объекты и методы |
Каковы преимущества функционального программирования?
По разным причинам функциональное программирование до сих пор может многим казаться сложным. Но, если мы более внимательно посмотрим на преимущества, то сможем изменить наш образ мышления.
Во-первых, использование этой парадигмы программирования помогает разбивать каждое приложение на более мелкие, более простые части, которые надежны и легки для понимания. Это связано с тем, что функциональный исходный код часто более лаконичен, предсказуем и его легче тестировать. Но как мы можем это обеспечить?
- Поскольку чистые функции не полагаются ни на какое состояние, а зависят только от своих входных данных, их гораздо легче понять. То есть, чтобы понять, что делает функция, не нужно искать другие части кода, которые могут повлиять на ее работу. Это известно как локальное рассуждение (local reasoning).
- Код чистых функций имеет тенденцию быть более кратким, что приводит к меньшему количеству ошибок.
- Процесс тестирования и отладки становится намного проще с функциями, которые только получают входные данные и производят выходные данные.
- Поскольку чистые функции детерминированы, приложения ведут себя более предсказуемо.
- Функциональное программирование позволяет писать правильные параллельные программы, поскольку нет возможности иметь изменяемое состояние, поэтому невозможны типичные проблемы параллелизма, такие как условия гонки.
Поскольку Scala поддерживает парадигму функционального программирования, эти преимущества распространяются и на сам язык. В результате все больше и больше компаний используют Scala, включая таких гигантов, как LinkedIn, Twitter и Netflix.
Но... в реальных приложениях должны быть побочные эффекты!
Теперь мы знаем, что функциональное программирование — это программирование с использованием чистых функций, и что чистые функции не могут иметь побочных эффектов, поэтому возникает несколько логичных вопросов:
- Как тогда мы можем написать приложение с использованием функционального программирования, которое в то же время взаимодействует с внешними службами, такими как базы данных или сторонние API? Разве это не слегка противоречиво?
- Означает ли это, что функциональное программирование нельзя использовать в реальных приложениях, а только в академических условиях?
Ответ на эти вопросы следующий: Да, можно использовать функциональное программирование в реальных приложениях, а не только в академических условиях. Чтобы наши приложения могли взаимодействовать с внешними сервисами, можно сделать следующее: вместо написания функций, взаимодействующих с внешним миром, мы пишем функции, описывающие взаимодействия с внешним миром, которые выполняются только в определенной точке нашего приложение (обычно называемое "конец света" (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 эффект:
- Для запуска требуется контекст типа
R
(этот контекст может быть любым: подключение к базе данных, REST-клиент, объект конфигурации и т.д.). - Он может завершиться с ошибкой типа
E
или завершиться успешно, вернув значение типаA
.
Общие псевдонимы для типа данных ZIO
Стоит отметить, что ZIO предоставляет некоторые псевдонимы типов для типа данных ZIO, которые очень полезны, когда дело доходит до представления некоторых распространенных вариантов использования:
-
Task[+A] = ZIO[Any, Throwable, A]
- означает, чтоTask[A]
является эффектом ZIO, который:- Не требует среды для запуска (поэтому тип
R
заменен наAny
, что означает, что эффект будет работать независимо от того, что мы предоставляем ему в качестве среды) - Может потерпеть неудачу с
Throwable
- Может завершиться успешно с
A
- Не требует среды для запуска (поэтому тип
-
UIO[+A] = ZIO[Any, Nothing, A]
- означает, чтоUIO[A]
является эффектом ZIO, который:- Не требует среды для запуска
- Не может потерпеть неудачу
- Может завершиться успешно с
A
-
RIO[-R, +A] = ZIO[R, Throwable, A]
- означает, чтоRIO[R, A]
является эффектом ZIO, который:- Требует среду
R
для запуска - Может потерпеть неудачу с
Throwable
- Может завершиться успешно с
A
- Требует среду
-
IO[+E, +A] = ZIO[Any, E, A]
- означает, чтоIO[E, A]
является эффектом ZIO, который:- Не требует среды для запуска
- Может потерпеть неудачу с
E
- Может завершиться успешно с
A
-
URIO[-R, +A] = ZIO[R, Nothing, A]
- означает, чтоURIO[R, A]
является эффектом ZIO, который:- Требует среду
R
для запуска - Не может потерпеть неудачу
- Может завершиться успешно с
A
- Требует среду
Реализация игры «Виселица» с помощью ZIO
Далее реализуем игру «Виселица» с использованием ZIO в качестве наглядного примера того, как можно разрабатывать чисто функциональные приложения на Scala с помощью этой библиотеки.
Для справки вы можете посмотреть репозиторий Github с полным кодом по этой ссылке на Scala 2 или тот же самый пример для Scala 3.
Дизайн и требования
Игра «Виселица» состоит из случайного выбора слова и предоставления игроку возможности угадывать буквы до тех пор, пока слово не будет полностью угадано. Правила следующие:
- У игрока есть 6 попыток угадать буквы.
- За каждую неправильную букву вычитается одна попытка.
- Когда у игрока заканчиваются попытки, он проигрывает. Если вы угадаете все буквы, то выигрываете.
Итак, наша реализация игры «Виселица» должна работать следующим образом:
- При запуске игры каждому игроку задается имя, которое явно не должно быть пустым. Если пустое, должно появиться сообщение об ошибке, и снова будет запрошено имя.
- Затем приложение должно случайным образом выбрать из предопределенного списка слов слово, которое игрок должен угадать.
- Затем на консоли должно быть отображено начальное состояние игры, в основном состоящее из виселицы, ряда черточек, представляющих количество букв в слове, которое нужно угадать, и букв, уже опробованных игроком, что очевидно, будет равно нулю в начале игры.
-
Затем игрока нужно попросить угадать букву и написать ее на консоли.
Очевидно, что введенный символ должен быть допустимой буквой, независимо от того, прописная она или строчная:
- Если введенный символ недействителен, должно появиться сообщение об ошибке, и у игрока следует снова запросить символ.
- Если игрок ввел допустимую букву, но она не появляется в слове, игрок проигрывает попытку, на консоли отображается соответствующее сообщение, а статус игры обновляется, добавляя букву, которую недавно пробовал игрок, к попыткам, обновляя список опробованных букв и добавляя рисунок головы повешенного. Кстати, все последующие разы, когда игрок ошибается, следующие части тела будут показаны по порядку: туловище, правая рука, левая рука, правая нога и левая нога повешенного.
- Если пользователь ввел допустимую букву и она появляется в слове, на консоли отображается соответствующее сообщение и статус игры обновляется, добавляя букву, которую недавно пробовал пользователь, в список попыток и открывая в скрытом слове места, где появляется угаданная буква.
-
Предыдущий шаг повторяется до тех пор, пока пользователь не угадает слово целиком или не закончатся попытки.
- Если игрок выигрывает, отображается поздравительное сообщение.
- Если игрок проигрывает, отображается сообщение, указывающее, какое слово нужно было угадать.
Создание базовой структуры приложения
Определим наше приложение как проект 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
Опять же, у нас есть умный конструктор, который проверяет, что слово не является пустым и содержит только буквы, а не числа или небуквенные символы. Кроме того, этот класс содержит несколько полезных методов (кстати, все они являются чистыми функциями):
- Чтобы узнать, содержит ли слово определенный символ (
contains
). - Чтобы получить длину слова (
length
). - Чтобы получить список с символами слова (
toList
). - Получить
Set
с символами слова (toSet
).
Затем мы определяем еще один case class с именем State
,
который представляет внутреннее состояние приложения, в том числе:
- Имя игрока (
name
). - Использованные пользователем буквы на текущий момент (
guesses
). - Слово, которое нужно угадать (
word
)
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
включает несколько полезных элементов:
- получить количество неудач игрока (
failuresCount
) - узнать, проиграл ли игрок (
playerLost
) - узнать, выиграл ли игрок (
playerWonGuess
) - добавить букву в список использованных букв (
addGuess
)
Наконец, мы определяем модель GuessResult
, которая представляет результат, полученный игроком после использования буквы.
Этот результат может быть одним из следующих:
- Игрок выиграл, потому что угадал последнюю пропущенную букву.
- Игрок проиграл, потому что использовал последнюю оставшуюся попытку.
- Игрок правильно угадал букву, хотя для победы еще не хватило букв.
- Игрок неправильно угадал букву, хотя у него еще есть попытки.
- Игрок повторил букву, которую он ранее угадал, поэтому состояние игры не меняется.
Мы можем представить это перечислением следующим образом:
enum GuessResult:
case Won, Lost, Correct, Incorrect, Unchanged
В этом случае имеет смысл использовать перечисление,
так как GuessResult
может иметь только одно возможное значение из тех, что указаны в списке опций.
Важные детали о enum описаны в соответствующей документации
Создание скелета приложения
Теперь, когда мы определили модель предметной области приложения, можем продолжить с реализацией его самого.
Внутри файла Hangman.scala
создадим объект, реализующий трейт ZIOAppDefault
, например:
import zio.*
object Hangman extends ZIOAppDefault:
def run = ???
С помощью всего лишь этого небольшого фрагмента кода мы можем узнать несколько вещей:
- Для работы с ZIO нам нужно только добавить
import zio.*
, что даст нам, среди прочего, доступ к типу данных ZIO. - Каждое приложение ZIO должно реализовывать трейт
ZIOAppDefault
вместо трейтаApp
из стандартной библиотеки (точнее, каждое приложение ZIO должно реализовывать трейтZIOApp
. ОднакоZIOAppDefault
, который расширяетZIOApp
, более удобен, поскольку предоставляет некоторые значения по умолчанию).
Трейт ZIOAppDefault
требует, чтобы мы реализовали метод run
, который является точкой входа приложения.
Этот метод должен возвращать функциональный ZIO эффект,
который, как мы уже знаем, является лишь описанием того, что должно делать наше приложение.
Это описание будет переведено средой выполнения ZIO во время выполнения приложения
в реальные взаимодействия с внешним миром, то есть побочные эффекты.
Интересно, что нам, как разработчикам, не нужно беспокоиться о том, как это происходит - ZIO позаботится обо всем этом за нас.
Если предоставленный эффект не работает по какой-либо причине, она будет зарегистрирована,
а код выхода приложения будет ненулевым.
В противном случае код выхода приложения будет нулевым.
Следовательно, метод run
станет end of the functional world для нашего приложения.
Пока оставим его нереализованным.
Функционал для получения имени игрока через консоль
Теперь, когда у нас есть базовый скелет нашего приложения, во-первых, нужно написать функционал для получения имени игрока с помощью консоли, для этого напишем вспомогательную функцию внутри объекта Hangman, которая позволяет печатать любое сообщение и затем запрашивает текст от игрока:
def getUserInput(message: String): IO[IOException, String] =
Console.printLine(message)
Console.readLine
Рассмотрим эту функцию более подробно:
-
Чтобы напечатать предоставленное сообщение на консоли, мы используем функцию
Console.printLine
, включенную в пакетzio
. Эта функция возвращает эффект типаZIO[Any, IOException, Unit]
, эквивалентныйIO[IOException, Unit]
, что означает, что это эффект, который:- Не требует выполнения какой-либо определяемой пользователем среды
- Возможен сбой с ошибкой типа
IOException
- Возвращает значение типа
Unit
-
Затем, чтобы попросить пользователя ввести текст, мы используем функцию
Console.readLine
, также включенную в пакетzio
. Эта функция возвращает эффект типаZIO[Any, IOException, String]
, эквивалентныйIO[IOException, String]
, что означает, что это эффект, который:- Не требует выполнения какой-либо определяемой пользователем среды
- Возможен сбой с ошибкой типа
IOException
- Возвращает значение типа
String
Здесь важно отметить, как 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
, который возвращаетUnit
). - Возвращает новый эффект для выполнения, в данном случае
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
Как видете:
- Сначала игрока просят ввести свое имя.
-
Затем предпринимается попытка построить объект
Name
с помощью методаName.make
, который мы определили ранее.- Если введенное имя допустимо (то есть оно не пусто), мы возвращаем эффект,
который успешно завершается с соответствующим именем, используя метод
ZIO.succeed
. - В противном случае на экран выводится сообщение об ошибке (используя
Console.printLine
) и снова запрашиваем имя, рекурсивно вызываяgetName
. Это означает, что эффектgetName
будет запускаться столько раз, сколько необходимо, пока имя игрока недействительно.
- Если введенное имя допустимо (то есть оно не пусто), мы возвращаем эффект,
который успешно завершается с соответствующим именем, используя метод
Вот и все!
Однако мы можем написать эквивалентную версию:
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
).
Как видите, эта новая версия 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 соответствующего типа для указанного ввода, например:
Option
- Другие типы, такие как
Either
илиTry
(мы также могли бы использоватьZIO.fromEither
иZIO.fromTry
)
Функциональность для случайного выбора слова из словаря
Давайте теперь посмотрим, как реализовать функцию случайного выбора слова, которое игрок должен угадать. Для этого у нас есть словарь слов, представляющий собой просто список слов.
Реализация выглядит следующим образом:
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
,
этот эффект:
- Завершается успешно, если слово найдено в словаре по определенному индексу
и если это слово не является пустым (условие проверяется
Word.make
) - В противном случае вылетает ошибка
Если хорошенько подумать, может ли случиться, когда не удается получить слово из словаря?
Это нереалистично, потому что эффект chooseWord
никогда не будет пытаться получить слово,
индекс которого находится за пределами диапазона длины словаря,
а с другой стороны, все слова в словаре предопределены и не пусты.
Таким образом, мы можем без проблем исключить неверный вариант,
используя метод ZIO#orDieWith
, возвращающий новый эффект, который не может дать сбой,
а если он сработает, то это будет означать наличие серьезного, неисправимого дефекта в нашей программе
(например, что некоторые из предопределенных слов пусты, хотя не должны),
и поэтому приложение должно немедленно завершиться ошибкой с предоставленным исключением.
В конце концов, chooseWord
— это эффект с типом UIO[Word]
, что означает:
- Не требует выполнения какой-либо определяемой пользователем среды
- Не может потерпеть неудачу
- Успешно завершается объектом типа
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
, состоит из списка с шестью возможными вариантами).
Например:
hangmanStates(0)
содержит рисунок повешенного дляfailuresCount = 0
, то есть показывает только рисунок виселицы без повешенногоhangmanStates(1)
содержит рисунок повешенного дляfailuresCount = 1
, то есть он показывает рисунок виселицы и головы повешенного
Выражение 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
с обновленным статусом игры. - Если игрок угадал букву неправильно, но еще не проиграл игру,
отображается соответствующее сообщение о том, что он угадал неправильно,
и снова вызывается
gameLoop
с обновленным статусом игры. - Если игрок пробует букву, которую указывал ранее,
отображается соответствующее сообщение и снова вызывается
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 больше, то можете ознакомиться со следующими ресурсами:
Ссылки: