ZIO scala support

Introdução à Programação com Efeitos Funcionais utilizando ZIO

ZIO scala support

Introdução

O paradigma de programação funcional é frequentemente evitado por desenvolvedores de todo o mundo, que preferem continuar desenvolvendo aplicativos usando um paradigma de programação orientado a objetos, que é mais conhecido e mais amplamente utilizado. Essa rejeição da programação funcional acontece por simples falta de conhecimento ou porque os desenvolvedores tendem a achar que é algo difícil e útil apenas em áreas acadêmicas. Isso provavelmente ocorre porque há muitos conceitos novos que geralmente são explicados de maneira conceitual, mas estão cheios de jargões
matemáticos.

A linguagem Scala combina o melhor de dois mundos: programação funcional e orientada a objetos, e fornece interoperabilidade completa com o vasto ecossistema Java. Além disso, dentro do ecossistema Scala, existem bibliotecas como a ZIO, que permitem que os desenvolvedores sejam altamente produtivos e criem aplicativos modernos, de alta performance e concorrentes, aproveitando o poder da programação funcional – sem grandes barreiras à entrada.

Neste artigo, explicarei os princípios da programação funcional e, em seguida, demonstrarei como, com a ajuda de Scala e ZIO, podemos criar aplicações para resolver problemas do mundo real. Como exemplo ilustrativo, implementaremos um jogo da forca.

Programação Funcional 101 (para Iniciantes)

O que é programação funcional?

Programação funcional é um paradigma de programação, onde os programas são uma composição de funções puras.
Isso representa uma diferença fundamental da programação orientada a objetos, onde os programas são sequências de instruções/declarações (statements), geralmente atuando sobre um estado mutável. Essas declarações são organizadas em chamadas de funções/métodos, mas não são funções no sentido matemático, pois não atendem a algumas características fundamentais.

Uma função pura deve ser total

Primeiramente, uma função deve ser total, ou seja, para cada entrada fornecida à função deve haver uma saída definida.
Por exemplo, a seguinte função dividindo dois inteiros não é total:

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

Para responder por que essa função não é total, considere o que acontecerá se tentarmos dividir por zero:

divide(5, 0)

// java.lang.ArithmeticException: / by zero

A divisão por zero é indefinida e a JVM lida com isso lançando uma exceção. Isso significa que a função de divisão não é
total porque não retorna nenhuma saída no caso em que b = 0.

Algumas coisas importantes que podemos destacar aqui:

  • Se observarmos a assinatura dessa função, percebemos que ela está mentindo: para cada par de inteiros a e b, essa
    função sempre retornará outro inteiro, o que já vimos não ser verdade para todos os casos.
  • Isso significa que toda vez que chamarmos a função divide em alguma parte de nossa aplicação, teremos que ter
    muito cuidado, pois nunca podemos ter certeza absoluta de que a função retornará um inteiro.
  • Se esquecermos de considerar o caso não tratado ao chamar divide, em tempo de execução nossa aplicação lançará
    exceções e deixará de funcionar conforme o esperado. Infelizmente, o compilador não pode fazer nada para nos ajudar a
    evitar este tipo de situação, este código irá compilar e só veremos os problemas em tempo de execução (runtime).

Então, como podemos corrigir esse problema? Vejamos esta definição alternativa da função divide:

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

Nesse caso, a função divide retorna Option[Int] em vez de Int, então para quando b = 0, a função retorna None
em vez de lançar uma exceção. É assim que transformamos uma função parcial em uma função total e, graças a isso, temos
alguns benefícios:

  • A assinatura da função está comunicando claramente que algumas entradas não estão sendo tratadas porque retorna um
    Option[Int].
  • Quando chamamos a função divide em alguma parte de nossa aplicação, o compilador nos força a considerar o caso em
    que o resultado não está definido. Se não o fizermos, será um erro em tempo de compilação, o que significa que o
    compilador pode nos ajudar a evitar muitos bugs, e eles não aparecerão em tempo de execução.

Uma função pura deve ser deterministica e depender apenas de suas entradas

A segunda característica de uma função é que ela deve ser determinística e depender apenas de suas entradas. Isso
significa que para cada entrada fornecida à função, a mesma saída deve ser retornada, não importa quantas vezes a função
seja chamada. Por exemplo, a seguinte função para gerar números inteiros aleatórios não é determinística:

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

Para demonstrar por que essa função não é determinística, vamos considerar o que acontece na primeira vez que chamamos a função:

generateRandomInt() // Result: -272770531

E então, o que acontece quando chamamos a função novamente:

generateRandomInt() // Result: 217937820

Obtemos resultados diferentes! Claramente, essa função não é determinística e sua assinatura é mentirosa novamente, porque sugere que ela não depende de nenhuma entrada para produzir uma saída, enquanto na verdade há uma dependência oculta em um objeto scala.util.Random. Isso pode causar problemas, porque nunca podemos ter certeza de como a função generateRandomInt vai se comportar, dificultando o teste.

Agora, vamos dar uma olhada em uma definição alternativa. Para isso, usaremos um gerador de números aleatórios customizado, baseado em um exemplo do livro Functional Programming in 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

Esta nova versão da função generateRandomInt é determinística: não importa quantas vezes ela seja chamada, sempre
obteremos a mesma saída para a mesma entrada e a assinatura agora indica claramente a dependência da variável random.
Por exemplo:

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

Se quisermos gerar um novo inteiro, devemos fornecer uma entrada diferente:

val (n3, random3) = generateRandomInt(random2) // n3 = 1334288366, random3 = RNG(87443922374356)

Uma função pura não deve gerar efeitos colaterais (side effects)

Finalmente, uma função não deve ter efeitos colaterais. Alguns exemplos de efeitos colaterais são os seguintes:

  • mutações de memória;
  • interações com o mundo exterior, tais como:
  • imprimir mensagens no console;
  • chamar uma API externa;
  • consultar um banco de dados;

Isso significa que uma função pura só pode trabalhar com valores imutáveis e só pode retornar uma mesma saída para cada entrada correspondente, nada mais.

Por exemplo, a seguinte função increment não é pura porque utiliza a variável mutável a:

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

E a seguinte função também não é pura porque imprime uma mensagem no console:

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

Quais são as diferenças entre programação funcional e programação orientada a objetos?

A tabela a seguir resume as principais diferenças entre esses dois paradigmas de programação:

Programação funcionalProgramação orientada à objetos
Variáveisimutávelmutável
Modelo de programaçãodeclarativaimperativa
Foco em…o que” é feitocomo” é feito
Programação paralelaadequadanão adequada
Efeitos colateraisfunções não podem produzirmétodos podem produzir
Iteraçõesrecursãoloops (for, while)
Estado da aplicaçãoflui através de funções purasgeralmente compartilhado por vários objetos
Elementos chavevalores imutáveis e funçõesobjetos e métodos

Quais são os benefícios da programação funcional?

Por várias razões, a programação funcional ainda pode parecer complexa para muitos. Mas, se olharmos mais de perto os benefícios, podemos mudar nossa maneira de pensar.

Inicialmente, adotar esse paradigma de programação nos ajuda a dividir cada aplicação em partes menores e mais simples, confiáveis e fáceis de entender. Isso ocorre porque um código-fonte funcional geralmente é mais conciso, previsível e fácil de testar. Mas como podemos garantir isso?

  • Como as funções puras não dependem de nenhum estado e, em vez disso, dependem apenas das suas entradas, elas são muito
    mais fáceis de entender. Ou seja, para entender o que uma função faz, não precisamos procurar outras partes do código
    que possam afetar a sua operação. Isso é conhecido como raciocínio local (local reasoning).
  • O código tende a ser mais conciso, o que resulta em menos bugs.
  • O processo de teste e debug fica muito mais fácil com funções que apenas recebem dados de entrada e produzem saída.
  • Como as funções puras são determinísticas, as aplicações se comportam de forma mais previsível.
  • A programação funcional nos permite escrever programas paralelos corretos, pois não há possibilidade de ter um estado
    mutável, portanto, é impossível que ocorram problemas típicos de concorrência, como condições de corrida
    (race conditions).

Como o Scala suporta o paradigma de programação funcional, esses benefícios também se aplicam à própria linguagem. Consequentemente, cada vez mais empresas estão passando a utilizar Scala, incluindo gigantes como LinkedIn, Twitter e Netflix.

Entretanto… Aplicações reais necessitam executar side effects!

Agora que sabemos que programação funcional é sobre programação com funções puras, e que funções puras não podem produzir side effects, surgem várias questões lógicas:

  • Como podemos então ter uma aplicação escrita utilizando programação funcional, e, concomitantemente interage com
    serviços externos, como bancos de dados ou APIs de terceiros? Isso não é um pouco contraditório?
  • Isso significa que a programação funcional não pode ser usada em aplicações reais, mas somente em ambientes
    acadêmicos?

A resposta a essas perguntas é a seguinte: Sim, podemos usar programação funcional em aplicações reais, e não apenas em ambientes acadêmicos. Para que nossas aplicações possam interagir com serviços externos, podemos fazer o seguinte: em vez de escrever funções que interagem com o mundo externo, escrevemos funções que descrevem interações com o mundo externo, que serão executadas apenas em um ponto específico de nossa aplicação, (geralmente chamado de fim do mundo), por exemplo, a função main.

Se pensarmos nisso com cuidado, essas descrições de interações com o mundo exterior são simplesmente valores imutáveis que podem servir como entradas e saídas de funções puras, e desta forma não estaríamos violando um princípio básico da programação funcional: não produza side effects.

O que é esse fim do mundo (end of the world) mencionado acima? Bem, o fim do mundo é simplesmente um ponto específico em nossa aplicação onde o mundo funcional termina e onde as descrições das interações com o mundo exterior estão sendo executadas, geralmente o mais tarde possível, de preferência na borda do nosso programa que é a função main. Desta forma, toda a nossa aplicação pode ser escrita seguindo o estilo funcional, mas ao mesmo tempo capaz de realizar tarefas úteis.

Agora que sabemos tudo isso, surge uma nova pergunta: como podemos escrever nossas aplicações de tal forma que todas as nossas funções não executem side effects, mas apenas construam descrições do que queremos fazer? E é aí que entra uma biblioteca muito poderosa, que pode nos ajudar nessa tarefa: Apresentando a biblioteca ZIO.

Introdução à biblioteca ZIO

Qual a utilidade da Biblioteca ZIO?

ZIO é uma biblioteca que nos permite construir aplicações modernas que são assíncronas, concorrentes, resilientes, eficientes, fáceis de entender e testar, utilizando os princípios da programação funcional.

Por que dizemos que o ZIO nos permite construir aplicações fáceis de entender e testar? Porque nos ajuda a construir aplicações de qualquer complexidade de forma incremental, atravéz de uma combinação de descrições de interações com o mundo exterior. A propósito, essas descrições são chamadas de efeitos funcionais (functional effects).

Por que dizemos que o ZIO nos permite construir aplicativos resilientes? Porque o ZIO aproveita ao máximo o sistema de tipos Scala, de forma que pode capturar mais bugs em tempo de compilação, em vez de em tempo de execução. Isso é ótimo porque, apenas olhando para a assinatura de uma função, podemos dizer:

  • Se possui dependências externas;
  • Se pode falhar ou não, e também com que tipo erros pode falhar.
  • Se pode terminar com sucesso ou não, e também qual o tipo do dado retornado ao finalizar.

E, finalmente, por que dizemos que o ZIO nos permite construir aplicativos assíncronos e concorrentes? Porque o ZIO nos dá superpoderes para trabalhar com programação assíncrona e concorrente, usando um modelo baseado em fibers, que é muito mais eficiente que um modelo baseado em thread. Não entraremos em muitos detalhes sobre esse aspecto em particular neste artigo, porém vale ressaltar que é justamente nessa área que o ZIO se destaca, permitindo construir aplicações realmente de alto desempenho.

O tipo de dados (data type) ZIO

O data type mais importante na biblioteca ZIO (e também o bloco de construção básico de qualquer aplicativo baseado nessa biblioteca) também é chamado de ZIO:

ZIO[-R, +E, +A]

O data type ZIO é um functional effect, ou seja, é um valor imutável que contém uma descrição de uma série de interações com o mundo exterior (consultas de banco de dados, chamadas para APIs de terceiros, etc.). Um bom modelo mental do ZIO data type é o seguinte:

R => Either[E, A]

Isso significa que um ZIO effect:

  • Precisa de um contexto do tipo R para ser executado (esse contexto pode ser qualquer coisa: uma conexão com um banco de dados, um cliente REST, um objeto de configuração, etc.).
  • Ele pode falhar com um erro do tipo E ou pode ser concluído com êxito, retornando um valor do tipo A.

Aliases comuns para o ZIO data-type

É importante mencionar que o ZIO fornece alguns type aliases para o ZIO data type que são muito úteis quando se trata de representar alguns casos de uso comuns:

  • Task[+A] = ZIO[Any, Throwable, A]: Isso significa que uma Task[A] é um ZIO effect que:
  • Não requer um ambiente para ser executado (é por isso que o tipo R é substituído por Any, o que significa que o efeito será executado independentemente do que lhe fornecermos como ambiente);
  • Pode falhar com um Throwable;
  • Pode ter sucesso com um A;
  • UIO[+A] = ZIO[Any, Nothing, A]: Isso significa que um UIO[A] é um ZIO effect que:
  • Não requer um ambiente para ser executado;
  • Não pode falhar;
  • Pode ter sucesso com um A;
  • RIO[-R, +A] = ZIO[R, Throwable, A]: Isso significa que um RIO[R, A] é um ZIO effect que:
  • Requer um ambiente R para ser executado;
  • Pode falhar com um Throwable;
  • Pode ter sucesso com um A;
  • IO[+E, +A] = ZIO[Any, E, A]: Isso significa que um IO[E, A] é um ZIO effect que:
  • Não requer um ambiente para ser executado;
  • Pode falhar com um E;
  • Pode ter sucesso com um A;
  • URIO[-R, +A] = ZIO[R, Nothing, A]: Isso significa que um URIO[R, A] é um ZIO effect que:
  • Requer um ambiente R para ser executado;
  • Não pode falhar;
  • Pode ter sucesso com um A;

Implementando o Jogo da Forca (Hangman) usando ZIO

A seguir, implementaremos uma versão do jogo da forca usando ZIO, como exemplo ilustrativo de como podemos desenvolver
aplicativos puramente funcionais em Scala.

Para referência, veja este repositório do Github com o código completo neste link.

Design e requisitos

O jogo da forca consiste em pegar uma palavra aleatória e ofertar ao jogador a possibilidade de adivinhar letras até que
a palavra seja completada. As regras são as seguintes:

  • O jogador tem 6 tentativas para adivinhar letras;
  • Para cada letra incorreta, uma tentativa é subtraída;
  • Quando tentativas se esgotam, o jogador perde. Se letras são advinhadas, o jogador ganha;

A nossa implementação deve funcionar da seguinte forma:

  • Ao iniciar o jogo, deve-se perguntar ao jogador seu nome, que obviamente não deve estar vazio. Nesse caso, uma
    mensagem de erro deve ser exibida e o nome solicitado novamente;
  • A aplicação deve então selecionar aleatoriamente, dentro de um dicionário de palavras predefinido, a palavra que o jogador deve adivinhar;
  • Em seguida, o estado inicial do jogo deve ser exibido no console, que consiste basicamente numa forca, uma série de traços que representam o número de letras da palavra a ser adivinhada e as letras que já foram fornecidas pelo
    jogador, que será obviamente zero no início do jogo;
  • Em seguida, o jogador deve ser solicitado a adivinhar uma letra e escrevê-la no console. Obviamente, o caractere
    inserido deve ser uma letra válida, independentemente de ser maiúscula ou minúscula:
  • Se o caractere inserido for inválido, uma mensagem de erro deverá ser exibida e outro caracter deverá ser solicitada
    ao jogador;
  • Se o jogador digitou um caracter válido, mas não está contino na palavra, o jogador perde uma tentativa, uma
    mensagem apropriada é exibida no console e o status do jogo é atualizado, o caracter deve ser adicionado à lista de
    tentativas, e deve ser desenhando a cabeça do enforcado. A propósito, todas as vezes subsequentes que o jogador
    cometer um erro, as seguintes partes do corpo serão mostradas em ordem: o tronco, braço direito, braço esquerdo,
    perna direita e perna esquerda.
  • Se o jogador digitou um caracter válido e está contido na palavra, uma mensagem apropriada é exibida no console
    e o status do jogo é atualizado, adicionando o caracter fornecido à lista de tentativas e o revelando na palavra
    oculta na posição apropriada.
  • A etapa anterior é repetida até que o jogador adivinhe a palavra inteira ou fique sem tentativas.
  • Se o jogador vencer, uma mensagem de felicitações é exibida.
  • Se o jogador perder, uma mensagem é exibida, revelando qual a palavra oculta.

Criando a estrutura base da aplicação

Vamos definir nossa aplicação como um projeto sbt, o arquivo build.sbt deve conter as dependências do nosso projeto:

val scalaVer = "2.13.8"

val zioVersion = "2.0.0-RC6"

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)

Como pode ser observado, utilizamos Scala 2.13.8 e com ZIO 2.0.0-RC6.

Criando o modelo de domínio (domain model), usando um estilo funcional

Primeiramente, devemos definir o nosso domain model. Para isso definimos algumas classes no arquivo
com/example/package.scala.

final case class Name(name: String)

Definimos Name como uma case classe em vez de simplesmente defini-la como uma class normal. O uso de case classes
nos dá muitos benefícios, como:

  • Imutabilidade por padrão;
  • O método apply gerado automaticamente, o que nos permite construir objetos sem usar a keyword new;
  • O método unapply gerado automaticamente, que podemos usar em expressões de pattern matching;
  • O método de copy gerado automaticamente, o que nos permite fazer cópias do objeto bem como atualizar campos
    concomitantemente.

Definimos a classe como final para que ela não possa ser estendida por outras classes. Observamos também que a classe
Name apenas encapsula uma String que representa o nome do usuário. Poderíamos deixar este modelo como está, porém há
um problema. É possível criar objetos Name com uma String vazia, algo indesejado. Para evitar isso, podemos aplicar
uma técnica de funcional design chamada smart constructor, que consiste em definir um método no companion object da classe Name que nos possibilita implementar as respectivas validações antes de criar uma instância.
Teríamos algo assim:

final case class Name(name: String)
object Name {
  def make(name: String): Option[Name] = if (name.nonEmpty) Some(Name(name)) else None
}

Como pode ser observado, o método make é o nosso smart constructor que verifica se a String recebida está vazia ou
não. Se não estiver vazio, ele retornará um novo Name, encapsulado em um Some, estando vazio retornará um None. É
importante salientar que o método make é uma função pura, pois é total, determinística, a saída depende apenas de sua
entrada e não produz side effects.

Há algo que podemos melhorar na nossa implementação. Ainda é possível chamar diretamente o construtor Name com Strings
vazias. Para evitar isso, tornamos-o privado:

final case class Name private (name: String)
object Name {
  def make(name: String): Option[Name] = if (name.nonEmpty) Some(Name(name)) else None
}

Desta forma a única forma de construir objetos Name é chamar o método Name.make, ao menos isso é o esperado.
No entanto, ainda podemos construir objetos Name com Strings vazias usando o método Name.apply. Além disso, há
outro problema que devemos resolver, utilizando o método copy (gerado automaticamente para case classes), onde mesmo
com objeto Name válido, criado utilizando o smart constructor Name.make, podemos gerar um objeto inválido passando
para o método copy uma String vazia. Para evitar esses problemas, podemos definir a classe como sealed abstract ao
invéz de final, o que significa que os métodos apply e copy não serão gerados:

sealed abstract case class Name private (name: String)
object Name {
  def make(name: String): Option[Name] = if (name.nonEmpty) Some(new Name(name) {}) else None
}

Desta forma, temos a certeza de que qualquer objeto Name na nossa aplicação será sempre válido.

Semelhantemente, podemos definir uma case class chamada Guess, que representa qualquer letra fornecida pelo jogador:

sealed abstract case class Guess private (char: Char)
object Guess {
  def make(str: String): Option[Guess] =
    Some(str.toList).collect {
      case c :: Nil if c.isLetter => new Guess(c.toLower) {}
    }}

Empregamos a mesma técnica, definimos um smart construtor, que recebe uma String fornecida pelo jogador e verifica se
ela consiste apenas de uma letra. Se não for esse o caso (uma String inserido pelo jogador com vários caracteres, um
número ou um símbolo) None é retornado.

Definimos outra case class, Word, que representa a palavra a ser adivinhada pelo jogador:

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

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

Novamente, temos um smart construtor responsável em verificar se uma palavra não está vazia e contém apenas letras,
não números ou símbolos. Além disso, essa classe contém alguns métodos úteis (todos funções puras):

  • Para saber se uma palavra contém um determinado caractere (contains);
  • Para obter o comprimento de uma palavra (lenght);
  • Para obter uma lista com os caracteres da palavra (toList);
  • Para obter um Set com os caracteres da palavra (toSet);

Definimos outra case class, State, que representa o estado interno do aplicativo, incluindo:

  • O nome do jogador (name);
  • As letras fornecidas até o momento (guesses);
  • A palavra para ser adivinhada (word);
sealed abstract case class State private (name: Name, guesses: Set[Guess], word: Word) {
  def failuresCount: Int            = (guesses.map(_.char) -- word.toSet).size
  def playerLost: Boolean           = failuresCount > 5
  def playerWon: Boolean            = (word.toSet -- guesses.map(_.char)).isEmpty
  def addGuess(guess: Guess): State = new State(name, guesses + guess, word) {}
}

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

Temos um smart construtor, State.initial, que nos permite instanciar o estado inicial do jogo com o nome do jogador
e a palavra que deve ser adivinhada, obviamente com um conjunto vazio de letras adivinhadas. State também possui
alguns métodos úteis:

  • Para obter o número de falhas do jogador (failuresCount);
  • Para saber se o jogador perdeu (playerLost);
  • Para saber se o jogador ganhou (playerWonGuess);
  • Para adicionar uma letra ao conjunto de letras (addGuess);

Finalmente, definimos um model, GuessResult que representa o resultado obtido pelo jogador após fornecer uma letra.
Este resultado está entre:

  • O jogador ganhou, porque adivinhou a última letra que faltava;
  • O jogador perdeu, porque usou sua última tentativa restante;
  • O jogador adivinhou corretamente uma letra, embora ainda faltem letras para ganhar;
  • O jogador adivinhou incorretamente uma letra, embora ainda tenha mais tentativas;
  • O jogador repetiu uma letra que havia adivinhado anteriormente, portanto, o estado do jogo não muda;

Podemos representar isso com um enumeration, como segue:

sealed trait GuessResult
object GuessResult {
  case object Won       extends GuessResult
  case object Lost      extends GuessResult
  case object Correct   extends GuessResult
  case object Incorrect extends GuessResult
  case object Unchanged extends GuessResult
}

Nesse caso, um enumeration faz sentido, pois GuessResult pode ter apenas um valor possível entre os mencionados na
lista de opções. Alguns detalhes importantes aqui:

  • Definimos um enumeration como um sealed trait.
  • A keyword sealed é importante, pois garante que todas as classes/objetos que podem estender GuessResult estejam
    no mesmo arquivo (package.scala) e que nenhuma outra classe fora desse arquivo poderá estender GuessResult.
  • A palavra sealed também é importante, possibilita o compilador nos ajudar quando usamos pattern match nas
    instâncias de GuessResult, avisando-nos se não consideramos todas as opções possíveis.
  • Os valores possíveis de GuessResult são definidos no seu companion object.

Criando o esqueleto do aplicativo

Agora que definimos o domain model da nossa aplicação, podemos começar com a implementação no arquivo
com/example/Hangman.scala. Dentro deste arquivo vamos criar um objeto que implementa o trait ZIOAppDefault, como a
seguir:

import zio._

object Hangman extends ZIOAppDefault {
  def run = ???
}

Com apenas este pequeno pedaço de código podemos aprender várias coisas:

  • Para trabalhar com ZIO, precisamos apenas incluir import zio._, que nos dará, entre outras coisas, acesso ao ZIO
    data type.
  • Toda aplicação ZIO deve implementar o trait ZIOAppDefault, em vez do trait App da biblioteca padrão (Para ser mais
    preciso, deve-se implementar o trait ZIOApp, no entanto, ZIOAppDefault, que estende o ZIOApp, é mais
    conveniente por fornecer alguns valores default para nós).

O trait ZIOAppDefault requer que implementemos o método run, que é o entrypoint da aplicação. Ele deve retornar um
funcional effect ZIO, que já sabemos ser apenas uma descrição do que a aplicação deve fazer. Essa descrição será
traduzida pelo ZIO Runtime, no momento da execução da aplicação, em interações reais com o mundo externo, ou seja,
efeitos colaterais (o interessante disso é que nós, desenvolvedores, não precisamos preocupar-nos em como isso acontece,
a ZIO cuida de tudo isso para nós). Se o efeito fornecido falhar por qualquer motivo, a causa será registrada e o código
de saída será diferente de zero. Em caso de sucesso o código de saída será zero. Logo, o método run seria o end of the functional world da nossa aplicação, e vamos deixá-lo sem implementação por enquanto.

Funcionalidade para obter o nome do jogador pelo console

Com o esqueleto básico da nossa aplicação, primeiro precisamos escrever a funcionalidade para obter o nome do jogador
atravéz do console, para isso vamos escrever uma função auxiliar no objeto Hangman, que nos permite imprimir qualquer
mensagem e então solicita uma entrada do jogador:

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

Analisando melhor essa função:

  • Para imprimir a mensagem fornecida no console, usamos a função Console.printLine incluída no pacote zio. Esta
    função retorna um efeito do tipo ZIO[Any, IOException, Unit], equivalente a IO[IOException, Unit], o que significa
    ser um efeito que:
  • Não requer nenhum ambiente definido pelo usuário para ser executado;
  • Pode falhar com IOException;
  • Retorna um valor do tipo Unit;
  • Em seguida, para pedir ao jogador que digite um texto, utilizamos a função Console.readLine também do pacote zio.
    Esta função retorna um efeito do tipo ZIO[Any, IOException, String], equivalente a IO[IOException, String], o que
    significa ser um efeito que:
  • Não requer nenhum ambiente definido pelo usuário para ser executado;
  • Pode falhar com IOException;
  • Retorna um valor do tipo String;
  • Há algo muito importante a ser observado aqui sobre como o ZIO 2.0 funciona em comparação com o ZIO 1.0. No
    ZIO 1.0, a assinatura dessa função seria:
def getUserInput(message: String): ZIO[Console, IOException, String]

Isso significa que getUserInput retornaria um efeito ZIO que exigia o módulo padrão do Console fornecido pelo ZIO.
Isso foi simplificado no ZIO 2.0, para quando utilizarmos apenas módulos padrão ZIO (como Console ou Random),
eles não apareçam mais na assinatura, e isso facilitará muito a nossa vida. Apenas quando usamos módulos definidos pelo
usuário, eles serão refletidos na assinatura, como neste exemplo não definimos os nossos próprios módulos, o tipo do
environment será sempre Any.

E é isso! Bem… na realidade há um pequeno problema, se chamarmos esta função a partir do método run, nada seria
exibido no console e seria solicitado a entrada do usuário. O que está faltando então, se chamarmos Console.printLine
primeiro e depois Console.readLine? O problema é que tanto Console.printLine quanto Console.readLine retornam
descrições de interações com o mundo externo (chamadas de functional effects), se olharmos de perto: estamos realmente
fazendo algo com o functional effect retornado por Console.printLine? A resposta é não, estamos simplesmente
descartando, e o único efeito retornado por nossa função getUserInput é o retornado por Console.readLine. É
semelhante à função a seguir:

def foo(): Int = {
  4
  3
}

Estamos fazendo algo com o 4 nessa função? Não! É como se não estivesse lá, o mesmo acontece com a função
getUserInput, é como se a chamada Console.printLine não estivesse lá.

Logo, devemos modificar a função getUserInput para que o efeito retornado por Console.printLine seja realmente
usado:

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

O que esta nova versão faz é retornar um efeito que combina sequencialmente os efeitos retornados por
Console.printLine e Console.readLine, utilizando o operador ZIO#flatMap, que recebe uma função onde:

  • A entrada é o resultado do primeiro efeito (neste caso Console.printLine, que retorna Unit);
  • Retorna um novo efeito a ser executado, neste caso Console.readLine;
  • Algo importante sobre como o ZIO#flatMap funciona, é que caso o primeiro efeito falhar, o segundo efeito não será
    executado;

Dessa forma, não estamos mais descartando o efeito produzido por Console.printLine.

Devido o ZIO data-type também possuir o método ZIO#map, podemos escrever getUserInput utilizando um
for-comprehension, que ajuda a visualizar o código de uma maneira que se parece mais com um típico código imperativo:

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

Esta implementação funciona perfeitamente, mas podemos escrevê-la de forma diferente:

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

Neste caso, estamos usando outro operador para combinar dois efeitos sequencialmente, o operador <*> (equivalente ao
método ZIO#zip). Este operador, bem como o ZIO#flatMap, combina os resultados de dois efeitos,
com a diferença que o segundo efeito não depende do resultado do primeiro para ser executado. O resultado de <*> é um
efeito cujo valor de retorno é uma tupla, que neste caso seria (Unit, String). No entanto, observe: se o tipo de
sucesso de getUserInput for String, por que essa implementação está funcionando? Dado que o tipo de sucesso ao chamar
o operador <*> sendo (Unit, String), isso nem deveria compilar! Então por que está funcionando? A resposta: Esta
é outra simplificação do ZIO 2.0! No ZIO 1.0, a compilação teria falhado e precisaríamos fazer algo como:

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

Precisariamos chamar ZIO#map, método que nos permite transformar o resultado de um efeito, neste caso estaríamos
obtendo o segundo componente da tupla retornado por <*>.

Mas, no ZIO 2.0, temos os Compositional Zips, caracteristica onde no momento que o tipo de sucesso de uma
operação zip contiver um Unit em um dos membros da tupla, o Unit será automaticamente descartada. Então, no lugar de
(Unit, String), temos apenas String.

Poderiamos também, ao invés de usar o operador <*>, usarmos o operador *> (equivalente ao método ZIO#zipRight),
que descarta o resultado da computação do lado esquerdo, mesmo quando o tipo do resultado tipo não é Unit:

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

E, finalmente, a versão mais condensada de getUserInput seria a seguinte:

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

Temos então uma função para obter uma entrada do jogador, podemos implementar um functional effect para obter
especificamente o nome do jogador:

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

Observamos que:

  • Primeiro, é solicitado que o jogador digite seu nome;
  • Em seguida, é tentado construir um objeto Name por meio do método Name.make, previamente definido;
  • Caso o nome digitado for válido (ou seja, não estiver vazio) retornamos um efeito que termina com sucesso contendo o
    nome correspondente, para tanto utiliza o método ZIO.succeed;
  • Caso contrário exibimos uma mensagem de erro na tela (usando Console.printLine) e o nome é solicitado novamente,
    chamando getName recursivamente. Dessa forma o efeito getName será executado quantas vezes forem necessárias
    para obtermos um nome do jogador válido;

E é isso! Entretanto, podemos escrever uma versão equivalente:

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

Nesta versão, o resultado da chamada para Name.make, que é do tipo Option, é convertido num efeito ZIO, utilizando o
método ZIO.fromOption, que retorna um efeito terminado com sucesso caso o Option for um Some, e um efeito falhado
se o Option fornecido for None. Estamos utilizando um novo operador <> (equivalente ao método ZIO#orElse), que
permite combinar dois efeitos sequencialmente, mas de uma maneira um pouco diferente de <*>. A lógica de <> é:

  • Se o primeiro efeito for bem sucedido (neste caso: o nome do jogador for válido), o segundo efeito não será executado;
  • Se o primeiro efeito falhar (neste caso: se o nome do jogador for inválido), o segundo efeito é executado (neste caso:
    uma mensagem de erro é exibida e então getName é chamado novamente);

Embora esta nova versão do getName é um pouco mais concisa. Ainda há mais uma simplificação que podemos implementar:

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

Utilizamos ZIO.from no lugar ZIO.fromOption, que constrói um valor ZIO do tipo apropriado para a entrada fornecida,
por exemplo:

  • Um Option;
  • Outros tipos, como Either ou Try (podemos usar ZIO.fromEither e ZIO.fromTry também);

Funcionalidade para escolher uma palavra aleatoriamente do dicionário

Vamos agora ver como implementar a funcionalidade de escolher aleatoriamente qual palavra o jogador deve adivinhar.
Para tanto temos um dicionário de palavras, implementado por uma lista de palavras no arquivo
com/example/package.scala.

A implentação a seguir:

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

Como pode ser observado, utilizamos a função Random.nextIntBounded do pacote zio. Esta função retorna um efeito que
gera inteiros aleatórios entre 0 e um limite, neste caso o limite é o número de elementos contidos no dicionário. Tendo
o inteiro aleatório, obtemos a palavra correspondente do dicionário, com a expressão words.lift(index).flatMap(Word.make),
que retorna uma Option[Word], convertida num efeito ZIO usando o método ZIO.from, este efeito:

  • Termina com sucesso se a palavra for encontrada no dicionário para um determinado índice, e se esta palavra não for
    vazia (condição verificada por Word.make);
  • Caso contrário, falha;

Se analisarmos cuidadosamente, pode haver algum caso em que a obtenção de uma palavra do dicionário falhe? Não, pois o
efeito chooseWord nunca tentará obter uma palavra cujo índice esteja fora do intervalo do comprimento do dicionário e,
por outro lado, todas as palavras do dicionário são predefinidas e não vazias.
Portanto, podemos descartar o caso de falha sem problema, utilizando o método ZIO#orDieWith, que retorna um novo
efeito que não pode falhar e, se falhar, significaria que há algum defeito (defect) grave e irrecuperável em nossa
aplicação (por exemplo, algumas das palavras predefinidas estão vazias enquanto não deveriam estar) e, portanto, deve
falhar imediatamente com a exceção fornecida.

Por fim, chooseWord é um efeito do tipo UIO[Word], que significa:

  • Não requer nenhum ambiente definido pelo usuário para ser executado;
  • Não pode falhar;
  • Termina com sucesso com um objeto do tipo Word;

Funcionalidade para mostrar o estado do jogo por console

A próxima funcionalidade que precisamos implementar é a capacidade de exibir o estado do jogo no console:

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('#')
    }
  }
}

Não entraremos em detalhes profundos sobre a implementação dessa função, focaremos apenas uma única linha, que talvez
seja a mais interessante:

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

Podemos observar que esta linha realiza, em primeiro lugar, seleção de qual figura deve ser mostrada para representar o
enforcado, que difere conforme o número de falhas que o jogador cometeu (o arquivo com/example/package.scala contém
a variável hangmanStates que consiste em uma lista com as seis figuras possíveis). Por exemplo:

  • hangmanStates(0) contém o desenho do enforcado para failuresCount = 0, ou seja, mostra apenas o desenho da forca
    sem o enforcado;
  • hangmanStates(1) contém o desenho do enforcado para failuresCount = 1, ou seja, mostra o desenho da forca e a
    cabeça do enforcado;

Agora, a expressão hangmanStages(state.failuresCount) não é puramente funcional porque poderia lançar uma exceção se
state.failuresCount for maior que 6. Então, como estamos trabalhando com programação funcional, não podemos permitir
que nosso código produza side effects, como lançar exceções, devido a isso encapsulamos a expressão anterior dentro de
ZIO.attempt, que nos permite construir um efeito funcional a partir de uma expressão que pode lançar exceções.
O retorno de ZIO.attempt é um efeito que pode falhar com um Throwable, mas de acordo com o design da nossa
aplicação, esperamos que nunca haja realmente uma falha ao tentar obter o desenho do enforcado (já que o estado
.failuresCount nunca deve ser maior que 6). Logo, podemos descartar o caso de falha, chamando o método ZIO#orDie,
que retorna um efeito que nunca falha (já sabemos que caso ocorra alguma falha, deve ser considerado um defeito e a
aplicação falhará imediatamente). ZIO#orDie é muito parecida com ZIO#orDieWith, mas pode ser utilizada apenas com
efeitos que falham com Throwable.

Funcionalidade para obter uma letra do jogador

Obter uma letra do jogador é semelhante à forma como obtemos o nome, logo, não detalharemos:

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

Funcionalidade para analisar uma letra digitada pelo jogador

Esta funcionalidade é muito simples, tudo o que ela faz é analisar o estado anterior e o estado após a tentativa de um
jogador, para ver se o jogador ganha, perde, adivinhou corretamente uma letra mas ainda não ganhou o jogo, adivinhou
incorretamente uma letra mas ainda não perdeu o jogo, ou se tentou novamente uma carta que foi tentada anteriormente:

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

Implementação do loop do jogo

A implementação do loop do jogo usa as funcionalidades que definimos anteriormente:

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 ()

Como pode ser observado, a função gameLoop faz é o seguinte:

  • Exibe o status do jogo por console e recebe uma letra do jogador;
  • O estado do jogo é atualizado com a nova letra fornecida pelo jogador;
  • A nova letra fornecida pelo jogador é analisada e:
  • Se o jogador venceu, uma mensagem de felicitações é exibida e o status do jogo é exibido pela última vez;
  • Se o jogador perdeu, uma mensagem é exibida indicando a palavra a ser adivinhada e o estado do jogo é exibido pela
    última vez;
  • Se o jogador forneceu uma letra correta, mas ainda não ganhou o jogo, é exibida uma mensagem informando que acertou
    e o gameLoop é chamado novamente, com o estado atualizado do jogo;
  • Se o jogador forneceu uma letra incorreta, mas não perdeu o jogo, é exibida uma mensagem informando que errou e o
    gameLoop é chamado novamente, com o estado atualizado do jogo;
  • Se o jogador repetiu uma letra fornecida anteriormente, uma mensagem é exibida e o gameLoop é chamado novamente,
    com o estado atualizado do jogo;

No final, o gameLoop retorna um efeito ZIO que não depende de nenhum módulo definido pelo usuário, pode falhar com
IOException ou terminar com sucesso e valor Unit.

Unindo todas as peças

Finalmente, temos todos os pedaços da nossa aplicação, agora resta juntá-los e chamá-los do método run que, como já
mencionamos, é o entrypoint de qualquer aplicação baseada em ZIO:

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

Podemos obsevar que a lógica é bastante simples:

  • Uma mensagem de boas-vindas é exibida e o nome do jogador é solicitado;
  • Uma palavra é escolhida aleatoriamente para que o jogador adivinhe;
  • O loop do jogo é executado;

E é isso! Concluímos a implementação de um jogo da forca, usando um estilo de programação puramente funcional, com
ajuda da biblioteca ZIO.

Conclusões

Neste artigo vimos como, graças a bibliotecas como ZIO, podemos implementar aplicações completas usando o paradigma de
programação funcional, apenas escrevendo descrições de interações com o mundo exterior (chamadas de funtional effects) que podem ser combinadas umas com as outras para construir descrições mais complexas. Vimos que o ZIO permite
criar, combinar e transformar efeitos funcionais entre si de várias maneiras, embora não tenhamos visto todas as
possibilidades que o ZIO oferece. Mas, espero que isso sirva como um impulso para você continuar a explorar o
ecossistema fascinante que vem sendo construído ao redor desta biblioteca.

Algo importante que não fizemos neste artigo é escrever testes unitários para nossa aplicação, então se quiser saber
como escrever testes unitários, de forma puramente funcional, usando a biblioteca ZIO Test, pode dar se informar
mais neste artigo (em inglês) no Blog da Scalac.

E por último, se quer aprender mais sobre ZIO, pode obter mais informação nos seguintes recursos:

Leia também

Referências

Download e-book:

Scalac Case Study Book

Download now

Authors

Eder Ruiz

Latest Blogposts

17.04.2024 / By  Michał Szajkowski

Mocking Libraries can be your doom

Test Automations

Test automation is great. Nowadays, it’s become a crucial part of basically any software development process. And at the unit test level it is often a necessity to mimic a foreign service or other dependencies you want to isolate from. So in such a case, using a mock library should be an obvious choice that […]

04.04.2024 / By  Aleksander Rainko

Scala 3 Data Transformation Library: ducktape 0.2.0.

Scala 3 Data Transformation Library: Ducktape 2.0

Introduction: Is ducktape still all duct tape under the hood? Or, why are macros so cool that I’m basically rewriting it for the third time? Before I go off talking about the insides of the library, let’s first touch base on what ducktape actually is, its Github page describes it as this: Automatic and customizable […]

28.03.2024 / By  Matylda Kamińska

Scalendar April 2024

scala conferences april 2024

Event-driven Newsletter Another month full of packed events, not only around Scala conferences in April 2024 but also Frontend Development, and Software Architecture—all set to give you a treasure trove of learning and networking opportunities. There’re online and real-world events that you can join in order to meet colleagues and experts from all over the […]

software product development

Need a successful project?

Estimate project