It allows us to take a base typeEthat represents the error type and then capture the sequential and parallel composition of errors in a fully lossless fashion.
(번역) Cause는 에러(Exception)의 class type을 나타내는 E를 획득할 수 있게 하고, 에러의 누락 방직 차원에서 순차적인 혹은 병렬적인 에러의 포착을 제공한다.
Cause의 내부 구조는 다음과 같다
Empty : Cause의 초기상태로 ZIO.succeed(5)와 같이 error가 없는 상태를 의미한다.
Fail : expected error의 type E를 의미한다.
Die : defect(unexpected error)의 type E를 의미한다.
Iterrupt : fiber 등의 멀티 쓰레드 환경에서 interruption을 의미한다.
Stackless : stack trace 와 excution trace 정보를 담고 있다. (stack trace의 노출 레벨이 Die와는 다르다)
Both : 병렬 프로그램밍 상에서 2개 이상의 error가 발생하였을때의 정보를 담고 있다.
Then : 순차적 에러가 발생했을때 Error 객체 저장(고전 try-catch 모델 혹은 fail.ensuring 등)
sealed abstract class Cause[+E] extends Product with Serializable { self =>
import Cause._
def trace: Trace = ???
final def ++[E1 >: E](that: Cause[E1]): Cause[E1] = Then(self, that)
final def &&[E1 >: E](that: Cause[E1]): Cause[E1] = Both(self, that)
}
object Cause extends Serializable {
case object Empty extends Cause[Nothing]
final case class Fail[+E](value: E, override val trace: Trace) extends Cause[E]
final case class Die(value: Throwable, override val trace: Trace) extends Cause[Nothing]
final case class Interrupt(fiberId: FiberId, override val trace: Trace) extends Cause[Nothing]
final case class Stackless[+E](cause: Cause[E], stackless: Boolean) extends Cause[E]
final case class Then[+E](left: Cause[E], right: Cause[E]) extends Cause[E]
final case class Both[+E](left: Cause[E], right: Cause[E]) extends Cause[E]
}
ZIO상에서 에러의 처리를 위해서는 아래의 관련 함수를 숙지해야 한다.
.catchAll
.catchSome
.either / .absolve
[R, E, A] --> [R, Either[E, A]]
[R, Nothing, Either[E, A]] --> [R, E, A]
.absorb // defect to Failures (recover from both Die and Interruption)
위의 코드는 비지니스 로직, 즉 로직에 의한 output 데이터의 flow라고도 볼 수 있다.
데이터를 생성(create)하고, 생성된 데이터의 내부 값(get)을 변환 저장(create)하고, 기존 데이터는 삭제(delete)하는 흐름이다.
for comprehension 상에서 코드가 정의되었기에 create, get, delete는 개별적으로 Monad 객체라는 것을 짐작할 수 있다.
trait DBOps[A]
case class Create[A](key: String, value: A) extends DBOps[Unit]
case class Read[A](key: String) extends DBOps[A]
case class Update[A](key: String, value: A) extends DBOps[A]
case class Delete(key: String) extends DBOps[Unit]
def create[A](key: String, value: A): Free[DBOps, Unit] =
Free.liftM[DBOps, Unit](Create(key, value))
def get[A](key: String): Free[DBOps, A] =
Free.liftM[DBOps, A](Read[A](key))
def update[A](key: String, value: A): Free[DBOps, A] =
Free.liftM[DBOps, A](Update[A](key, value))
def delete(key: String): Free[DBOps, Unit] =
Free.liftM(Delete(key))
모든 함수는 Free[_,_] 의 인스턴스를 리턴한다. Free[_,_] 자체가 flatMap, pure 함수를 포함하는 Monad라는 것을 짐작할 수 있다.
다만 우리가 알고 있던 모나드의 모습은 M[_]인데 type 인자가 하나 더 있는 더 복잡한 구조라는 것을 짐작할 수 있다.(겁부터 먹는다)
일단 Free의 구조를 살펴보자.
trait Free[M[_], A] {
import Free.*
def flatMap[B](f: A => Free[M, B]): Free[M, B] = FlatMap(this, f)
def map[B](f: A => B): Free[M, B] = flatMap(a => pure(f(a)))
def foldMap[G[_]: Monad](natTrans: M ~> G): G[A] = this match {
case Pure(a) => Monad[G].pure(a)
case Suspend(ma) => natTrans.apply(ma)
case FlatMap(fa, f) => // need a G[B] --> fa = DBMonad = Suspend, f = Next DBMonad = Suspend
Monad[G].flatMap(fa.foldMap(natTrans))(a => f(a).foldMap(natTrans))
}
}
object Free {
def pure[M[_], A](a: A): Free[M, A] = Pure(a)
def liftM[M[_], A](ma: M[A]): Free[M, A] = Suspend(ma)
case class Pure[M[_], A](a: A) extends Free[M, A]
case class FlatMap[M[_],A,B](fa: Free[M, A], f: A => Free[M, B]) extends Free[M, B]
case class Suspend[M[_], A](ma: M[A]) extends Free[M, A]
}
짧은 지식이지만 Monad에는 flatMap, pure 함수가 있다는 걸 알았고 map은 보너스라는 건 알고 있다.
새롭게 보이는 function이 liftM과 foldMap 이다.
def liftM[M[_], A](ma: M[A]): Free[M, A] = Suspend(ma)
// 사용 예제
def create[A](key: String, value: A): Free[DBOps, Unit] =
Free.liftM[DBOps, Unit](Create(key, value))
// 참고
case class Create[A](key: String, value: A) extends DBOps[Unit]
liftM의 정의와 구현은 위와 같은데 뭔가 심오하다.
일단은 M[A] 를 받아서 Free[M,A]로 변환된 객체를 리턴하는 구조로 보면 되는데, DBOps[Unit] -> Free[DBOps, Unit] 의 형태로 변환하는 pure의 Free Monad 함수로 보면 될 것 같다.
그리고 Free Monad의 핵심 foldMap인데 우선 어떻게 사용되는지 살펴보자
val dbOps2IO: DBOps ~> IO = new (DBOps ~> IO) {
override def apply[A](fa: DBOps[A]): IO[A] = fa match {
case Create(key, value) => IO.create { // actual code that uses the database
println(s"insert into people(id, name) values ($key, $value)")
myDB += (key -> serialize(value))
()
}
case Read(key) => IO.create {
println(s"select * from people where id=$key limit 1")
deserialize(myDB(key))
}
case Update(key, value) => IO.create {
println(s"update people(name=$value) where id=$key")
val oldValue = myDB(key)
myDB += (key -> serialize(value))
deserialize(oldValue)
}
case Delete(key) => IO.create {
println(s"delete from people where id=$key")
()
}
}
}
val ioProgram: IO[Unit] = myLittleProgram.foldMap(dbOps2IO)
ioProgram.unsafeRun()
우선 사전 정의되었던 myLittlePrograme의 foldMap(dbOps2IO) 함수를 호출하여 DBOps 기반의 데이터를 IO 형태로 변환하고,
IO에서 제공하는 unsafeRun() 함수를 호출하여 실제 로직의 프로그램을 실행시킨다 (놀랍다)
즉 비지니스로직상의 전체 data flow와 세부 action의 로직을 foldMap() 함수가 연결시켜주는 역할을 한다.
처음으로 생각나는 Scala 문법 구조상 이해가 되지 않는 의문은 for comprehesion이다.
myLittleProgram의 최종 return type은 위의 코드에서 보듯이 Free[DBOps, Unit] 이다.
여기서 foldMap의 동작 원리를 이해하기 위해서 for comprehension의 동작 방식에 대한 의문이 든다.
우리가 알고 있는 for comprehension은 각 Monad 객체 flatMap 연산을 chain형태로 순차적으로 호출하고 최종 결과값을 return하는 구조로 알고 있다.
chain과 같은 정방향의 실행구조 (a.flatMap(a1 => b.flatMap(b1 => c.flatMap(c1 => c1.map(.. ))) 를 가지고 최종 결과값에 대한 접근만 myLittleProgram 함수명을 통하여 가능하다. 즉 myLittleProgram은 아래와 같이 풀어 쓸 수 있다.
최상 root 객체가 FlatMap이고 chain 혹은 Linked List와 같은 형태의 구조적인 데이터 구조를 이루고 있다.
즉, myLittleProgram의 참조 객체는 FlatMap이 된다.
실제로 foldMap() 함수를 호출하였을때 실행되는 순서는 아래와 같다.
[FM] FlatMap: Suspend(Create(123-456,Daniel)), f =>c.y.study.FreeMonadSample$$$Lambda$17/0x0000000800096040@77846d2c
[FM] Suspend: Create(123-456,Daniel)
insert into people(id, name) values (123-456, Daniel)
[FM] FlatMap: Suspend(Read(123-456)), f =>c.y.study.FreeMonadSample$$$Lambda$23/0x0000000800091840@3b94d659
[FM] Suspend: Read(123-456)
select * from people where id=123-456 limit 1
[FM] FlatMap: Suspend(Create(567,DANIEL)), f =>c.y.study.FreeMonadSample$$$Lambda$25/0x0000000800118040@103f852
[FM] Suspend: Create(567,DANIEL)
insert into people(id, name) values (567, DANIEL)
[FM] FlatMap: Suspend(Delete(123-456)), f =>c.y.study.FreeMonadSample$Free$$Lambda$27/0x000000080011a040@71623278
[FM] Suspend: Delete(123-456)
delete from people where id=123-456
[FM] Pure :()
데이터의 비지니스로직을 정의한 순서에 따라 순회하여 데이터를 참조 변환이 가능한 구조가 된다. 아래의 코드가 실질적인 그 역할을 하고 있다.
trait Free[M[_], A] {
// ...
def foldMap[G[_]: Monad](natTrans: M ~> G): G[A] = this match {
case Pure(a) => Monad[G].pure(a)
case Suspend(ma) => natTrans.apply(ma)
case FlatMap(fa, f) => // need a G[B] --> fa = DBMonad = Suspend, f = Next DBMonad = Suspend
Monad[G].flatMap(fa.foldMap(natTrans))(a => f(a).foldMap(natTrans) )
}
}
// 참고1
def flatMap[B](f: A => Free[M, B]): Free[M, B] = FlatMap(this, f)
// 참고2
case class FlatMap[M[_],A,B](fa: Free[M, A], f: A => Free[M, B]) extends Free[M, B]
// 참고3
given ioMonad: Monad[IO] with {
override def pure[A](a: A) = IO(() => a)
override def flatMap[A, B](ma: IO[A])(f: A => IO[B]) =
IO(() => f(ma.unsafeRun()).unsafeRun())
}
데이터 구조(리스트라 하자)를 순회하며 기존 데이터를 Free[DBOps, A] --> IO[A] 형태로 변환하며, 비지니스로직의 실제 구현체가 호출(unsafeRun())되는 구조를 가진다.
foldMap은 FlatMap을 발견하면 내부의 본래 데이터로직(Suspend)을 실행하고, 실행 결과를 다음 로직(FlatMap)이 참조하여 실행될 수 있도록 실행하는 구조를 가진다.
즉, FlatMap은 내부 데이터를 추출 검증(실행)하고, 다음 로직을 실행하는 역할, Suspend는 실제 로직을 실행(() => a)하는 역할을 한다 보면된다.
---
굉장히 복잡해 보일 수 있는데 FreeMonad는 단순하게 비지니스로직을 정의하고 실제 구현로직은 IO로 정의하여 순차적으로 구현로직이 비지니스 로직의 정의에 따라 실행될 수 있도록 내부적으로 복잡한 변환 로직을 가지고 있다.
물론 로직의 실행 흐름은 Monad의 for comprension에 정의된 흐름의 틀에 어느정도 의존성을 가진다 볼 수 있다.
ZIO, Cats 등 Purely Fuctional을 지향하는 모든 툴에서 위와 유사한 구조의 코드를 볼 수 있다.
---
즉, 위의 Free Monad 코드와 개념, 활용을 정확히 이해하는 것이 Scala 시작을 위한 준비라 볼 수 있겠다 -_-;;