/*
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
'Tech > Scala' 카테고리의 다른 글
함수형 프로그래밍 Type Classes: Applicative, Apply (0) | 2024.02.18 |
---|---|
함수형 프로그래밍 Type Class : Semigroupal (1) | 2024.02.14 |
함수형 프로그래밍의 시작점(?), Free Monad in Scala (0) | 2023.10.13 |
Scala Slick 괴문법 <>의 원리를 찾아서 (0) | 2022.01.14 |
Scala의 Case Classes 이해 (0) | 2013.03.09 |