동기

  • Monad를 규명하고 싶은자들에게 필수적인 내용을 담고 있다.

FlatMap의 Signature

  • flatMap 함수하나를 포함하고 있다.
trait CustomFlatMap[M[_]] {
  def flatMap[A, B](ma: M[A])(f: A => M[B]): M[B]
}

 

활용 For comprehension

  import cats.FlatMap
  import cats.syntax.flatMap._
  import cats.syntax.functor._

  def getPairs[M[_] : FlatMap](numbers: M[Int], chars: M[Char]): M[(Int, Char)] = for {
    n <- numbers
    c <- chars
  } yield (n, c)

  // Generalized
  def getPairs[M[_] : FlatMap, A, B](ma: M[A], mb: M[B]): M[(A, B)] = for {
    n <- ma
    c <- mb
  } yield (n, c)

 

타 Class와의 관계 규명

  • Apply(Applicative)의 특성을 가진다.
  • Apply에서 제공하는 map함수와 flatMap 함수를 조합하여 Apply.ap 함수를 구현할 수 있다.
  trait MyFlatMap[M[_]] extends Apply[M] {
    def flatMap[A, B](ma: M[A])(f: A => M[B]): M[B]
    def ap[A, B](wf: M[A => B])(wa: M[A]): M[B] =
      flatMap(wa)(a => map(wf)(f => f(a)))
  }

 

Monad의 한 부분

  • 아래와 같은 구조(pure, map, flatMap 조합)로 Monad의 완전체를 구현할 수  있다.
  trait MyMonad[M[_]] extends Applicative[M] with CustomFlatMap[M] {
    override def map[A, B](ma: M[A])(f: A=>B): M[B] =
      flatMap(ma)(x => pure(f(x)))
  }
 

 

Monad를 위한 정리

  • 지금까지의 Type Class와 Monad는 아래와 같은 구조를 이룬다. (하위 Monad가 상위 모든 클래스의 특징을 가진다)
  • StarUML은 Higher Kinded Type 표기를 지원하지 않아 Class Diagram상 함수명만 표기하였다.

동기

  • FP Monad의 기원을 찾는 자들에게 필요한 내용일 수 있다.

Signature

  • Applicative는 pure와 map으로 구성되어 있다.
  trait Applicative[W[_]] {
    def pure[A](x: A): W[A]
    def map[A, B](fa: W[A])(f: A=>B): W[B]
  }
  • Applicative를 활용하여 Semigroupal의 product 및 Fuctior의 map을 구현할 수 있다. 
    • product 함수의 구현에 있어 ap 함수가 필수적으로 필요하고 Apply Classd에 ap를 구현하여 Application이 완성되는 형태가 된다.
trait MyApply[W[_]] extends Functor[W] with Semigroupal[W] {
    override def product[A, B](fa: W[A], fb: W[B]): W[(A, B)] = {
      val fuctionWrapper: W[B => (A, B)] = map(fa)(a => (b: B) => (a, b))
      ap(fuctionWrapper)(fb)
    }
    
    def ap[W[_], B, T](wf: W[B=>T])(wa: W[B]): W[T]
}

 

  • Application으로 Semigroupal.product을 구현할 수 있다.
    • Apply의 ap 함수가 없으면 구현이 불가능하다.
  def productWithApplicatives[W[_], A, B](wa: W[A], wb: W[B])(implicit applicative: Applicative[W]): W[(A, B)] = {
    val fuctionWrapper: W[B => (A, B)] = applicative.map(wa)(a => (b: B) => (a, b))
    applicative.ap(fuctionWrapper)(wb)
  }

 

 

활용 (Appicatives)

  • Applicative 의 pure함수를 활용하면 아래와 같이 Wrapper[Type] 형태의 데이터를 쉽게 생성할 수 있다.
    • map 함수를 활용하여 내부 데이터의 변환도 용이하다.
  • 아래 Validated.valid(22)와 같이 다양한 이름으로 pure 함수가 제공된다.
  import cats.Applicative
  import cats.instances.list._
  val listApplicative = Applicative[List]
  val aList = listApplicative.pure(2) // List(2)

  import cats.instances.option._
  val optionApplicative = Applicative[Option]
  val anOption = optionApplicative.pure(2)  // Some(2)

  // pure extension method
  import cats.syntax.applicative._
  val aSweetList = 2.pure[List]
  val aSweetOption = 2.pure[Option]

  // Monads extends Applicatives
  // Applicatives extends Functors
  import cats.data.Validated
  type ErrorOr[T] = Validated[List[String], T]
  val aValidValue = Validated.valid(22) // pure
  val aModifiedValidate: ErrorOr[Int] = aValidValue.map(_ + 1)  // map

 

Apply

  • Cats에서는 Apply에 product와 ap 함수를 정의하고 있다.
  trait MyApply[W[_]] extends Functor[W] with Semigroupal[W] {
    override def product[A, B](fa: W[A], fb: W[B]): W[(A, B)] = {
      val fuctionWrapper: W[B => (A, B)] = map(fa)(a => (b: B) => (a, b))
      ap(fuctionWrapper)(fb)
    }
    def ap[W[_], B, T](wf: W[B=>T])(wa: W[B]): W[T]
  }

 

활용(Apply)

  • Wrapping된 Data에 정의한 함수로 조작이 가능하다.
  • 여러 데이터의 동시 조작을 위해 mapN형태의 함수를 제공한다.
  import cats.Apply
  import cats.instances.option._

  val applyOption = Apply[Option]
  val funcApp = applyOption.ap(Some((x:Int) => x + 1))(Some(2)) // Some(3)

  import cats.syntax.apply._
  val tupleOfOptions = (Option(1), Option(2), Option(3))
  val optionOfTuples = tupleOfOptions.tupled
  val sumOption = tupleOfOptions.mapN(_ + _ + _)

 

정리

  • 아래와 같이 Monad는 여러 특성을 상속받는다.
    • trait Monad extends Applicative with Apply with Semigroupal with Functor 

---

[Functor][Semigroupal]

[Apply]

[Applicative]

[Monad]

 

 

Signature

  trait  MySemigroupal[F[_]] {
    def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]
  }

 

Example

import cats.instances.future._
implicit val ec: ExecutionContext = ExecutionContext.fromExecutorService(Executors.newFixedThreadPool(0))
val aTupledFuture = Semigroupal[Future].product(Future("the meaning of life"), Future(45)) 
// -> Future(("the meaning of life", 45))

import cats.instances.list._
val aTupledList = Semigroupal[List].product(List(1,2), List("a", "b"))  
// --> List((1,a), (1,b), (2,a), (2,b))

 

 

Monad와 연관성

- Monad extends Semigroupals

- Monad의 요소로 Semigroupls의 product을 구현할 수 있다.

  trait CustomMonad[M[_]] {
    def pure[A](value: A): M[A]
    def flatMap[A, B](ma: M[A])(f: A => M[B]): M[B]
    def map[A, B](ma: M[A])(f: A => B): M[B] =
      flatMap(ma)(x => pure(f(x)))
    // ----
    def product[A, B](fa: M[A], fb: M[B]): M[(A, B)] =
      flatMap(fa)(a => map(fb)(b => (a, b)))
  }

 

Use Case

- Monad는 short circuit이 발생하는 반면, Semigroup을 활용하면 모든 데이터를 하나로 모을 수 있다.

  import cats.data.Validated
  type ErrorsOr[T] = Validated[List[String], T]
  val validatedSemigroupal = Semigroupal[ErrorsOr]

  val invalidsCombination = validatedSemigroupal.product(
    Validated.invalid(List("Error A", "Error B")),
    Validated.invalid(List("Error C"))
  )
  
  // --> Invalid(List(Error A, Error B, Error C))
 
 

게임 회사에 입사했을때 처음으로 주어진 업무는

아이온이라는 MMORPG 게임에서 캐릭터를 하나 생성해서 만랩으로 만드는 것이었다.

업무시간에 점심시간에 야근으로 게임을 하더라도 아무도 뭐라 그러는 사람이 없으니 한편으론 문화충격이기도 했고..

게임에 관심이 별로 없었던 나로써는 이건 왜 해야하지라는 의문만 들었다.

 

물론 만랩에 도달하고서야 알게되었는데,

캐릭터를 일단 만랩으로 만들고 나서 시작되는 퀘스트와 할 수 있는 것들이 너무나도 많았다.

 

결국 게임 시작의 필요조건은 본인 캐릭터를 만렙으로 만드는 것이라는 것을 알았다.

----

 

Scala를 공부하면서 드는 생각은 파도파도 끝이 없는데 도대체 어디까지 가야 만랩의 시작점일까?

물론 Spark 정도를 돌려보는 수준이라면 Scala의 5% 정도만 알아도 대부분의 기능을 원활하게 쓸 수 있다.

 

그 이외의 것들을 하기위한 95%의 노력이 들어가는 영역. 외로운 싸움인 것 같다.

Scala를 시작하고 2년 정도 흘러간 현시점.

ZIO, Cats를 조금씩 쓰고 있는데 결국은 Monad 구조를 코드에 얼마나 잘 녹여 넣는가로 정리가 되어 가는 느낌이다.

Scala의 Purely Functional을 지향하는 대부분의 툴들은 Free Monad 구조가 녹아 있는 부분이 많다.

Java에 Spring Framework이 있다면 현 시점 Scala에는 ZIO가 있고 Scala에 대표 툴들이 ZIO로 통합되어가고 있는 분위기다.

 

Free Monad를 몰라도 ZIO 등의 Free Monad 친화적 구조의 툴 들을 쓰는데 큰 문제는 없지만 쓰면 쓸수록 뭔가 마음속에 답답한 짐이 커짐을 느낀다.

Free Monad의 구현방식을 정확하게 이해하지 않으면 Scala와의 여정이 시작부터 쉽지 않을 것 같다.

일단 Free Monad 구현 코드를 꼼꼼히 살펴보자.

----

 

구현 코드는 스칼라 공부하는 사람의 천사, 다니엘님의 영상과 블로그를 참고하였다.

https://blog.rockthejvm.com/free-monad/

 

Free Monad in Scala

A tutorial on the Free monad in Scala, how it works and what it’s good for.

blog.rockthejvm.com

매우 잘 정리된 글에 영상이지만 그저 놀라운 모습을 바라만 보고 있기를 반복하는데 뭔가 막히는 부분을 찾아내고 정리하는 과정이 필요하다 생각되었다.

 

우선 Free Monad로 쓸 수 있는 유용한 좋은 점중 하나가 비지니스로직과 구현로직의 분리이다.

Java나 객체지향 언어에서 사용한 Tempate Method Pattern은 상위계층에서 Function의 flow를 정의하고 구현 Class를 분리하는데, 

Scala역시 Free Monad를 이용하여 함수적으로 로직과 구현을 분리한다.

 

def myLittleProgram = for { 
  _ <- create[String]("123-456", "Daniel")
  name <- get[String]("123-456")
  _ <- create[String]("567", name.toUpperCase())
  _ <- delete("123-456")
} yield ()

위의 코드는 비지니스 로직, 즉 로직에 의한 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이다.

def myLittleProgram: Free[DBOps, Unit] = for { // monadic
  _ <- create[String]("123-456", "Daniel")
  name <- get[String]("123-456")
  _ <- create[String]("567", name.toUpperCase())
  _ <- delete("123-456")
} yield ()

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은 아래와 같이 풀어 쓸 수 있다.

val myLittleProgram2: Free[DBOps, Unit] = create[String]("123-456", "Daniel").flatMap(a1 => {
  get[String]("123-456").flatMap(name => {
    create[String]("567", name.toUpperCase()).flatMap(a3 => {
      delete("123-456").map(a4 => ())
    })
  })
})

for comprehension의 표현을 단순히 풀어 쓴 코드라 myLittleProgram2의 return type 역시 Free[DBOps, Unit]으로 동일하다. 

foldMap 구현시 첫번째로 해야될 일은 DBOps[String], 즉 Free[DBOps, String]을 IO[String]으로 변환하는 로직일 것이다.

 

그런데 다시 create[String]에 어떻게 접근 혹은 진입을 할 수 있을까?

 

결론부터 얘기를 하면 우리가 알고 있던 flatMap과 map은 뭔가 연산을 하고 결과값을 던져주는 함수인데,

여기서 사용되는 flatMap은 단순히 원본의 데이터를 IO 형태로 변환시켜주는 역할만을 한다.

println("MyLittleProgram => " + myLittleProgram)

// output
MyLittleProgram => FlatMap(Suspend(Create(123-456,Daniel)),c.y.study.FreeMonadSample$$$Lambda$17/0x0000000800096040@319b92f3)

위의 코드는 놀랍게도 for comprehension의 실행 결과가 값이 아닌 변환된 데이터 타입을 보여준다. 변환된 데이터 타입을 풀어쓰면 아래와 같다. 

def myLittleProgram = FlatMap(Suspend(Create(123-456,Daniel)),
	FlatMap(Suspend(Read("123-456")), 
		FlatMap(Suspend(Create(Read(..)), 
        		FlatMap(Suspend(Delete("123-456")), Pure(Unit))))

최상 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 시작을 위한 준비라 볼 수 있겠다 -_-;;

 

/*

Monad는 FP의 진입 관문이면서 정확히 이해하기 쉽지않은 개념이다. 

어떤 글을 보아도 100% 충족되지 않아 의식의 흐름대로 개념을 정리해 보았다.

*/

 

Functor, Monoid의 이해

 

Monad 진입전 Functor, Monoid 등의 이해가 필요하다.

 

Functor는 내부의 데이터를 감싸고 있는 일종의 Wrapper이다.

내부의 data는 map function을 통해 접근 및 추가 연산의 처리가 가능하다.

대수적으로 map(x)(a => a) == x 을 만족해야 한다.

즉 오직 자료구조의 요소를 수정할 수 있으나, 구조 자체의 형태(순서, length, size 등)는 변함이 없어야 한다.

trait Functor[F[_]] {
  def map[A,B](da: F[A])(f: A=> B): F[B]
}

val listFunctor = new Functor[List] {
  override def map[A, B](da: List[A])(f: A => B): List[B] = da map f
}

listFunctor.map(List[Int](1,2,3))(_ + 3).foreach(println)

/* 
output:
4
5
6
*/

Monoid(모노이드)는 함수 합성의 대수적 접근(항등원, 결합법칙)을 함수의 합성에 활용하는 개념이다.

(이 포스트에서는 일단 자세한 설명은 생략한다. 파면 팔수록 나오는데 그것들을 하나하나 확인하고 가다 보면 이 글을 맺음하는데 수백년은 걸릴 것 같다.)

 

Monad란 무엇인가

 

Monad는 자료를 감싸고 있는 일종의 Wrapper이다.

 

Monad의 Wrapper가 '선물의 포장같은..' 이라는 표현을 쓰는 게시글도 보았는데, 선물의 내용물을 가공하여 새로운 내용물을 창조한다는 관점에서 뭔가 적절치 않은 것 같다.

 

Monad는 모호한 부분이 있다.

단순히 배워왔던 Design Pattern적 개념으로 접근해선 안된다.

그 보다 상위의 추상적 원칙에 가깝고, function의 명을 강제하지 않지만 signature에 의거한 최소한의 fuction을 제공해야 한다.

 

Monad는 기본적으로 아래의 연산을 function으로 제공한다.

- identity : unit(x) or pure

- bind : flatMap(d: data)(f: function) or bind

def unit[A](x: A): M[A]

trait M[A] {
  def flatMap[B](f: A => M[B]): M[B]
}

실제 Scala에서는 위의 Monad 관련 trait을 API적으로 제공하지 않는다. 

위에서 설명했듯이 디자인패턴적인 접근보다는 개념, 법칙, 원칙적 정의에 의한 접근이 필요하다.

 

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을 수용하면서 적용 가능한 코드 중복을 줄일려면..

map function으로는 어렵다.

def flatMap[B](f: (A) => U[B]): U[B]

val listInt2Str = (i: Int) => List(i, i+1)

println(List(1,2).map(listInt2Str))
//List(List(1, 2), List(2, 3))

println(List(1,2).flatMap(listInt2Str))
//List(1, 2, 2, 3)

Functor의 map 연산시 A => M[B] 로 가정하면 Input A의 length값과 M[B]의 length값은 동일하지만,

flatMap을 사용할 경우 input에 대한 output의 형태(Type, Length 등)적인 제약이 사라진다.

 

만약 monad 로직상에서 map이 필요한 경우 flatMap과 unit의 조합으로 map의 구현이 가능하다.

m map g = m flatMap (x => unit(g(x)))

즉, 기능적으로 flatMap은 map의 확장이며 이를 활용하여 자유도가 더 높은 코드의 구현이 가능해진다.

 

Functor와 Monad의 차이는 flatten(join) 연산이 있고 없고의 차이로 정리가 된다.

다시 해석하면 Monad도 Functor이다. (더 많은 기능을 제공하는..)  

 

Monad의 대수적 조건(3원칙)

Monad는 함수 합성시 연산의 대수적 특성으로 항등법칙, 결합법칙이 성립해야된다.

 

- left-identity law:

//표현1 :
unit(x).flatMap(f)==f(x)

//표현2 :
Monad(x).flatMap(f) == f(x)

//예제1 (unit을 List로 가정) :
def twoConsecutive(x: int) = List(x, x+1)
twoConsecutive(3)	// == List(3,4)
List(3).flatMap(twoConsecutive)	// == List(3,4)

- right-identiy law:

//표현1 :
m.flatMap(unit) == m

//표현2 :
Monad(x).flatMap(x => Monad(x)) == Monad(x)

//예제1 (Monad를 List로) :
List(1,2,3).flatMap(x => List(x)) // == List(1,2,3)

- associativity law:

//정의1 :
m.flatMap(f).flatMap(g) == m.flatMap(x ⇒ f(x).flatMap(g))

//정의2 :
MyMonad(x).flatMap(f).flatMap(g) == MyMonad(x).flatMap(x => f(x).flatMap(g))

//예제1 :
val numbers = List(1,2,3)
val incrementer = (x: Int) => List(x, x + 1)
val doubler = (x: Int) => List(x, 2 * x)

numbers.flatMap(incrementer).flatMap(doubler) == List(1,2,2,4,2,4,3,6,3,6,4,8)
//output: true

//연산순서
[1,2,3].flatMap(incremeter) == List(1,2, 2,3, 3,4)
[1,2,2,3,3,4].flatMap(doubler) == List(1,2,2,4,2,4,3,6,3,6,4,8)

numbers.flatMap(x => incrementer(x).flatMap(doubler)) == List(1,2,2,4,2,4,3,6,3,6,4,8)
//output: true 

//연산순서
[
  incrementer(1).flatMap(doubler),	
  // 1 => List(1,2) => (List(1,2), List(2,4)) => List(1,2,2,4)
  incrementer(2).flatMap(doubler),
  // ...
  incrementer(3).flatMap(doubler)
  // ...
]


numbers.flatMap(incrementer).flatMap(doubler) 
== numbers.flatMap(x => incrementer(x).flatMap(doubler))
//output: true

앞서 언급하였듯 이러한 대수적 특징은 모노이드(Monoid)의 특징이기도 하다. 

 

Monad 정리

 

요약 : 함수 합성을 위한 추상 개념, 대수적인 인터페이스

첨언 : 공통점이 없어 보이는 객체(List, Option, Future ..)에 동일한 로직 flow를 추상적으로 정의할 수 있고, 완전히 다른 내부 자료형을 처리하더라도 동일 코드로 처리될 수 있다.

 

1) Monad는 아래의 최소 2개의 function 조합을 제공한다.

- unit, flatMap

- unit, compose

- unit, map, join

 

2) 함수 합성시 결합법칙과 항등법칙의 성립

 

Scala에서는 별도로 Monad trait을 API적으로 제공하지는 않는다.

디자인패턴 등에 비하면 추상적인 개념원칙에 가깝고 실제 활용될 class 특성에 따라 구현 코드의 표현상의 자유도가 높아 질 수 있기 때문이다.

위에서 언급한 unit(...) 이라는 이름으로 사용되는 경우는 아주 드물다.

trait Monad[F[_]] {
  def flatMap[A,B](fa: F[A])(f: A => F[B]): F[B]
}

그래도 trait으로 정의를 하자면 위와 같은 모양이 된다. 물론 답은 여러가지 형태가 될 수 있다.

 

Monad의 활용

- Option[T]의 Monad적 활용

Option은 get, getOrElse, flatMap 등의 코드 지옥에 빠질 수 있으나,

For comprehension 적용시 코드의 가독성을 증가 시키며 코드 지옥에서 해방될 수 있다.

object UserService {
  def loadUser(name: String) =  Option(User("A", Some(User("A-1", Some(User("A-1-1", None))))))
  def loadUserOnlyChild(name: String) =  Option(User("A", Some(User("A-1", None))))
  def loadUserAlone(name: String) =  Option(User("A", None))
}

val noChild = UserService.loadUserAlone("A").flatMap(_.child).flatMap(_.child)
println(noChild) //output: None

//for comprehension (syntax sugar)
val res = for {
  user <- UserService.loadUser("A")
  userChild <- user.child
  grandChild <- userChild.child
} yield grandChild

println(res) //output: Some(User(A-1-1,None))

참고로 Option.flatMap의 구현은 아래와 같다.

  /** 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은 순차적으로 실행된다.

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
trait Order
trait Item
trait PurchaseResult
trait LogResult
object OrderService {  
  def loadOrder(username: String): Future[Order] 
}
object ItemService {  
  def loadItem(order: Order): Future[Item] 
}
object PurchasingService { 
  def purchaseItem(item: Item): Future[PurchaseResult]
  def logPurchase(purchaseResult: PurchaseResult): Future[LogResult] 
}

// ---
val loadItem: Order => Future[Item] = {
  order => ItemService.loadItem(order)
}
val purchaseItem: Item => Future[PurchaseResult] = {
  item => PurchasingService.purchaseItem(item)
}

val logPurchase: PurchaseResult => Future[LogResult] = {
  purchaseResult => PurchasingService.logPurchase(purchaseResult)
}

// ---
val result = 
  OrderService.loadOrder("customerUsername")
  .flatMap(loadItem)
  .flatMap(purchaseItem)
  .flatMap(logPurchase)
  
val result2 =
  for {
    loadedOrder    <- orderService.loadOrder(“customerUsername”)
    loadedItem     <- itemService.loadItem(loadedOrder)
    purchaseResult <- purchasingService.purchaseItem(loadedItem)
    logResult      <- purchasingService.logPurchase(purchaseResult)
  } yield logResult
  
println(result == result2)	//output: true

참고로 Future.flatMap의 정의는 아래와 같이 생겼다.

/* 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의 코드를 작성하면 아래와 같은 형태가 된다.

    (for {
        // validate user
        _    <- MyAuthService.doAuth(request)
        // log request
        _    <- logRequest(request)
        // core business logic
        user <- dbService.lookupUsersById(id).map(Response.json(_.json))
        resp <- Response.json(user.toJson)
        // log response
        _    <- logResponse(resp)                
    } yield resp)
            .timeout(2.seconds)
            .retryN(5)

 

더 봐야 될 것들 (TBD, 지친다)

Free Monad

IO Monad

 

 

[좋았던 참고 자료]

https://medium.com/free-code-camp/demystifying-the-monad-in-scala-cc716bb6f534

https://www.youtube.com/watch?v=d-dy1x33moA 

https://www.youtube.com/watch?v=a0C-RrncrYA 

 

Slick 개발시 아래와 같이 * method의 구현 코드를 작성하게 된다.

Table 생성시 (Int, String)의 Tuple type에 정의된 순서에 따라 (id, firstName) 형태의 구현이 필요하며,

정의된 Type의 범위를 벗어나는 경우 compile error가 발생한다.

class Hell extends Table[(Int, String)]("TB_HELL") {
  def id = column[Int]("HELL_ID")
  def firstName = column[String]("FIRST_NAME")

  def * = (id, firstName)
}

위의 코드에서 method * 는 column의 return type이 Rep class로 구성된 Tuple의 형태로 구현 혹은 할당된 모습으로 보인다.

column[C]의 정의는 아래와 같으며 Rep[C]를 return 하고 있다.

def column[C](n: String, options: ColumnOption[C]*)(implicit tt: TypedType[C]): Rep[C] = {
    /** ... **/
}

그러면 def *의 정의 구조를 살펴보자.

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이 어디인가 정의되어 있음을 추정할 수 있다.

(Rep[Int], Rep[String]) ------> ProvenShape[(Int, String)]

결론부터 말하자면,

적용된 implicit method를 명시적으로 호출되도록 구현을 하면 본질적으로 아래와 같은 호출 & 변환 구조라는 것을 알게 된다.

def * = ProvenShape.proveShapeOf(id, firstName)
		(tuple2Shape(repColumnShape(intColumnType), repColumnShape(stringColumnType)))

전체 implicit의 전체 처리 구조를 보기 좋게 재정리하면 아래와 같다.

눈에 보이는 것을 제외하곤 모든 것이 implicit 이다.

def * = ProvenShape.proveShapeOf(id, firstName)
       (
            tuple2Shape(
                repColumnShape(
                    intColumnType
                ), 
                repColumnShape(
                    stringColumnType
                )
            )
       )

 

우선 최상위 implicit 변환인 provenShapeOf 부터 살펴보면,

해당 변환의 정의는 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]로 변환 처리됨을 알 수 있다.

implicit def proveShapeOf[T, U](v: T)
	(implicit sh: Shape[_ <: FlatShapeLevel , T, U, _]): ProvenShape[U]

 

provenShapeOf는 2개의 parameter를 필요로 하는 currying이 적용된 method이다.

 

최초의 샘플코드를 기준으로 하나하나 살펴보면,

위의 Type T는 (Rep[Int], Rep[String])의 Tuple2에 해당되며, Type U는 (Int, String)의 Tuple2에 해당된다.

implicit parameter인 Shape는 Shape[Level, (M1,M2)(U1,U2), (P1,P2)]의 형태로 대응될 수 있다. 

 

하나하나 연관관계를 대응해보면 아래와 같이 표현할 수 있다.

ProvenShape.proveShapeOf(M1, M2)(Shape[Level, (M1,M2), (U1,U2), (P1,P2)])

M1 => Rep[Int]
M2 => Rep[String]
U1 => Int
U2 => String

 

첫번째 parameter는,

(v: T)의 T는 앞서 정의한 입력 parameter 값에 따라 정해지는 Type으로 (Rep[Int], Rep[String])의 형태의 두개의 값을 가지는 Tuple2의 Type으로 정의될 수 있다. (T = Tuple2)

 

두번째 parameter는,

아래와 같이 implicit 생성 규칙에 의해 생성된 Shape instance 이다.

호출시 사용된 parameter의 type인 Tuple2가 Type 인자 T로 정의되어 있음을 알 수 있고 Table 최초 정의시의  Schema인 (Int, String)이 U로 대응되는 Shape instance가 생성되어 있어야 함을 의미한다. 

(implicit sh: Shape[_ <: FlatShapeLevel , T, U, _])

위의 Shape의 구조와 instance을 생성하는 implicit 의 구현을 살펴보자. 

Shape object에 3개의 implicits의 trait을 상속 받고 있으며, Tuple 구조의 데이터를 처리하는 TupleShapeImplicit를 역시 상속받고 있다.

모든 Shape instance에서 해당 traits이 적용 범위가 될 수 있음을 의미한다.

 

앞서 설명하였던 Tuple 이외에 다른 형태의 자료형을 처리하는 Implicit가 다른 trait에 정의 되어있음을 추정할 수 있다. 

(내용이 방대한 관계로 여기서는 Tuple을 처리하는 implicit 만 소개하도록 하겠다.)

abstract class Shape[Level <: ShapeLevel, -Mixed_, Unpacked_, Packed_] {
  /** ... **/
}

object Shape extends ConstColumnShapeImplicits 
	with AbstractTableShapeImplicits with TupleShapeImplicits {
  /** ... **/
}

trait TupleShapeImplicits {
  @inline
  implicit final def tuple2Shape[Level <: ShapeLevel, M1,M2, U1,U2, P1,P2]
  	(implicit u1: Shape[_ <: Level, M1, U1, P1], 
    		u2: Shape[_ <: Level, M2, U2, P2])
    : Shape[Level, (M1,M2), (U1,U2), (P1,P2)] =
    new TupleShape[Level, (M1,M2), (U1,U2), (P1,P2)](u1,u2)
    
  @inline
  implicit final def tuple3Shape[Level <: ShapeLevel, M1,M2,M3, U1,U2,U3, P1,P2,P3]
  	(implicit u1: Shape[_ <: Level, M1, U1, P1], 
    		u2: Shape[_ <: Level, M2, U2, P2], 
        	u3: Shape[_ <: Level, M3, U3, P3])
    : Shape[Level, (M1,M2,M3), (U1,U2,U3), (P1,P2,P3)] =
    new TupleShape[Level, (M1,M2,M3), (U1,U2,U3), (P1,P2,P3)](u1,u2,u3)
    
  /** ... **/
}

final class TupleShape[Level <: ShapeLevel, M <: Product, U <: Product, P <: Product]
	(val shapes: Shape[_ <: ShapeLevel, _, _, _]*) 
    extends ProductNodeShape[Level, Product, M, U, P] {
    
  override def getIterator(value: Product) = value.productIterator
  def getElement(value: Product, idx: Int) = value.productElement(idx)
  def buildValue(elems: IndexedSeq[Any]) = TupleSupport.buildTuple(elems)
  def copy(shapes: Seq[Shape[_ <: ShapeLevel, _, _, _]])  = new TupleShape(shapes: _*)
}

일단은 위의 tuple2Shape implicit 함수가 아래의 sh implicit parameter의 값(instacne)를 생성한다고 볼 수 있다.

implicit def proveShapeOf[T, U](v: T)
	(implicit sh: Shape[_ <: FlatShapeLevel , T, U, _]): ProvenShape[U]

Type이 너무 많은 코드라 복잡하고 뭐가 뭔지 알 수가 없다. 갈 길을 잃을 타이밍이나 아래의 내용만 명심하면 된다.

 

tuple2Shape는 tuple2의 type구조를 반영하는 Shape 객체를 그저 만들어 내는 역할을 한다.

 

TupleShapeImplicits trait 파일을 실제로 살펴보면 Tuple 의 갯수별로 implicit function이 개별적으로 구현되어 있는데 입력되는 Tuple의 갯수가 한정적이고, 데이터 갯수에 따라서 1:1로 implicit conversion이 정의되어 있음을 알 수 있다. 

 

여기서 드는 의문은 동일한 TupeShape Class의 객체라 할지라도 선언된 Type parameter와 문맥이 일치하는 객체를 찾아 implicit converstion에 적용할 수 있느냐 이다. 아래의 예제를 살펴보자.

object TypeImplicits {
  abstract class Shape[A,B,C,D] {
    type RepType = B
//    def print = println("Type" + this.asInstanceOf[B])
    def print
  }

  trait ProvenShape[T] {
    def dsp
  }

  def main(args: Array[String]): Unit = {
    implicit def shapeA[A,B1,B2,C1,C2,D] : Shape[A, (B1,B2), (C1,C2), (D, D)] =
      new Shape[A,(B1,B2),(C1,C2),(D, D)] {
        def print = println("Tuple2 Shape")
      }
    implicit def shapeB[A,B1,B2,B3,C1,C2,C3,D] : Shape[A, (B1,B2,B3), (C1,C2,C3), (D, D)] =
      new Shape[A, (B1,B2,B3), (C1,C2,C3), (D, D)] {
        def print = println("Tuple3 Shape")
      }

    implicit def shapeOf[T, U](t : T)(implicit sh: Shape[_, T, U, _]) : ProvenShape[U] = 
      new ProvenShape[U] {
        def dsp = sh.print
      }

    val aa : ProvenShape[(Int, Int)] = (1, 2)
    aa.dsp
    val aaa : ProvenShape[(String, String, String)] = ("A", "B", "C")
    aaa dsp
  }
}

output:
Tuple2 Shape
Tuple3 Shape

implicit는 생각 이상으로 똑똑하다. 최조 정의된 data의 Type에 따라서 shapeA와 shapeB의 Type에 맞춰 구분하여 implicit conversion이 적용된다.

 

다시 본론으로 돌아와서,

다시 불행(?)하게도 tuple2Shape역시 두개의 Shape instance를 implicit parameter로 입력받고 있다. 

(implicit u1: Shape[_ <: Level, M1, U1, P1], u2: Shape[_ <: Level, M2, U2, P2])

M1 -> Rep[Int]
U1 -> Int
M2 -> Rep[String]
U2 -> String

최초 Table class에서 정의한 개별 Column에 1:1로 대응되는 개별의 Shape 객체가 implicit parameter로 처리되고 있다. 

해당 Shape parameter의 instance(Column의 Type별로 대응되는 Shape 객체)를 생성하는 implicit는 아래와 같다.

trait RepShapeImplicits extends OptionShapeImplicits {
  /** A Shape for single-column Reps. */
  @inline implicit def repColumnShape[T : BaseTypedType, Level <: ShapeLevel] 
  	= RepShape[Level, Rep[T], T]
}

실제 return object 생성의 구현은 아래의 RepShape object 에서 처리를 하고 있다.

object RepShape extends Shape[FlatShapeLevel, Rep[_], Any, Rep[_]] {
  def apply[Level <: ShapeLevel, MP <: Rep[_], U]: Shape[Level, MP, U, MP] = this.asInstanceOf[Shape[Level, MP, U, MP]]

  /** ... **/
}

 

특이한 점은 apply method 정의에서 별도의 입력 parameter가 없는데, 실제 처리에서는 아래와 같이 type과 관련된 매개변수를 전달받는 형태라는 것이다.

repColumnShape(intColumnType), repColumnShape(stringColumnType)

repColumnShape method의 parameter는 어디에 숨어있는가? 답은 해당 method의 정의에서 찾을 수 있다.

 

repColumnShape[T : BaseTypedType, Level <: ShapeLevel] 에서 T : BaseTypedType 는 context bound(한국말, 맥락 바운드)에 해당된다.  

 

context bound이 이해를 위해 아래의 간략한 예제를 살펴보자

/** ref.scala **/
trait Stype[A]
trait FruiteImplicit {
  @inline implicit def makeFruite[A: Stype] = Apple[A, A]
}

abstract class BaseFruite[A, B] {
  def name() : String
}

object Apple extends BaseFruite {
  def apply[A, B]: BaseFruite[A, B] = this.asInstanceOf[BaseFruite[A, B]]
  override def name(): String = "Apple!"
}

/** main.scala **/
object Main extends App {
  println(makeFruite(new Stype[Int]{}).name())
  
  implicit val sTypeInt = new Stype[Int] {}
  println(makeFruite.name())
}

output:
Apple!
Apple!

implicit def makeFruite[A: Stype]는 명시적으로는 parameter가 정의가 없는 형태이다. 

 

makeFruite은 정의에서 [A: Stype]의 context bound가 사용되고 있다. 암시적으로 (implict a: Stype[A]) parameter가 정의된다.

실질적으로는 아래와 같이 처리된다.

makeFruite[A, Stype](implicit a: Stype[A])

위의 예제에서 암시적 정의부를 명시적으로 호출하면 아래와 같이 정상적인 출력 결과 Apple! 을 확인할 수 있다.

println(makeFruite(new Stype[Int]{}).name())
// Apple!

다른 특이점으로 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로 정의되어 있을까?

/** JdbcTypesComponent.scala **/
trait JdbcTypesComponent extends RelationalTypesComponent { self: JdbcProfile =>
  trait ImplicitColumnTypes extends super.ImplicitColumnTypes {
    /** ... **/
    implicit def intColumnType = columnTypes.intJdbcType
    implicit def longColumnType = columnTypes.longJdbcType
    implicit def shortColumnType = columnTypes.shortJdbcType
    implicit def stringColumnType = columnTypes.stringJdbcType
    
  }
  
  class JdbcTypes {
    val instantType = new InstantJdbcType
    val stringJdbcType = new StringJdbcType
    /** ... **/
    
    class IntJdbcType extends DriverJdbcType[Int] with NumericTypedType {
      def sqlType = java.sql.Types.INTEGER
      def setValue(v: Int, p: PreparedStatement, idx: Int) = p.setInt(idx, v)
      def getValue(r: ResultSet, idx: Int) = r.getInt(idx)
      def updateValue(v: Int, r: ResultSet, idx: Int) = r.updateInt(idx, v)
    }
    
    class StringJdbcType extends DriverJdbcType[String] {
      /** ... **/
    }
    
    /** ... **/
  }
}

위와 같이 정의되어 있다. 각 Column의 data type에 1:1로 대응되는 객체를 필요할때마다 가져다 쓰는 느낌으로 보면 될 듯하다.

Slick의 자료구조는 계층적으로 매우 복잡한데 Context Bound로 정되었던 BaseTypedType[T]는 계층구조에서 아래와 같이 상위에 위치하고 있음을 확인할 수 있다.

그림을 마지막에 첨부한 이유는,

이 분석의 시작은 provenShapeOf 가 아닌 위의 Data 계층구조부터 시작하는게 맞는게 아니었나 하는 생각이 들었기 때문이다.

무엇이든 기반부터 이해하고 시작하는게 중요하지만 결코 쉽지는 않은 것 같다.

 

분석 후기

잘 알지도 못하는 Scala를 조금은 잘해보고 싶어서 시작한 분석이 한달이 넘게 걸릴 줄은 몰랐다. 

중간중간 막히는 Scala 코드의 패턴과 숨어있는 implicit를 찾아내느라.. 

어느 스칼라 고수의 말 처럼 '스칼라는 건드리는 거 아니다' 이게 답인지도 모르겠다.

Java와는 달리 Scala는 Type의 정의에 과도하게 집착하는 모습을 볼 수 있었고,

Slick 개발자는 반복과 비효율을 싫어하는지 implicit도 아주 과다하지만 계층적으로 잘 사용하고 있음을 확인할 수 있었다.

이러한 구현은 만드는 사람은 괴롭지만 쓰는 사람의 입장에서는 극단적으로 간결하고 Readabilty가 효율적인 코드를 구사할 수 있게 해준다.

물론 간결한 코드에 의문을 가지고 그 의문을 해결하고자 한다면 implicit와 Type의 악몽에 빠질 수 있음을 명심하자.

 

많이는 없겠지만 Scala 공부를 하는 자들을 위해 위의 코드를 극단적으로 단순화시킨 코드를 첨부한다.

코드 패턴을 연습해보자.

머리로 이해하면 문제는 풀 수 있지만 손이 이해해야 창조가 가능..

/** VirtualSlick.scala **/

object Virtual {
  val types = new JdbcTypes
  implicit def intCon = types.intJdbcType
  implicit def stringCon = types.stringJdbcType

  class Hell extends Table[(Int, String)]("TB_HELL") {
    def id = column[Int]("HELL_ID")
    def firstName = column[String]("FIRST_NAME")

    def * = (id, firstName)
  }

  def main(args: Array[String]): Unit = {

  }
}



/** VirtualSlickCore.scala **/

trait JdbcTypesComponent { self: JdbcProfile =>
  trait ImplicitColumnTypes {
    implicit def intColumnType = columnTypes.intJdbcType
    implicit def stringColumnType = columnTypes.stringJdbcType
  }
}

trait JdbcProfile {
  val columnTypes = new JdbcTypes
}

class JdbcTypes {
  val intJdbcType = new IntJdbcType
  val stringJdbcType = new StringJdbcType

  class IntJdbcType extends BaseTypedType[Int]
  class StringJdbcType extends BaseTypedType[String]
}

trait ProvenShape[U] {
  def value: Any
}

object ProvenShape {
  implicit def proveShapeOf[T, U](v: T)(implicit sh: Shape[_ <: FlatShapeLevel , T, U, _]): ProvenShape[U] =
    new ProvenShape[U] {
      override def value: Any = ???
    }
}

abstract class Shape[Level <: ShapeLevel, -Mixed_, Unpacked_, Packed_] {
}

object Shape extends TupleShapeImplicits with RepShapeImplicits {
}

class TupleShape[Level <: ShapeLevel, A, B, C](val shapes: Shape[_ <: ShapeLevel, _, _, _]*) extends Shape[Level, A, B, C] {
}

trait BaseTypedType[T]
trait RepShapeImplicits {
  @inline implicit def repColumnShape[T : BaseTypedType, Level <: ShapeLevel] = RepShape[Level, Rep[T], T]
}

object RepShape extends Shape[FlatShapeLevel, Rep[_], Any, Rep[_]] {
  def apply[Level <: ShapeLevel, MP <: Rep[_], U]: Shape[Level, MP, U, MP] = this.asInstanceOf[Shape[Level, MP, U, MP]]
}

trait ShapeLevel
trait FlatShapeLevel extends ShapeLevel

trait TupleShapeImplicits {
  @inline
  implicit final def tuple2Shape[Level <: ShapeLevel, M1,M2, U1,U2, P1,P2](implicit u1: Shape[_ <: Level, M1, U1, P1], u2: Shape[_ <: Level, M2, U2, P2]): Shape[Level, (M1,M2), (U1,U2), (P1,P2)] =
    new TupleShape[Level, (M1,M2), (U1,U2), (P1,P2)](u1,u2)
  @inline
  implicit final def tuple4Shape[Level <: ShapeLevel, M1, M2, M3, M4, U1, U2, U3, U4, P1, P2, P3, P4](implicit u1: Shape[_ <: Level, M1, U1, P1], u2: Shape[_ <: Level, M2, U2, P2], u3: Shape[_ <: Level, M3, U3, P3], u4: Shape[_ <: Level, M4, U4, P4]): Shape[Level, (M1, M2, M3, M4), (U1, U2, U3, U4), (P1, P2, P3, P4)] =
    new TupleShape[Level, (M1, M2, M3, M4), (U1, U2, U3, U4), (P1, P2, P3, P4)](u1, u2, u3, u4)
}

trait Rep[T]
abstract class AbstractTable[T](val tableName: String) extends Rep[T] {
  type TableElementType = T
  def * : ProvenShape[T]
}
abstract class Table[T](tableName: String) extends AbstractTable[T](tableName) {
  def column[C](n: String) : Rep[C] = new Rep[C] {
  }
}

 

+ Recent posts