Sortie de gâteau pour ZIO 1.0

⚠️
Attention, on est maintenant en ZIO 2.0, la gestion de l’enviRonement dans ZIO[-R, +E, +A] a changé.
  • Avant la RC-18, on avait une sorte de cake pattern pour gérer R (et donc la composition était complexe)
  • Avec la RC-18 / ZIO 1.0, on a eu Has qui a permis de composer facilement, sans encodage, le set hétérogène des ressources que l’IO va utiliser (dépendances)
  • Avec ZIO 2.0, on a un système beaucoup plus à plat pour gérer l’enviRonement, qui est beaucoup plus intégré. Par exemple, si on a besoin d’une DB, et d’une API, on va avoir un ZIO[DB with API, E, A], au lieu de ZIO[Has[DB] with Has[API], E, A].

Parlons un peu de Has et du Zlayer, ZIO approche bientôt la version 1.0, néanmoins beaucoup de choses ont changé entre le RC17 et la RC18.

ZIO c'est le Future

L'objectif principal de ZIO est de fournir l'outil qui permet de faire de la programmation fonctionnelle pleinement.

Une fonction A ⇒ B se doit d'être pure, c'est-à-dire :

  • être complète
  • être déterministe
  • sans effet de bord

Les outils classiques pour résoudre ce genre de problèmes sont :

  • Either, qui offre une sortie alternative pour être complète, A ⇒ Either[E, B]
  • State, qui permet de représenter de manipuler qui ont besoin d'un état : (S, A) ⇒ (S, B)
  • Reader, qui permet de manipuler des fonctions qui ont besoin d'une dépendance : (R, A) ⇒ B (ou A ⇒ R ⇒ B)
  • IO ou Task, qui permet de marquer une valeur comme étant disponible que si on exécute le programme : A ⇒ IO[B]

Ce qui donne une pile assez classique :

  • A ⇒ IO[Reader[R, State[S, Either[E, B]]]] !

Sans parler du reste pour faire de la programmation concurrente.

Finalement A ⇒ ZIO[R, E, B] est une réponse moderne, en un seul type, à l'équivalent de A ⇒ Reader[R, IO[Either[E, B]]], mais avec un type et les combinateurs qui vont avec ! Qui est efficace pour représenter les différents cas limites :

  • B
  • IO[B]
  • IO[Either[Throwable, B]]
  • R => IO[Either[E, B]]
  • (R, IO[Reader[R, Either[E, B]]]) // Provide
  • ...

Pourquoi intégrer aussi le Reader ? ZIO fournit à la base pour les erreurs dans IO[+E, +B] , au lieu de faire IO[Either[E, B]], cela a permis de mieux gérer les erreurs et l'absence d'erreur. Néanmoins dans les cas d'usage classique, on devait continuer à se trainer des fonctions tout le temps.

L'année dernière ZIO est passé de

def println(line: String): Console => IO[Nothing, Unit]
// Console : Il faut fournir une Console
// Nothing : Il n'y a pas d'erreur attendue
// Unit : Il n'a a pas de valeur de retour attendue

à

def println(line: String): ZIO[Console, Nothing, Unit]

Comme à l'arrivée du rasoir à 5 lames, on s'est demandés si ZIO n'allait pas ajouter un nouveau type chaque année. Pour l'instant ce n'est pas le cas, néanmoins il y a du changement.

Composition

Pour la composition, ZIO a choisi de rester très proche de ce qui est offert par le design de Scala 2, en se basant sur la covariance et la contravariance. On a un ZIO[-R, +E, +A] qui a même variance qu'une fonction R => Either[E, A].

Pour la composition, on utilise flatMap qui repose sur le mécanisme de variance :

trait ZIO[-R, +E, +A] {
  def flatMap[R1 <: R, E1 >: E, B](k: A => ZIO[R1, E1, B]): ZIO[R1, E1, B]
}

Si on n'avait pas cette variance, on aurait des compositions un peu étranges et besogneuses.

Le error channel

Avec les fonctions :

def get: IO[E1, A]
def set(a: A): IO[E2, Unit]

Si on compose sans utiliser le mécanisme de variance, on obtient :

val prg: IO[E1 Either E2, Unit] = for {
  a <- get
  _ <- set(a)
} yield {}

Plus on compose, plus on va avoir un type complexe sur l'erreur qui va devenir difficilement gérable. Ici on a Either[E1, E2] (que l'on peut écrire E1 Either E2), mais avec plusieurs erreurs obtient des Either[E1, Either[E2, Either[E3, ...]].

La composition sur l'erreur se fait en utilisant la covariance :

trait IO[+E, +A] {
  def flatMap[B, EE >: E](f: A => IO[EE, B]): IO[EE, B]
  //def flatMap[B, E2](f: A => IO[E2, B]): IO[Either[E, E2], B]
}

Dans notre cas, le compilateur Scala va chercher un type EE, qui est un supertype de E1 et de E2.

Lorsque l'on définit les erreurs avec un super type (ici KVSError), le compilateur fait le reste.

sealed abstract class KVSError(msg:String) extends Throwable(msg)

class E1 extends KVSError("e1 error")

class E2 extends KVSError("e2 error")

class E3 extends KVSError("e3 error")


val prg: IO[KVSError, Unit] = for {
  a <- get
  _ <- set(a)
} yield {}

Pour avoir soit E1, soit E2, le compilateur trouve le type juste au-dessus, une solution qui est moins précise que Either[E1, E2], mais beaucoup plus utilisable pour faire de la composition.

def toKVSError(e: Either[E1, E2]): KVSError = e.merge

//Ici on est moins précis, KVSError est un supertype de E3 aussi.
def toEither(e: KVSError): Option[Either[E1, E2]] = {
  e match {
    case e1: E1 => Some(Left(e1))
    case e2: E2 => Some(Right(e2))
    case _      => None
  }
}

R (l'injection de dépendance)

Pour R, il va se passer relativement la même chose. Avec les fonctions suivantes. Sans le R dans ZIO[-R, +E, +A]

type IO[E, A] = ZIO[Any, E, A]
type UIO[A]   = IO[Nothing, A]

def currentTime(unit: TimeUnit): Clock => UIO[Long]
def putStrLn(line: String): Console => UIO[Unit]

def printTime: (Clock, Console) => UIO[Unit] = 
  (clock, console) => for {
    t <- currentTime(TimeUnit.SECONDS)(clock)
    _ <- putStrLn(t + "s")(console)
  } yield {}

ou avec les implicites

type IO[E, A] = ZIO[Any, E, A]
type UIO[A]   = IO[Nothing, A]

def currentTime(unit: TimeUnit)(implicit clock: Clock): UIO[Long]
def putStrLn(line: String)(implicit console: Console): UIO[Unit]

def printTime(implicit clock: Clock, console: Console): UIO[Unit] = 
  for {
    t <- currentTime(TimeUnit.SECONDS)
    _ <- putStrLn(t + "s")
  } yield {}

Avec le nouveau paramètre, on va avoir :

type IO[E, A]   = ZIO[Any, E, A]
type UIO[A]     = IO[Nothing, A]
//URIO est un effet qui dépend de R pour produire sans erreur un A
type URIO[R, A] = ZIO[R, Nothing, A]

def currentTime(unit: TimeUnit): URIO[Clock, Long]
def putStrLn(line: String): URIO[Console, Unit]

def printTime: URIO[Clock with Console, Unit] = 
  for {
    t <- currentTime(TimeUnit.SECONDS)
    _ <- putStrLn(t + "s")
  } yield {}


Pour le channel d'erreur en sortie Either[E1, E2] par EE avec EE >: E1, EE >: E2.
En entrée, on va pouvoir remplacer (R1, R2) par R = Env[R1 with R2] avec R <: R1, R <: R2.

trait Env[+T] {
	//def get[A >: T]:A
	//def add[A](a: A): Env[T with A]
}

object Env {
	//def zero: Env[Any]
}

def toT[R1, R2](r: Env[R1 with R2]): (R1, R2) = ???
def toR[R1, R2](r1: R1, r2: R2): Env[R1 with R2] = ??? // à la main ou macro ...

Le code compose mieux et permet de gérer automatiquement les dépendances que l'on a lors de la composition. A condition que l'on puisse faire la composition :

def toR[R1, R2](r1: R1, r2: R2): R1 with R2

Les dépendances que l'on va pouvoir récupérer au fur à mesure que l'on en a besoin dans ZIO :

Les resources de ZIO

Les ressources qui sont disponibles par défaut dans ZIO sont des re-implémentations des composants de base de la JVM pour que l'on puisse les utiliser en FP, ainsi que les manipuler lors des tests unitaires.

Clock

def currentTime(unit: TimeUnit): URIO[Clock, Long]
def currentDateTime: ZIO[Clock, DateTimeException, OffsetDateTime]
def nanoTime: URIO[Clock, Long]
def sleep(duration: Duration): URIO[Clock, Unit]

Si on prend nanoTime , cela se lit comme un Clock => IO[Long].

Console

On est sur le classique, comment lire et écrire dans la console

def putStr(line: String): URIO[Console, Unit]
def putStrLn(line: String): URIO[Console, Unit]
def getStrLn: ZIO[Console, IOException, String]

avec Clock et Console, on va pouvoir faire des programmes assez classiques :

object Hello {
  import zio._
  import zio.console._
  import zio.clock._

  val prg: ZIO[Console with Clock, Exception, Unit] = for {
    _    <- putStr("Hello, what is your name ? > ")
    name <- getStrLn
    dt   <- currentDateTime
    _    <- putStrLn(s"Hello $name, it is $dt")
  } yield {}

  def main(args: Array[String]): Unit = {
    zio.Runtime.default.unsafeRun(prg)
  }
}

Notre programme est un ZIO[Console with Clock, Exception, Unit] alors que cela utilise avec des effets une fois l'horloge et trois fois la console.

System

def env(variable: String): ZIO[System, SecurityException, Option[String]]
def property(prop: String): ZIO[System, Throwable, Option[String]]
def lineSeparator: URIO[System, String]

Random

def nextBoolean: ZIO[Random, Nothing, Boolean]                  
def nextBytes(length: => Int): ZIO[Random, Nothing, Chunk[Byte]]
def nextDouble: ZIO[Random, Nothing, Double]                    
def nextFloat: ZIO[Random, Nothing, Float]                      
def nextGaussian: ZIO[Random, Nothing, Double]                  
def nextInt(n: => Int): ZIO[Random, Nothing, Int]               
def nextInt: ZIO[Random, Nothing, Int]                          
def nextLong: ZIO[Random, Nothing, Long]                        
def nextLong(n: => Long): ZIO[Random, Nothing, Long]            
def nextPrintableChar: ZIO[Random, Nothing, Char]               
def nextString(length: => Int): ZIO[Random, Nothing, String]    
def shuffle[A](list: => List[A]): ZIO[Random, Nothing, List[A]]

Blocking

Blocking sert à wrapper les effets qui ont besoin d'être mis dans une Thread Pool à part (explication). Par exemple, dans le dernier article à la fin :

def blocking[R <: Blocking, E, A](zio: ZIO[R, E, A]): ZIO[R, E, A]
def effectBlocking[A](effect: => A): ZIO[Blocking, Throwable, A]
def effectBlockingCancelable[A](effect: => A)(cancel: UIO[Unit]): ZIO[Blocking, Throwable, A]
def effectBlockingIO[A](effect: => A): ZIO[Blocking, IOException, A]
def effectBlockingInterrupt[A](effect: => A): ZIO[Blocking, Throwable, A]

(R1, R2) ⇒ R1 with R2

La clé pour rendre le code composable est la contravariance sur R, qui permet de composer différent effet qui dépendent de différents R, sans combiner à la main.

Une fois que l'on a un val prg = IO[Console with Clock, Exception, Unit] il faut lui fournir d'une manière ou d'une autre un Console with Clock

Jusqu'en RC17, ZIO utilisait la Service pattern, ce qui permet de mettre en râteau les différentes parties.

// (Clock, Console) => Clock with Console
def mix(clock_ : Clock, console_ : Console) = new Clock with Console {
  override val clock   = clock_.clock
  override val console = console_.console
}

def main(args: Array[String]): Unit = {
  val providedPrg: ZIO[Any, Exception, Unit] = 
    prg.provide(mix(Clock.Live, Console.Live))
  
  new DefaultRuntime {}.unsafeRun(providedPrg)
}

Chaque service est défini en plusieurs morceaux, ce qui le rend utilisable et injectable.

trait Clock {
  val clock: Clock.Service[Any]
}

object Clock extends Serializable {

  trait Service[R] {
    def currentTime(unit: TimeUnit): ZIO[R, Nothing, Long]
    def currentDateTime: ZIO[R, Nothing, OffsetDateTime]
    val nanoTime: ZIO[R, Nothing, Long]
    def sleep(duration: Duration): ZIO[R, Nothing, Unit]
  }

  trait Live extends Clock {
    val clock: Clock.Service[Any] = 
      /**
       * ... Implementation
       */
  }

  object Live extends Live
}

package object clock extends Clock.Service[Clock] {
  /**
   * ...
   */
}

Cela a été remis à plat avec la RC18.

Has et ZLayer

Le gros changement depuis la RC18, c'est la refonte de la gestion de R. En tant qu'utilisateur la code de base ne va pas beaucoup changer, on va voir que la composition est meilleure quand on veut fournir les ressources :

val providedPrg: Task[Unit] = prg.provideLayer(Clock.live ++ Console.live)

//prg.provideSomeLayer(Clock.live).provideLayer(Console.live)

zio.Runtime.default.unsafeRun(providedPrg)

On va même pouvoir fournir pas à pas :

//val prg: ZIO[Console with Clock, Exception, Unit]
val prgWithClock: RIO[Console, Unit] = prg.provideSomeLayer(Clock.live)
val providedPrg: Task[Unit] = prgWithClock.provideLayer(Console.live)


zio.Runtime.default.unsafeRun(providedPrg)

Le gain de composition vient de l'introduction d'un mécanisme de "Tagging" qui permet de composer facilement des types hétérogènes qui représente le set dépendance nécessaire pour exécuter l'effet. Ce mécanisme c'est le Has.

Has, le HSet sans les Shapelesseries

Has[_] stocke une Map[TagType, Scala.Any] :

final class Has[A] private (private val map: Map[TagType, scala.Any]) {
  def size: Int = map.size
	
  override def equals(that: Any): Boolean = that match {
    case that: Has[_] => map == that.map
  }
  
  override def hashCode: Int = map.hashCode

  override def toString: String = map.mkString("Map(", ",\n", ")")
}

Ce qui permet de l'utiliser comme une map hétérogène strictement typée avec la méthode get :

import zio.Has

val h1: Has[String] = Has("A")
val h2: Has[Int] = Has(1)

type H3 = Has[String] with Has[Int]

val h3: H3 = h1 ++ h2
/*Map(      String    -> A,
               Int    -> 1)

   */

assert(h3.get[Int] == 1)
assert(h3.get[String] == "A")

//Has.HasSyntax(h3).get[String](evidence,tag) == "A"

type H4 = H3 with Has[Option[Int]] with Has[Option[String]]

val h4: H4 = h3 add Option(2) add Option("B")

/*Map(      String    -> A,
               Int    -> 1,
      Option[+Int]    -> Some(2),
      Option[+String] -> Some(B))
   */

assert(h4.get[Option[Int]]    == Option(2))
assert(h4.get[Option[String]] == Option("B"))

On peut même utiliser des "kinds" comme Option, ce qui pourrait permettre d'utiliser Has dans des contextes où l'on modélise l'accès les accès aux données.

type Domain = Has[Repo[Account]] with Has[Repo[Transaction]]

La structure est aussi covariante :

sealed trait A
case object B extends A

val b = Has(B)

assert(b.get[A] == B)

Néanmoins cela peut devenir non-déterministe sur le résultat

case object C extends A
val bc = b add C

bc.get[A]
//res1: A = B
bc.get[B.type]
//res2: B.type = B
bc.get[C.type]
//res3: C.type = C

Et cela contient un cache, pour éviter de casser les perfs lors des recherches de la bonne valeur.

Donc Has est un Set, avec les super pouvoir du type system de Scala et une garantie que Has est non vide.

import zio.Has

val set = Set(1, "A")
set.size // 2

val has = Has.allOf(1, "A")
has.size // 2

set.exists(_.isInstanceOf[Int]) // true
has.isInstanceOf[Has[Int]] // true

set.collectFirst({ case i: Int => i }) // Option(1)
has.get[Int] // 1

set.collectFirst({ case i: Long => i }) // None
has.get[Long] // DON'T COMPILE
//Cannot prove that zio.Has[Int] with zio.Has[String] <:< zio.Has[_ <: Long]

Has est donc un cas solide pour faire le mix-in, à condition de l'utiliser en premier paramètre de ZIO.

def toR[R1 <: Has[_], R2 <: Has[_]](r1: R1, r2: R2): R1 with R2 = r1 ++ r2 

Revenons à la gestion du R

Aussi la définition d'une dépendance est beaucoup plus propre. On va avoir l'object qui tient le tag Has , le trait pour le Service, l'implémentation et quelques méthodes pour faciliter l'expérience utilisateur comme : Clock.live, ou les méthodes dans clock qui retournent des ZIO[Clock, E, A].

package object clock {

  type Clock = Has[Clock.Service]

  object Clock {
    trait Service {
      def currentTime(unit: TimeUnit): UIO[Long]
      def currentDateTime: IO[DateTimeException, OffsetDateTime]
      def nanoTime: UIO[Long]
      def sleep(duration: Duration): UIO[Unit]
    }

    object Service {
      val live: Service = new Service {
        /**
         *
         */
      }
    }
    
    val live: Layer[Nothing, Clock] = ZLayer.succeed(Service.live)
  }
  

  def currentTime(unit: => TimeUnit): ZIO[Clock, Nothing, Long] =
    ZIO.accessM(_.get.currentTime(unit))
}

 

On peut injecter les ressources mieux qu'avant, vu que le mix in est inclus avec Has

val providedPrg: Task[Unit] = prg.provideLayer(Clock.live ++ Console.live)

zio.Runtime.default.unsafeRun(providedPrg)

Aussi pas à pas

val prgWithClock: ZIO[Console, Exception, Unit] = prg
  .provideSome[Console](x => x ++ Has(Clock.Service.live))

val providedPrg: IO[Exception, Unit] =
  prgWithClock.provide(Has(Console.Service.live))

zio.Runtime.default.unsafeRun(providedPrg)

Grâce à la magic de Has ! Problème résolu donc, ZIO[R, E, A] est :

  • facilement utilisable, pas de "boilerplate"
  • facilement extensible, on peut utiliser nos propres ressources, en dehors de Random, Console, Clock, System et Blocking.

Néanmoins ZIO est allé beaucoup loin que Has, et propose une solution pour résoudre le problème de la construction des dépendances.

Le ZLayer, c'est la fin du printemps

Le ZLayer est l'injection de ressources pour ZIO, les ressources sont disponibles à l'usage et libérées dès que l'on en a plus besoin.

Pour rentrer dans les détails du fonctionnement du ZLayer, il faut d'abord aller voir un autre type, le ZManaged.

Le ZManaged

Le ZManaged est un type spécifique de ZIO qui permet l'acquisition des ressources de manière composable.
En Java ou en impératif, nous allons utiliser soit un try/catch/finally ou une syntaxe dédiée pour faire cela.

import java.io.FileNotFoundException;
import java.io.PrintWriter;

public class PrintHelloFromJava {
    
    public static void main(String[] args) throws FileNotFoundException {

        try(PrintWriter obj = new PrintWriter("log.txt")) {
            obj.println("Hello from Java!");
        }

        System.exit(0);
    }
}

ou avec try finally

        PrintWriter obj = new PrintWriter("log.txt");
        try {
            obj.println("Hello from Java!");
        } finally {
            obj.close();
        }

Avec ZIO on va pouvoir utiliser l'abstraction sur les effets pour faire la même chose avec les fonctions suivantes :


//1. la structure
def makeExit[R, E, A](acquire: ZIO[R, E, A])
                     (release: (A, Exit[Any, Any]) => ZIO[R, Nothing, Any]
  ): ZManaged[R, E, A] = //???


//2. avec un helper pour ne pas gérer l'exit
def make[R, E, A](acquire: ZIO[R, E, A])
                 (release: A => ZIO[R, Nothing, Any]
  ): ZManaged[R, E, A] = //makeExit(acquire)((a, _) => release(a))


//3. depuis l'interfaçe AutoCloseable de Java
def fromAutoCloseable[R, E, A <: AutoCloseable](fa: ZIO[R, E, A]
  ): ZManaged[R, E, A] = //make(fa)(a => UIO(a.close()))

L' Exit est un type algébrique qui représente un Either[Cause[E], A] (spécialisé):

case class Success[+A](value: A)   extends Exit[Nothing, A]
class Failure[+E](cause: Cause[E]) extends Exit[E, Nothing]

La Cause est un autre type algébrique (généralisé et récursif) qui représente la sortie d'une exécution sinistre :

case object Empty extends Cause[Nothing]
case class Both[+E](left: Cause[E], right: Cause[E]) extends Cause[E]
case class Die(value: Throwable) extends Cause[Nothing]
case class Fail[+E](value: E) extends Cause[E]
case class Interrupt(fiberId: Fiber.Id) extends Cause[Nothing]
case class Meta[+E](cause: Cause[E], data: Data) extends Cause[E]
case class Then[+E](left: Cause[E], right: Cause[E]) extends Cause[E]
case class Traced[+E](cause: Cause[E], trace: ZTrace) extends Cause[E]

Cause permet de gérer les cas d'erreur par exception, erreur en contexte d'exécution parallèle, le suivit de la stack dans un contexte d'exécution concurrente.

Donc dans le ZManaged, on va :

  1. indiquer comment on va acquérir une "ressource" A avec ZIO[R, E, A]
  2. Donner la commande que l'on doit exécuter à la fin de l'utilisation de la ressource

Exemple avec le fichier :

object HelloFromScala extends zio.App {

  //ZIO[zio.ZEnv, Nothing, ExitCode]
  override def run(args: List[String]) = prg.useNow.orDie.exitCode

  //ZManaged[Any, Throwable, Unit]
  lazy val prg = printer.mapEffect(obj => obj.println("Hello From ZIO!"))
  
	
  //ZManaged[Any, Throwable, PrintWriter]
  lazy val printer = ZManaged.fromAutoCloseable(ZIO(new PrintWriter("log.txt")))
} 

Conclusion

ZIO a revu complètement la façon de composer R et de gérer les ressources pour sortir une version 1.0 avec une ergonomie intéressante.

On a pu voir, avec quelques exemples, comment gérer R, et la capture des ressources.