위의 코드는 비지니스 로직, 즉 로직에 의한 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 시작을 위한 준비라 볼 수 있겠다 -_-;;
위에서 설명했듯이 디자인패턴적인 접근보다는 개념, 법칙, 원칙적 정의에 의한 접근이 필요하다.
unit은 특정 값을 특정 Class로 감싸는 Wrapping 함수라 할 수 있다.
실제로 Scala에서는 'unit 이라는 이름으로 이것은 Monad의 unit 함수입니다'와 같이 제공하지는 않는다.
(Monad인 Option, List 등 어디에도 unit 함수는 없다)
아래와 같이 apply(..)를 활용하거나 class의 생성자 등 해당 funtion의 표현상의 제약은 없다.
// case class 생성자 활용
case class WrappedValue[+T](private val internalValue: T) {
def get: T = synchronized {
internalValue
}
def flatMap[S](transformer: T => WrappedValue[S]): SafeValue[S] = syncroized {
trainsformer(internalValue)
}
}
val wrappedInt = WrappedValue(1) // unit
val wrappedString = WrappedValue("One") //unit
// Type이 List이고 value가 1:Int 인 경우
val listInt = List(1)
listInt.flatMap(a => a) // == List(1)
다음은 flatMap인데 일단 아래의 의문에서 시작하는 것이 좋은 접근으로 보인다.
'flatMap이 왜 필요한가? map으로는 안되는 것인가?'
결론은 Type 중첩 제거와 데이터 변환 등의 로직 함수 적용시 코드 효율에 있다.
다양한 Type으로 확장시 구조적으로 모든 Type을 수용하면서 적용 가능한 코드 중복을 줄일려면..
/** Returns the result of applying $f to this $option's value if
* this $option is nonempty.
* Returns $none if this $option is empty.
* Slightly different from `map` in that $f is expected to
* return an $option (which could be $none).
*
* This is equivalent to:
* {{{
* option match {
* case Some(x) => f(x)
* case None => None
* }
* }}}
* @param f the function to apply
* @see map
* @see foreach
*/
@inline final def flatMap[B](f: A => Option[B]): Option[B] =
if (isEmpty) None else f(this.get)
- Future[T]의 Monad적 활용
DB에서 조회 쿼리를 호출했으나 해당 요청이 비동기로 처리되는 경우를 가정하자
(
실제 Scala Slick(DB util)의 모든 호출이 이러한 비동기 구조이다.
대부분의 예제 코드에서 결과값을 받아 와서 처리하는 부분은 Await.result(TableQuery.select(...)) 와 같은 비동기 호출을 동기적으로 바꾸는 식으로 많이 작성되어 있으나,
아래 Future 예제는 비동기 호출에 대한 답을 Monad로 찾을 수 있다는 답을 제시하고 있다.
)
flatMap을 활용하면 코드적으로 onComplete() 구현을 통한 callback 처리를 생략할 수 있고,
데이터 처리를 위한 operation은 future가 complete 되었을때 1회 실행된다.
즉, 아래의 for comprehension나 flatMan chain은 순차적으로 실행된다.
/* Creates a new future by applying a function to the successful result of the function
as the new future. If this future is completed with an exception then the new future
will also contain this exception.
Example:
val f = Future { 5 }
val g = Future { 3 }
val h = for {
x: Int <- f // returns Future(5)
y: Int <- g // returns Future(3)
} yield x + y
is translated to:
f flatMap { (x: Int) => g map { (y: Int) => x + y } }
Params:
f – the function which will be applied to the successful result of this Future
Type parameters:
S – the type of the returned Future
Returns:
a Future which will be completed with the result of the application of the function
*/
def flatMap[S](f: T => Future[S])(implicit executor: ExecutionContext): Future[S] =
transformWith {
t =>
if(t.isInstanceOf[Success[T]]) f(t.asInstanceOf[Success[T]].value)
else this.asInstanceOf[Future[S]] // Safe cast
}
ZIO http 를 활용하여 db의 내용을 조회하여 응답하는 API를 개발하게 될 경우 Monad적으로 Controller의 코드를 작성하면 아래와 같은 형태가 된다.
abstract class AbstractTable[T](val tableTag: Tag, val schemaName: Option[String], val tableName: String) extends Rep[T] {
/** ... **/
/** The * projection of the table used as default for queries and inserts.
* Should include all columns as a tuple, HList or custom shape and optionally
* map them to a custom entity type using the <> operator.
* The `ProvenShape` return type ensures that
* there is a `Shape` available for translating between the `Column`-based
* type in * and the client-side type without `Column` in the table's type
* parameter. */
def * : ProvenShape[T]
}
위와 같이 AbstractTable[T]의 내부 method로 정의되며 ProvenShape[T]가 Return Type이다.
그리면 이어서 ProvenShape[T]를 살펴보자.
trait ProvenShape[U] {
def value: Any
val shape: Shape[_ <: FlatShapeLevel, _, U, _]
def packedValue[R](implicit ev: Shape[_ <: FlatShapeLevel, _, U, R]): ShapedValue[R, U]
def toNode = packedValue(shape).toNode
}
위와 같은 형태의 trait으로 추론해보면,
(Rep[Int], Rep[String]) 형태의 원천 Tuple 데이터를 ProvenShape[(Int, String)] 형태로 변환하는 implicit type converstion이 어디인가 정의되어 있음을 추정할 수 있다.
해당 변환의 정의는 object ProvenShape에서 찾을 수 있으며, 해당 object는 trait ProvenShape[T]의 동반 객체(Companion Object)로 해당 object내에 정의된 implicit def는 별도의 import 없이 ProvenShape[T]를 사용하는 스코프내에서 처리될 수 있다.
object ProvenShape {
/** Convert an appropriately shaped value to a ProvenShape */
implicit def proveShapeOf[T, U](v: T)(implicit sh: Shape[_ <: FlatShapeLevel, T, U, _]): ProvenShape[U] =
new ProvenShape[U] {
def value = v
val shape: Shape[_ <: FlatShapeLevel, _, U, _] = sh
def packedValue[R](implicit ev: Shape[_ <: FlatShapeLevel, _, U, R]): ShapedValue[R, U] = ShapedValue(sh.pack(value).asInstanceOf[R], sh.packedShape.asInstanceOf[Shape[FlatShapeLevel, R, U, _]])
}
/** ... **/
}
정의된 Tuple의 값이 아래의 provenShapeOf(A)(B) function을 통해 최종적으로는 ProvencShape[U]로 변환 처리됨을 알 수 있다.
Type이 너무 많은 코드라 복잡하고 뭐가 뭔지 알 수가 없다. 갈 길을 잃을 타이밍이나 아래의 내용만 명심하면 된다.
tuple2Shape는 tuple2의 type구조를 반영하는 Shape 객체를 그저 만들어 내는 역할을 한다.
TupleShapeImplicits trait 파일을 실제로 살펴보면 Tuple 의 갯수별로 implicit function이 개별적으로 구현되어 있는데 입력되는 Tuple의 갯수가 한정적이고, 데이터 갯수에 따라서 1:1로 implicit conversion이 정의되어 있음을 알 수 있다.
여기서 드는 의문은 동일한 TupeShape Class의 객체라 할지라도 선언된 Type parameter와 문맥이 일치하는 객체를 찾아 implicit converstion에 적용할 수 있느냐 이다. 아래의 예제를 살펴보자.
다른 특이점으로 Apple object의 apply method 상에서 parameter 정의가 없으나 실제 호출시 명시적으로 값을 설정할 수 있으며, this.asInstanceOf[BaseFruite[A, B]]를 통하여 해당 object의 super class type의 instance를 반환하고 있다. (코드 패턴으로 보임)
다시 본론으로 돌아와서,
아래의 reColumnShape는 context bound 로 정의( [T: BaseTypedType,..] )로 인하여 BaseTypedType[T]의 parameter를 역시나 암시적 매개변수(implicit parameter)로 처리된다.
repColumnShape(intColumnType), repColumnShape(stringColumnType)
// 참고
trait RepShapeImplicits extends OptionShapeImplicits {
/** A Shape for single-column Reps. */
@inline implicit def repColumnShape[T : BaseTypedType, Level <: ShapeLevel]
= RepShape[Level, Rep[T], T]
}
그러면 위의 intColumnType, StringColumnType 은 어떤식으로 implicit로 정의되어 있을까?