본문 바로가기

Programmer Jinyo/Scala & FP

scala with cats 책 읽으면서 필기(하다보니 번역급) Chapter 5 (Monad Transformers)


투명한 기부를 하고싶다면 이 링크로 와보세요! 🥰 (클릭!)

바이낸스(₿) 수수료 평생 20% 할인받는 링크로 가입하기! 🔥 (클릭!)

이 글은 

2020/02/17 - [Programmer Jinyo/Scala & AKKA] - scala with cats 책 읽으면서 필기(하다보니 번역급) Chapter 4 (Monads)

요기에서 이어지는 글입니다.

다음글은

2020/02/21 - [Programmer Jinyo/Scala & AKKA] - scala with cats 책 읽으면서 필기 Chapter 6 (Semigroupal and Applicative)

입니다.

Scala with cats 책을 읽으며 적은 글입니다.


 

모나드는 중첩된 for-comprehensions를 통해 코드 베이스가 부풀어 오를 수 있다. 

우리가 데이터베이스와 상호작용 하고 있다고 생각 해 보자. 우리는 유저 레코드를 살펴보고 싶다. 유저가 안철수 할 수 있기 때문에 우리는 Option[User]을 리턴한다. 우리의 데이터베이스 질의는 여러 이유로 실패할 수 있기 때문에, result는 Either로 감싸져서 최종 전송된다. 이는 Either[Error, Option[User]]의 형태이다.

 

이 것을 사용하기 위해서 우리는 flatMap을 중첩해야 한다 (혹은 for-comprehensions라고 해도 여전히 같은 말이다)

def lookupUserName(id: Long): Either[Error, Option[String]] =
  for {
    optUser <- lookupUser(id)
  } yield {
    for { user <- optUser } yield user.name
  }

갑자기 굉장히 극혐이 됐다.

(유저를 찾고, 넘겨서 에러면 에러 조지고 아니면 option으로 있거나 없거나, 있으면 name을 찾아주기의 간소화된 코드인 것 같이 읽힌다)

 

5.1 Exercise: Composing Monads

질문이 생긴다. 두개의 임의의 모나드가 주어졌을 때, 이 모나드를 하나로 합성할 수 있는 방법이 있을까? 다른 말로는 , 모나드는 합성이 되냐? 함 해볼 수는 있는데, 문제가 생긴다.

import cats.Monad
import cats.syntax.applicative._ // for pure
import cats.syntax.flatMap._ // for flatMap
import scala.language.higherKinds

// Hypothetical example. This won't actually compile:
def compose[M1[_]: Monad, M2[_]: Monad] = {
  type Composed[A] = M1[M2[A]]

  new Monad[Composed] {
    def pure[A](a: A): Composed[A] =
    a.pure[M2].pure[M1]

  def flatMap[A, B](fa: Composed[A])
    (f: A => Composed[B]): Composed[B] =
      // Problem! How do we write flatMap?
      ???
  }
}

우리는 M1이나 M2에 대한 정보 없이는 flatMap을 완성할 수 없다. 그러나 만약에 우리가 그 중 하나에 대한 정보가 있다면, 우리는 이 코드를 완성할 수 있다. 예를 들어, 우리가 M2 타입이 option이라는 정보가 있다면, flatMap에 대한 정의가 훨씬 뚜렷해진다. 

def flatMap[A, B](fa: Composed[A])(f: A => Composed[B]): Composed[B] =
    fa.flatMap(_.fold[Composed[B]](None.pure[M1])(f))

사실 뭐 이 코드로 바꿔도 안되긴 하는 것 같다.

디테일하게 뭘 좀 바꿔서 쓰기는 해야 하는 것 같고, Cats는 여러 보나드들을 위해 합성할 수 있는 추가적인 그런 지식같은 것들을 제공한다.

예시를 보자.

 

5.2 A Transformative Example

Cats는 많은 모나드를 위한 transformer을 제공하고, 각각은 T suffix로 이름붙여져있다. EiitherT 는 Either을 다른 모나드와 합성하고, OptionT는 Option을, 그리고 등등등이다.

 

아래에 OptionT를 List와 Option을 합성하기 위해 사용하는 예시가 있다. List[Option[A]]를 단일 모나드로 표현하기 위해서 OptionT[List,A]를 사용할 수 있고, 이는 aliased된 ListOption[A]로 편리하게 사용할 수 있다. 

import cats.data.OptionT

type ListOption[A] = OptionT[List, A]

그러니까, 정리하자면 List[Option[A]]를 한번에 정의하려면 OptionT[List,A] 라고 하면 된다는 뜻.

어떻게 ListOption을 안과 밖을 바꿔서 정의하는지 보자. 우리는 List를 넘기고, Outer monad(OptionT)는 이것을 파라미터로 사용해서 안쪽에서 List의 flatMap에 무엇을 전달 해 주어야하는지와 같은 것들을 이어 붙여 주는 것이다.

 

사실 이게 되게 자연스러운게, OptionT의 입장에서는 이미 flatMap이 정의된 어떤 추가적인 모나드가 나를 감싸고 있다면, 내가 그냥 일반적인 데이터 타입같이 행동하는 방법을 정의 해 놓으면 나를 감싸는 모나드에게 일반적으로 행동하게 할 수 있다. List 입장에서 나의 안쪽에 있는 데이터 타입을 다루는 방법은 이미 오리지널 List 모나드에 정의 되어 있다.

 

우리는 이제 OptionT 생성자를 통해서 ListOption 인스턴스를 만들 수 있고, 혹은 더욱 쉽게 pure을 사용해도 된다.

 

import cats.Monad
import cats.instances.list._ // for Monad
import cats.syntax.applicative._ // for pure

val result1: ListOption[Int] = OptionT(List(Option(10)))
// result1: ListOption[Int] = OptionT(List(Some(10)))

val result2: ListOption[Int] = 32.pure[ListOption]
// result2: ListOption[Int] = OptionT(List(Some(32)))

map과 flatMap 메소드는 List와 Option의 해당하는 메소드를 하나의 연산으로 처리한다.

result1.flatMap { (x: Int) =>
  result2.map { (y: Int) =>
    x + y
  }
}
// res1: cats.data.OptionT[List,Int] = OptionT(List(Some(42)))

이것이 모든 모나드 transformer의 기본이다. 합성된 map과 flatMap 메소드는 각 연산 스테이지마다 재귀적으로 unpack 하고 repack 하는 그 과정 없이도 그 연산을 사용할 수 있게 해 준다. 이제, API를 좀더 깊이 살펴보자.

 

Complexity of Imports

위의 코드 샘플에서의 import는 어떻게 모든것들이 서로 맞아떨어지는지에 대한 힌트이다.

우리는 cats.syntax.applicative 를 pure syntax를 얻기 위해 import했다. pure은 타입 Applicative[ListOption]의 implicit 파라미터가 필요하다. 우리가 아직 Applicatives는 살펴보지 않았지만, 모든 모나드는 동시에 Applicatives이므로 우리는 지금은 다른것을 무시하자.

Applicative[ListOption]을 생성하기 위해서는 우리는 List와 OptionT를 위한 Applicative의 인스턴스들이 필요하다. OptionT는 Cats의 데이터 타입이므로 그 인스턴스는 그의 동반객체를 통해 제공된다. List의 인스턴스는 cats.instances.list에서 온다.

우리가 cats.syntax.functor이나 cats.syntax.flatMap을 importing하지 않았음을 주목하자. 그 이유는 OptionT은 그 스스로의 map과 flatMap을 가지는 기본 데이터 타입이기 때문이다. 다만, 우리가 syntax를 포함하였다고 하더라도 컴파일러는 이미 분명한 메소드 때문에 무시하기 때문에 문제를 일으키지 않았을 것이다.

다만 이 책에서 cats.implicits를 import 안하기 때문에 이런 일들이 일어나는거지, 만약 import했다면 모든 것들이 이미 inscope 된 상태일 것이다.

 

5.3 Monad Transformers in Cats

각 모나드 transformer은 cats.data에 정의된 데이터 타입이고, 우리에게 monad의 stack을 wrap하게 해줌으로써 새로운 모나드를 생성할 수 있게 해 준다. 우리는 우리가 Monad type class를 통해 생성한 모나드를 사용한다. 우리가 모나드 transformers를 커버하기 위해 이해해야 하는 주된 컨셉은 다음과 같다.

 

• transformer가 가능한 클래스들 / the available transformer classes;

• transformer을 사용해서 stack을 만드는 방법 / how to build stacks of monads using transformers;

• 어떻게 모나드 stack의 인스턴스를 만드는지 / how to construct instances of a monad stack; and

• 어떻게 감싸진 모나드를 뜯어내서 접근하는지 /  how to pull apart a stack to access the wrapped monads

 

5.3.1 The Monad Transformer Classes

관습적으로, Cats에서, 모나드 Foo는 FooT라는 transformer class를 가진다. 사실, Cats의 많은 모나드들은 모나드 Transformer와 Id 모나드의 결합으로 정의되어 있다. 기본적으로, 몇몇 가능한 인스턴스들은 다음과 같다.

 

• cats.data.OptionT for Option;

• cats.data.EitherT for Either;

• cats.data.ReaderT for Reader;

• cats.data.WriterT for Writer;

• cats.data.StateT for State;

• cats.data.IdT for the Id monad.

 

Kleisli Arrows

4.8 절에서, Reader monad가 cats.data.Kleisli에 있는, 더 일반적인 "Kleisli arrow"라고 불리는 컨셉의 특별한 버전이라고 언급했었다.

우리는 이제 Kleisli와 ReaderT가 무엇인지 밝힐 수 있다. 사실, 같은 것이다! ReaderT는 사실 Kleisli의 type alias이다. 따라서, 우리는 Readers 를 저번 챕터에서 만들었을 때, Kleislis를 콘솔에서 본 것이었다.

 

5.3.2 Building Monad Stacks

모든 모나드 transformer들은 어떤 관습을 따른다. transformer 스스로는 모나드를 스택 안의 모나드로 표현하는데, 그 첫 파라미터는 밖의 모나드를 나타낸다. 남은 파라미터는 우리가 해당 모나드를 만드는데에 필요한 파라미터들이다.

예를 들어, 우리가 본 ListOption는, OptionT[List, A]를 위한 타입 얼라이어스였지만, 결과는 사실상 List[Option[A]] 이었다. 다른 말로 하자면, 우리는 모나드 스택을 안에서부터 밖으로 만든다는 뜻이다.

type ListOption[A] = OptionT[List, A]

많은 모나드들과 모든 transformer들은 적어도 두개의 파라미터를 가지므로, 우리는 중간 스테이지를 위해 자주 타입 얼라이어스를 사용해야 한다.

예를 들어, 우리가 Either으로 Option을 감싸고 싶다고 해 보자. Option은 가장 안의 타입이므로 우리는 OptionT 모나드 Transformer를 사용해야 한다. 우리는  Either을 첫 파라미터로 받을 것이다. 그렇지만 Either은 그 스스로 두개의 파라미터를 가지고, 모나드는 하나만 가진다. 따라서 타입 에일리어스를 통해 올바른 모양으로 바꿔야 한다.

// Alias Either to a type constructor with one parameter:
type ErrorOr[A] = Either[String, A]

// Build our final monad stack using OptionT:
type ErrorOrOption[A] = OptionT[ErrorOr, A]

ErrorOrOption 은 모나드이다 (ListOption처럼). 우리는 pure, map, flatMap을 인스턴스의 생성과 변환을 위해 사용할 수 있다.

import cats.instances.either._ // for Monad

val a = 10.pure[ErrorOrOption]
// a: ErrorOrOption[Int] = OptionT(Right(Some(10)))

val b = 32.pure[ErrorOrOption]
// b: ErrorOrOption[Int] = OptionT(Right(Some(32)))

val c = a.flatMap(x => b.map(y => x + y))
// c: cats.data.OptionT[ErrorOr,Int] = OptionT(Right(Some(42)))

우리가 3개 이상의 모나드를 쌓으려고 하면 더 복잡해진다.

예를 들어, Option의 Either의 Future을 만든다고 해 보자. 우리가 OptionT의 EitherT의 Future 식으로 안에서부터 밖으로 이 모양을 만들어 보자. 그러나 우리는 한줄에 이걸 만들지 못하는게, EitherT는 3개의 타입 파라미터가 있기 때문이다.

case class EitherT[F[_], E, A](stack: F[Either[E, A]]) {
  // etc...
}

세 파라미터는 다음과 같다.

F[_]는 스택 밖의 모나드이다. (안쪽은 Either가 들어간다) (F[_] is the outer monad in the stack (Either is the inner);)

• E는 Either의 에러 타입이다. (E is the error type for the Either;)

• A는 Either의 결과 타입이다. (A is the result type for the Either.)

 

이제, 우리는 Future, Error, A를 입력으로 받는 EitherT를 위한 타입 에일리어스를 만들어야 한다.

import scala.concurrent.Future
import cats.data.{EitherT, OptionT}

type FutureEither[A] = EitherT[Future, String, A]

type FutureEitherOption[A] = OptionT[FutureEither, A]

우리의 거다란 스택은 3개의 모나드를 포함하며 map flatMap 메소드는 세 레이어의 추상화를 거치게 된다.

import cats.instances.future._ // for Monad
import scala.concurrent.Await
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._

val futureEitherOr: FutureEitherOption[Int] =
  for {
    a <- 10.pure[FutureEitherOption]
    b <- 32.pure[FutureEitherOption]
  } yield a + b

(? 왜 이 예시 안되냐.. import cats.implicits._ 를 안해서 그렇다.)

  import scala.language.higherKinds
  import scala.concurrent.Future
  import cats.data.{EitherT, OptionT}
  import scala.concurrent.ExecutionContext.Implicits.global
  import cats.implicits._

  type FutureEither[A] = EitherT[Future, String, A]

  type FutureEitherOption[A] = OptionT[FutureEither, A]

  val futureEitherOr: FutureEitherOption[Int] =
    for {
      a <- 10.pure[FutureEitherOption]
      b <- 32.pure[FutureEitherOption]
    } yield a + b

 

 

만약 모나드 스택을 위해 자주 여러 타입 얼라이어스를 정의하고 있다면, Kind Project 컴파일러 플러그인을 시도해 보는 것도 좋다.

https://github.com/typelevel/kind-projector 스칼라 타입 신텍스를 정의하게 편하게 만들어 준다. 

import cats.instances.option._ // for Monad
// import cats.instances.option._

123.pure[EitherT[Option, String, ?]]
// res7: cats.data.EitherT[Option,String,Int] = EitherT(Some(Right(123)))

모든 타입을 간단하게 만들어주지는 않지만, 꽤 많은 중간 정의 단계를 훨씬 쉽게 만들어준다.

 

 

5.3.3 Constructing and Unpacking Instances

우리가 위에서 봤듯, 우리는 transform된 모나드 스택을 관련 모나드 transformer의 apply 메소드를 사용하거나 pure syntax를 사용해서 생성할 수 있다.

// Create using apply:
val errorStack1 = OptionT[ErrorOr, Int](Right(Some(10)))
// errorStack1: cats.data.OptionT[ErrorOr,Int] = OptionT(Right(Some(10)))
// Create using pure:
val errorStack2 = 32.pure[ErrorOrOption]
// errorStack2: ErrorOrOption[Int] = OptionT(Right(Some(32)))

우리가 모나드 transformer stack을 완성한 후에, 우리는 그의 value 메소드를 사용해서 unpack할 수 있다. 우린 그 후 개개인의 모나드를 자주 사용하던 방식으로 다룰 수 있다.

// Extracting the untransformed monad stack:
errorStack1.value
// res11: ErrorOr[Option[Int]] = Right(Some(10))

// Mapping over the Either in the stack:
errorStack2.value.map(_.getOrElse(-1))
// res13: scala.util.Either[String,Int] = Right(32)

value를 한번 쓸 때 마다 monad transformer가 하나 unpack 된다. 만약 large stack을 풀기 위해선 한번 이상 사용해야 할 수도 있다. 예를 들어, FutureEutherOption stack을 Await 하기 위해서는 우리는 두번 value를 호출해야 한다. 

  import scala.concurrent._
  import scala.concurrent.duration._
  
  println(futureEitherOr)
  // OptionT(EitherT(Future(<not completed>)))
  
  val intermediate = futureEitherOr.value
  // intermediate: FutureEither[Option[Int]] = EitherT(Future(Success(Right(Some(42)))))
  
  val stack = intermediate.value
  // stack: scala.concurrent.Future[Either[String,Option[Int]]] = Future(Success(Right(Some(42))))
  
  println(Await.result(stack, 10.second))
  // Right(Some(42))

 

5.3.4 Default Instances

많은 Cats의 모나드는 그의 transformer과 Id 모나드를 사용하여 정의되어 있다. 이것은 monad와 transformer의 API들이 동일하다는 점0에서 안정적이다. Reader, Writer, State는 전부 다음과 같이 정의되어 있다.

type Reader[E, A] = ReaderT[Id, E, A] // = Kleisli[Id, E, A]
type Writer[W, A] = WriterT[Id, W, A]
type State[S, A] = StateT[Id, S, A]

다른 경우에는, 모나드 transformers는 대응되는 모나드와 분리되어 작성되어 있다. 이 경우, transformer의 메소드는 모나드의 메소드를 복제하려고 한다. 예를 들어, OptionT는 getOrElse를, EitherT는 fold,bimap,swap, 등등의 유용한 메소드들을 정의하고 있다.

 

5.3.5 Usage Patterns

넓게 퍼져있는 monad transformers의 사용은 떄로는 어려운게, 왜냐하면 그들은 모다느를 미리 정의된 방법으로 융합 시키기 때문이다. 조심스레 생각하지 않으면, 서로 다른 context에서 다른 configurations에서 unpack / repack을 하게 될 수도 있다.

우리는 이것을 다양한 방법으로 대응할 수 있다. 한 방법으로는, 하나의 "super stack"을 만들고 우리의 코드베이스로 그것을 붙이는 것이다. 이것은 코드가 간단하며 본질적으로 통일되어 있을 때 효과적이다.  예를 들어, 웹 어플리케이션에서 우리는 모든 request handler들이 비동기적이며 같은 HTTP error code를 공유한다는 것을 알고 있다. 우리는 코드 전부에 걸쳐 에러를 표현하는 커스텀 ADT를 디자인 하고 Future / Either을 섞어 사용 할 수 있다.

sealed abstract class HttpError
final case class NotFound(item: String) extends HttpError
final case class BadRequest(msg: String) extends HttpError

// etc...
type FutureEither[A] = EitherT[Future, HttpError, A]

"super stack"방법은 규모가 커질수록 실패하고, 더 다른 맥락의 코드들이 만들어짐에 따라서 더욱 코드 베이스들이 다차원적이 된다. 이런 경우에 monad transformer을 사용하는 더 말이 되는 또다른 디자인 패턴으로는, monad transformer을 local 의 "glue code"로 사용하는 것이다. 우리는 transformed 되지 않은 stack를 모듈 바운더리에서 찾고, locally 동작할 수 있게 transform하고, 그들을 passing하기 전에 untransform한다. 이것은 각 코드의 모듈이 어떤 transformer을 사용할 지 정할 수 있게 해 준다.

import cats.data.Writer

type Logged[A] = Writer[List[String], A]

// Methods generally return untransformed stacks:
def parseNumber(str: String): Logged[Option[Int]] =
  util.Try(str.toInt).toOption match {
    case Some(num) => Writer(List(s"Read $str"), Some(num))
    case None => Writer(List(s"Failed on $str"), None)
  }

// Consumers use monad transformers locally to simplify composition:
def addAll(a: String, b: String, c: String): Logged[Option[Int]] = {
  import cats.data.OptionT

  val result = for {
    a <- OptionT(parseNumber(a))
    b <- OptionT(parseNumber(b))
    c <- OptionT(parseNumber(c))
  } yield a + b + c

  result.value
}

// This approach doesn't force OptionT on other users' code:
val result1 = addAll("1", "2", "3")
// result1: Logged[Option[Int]] = WriterT((List(Read 1, Read 2, Read3),Some(6)))

val result2 = addAll("1", "a", "3")
// result2: Logged[Option[Int]] = WriterT((List(Read 1, Failed on a),None))

불행히도, 모나드 transformer을 사용하면서 하나의 방법으로 전부 다 커버되는 마법같은 방법은 없다. 다양한 요인을 동시에 고려하는 것이 중요할 것이다. team 의 사이즈, 코드베이스의 복잡도, 등등 말이다. 모나드 transformers가 핏이 잘 맞는지 등등을 동료들로부터 의견 듣는것이 좋아 보인다.

 

5.4 Exercise: Monads: Transform and Roll Out

Autobots (위장을 잘 하는 것으로 알려진)는 그들의 team mates의 power levels을 전투 중에 요청하는 메시지를 주기적으로 보낸다. 이것은 그들이 전략을 설립하고 파괴적인 공격을 발생시키게 해준다. 메시지 전송 메소드는 다음과 같이 생겼다.

def getPowerLevel(autobot: String): Response[Int] = ???

무슨 설명 설명. 실패가 가능하므로 Response는 Future[Either[String,A]] 모양이다.

type Response[A] = Future[Either[String, A]]
// defined type alias Response

 

Optimus Prime은 중첩된 for comprehension에 지쳤다. 모나드 transformer을 사용하여 Response를 재 작성해서 도와주자.

  type Response[A] = EitherT[Future,String,A]

이지겜~

이제 가상의 동맹의 데이터를 검색하기 위해 getPowerLevel을 구현하여 코드를 테스트 해 보자. 우리가 쓸 데이터는 저기 있다.

val powerLevels = Map(
  "Jazz" -> 6,
  "Bumblebee" -> 8,
  "Hot Rod" -> 10
)

Autobot이 powerLevels map에 있지 않다면, 찾을 수 없다고 에러 메시지를 찍자. 좋은 효과를 위해 메시지에 이름을 포함하자.

주의 할 점이

        Future(Either.right(avg))
        // type : Future[Either[???,A]]

이런식으로 만들면 안된다 ㅠ 이미 우리가 T type을 사용하여 선언을 했기 때문이다.

  def getPowerLevel(autobot: String): Response[Int] = {
    powerLevels.get(autobot) match {
      case Some(avg) => {
        EitherT.right(Future(avg))
      }
      case None => {
        EitherT.left(Future(s"$autobot unreachable"))
      }
    }
  }

이렇게 해야 한다.

 

두 autobots는 두 power level이 합쳐서 15가 넘으면 특별한 움직임을 수행할 수 있다. canSpecialMove라는 두번째 method를 만들어서 체크해줘라. 만약에 하나라도 발견이 불가능하면 error message를 뱉어라.

와,, 난 무식하게 이렇게 했는데

  def canSpecialMove(ally1: String, ally2: String): Response[Boolean] = {
    powerLevels.get(ally1) match {
      case Some(avg) => {
        powerLevels.get(ally2) match {
          case Some(avg2) => {
            EitherT.right(Future(avg + avg2 > 15))
          }
          case None => EitherT.left(Future(s"$ally2 unreachable"))
        }
      }
      case None => {
        EitherT.left(Future(s"$ally1 unreachable"))
      }
    }
  }

이런 방법이 있네..!

  def canSpecialMove(ally1: String, ally2: String): Response[Boolean] = {
    for {
      a <- getPowerLevel(ally1)
      b <- getPowerLevel(ally2)
    } yield (a+b)>15
  }

마지막으로, tacticalReport라는, 두개의 ally 이름을 받고 그들이 특별한 움직임을 수행할 수 있는지 메시지를 프린트하는 메소드를 작성하라.

  def tacticalReport(ally1: String, ally2: String): String = {
    val future = canSpecialMove(ally1, ally2).value
    Await.result(future,10.second) match {
      case Right(a) =>{
        if(a) s"$ally1 $ally2 good"
        else "nono...."
      }
      case Left(a) => {
        a
      }
    }
  }

나는 일케 했다~

아래의 명령어로 테스트 해 봐라.

tacticalReport("Jazz", "Bumblebee")
// res28: String = Jazz and Bumblebee need a recharge.
tacticalReport("Bumblebee", "Hot Rod")
// res29: String = Bumblebee and Hot Rod are ready to roll out!
tacticalReport("Jazz", "Ironhide")
// res30: String = Comms error: Ironhide unreachable

 

5.5 Summary

stack 중첩 풀기

transformer 짱짱

T 클래스들 제공

굿.

flatMap으로 여러개 엮는거 잘 배웠다 여태!

 

 

 

 

오개념 / 잘못된 번역에 대한 제보는 언제나 환영 입니다.