투명한 기부를 하고싶다면 이 링크로 와보세요! 🥰 (클릭!)
바이낸스(₿) 수수료 평생 20% 할인받는 링크로 가입하기! 🔥 (클릭!)
2020/02/11 - [Programmer Jinyo/Scala & AKKA] - scala with cats 책 읽으면서 필기(하다보니 번역급) Chapter 2
위 글에서 이어지는 글입니다.
로 이어집니다.
Funtors
이 챕터에서는 functors를 살펴 볼 것이다. 펑터는 추상적 개념인데, 우리에게 컨텍스트(List, Option등이 컨텍스트이다) 안에서 연산의 시퀀스를 표현하는 것을 허락 해 준다.(컨텍스트는 직관적인 이해가 힘들면 어떤 원소를 가지고 있는 wrapper이라고 생각해도 크게 벗어나지 않는다.) 펑터는 그 스스로는 그렇게 유용하지 않지만, 몇몇 스페셜 케이스인 monads나 applicative functors에서 Cats의 추상화와 일반적으로 쓰인다.
3.1 Examples of Functors
비공식적으로, 펑터는 map method가 있다면 펑터라고 봐도 무방하다. 아마 이런 타입을 많이 알고 있을 것이다. Option, List, Either등등
우리는 보편적으로 Lists를 순회할 때 map을 만나게 된다. 그렇지만 펑터를 이해하기 위해서 우리는 map 메소드를 다르게 바라봐야 한다.그것을 순회한다라는 느낌보다, 모든 안의 원소들의 값들을 한번에 변환시킨다고 이해하는것이 맞다. 우리는 적용할 펑터를 특정하고, 모든 아이템에 확실히 매핑되게 한다. 값은 바뀌지만 이 전체 구조는 바뀌지 않는 것!
List(1, 2, 3).map(n => n + 1)
// res0: List[Int] = List(2, 3, 4)
비슷하게, 우리가 옵션을 map할때, Some None은 그대로 놔두고 변경한다. Either도 왼쪽 오른쪽 컨텍스트는 그대로 놔두고 안의 원소만 변경한다. 3.1 그림 보면 직관적이다. 다른 데이터 타입들에 걸쳐있는 맵의 동작을 표현한 것.
맵은 컨텍스트의 구조를 변경하지 않은 채로 남겨두기 때문에, 초기 데이터 구조의 데이터에 대해 여러 연산을 순차적으로 여러번 호출할 수 있다.
List(1, 2, 3).
map(n => n + 1).
map(n => n * 2).
map(n => n + "!")
// res1: List[String] = List(4!, 6!, 8!)
우리는 map을 iteration pattern이라고 생각하지 않아야 하고 대신 데이터 타입 때문에 발생하는 내부 value들에 대한 복잡한 계산을 무시하며 연속적인 연산을 하는 방법이라고 생각하면 된다.
3.2 More Examples of Functors
List, Option, Either 등의 map methods는 함수를 열심히(?eagerly를 다르게 해석하는게 있나?) 적용한다. 그러나 시퀀싱 연산은 이것보다 더 일반적이다. 다른 방법으로 적용되는 펑터들에 대해서 알아보도록 하자.
Futures
퓨처는 연속적인 비동기적 연산을 큐에 넣어서 이전의 것이 끝나면 그 다음것이 적용될 수 있도록 해주는 펑터이다. 3,2그림에 표시되어있는 그것의 map method의 타입 시그니처를 보면 모두 같은 맥락의 모양이라고 할 수 있다. 그러나 실질적인 동작 자체는 아주 다르다.
만약 우리가 퓨처를 사용할 때면 그것의 초기 상태에 대한 어떠한 보장도 없다. wrap된 연산은 진행중일수도, 끝났을수도, reject되었을 수도있다. 퓨쳐가 끝나면 그 다음 mapping function은 바로 호출된다. 만약 그렇지 않으면 동작하고 있는 몇몇 쓰레드들이 function call을 큐에 넣고 나중에 다시 확인하러 온다. 우리는 언제 그 함수가 호출될지는 모르지만, '어떤 순서로' 호출 될 지는 알고 있다. 이런 방식으로 Future은 List,Option, Either등에서 볼 수 있었던 연속적인 동작을 지원한다.
import scala.concurrent.{Future, Await}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
val future: Future[String] =
Future(123).
map(n => n + 1).
map(n => n * 2).
map(n => n + "!")
Await.result(future, 1.second)
// res3: String = 248!
Futures and Referential Transparency
스칼라의 퓨쳐는 순수한 함수형 프로그래밍에 좋은 예시는 아니라는 것을 기억하자. 왜냐면 참조 투명하지 않기 때문이다.
참조 투명성
https://ko.wikipedia.org/wiki/%EC%B0%B8%EC%A1%B0_%ED%88%AC%EB%AA%85%EC%84%B1
퓨처는 연산 결과를 캐싱하고 우리는 이것을 변경할 방법이 없다. 이 말은 사이드 이펙트가 있는 연산을 퓨처에 감싸게 되면 우리가 예상치 못한 결과를 받아들 수 있다는 것이다. 예를 들어,
import scala.util.Random
val future1 = {
// Initialize Random with a fixed seed:
val r = new Random(0L)
// nextInt has the side-effect of moving to
// the next random number in the sequence:
val x = Future(r.nextInt)
for {
a <- x
b <- x
} yield (a, b)
}
val future2 = {
val r = new Random(0L)
for {
a <- Future(r.nextInt)
b <- Future(r.nextInt)
} yield (a, b)
}
val result1 = Await.result(future1, 1.second)
// result1: (Int, Int) = (-1155484576,-1155484576)
val result2 = Await.result(future2, 1.second)
// result2: (Int, Int) = (-1155484576,-723955400)
우리는 result1과 2가 같은 값을 가지기를 원했다. ? 뭐야 이거 예시가 이상한데?
머 여튼 퓨처 사이드 이펙트가 있으면 race condition이라던지 등등의 여러 상황에서 문제가 발생할 수 있다구~ 오이오이
만약 퓨처가 참조투명하지 않다면, 우리는 다른 데이터 타입을 살펴봐야 할 것이다. 이걸로 해결해보도록 할까?
Functions (?!)
변수가 한개인 함수도 또한 functor이다 ㅋㅋ 이걸 보이기 위해서는 타입 자체를 약간 비틀어 보자. function A=>B는 두개의 타입 파라미터가 있다. 파라미터 타입 A 와 결과 타입 B 가 그것이다. 올바른 모양을 가지도록 강제하기 위해서 우리는 파라미터 타입을 고치고 결과 타입을 바꿔볼 수 있다.
• start with X => A;
• supply a function A => B;
• get back X => B.
우리가 X=>A 를 MyFunc[A]로 alias 한다면, 우리는 이 챕터의 다른 예시에서 이런 패턴을 본 적이 있을 것이다. 그림 3.3을 보자.
• start with MyFunc[A];
• supply a function A => B;
• get back MyFunc[B].
다른 말로, Function1을 통해 mapping over하는 것은 합성함수이다. (그림 3.3)
import cats.instances.function._ // for Functor
import cats.syntax.functor._ // for map
val func1: Int => Double = (x: Int) => x.toDouble
val func2: Double => Double = (y: Double) => y * 2 (func1 map func2)(1) // composition using map
// res7: Double = 2.0
(func1 andThen func2)(1) // composition using andThen
// res8: Double = 2.0
func2(func1(1)) // composition written out by hand
// res9: Double = 2.0
어떻게 이게 우리의 일반적인 연속된 연산들의 패턴과 관련있을 수 있냐? 합성함수는 연속적이다 그치? 우리는 하나의 연산을 처리하는 함수로 시작해서 map을 쓸때마다 우리는 연속적으로 다른 연산을 계산하는 것이다. map자체는 실제로 어떤 연산을 실행시키지는 않고, 다만 우리가 연산들을 거치면서 최종 목적지까지 우리의 파라미터를 보내는 것이다. 그럼 결국 우리는 이것을 Future와 비슷하게 큐에 연산이 들어가서 해결되어 나간다고 볼 수 있다.
val func =
((x: Int) => x.toDouble).
map(x => x + 1).
map(x => x * 2).
map(x => x + "!")
func(123)
// res10: String = 248.0!
Partial Unification
위 예시를 동작하게 하기 위해 우리는 build.sbt에 아래의 컴파일러 동작 옵션을 추가해야 한다.
scalacOptions += "-Ypartial-unification"
그렇지 않으면 컴파일 에러를 맞이하게 될 것이다.
func1.map(func2)
// <console>: error: value map is not a member of Int => Double
// func1.map(func2)
^
어떤 일이 일어나는지 3.8절에서 좀더 자세히 다룬다.
3.3 Definition of a Functor
우리가 지금까지 본 예시는 전부 펑터였다. 연속적인 연산을 캡슐화하는 클래스 말이다. 공식적으로는 functor은 (A=>B) => F[B] 인 map 연산이 정의되어 있는 type F[A] 를 의미한다. 일반적인 타입 차트는 그림 3.4에 있다. Cats는 펑터를 cats.Functor에 있는 타입클래스로 인코딩한다. 그래서, 메소드가 조금 다르게 생겼다. 이친구는 초기 F[A]를 파라미터와 함께 적용할 변환 함수를 받는다. 아래에 단순화된 버전의 정의가 있다.
package cats
import scala.language.higherKinds
trait Functor[F[_]] {
def map[A, B](fa: F[A])(f: A => B): F[B]
}
F[ _ ]와 같은 문법을 본 적이 없다면, 타입 생성자와 higher kinded types에 대해 알아보기 위해 조금 쉬어갈 시간이 된 것 같다 ㅎㅎ.
scala.language 임포트 또한 함께 설명 해 줄 것이다.
Functor Laws
펑터는 우리가 많은 연산을 하나씩 처리하거나, 한번에 연산들을 묶에서 큰 함수로 만들어서 적용해버리거나 같은 의미가 되도록 보장한다. 단 다음의 법칙을 만족할 때만 이것을 만족함을 기억하자.
Identity: identity function (항등함수)를 부르면 아무것도 안한거랑 똑같다.
fa.map(a => a) == fa
Composition : 두 함수 f 와 g는 f먼저 그리고 g다음 처리해도 결과가 같다.
fa.map(g(f(_))) == fa.map(f).map(g)
3.4 Aside: Higher Kinds and Type Constructors
Kinds는 타입들을 위한 타입이다. (!!!!!) 얘네들은 type에 "구멍"이 몇개가 있는지 설명해준다. 우리는 구멍이 없는 일반적인 타입과 타입을 생성하기 위해 채워야 할 구멍을 가지고 있는 "Type constructors" 를 구분한다.
예를 들어, List는 하나의 구멍을 가진 타입 생성자(type constructors)이다. 우리가 이 구멍을 파라미터를 특정함으로 채우게 된다. 예를 들어 List[Int]나 List[A]와 같이 말이다. 트릭은 타입 생성자와 제네릭 타입을 헷갈리지 않는 것이다. List는 타입 생성자이고 List[A]는 타입이다.
List // type constructor, takes one parameter
List[A] // type, produced using a type parameter
값과 함수에 대한 거의 비슷한 비유가 있다. 함수는 "값 생성자"이다. 그들은 우리가 파라미터를 채워주면 값을 만들어낸다.
math.abs // function, takes one parameter
math.abs(x) // value, produced using a value parameter
스칼라에서 우리는 타입 생성자를 언더바를 사용해서 선언(declare)할 수 있다. 그러나 우리가 한번 선언하고 나면, 우리는 그들을 간단한 식별자로 언급(refer)한다.
// Declare F using underscores:
def myMethod[F[_]] = {
// Reference F without underscores:
val functor = Functor.apply[F]
// ...
}
이것은 함수의 정의에 함수의 파라미터를 명시하며 참조(referring)할 때는 파라미터를 쓰지 않는 것과 비슷하다고 할 수 있다.
// Declare f specifying parameters:
val f = (x: Int) => x * 2
// Reference f without parameters:
val f2 = f andThen f
이러한 타입 생성자에 대한 지식으로 무장한 상태라면, Functor에 대한 Cats의 정의가 우리에게 어떠한 단일-파라미터 타입 생성자라도 인스턴스를 만들 수 있게 한다는 것을 알 수 있다. List, Option, Future, 혹은 MyFunc와 같은 타입 알리아스(alias)같은 것들 말이다.
Language Feature Imports
Higher kinded types는 스칼라에서 advanced language feature로 여겨진다. 타입 생성자를 A[ _ ] 문법으로 선언할 때 마다, 우리는 컴파일러로부터 경고를 없애기 위해 higher kinded type 언어 특성을 활성화 시켜야 한다. 우리는 이것을 위와 같이 "langage import"를 통해서도 할 수 있다.
import scala.language.higherKinds
혹은 아래의 build.sbt에 scalacOptions 를 추가하여도 된다.
scalacOptions += "-language:higherKinds"
우리는 이 책에서 이를 최대한 분명히 드러내기 위해서 language impoort를 사용할 것이다. 실제로는 사실 scalacOptions flag를 선언하는 것이 훨씬 쉽고 코드가 덜 장황해진다.
3.5 Functors in Cats
Cats안에 있는 functor implementation에 대해 살펴보자. 우리는 우리가 모노이드를 위해 했던 것들을 시험 해 볼 것이다.
3.5.1 The Functor Type Class
functor 타입클래스는 cats.Functor이다. 우리는 인스턴스들을 동반객체에 존재하는 Functor.apply 메소드를 통해서 얻어낼 수 있다. 늘 그렇듯, 기본 인스턴스들은 cats.instances 패키지 안에 존재한다.
import scala.language.higherKinds
import cats.Functor
import cats.instances.list._ // for Functor
import cats.instances.option._ // for Functor
val list1 = List(1, 2, 3)
// list1: List[Int] = List(1, 2, 3)
val list2 = Functor[List].map(list1)(_ * 2)
// list2: List[Int] = List(2, 4, 6)
val option1 = Option(123)
// option1: Option[Int] = Some(123)
val option2 = Functor[Option].map(option1)(_.toString)
// option2: Option[String] = Some(123)
Functor은 lift method도 제공하는데, type A => B 로 변환하는 함수를 가지고 functor에서 동작하게 하여 type F[A] => F[B] 로 바뀔 수 있게 함수로 만들어준다.
val func = (x: Int) => x + 1
// func: Int => Int = <function1>
val liftedFunc = Functor[Option].lift(func)
// liftedFunc: Option[Int] => Option[Int] = cats.Functor$$Lambda$116981630828883@722bf240
liftedFunc(Option(1))
// res0: Option[Int] = Some(2)
3.5.2 Functor Syntax
펑터를 위해 제공되는 주된 메소드는 map이다. 이 기능을 옵션과 리스트들을 가지고 보여주기에는 사실 힘든 감이 있다. 왜냐하면 built-in map method들이 이미 존재하고, 스칼라 컴파일러의 경우 항상 extention method보다 빌트인 메소드를 선호하기 때문이다.우리는 이러한 맥락에서 두개의 예시를 다뤄 볼 것이다.
우선, 함수들에서 이뤄지는 매핑을 살펴보자. 스칼라의 Function1 타입은 map method가 없다. (그건 andThen이라고 불린다) 그래서 naming conflicts가 일어나지 않는다.
import cats.instances.function._ // for Functor
import cats.syntax.functor._ // for map
val func1 = (a: Int) => a + 1
val func2 = (a: Int) => a * 2
val func3 = (a: Int) => a + "!"
val func4 = func1.map(func2).map(func3)
func4(123)
// res1: String = 248!
또다른 예시를 보자. 이번엔 펑터에 걸친 추상화를 진행하여 우리가 어떠한 실제화된 타입을 다루지 않을 것이다.
우리는 어떤 펑터 컨텍스트에 들어있던지간에 숫자를 수식에 적용하는 메소드를 작성할 수 있다.
import scala.language.higherKinds
import cats.Functor
import cats.syntax.functor._ // for map
import cats.instances.option._ // for Functor
import cats.instances.list._ // for Functor
def doMath[F[_]](start: F[Int])(implicit functor: Functor[F]): F[Int] =
start.map(n => n + 1 * 2)
doMath(Option(20))
doMath(List(1, 2, 3))
* 이 문장은 그냥 넋두리기 때문에 굳이 신경써서 읽을 필요 없다.. 순간 번역하다 왜 implicit functor: Functor[F] 인지에 대한 뇌절이 왔는데, (functor 변수를 안쓰니까?) 저 문장의 의미가 그냥 Functor의 타입클래스를 상속받는 F를 찾는다는 의미였다..
이게 어떻게 동작하는지 보려면, cats.syntax.functor에 정의되어 있는 map method를 보자. 여기에 간소화된 버전의 코드가 있다.
implicit class FunctorOps[F[_], A](src: F[A]) {
def map[B](func: A => B)
(implicit functor: Functor[F]): F[B] =
functor.map(src)(func)
}
컴파일러는 built-in map이 사용 가능하지 않을 때라도 이 extension 메소드를 map 메소드에 insert 하는데에 사용할 수 있다.
foo.map(value => value + 1)
foo가 built-in map method가 없다고 가정하면, 컴파일러가 잠재적 에러를 감지하고 FunctorOps안에서 expression을 감싸서 코드를 고치려고 할 것이다.
new FunctorOps(foo).map(value => value + 1)
FunctorOps의 map method는 implicit Functor을 파라미터로 요구한다. 그 말은, 이 코드는 F를 위한 Functor가 scope안에 있어야 한다는 뜻이다. 만약 그렇지 않다면 컴파일 에러가 날 것이다.
final case class Box[A](value: A)
val box = Box[Int](123)
box.map(value => value + 1)
// <console>:34: error: value map is not a member of Box[Int]
// box.map(value => value + 1)
// ^
3.5.3 Instances for Custom Types
우리는 어떤 것의 map method를 정의함으로써 펑터를 정의할 수 있다. 아래에 option을 위한 펑터의 예시가 있다 (cats.instances에 이미 있기는 하지만 말이다). 구현은 별게 없다. 간단하게 Option의 map method를 부르면 된다.
implicit val optionFunctor: Functor[Option] = new Functor[Option] {
def map[A, B](value: Option[A])(func: A => B): Option[B] =
value.map(func)
}
( ...? 보여주기 위한 건가? 왜 이미 있는걸 이렇게 쓰지..? )
때때로, 우리는 우리의 인스턴스 안에 의존성을 주입해야 할 때가 있다. 예를 들어, 우리가 퓨처를 위한 커스텀 펑터를 정의해야 한다면 (또다른 가상의 예시로는 Cats에서 cats.instances.future을 제공하고 있다) 우리는 future.map에 있는 implicit ExecutionContext 파라미터를 처리해야 한다. 우리는 functor.map에 추가적인 파라미터를 추가할 수 없으므로 인스턴스를 만들 때 의존성을 처리해야 한다.
의존성 주입이란 ?
https://ko.wikipedia.org/wiki/%EC%9D%98%EC%A1%B4%EC%84%B1_%EC%A3%BC%EC%9E%85
import scala.concurrent.{Future, ExecutionContext}
implicit def futureFunctor(implicit ec: ExecutionContext): Functor[Future] = new Functor[Future] {
def map[A, B](value: Future[A])(func: A => B): Future[B] = value.map(func)
}
ExecutionContext : 쉽게 말하면 쓰레드 풀 관리자 같은거다. 암시적으로 선언 해 놓으면, Future을 실행할 때 암시적으로 넘어간다. 위의 예시의 경우 value.map(func)(ec) 의 상태로 넘어간다.
스칼라 동시성 프로그래밍을 위한 Execution Context
https://hamait.tistory.com/768
우리가 Future을 위한 Functor을 불러올 때, 직접적으로 Functor.apply를 쓰거나 아니면 map extention method를 통해서던지 간에, 컴파일러는 futureFunctor를 call 시점에서 ExecutionContext를 위해서 implicit resolution과 재귀적인 탐색을 통해 위치 시킬 것이다. 아래에, expansion이 대충 어떻게 생겼는지 보자.
// We write this:
Functor[Future]
// The compiler expands to this first:
Functor[Future](futureFunctor)
// And then to this:
Functor[Future](futureFunctor(executionContext))
3.5.4 Exercise: Branching out with Functors
아래의 이진 트리 데이터 타입을 위한 functor을 만들어 봐라. 줄기와 잎에 대해서 기대했던 대로 동작하는지 확인하라.
sealed trait Tree[+A]
final case class Branch[A](left: Tree[A], right: Tree[A])
extends Tree[A]
final case class Leaf[A](value: A) extends Tree[A]
음.. 좀 헤맸는데 내 답은 아래와 같다
import cats.Functor
sealed trait Tree[+A]
final case class Branch[A](left: Tree[A], right: Tree[A]) extends Tree[A]
final case class Leaf[A](value: A) extends Tree[A]
object FunctorInstances {
implicit val TreeFunctor : Functor[Tree] = new Functor[Tree] {
override def map[A, B](fa: Tree[A])(f: A => B): Tree[B] = {
fa match {
case x : Branch[A] => Branch(map(x.left)(f),map(x.right)(f))
case x : Leaf[A] => Leaf(f(x.value))
}
}
}
}
import FunctorInstances._
import cats.implicits._
println(Branch(Branch(Leaf(3),Leaf(4)),Leaf(5)))
// implicitly[Functor[Tree]].map(Branch(Branch(Leaf(3),Leaf(4)),Leaf(5))) {
// x => x + 1
// }
val tree: Tree[Int] = Branch(Branch(Leaf(3),Leaf(4)),Leaf(5))
println(tree.map(_ * 2))
3.6 Contravariant and Invariant Functors
우리가 여태까지 본 대로, Functor의 map method는 chain에 변환을 '이어붙인다' 라고 바라 볼 수도 있다. 이제 우리는 두개의 다른 타입 클래스들을 볼 건데, 하나는 chain에 대한 선행 연산을 나타내며, 하나는 양방향 체인 연산의 제작을 나타낸다. 이것은 각각 contravariant 와 invariant functors 라고 부른다.
This Section is Optional!
이거 몰라도 모나드 이해하는데에는 지장 없다. 챕터 5 까지는 스킵하고 보려면 봐도 되고 챕터 6에서 다시 돌아와서 공부하면 된다.
3.6.1 Contravariant Functors and the contramap Method
우리의 첫 타입 클래스로 contravariant functor을 볼 것인데, 얘는 체인에 연산을 선행시키는 함수인 contramap이라는 연산을 제공한다. 일반적인 도식은 그림 3.5에 있다.
contramap은 transformation을 표현하는 데이터 타입에만 말이 된다. 예를 들어, 우리는 옵션에 contramap을 적용할 수 없는데 그 이유는 함수 A=>B에 Option[B]안의 값을 반대로 집어넣을 방법이 없기 때문이다. 그러나, 우리는 챕터1에서 다뤘던 Printable 타입클래스에 contramap을 적용할 수 있다.
trait Printable[A] {
def format(value: A): String
}
Printable[A]는 A를 String으로 바꾸는 변환이다. 이것의 contramap method는 function B => A를 적용하고 그 후 새로운 Printable[B]를 만들어내는 것이다.
* 처음엔 헷갈릴 수 있는데, 우리가 원래 가진게 F[A] -> A -> B -> F[B]에 대한 매핑을 가지고 있는 것이다.
trait Printable[A] {
def format(value: A): String
def contramap[B](func: B => A): Printable[B] = ???
}
def format[A](value: A)(implicit p: Printable[A]): String = p.format(value)
3.6.1.1 Exercise: Showing off with Contramap
위에 있는 Printable을 위한 contramap method를 만들어라. 아래의 코드로 시작해서 ??? 를 동작하는 메소드 본체로 바꿔라.
trait Printable[A] {
def format(value: A): String
def contramap[B](func: B => A): Printable[B] =
new Printable[B] {
def format(value: B): String =
???
}
}
도저히 몰라서 답 배낌.
+ 답 배껴도 몰라서 더 고민해서 추가적으로 활용하는 법 까지 달았슴다
trait Printable[A] {
self =>
def format(value: A): String
def contramap[B](func: B => A): Printable[B] = new Printable[B] {
def format(value: B): String = {
self.format(func(value))
}
}
}
object Printable {
def apply[A](implicit ev: Printable[A]):Printable[A] = ev
}
def format[A](value: A)(implicit p: Printable[A]): String = p.format(value)
implicit val intPrintable : Printable[Int] = new Printable[Int] {
override def format(value: Int): String = {
value.toString()
}
}
println(Printable[Int].contramap( (x : String) => (x.toInt) ).format("123"))
println(Printable[Int].contramap( (x : Double) => (x.toInt) ).format(3.6))
테스트의 목적으로, 몇 String과 Boolean을 위한 Printable 인스턴스를 정의하자.
implicit val stringPrintable: Printable[String] =
new Printable[String] {
def format(value: String): String =
"\"" + value + "\""
}
implicit val booleanPrintable: Printable[Boolean] =
new Printable[Boolean] {
def format(value: Boolean): String =
if(value) "yes" else "no"
}
format("hello")
// res3: String = "hello"
format(true)
// res4: String = yes
이제 아래의 Box 케이스 클래스를 위한 Printable 인스턴스를 정의하자. Section 1.2.3에 설명 된 대로 이것을 implicit def 로 선언해야 할 것이다.
final case class Box[A](value: A)
완벽한 정의를 첨부터 쓰는것 대신, 이미 존재하는 인스턴스인 contramap을 사용해서 당신의 instance를 만들자.
아래와 같은 것이 동작 해야 한다.
format(Box("hello world"))
// res5: String = "hello world"
format(Box(true))
// res6: String = yes
->
기존에 하던 방식 말고 contramap만을 사용해서 모든것이 가능하다!
implicit def boxPrintable[A](implicit ev: Printable[A]):Printable[Box[A]] = {
ev.contramap[Box[A]](x=>x.value)
}
요로케 만들어 놓으면 알아서 삭 삭 찾아서 Box(true).format을 적용 해 준다. 개쩔G?
어케 콘트라맵(backward방향의 연산)만 정의하면 알아서 포맷(forward 방향의 연산)까지 알아서 해줄까?
우선 Printable[Box[Boolean]].format을 찾는다고 해보자.
그냥 원래 존재하던 implicit val 연산들로부터는 못찾는다.
그러면 Box[Boolean] => Printable안에 정의된 연산들중에 바꿀 수 있는 타입 이 있나 찾아본다. 어라? implicit def중에 Printable[Box[A]]로 가는게 있네! 그러면 Printable[Box[A]]한테 format을 적용할 수 있겠고만~ 이라고 컴파일러가 생각하는 것.
조금 더 디테일하게 보자.
우리가 위에 적은 콘트라맵의 정의이다.
def contramap[B](func: B => A): Printable[B] = new Printable[B] {
def format(value: B): String = {
self.format(func(value))
}
}
Printable[B]를 넘겨줄 수 있는 이유는?
func를 적용한 후에 그 결과값을 format에 넣어주는것으로 contramap안에서 Printable을 다시 정의하고 있다.
이걸 정의를 해 줬으니까? Printable[B]를 리턴할 수 있는 거겠죠?
요 상황에서는 결국 format[A]를 호출하고 있게 되는 것이다.
굿.
만약에 박스 안에 Printable type이 없다면 format을 부르는 과정에서 오류가 날 것이다.
format(Box(123))
// <console>:21: error: could not find implicit value for parameter p: Printable[Box[Int]]
// format(Box(123))
// ^
3.6.2 Invariant functors and the imap method
Invariant functors 는 map과 imap의 합성과 비슷한 것이며, imap이라고 불리는 메소드를 구현한다. map이 새로운 타입클래스 인스턴스들을 chain에 function을 이어붙여서 생성하고, contramap은 chain 앞에 연산을 붙여서 그들을 생성하면, imap 은 그들을 한 쌍의 양방향 변환으로 묶어서 생성한다.
이것의 가장 직관적인 예시로는 어떤 데이터 타입의 인코딩과 디코딩을 표현하는 타입클래스 (가령, Play JSON's format and scodec's Codec) 이다. 우리는 우리의 코덱을 printable을 String의 인코딩과 디코딩을 지원하게 업그레이드 하면서 만들어 볼 수 있다.
trait Codec[A] {
def encode(value: A): String
def decode(value: String): A
def imap[B](dec: A => B, enc: B => A): Codec[B] = ???
}
def encode[A](value: A)(implicit c: Codec[A]): String = c.encode(value)
def decode[A](value: String)(implicit c: Codec[A]): A = c.decode(value)
imap을 위한 도식화 자료는 그림 3.6에 있다! 만약에 Codec[A]와 함수 A=>B , B=>A 쌍이 있다면, imap메소드는 Codec[B]를 만들어낸다.
실제 유즈케이스를 보면, 우리가 기본적인 Codec[String]이 있다고 해 보자. 얘의 인코드와 디코드는 둘다 아무 일도 하지 않는다(no-op).
implicit val stringCodec: Codec[String] = new Codec[String] {
def encode(value: String): String = value
def decode(value: String): String = value
}
우리는 stringCodec의 여러 타입을 위한 imap을 만들면서 많은 유용한 Codec들을 만들 수 있다.
implicit val intCodec: Codec[Int] = stringCodec.imap(_.toInt, _.toString)
implicit val booleanCodec: Codec[Boolean] = stringCodec.imap(_.toBoolean, _.toString)
Coping with Failure
우리의 Codec 타입클래스가 실패에 대한 대비가 되어있지 않다는 것을 알아두자. 우리가 우리의 모델이 더욱 섬세하게 관계되어있길 원한다면 lenses와 optics를 봐봐라.
Optics는 이 책에서 다루는 내용을 넘어간다. 그러나 http://julien-truffaut.github.io/Monocle/에 좋은 참고 자료가 있다.
3.6.2.1 Transformative Thinking with imap
위에서 설명한 imap method Codec을 구현 해 봐라.
요런 느낌인데
1번을 구현해놓은게 Codec[A] 이다.
우리는 Codec[B]를 구현하므로 인해서 이미 구현된 Codec[A]의 효능을 누리고자 하는 것이다.
trait Codec[A] {
self =>
def encode(value: A): String
def decode(value: String): A
def imap[B](dec: A => B, enc: B => A): Codec[B] = new Codec[B] {
override def encode(value: B): String = {
self.encode(enc(value))
}
override def decode(value: String): B = {
dec(self.decode(value))
}
}
}
그러므로 요로케 조지면 된다.
너의 imap method이 Double을 위한 Codec을 만들 수 있음을 보여라
//만약에 동반객체 안만들었으면 만들어주시고~
object Codec {
def apply[A](implicit ev:Codec[A]):Codec[A] = {
ev
}
}
//더블 코덱 만들고
implicit val doubleCodec: Codec[Double] = stringCodec.imap(x=>(x.toDouble),x=>(x.toString()))
//아래 친구가 String인지 확인해주기!
Codec[Double].encode(3.6)
위처럼 조졌다.
마지막으로, 아래의 박스 타입을 위한 코덱을 조져바라
case class Box[A](value: A)
정답은~아래와 같다
(맨첨에 implicit val로 적어놓고 왜안되나 했네 ㅠ_ㅠ..)
implicit def BoxCodec[A](implicit ev:Codec[A]): Codec[Box[A]] = {
ev.imap(x=>Box(x),x=>x.value)
}
println(encode(123.4))
// res0: String = 123.4
println(decode[Double]("123.4"))
// res1: Double = 123.4
println(encode(Box(123.4)))
// res2: String = 123.4
println(decode[Box[Double]]("123.4"))
// res3: Box[Double] = Box(123.4)
What's With the Names?
contravariance, invariance, covariance의 용어에는 어떤 연관이 있고 이 functor들은 뭐가 다를까?
1.6.1절을 되돌아보면, variance는 subtyping에 영향을 받는데, 근본적으로 이미 있는 한 타입의 값을 코드를 고치지 않으면서 다른 타입으로 대체하는 것이다.
subtyping은 conversion으로 볼 수도있다. 만약 B가 A의 서브타입일 경우, 우리는 B를 A로 변경할 수 있다.
동일한 맥락에서, A=>B가 정의 되어 있으면, B가 A의 서브타입이라고 할 수 있다. 기본적인 covariant functor가 바로 이것이다. F가 covariant functor이라면, 우리가 F[A]를 가지고 있을 때 A=>B변환이 있다면 우리는 항상 F[B]를 만들어낼 수 있다.
contravariant functor은 반대의 경우를 잡아낸다. 만약 F가 covariant functor이라면, F[A]가 있고 B=>A가 있다면 우리는 F[B]를 만들어 낼 수 있다.
마지막으로, invariant functors는 F[A]를 F[B]로 가게 하는데에 A=>B와 B=>A를 동시에 쓰는거다.
3.7 Contravariant and Invariant in Cats
이제 Cats안에 cats.Contravariant와 cats.Invariant 타입클래스들에 있는 contravariant와 invariant functors에 대해서 살펴보자. 아래에 간단한 버전의 코드가 있다.
trait Contravariant[F[_]] {
def contramap[A, B](fa: F[A])(f: B => A): F[B]
}
trait Invariant[F[_]] {
def imap[A, B](fa: F[A])(f: A => B)(g: B => A): F[B]
}
3.7.1 Contravariant in Cats
우리느 Contravariant.apply 메소드를 사용해서 Contravariant의 인스턴스를 불러올 수 있다. Cats는 Eq.Show,Function1등을 포함해서, 파라미터를 사용하는 데이터 타입들을 위한 instances를 제공한다. 아래에 예시가 있다.
import cats.Contravariant
import cats.Show
import cats.instances.string._
val showString = Show[String]
val showSymbol = Contravariant[Show].contramap(showString)((sym: Symbol) => s"'${sym.name}")
showSymbol.show('dave)
// res2: String = 'dave
contramap은 F[A]를 주고 B=>A를 주면 F[A]=>F[B]를 제공해준다 ! (걍 복습겸)
더 쉽게, 우리는 cats.syntax.contravariant를 사용할 수 있는데, 얘네는 contramap extension method를 제공한다.
import cats.syntax.contravariant._ // for contramap
showString.contramap[Symbol](_.name).show('dave)
// res3: String = dave
3.7.2 Invariant in Cats
다른 타입들에 걸쳐, Cats는 Monoid를 위한 Invariant인스턴스를 제공한다. 3.6.2절의 Codec 예제와는 조금 다르다. 기억을 더듬어 보면 모노이드는 다음과 같아 보였을 것이다.
package cats
trait Monoid[A] {
def empty: A
def combine(x: A, y: A): A
}
우리가 스칼라 symbol type에 대한 monoid를 만들기 원한다고 생각 해 보자. Cats는 Symbol을 위한 Monoid를 제공하지 않지만 비슷한 타입인 String에 대한 Monoid는 제공한다. 우리는 우리의 메소드 없이 empty string에 놓여있는 새로운 semigroup을 작성할 수 있고, combine method를 다음과 같이 작동하게 해보자.
1. 두 symbol 파라미터를 받아들인다.
2. String으로 변환한다.
3. Monoid[String]을 사용해서 합치자.
4. Symbol로 재 변환해서 리턴한다.
우리는 combine을 imap을 사용해서 구현할 수 있고, String => Symbol과 Symbol => String 함수를 파라미터로 넘길 수 있다. 여기에 cats.syntax.invariant에서 제공되는 imap 확장 메소드를 사용해 작성된 코드가 있다.
import cats.Monoid
import cats.instances.string._ // for Monoid
import cats.syntax.invariant._ // for imap
import cats.syntax.semigroup._ // for |+|
implicit val symbolMonoid: Monoid[Symbol] = Monoid[String].imap(Symbol.apply)(_.name)
Monoid[Symbol].empty
// res5: Symbol = '
'a |+| 'few |+| 'words
// res6: Symbol = 'afewwords
약간의 개인적 해석을 덧붙이자면, implicit val을 선언했으니까, Monoid[Symbol]을 Monoid[String]머시기 저시기로 바꿔줄 수 있으면 바꿔주는데, imap의 경우 f: A=>B / B=>A 를 동시에 주면 String의 Monoid에서 사용 가능한 친구들을 Monoid[Symbol]에서 사용할 수 있게해주므로 해당 함수들을 보여준 것. Symbol.apply 는 String => Symbol 함수이고, Symbol.name 함수는 Symbol => String이니까 맞게 적용된 것이다.
3.8 Aside: Partial Unification
3.2절에서 우리는 요상한 컴파일 에러를 봤을 것이다. 아래 코드는 -Ypartial-unification 컴파일러 flag가 활성화 되어 있었다면 컴파일 되었을 것이다.
import cats.Functor
import cats.instances.function._ // for Functor
import cats.syntax.functor._ // for map
val func1 = (x: Int) => x.toDouble
val func2 = (y: Double) => y * 2
val func3 = func1.map(func2)
// func3: Int => Double = scala.runtime.AbstractFunction1$$Lambda$7404/929670420@4e92f350
그치만.... 만약 flag가 없었다면? f ! l ! a ! g !
val func3 = func1.map(func2)
// <console>: error: value map is not a member of Int => Double
// val func3 = func1.map(func2)
^
명확하게, partial unification은 옵셔널한 컴파일러 동작같은 것인데, 어떤 코드가 컴파일 될지 안될지를 정해준다. 이것에 대해 조금 더 알아보고 조금 더 잘 깨달을 필요가 있다.
3.8.1 Unifying Type Constructors
위의 func1.map(func2)를 컴파일 하기 위해서는 , 컴파일러가 Function1을 위한 Functor을 찾아야 한다. 그러나 Functor은 하나의 파라미터만 가진 타입 생성자만에 영향을 미친다.
trait Functor[F[_]] {
def map[A, B](fa: F[A])(func: A => B): F[B]
}
그리고 Function1은 타입 파라미터가 두개이다. (function argument와 결과 타입 각각을 하나씩 받는다.)
trait Function1[-A, +B] {
def apply(arg: A): B
}
컴파일러는 Functor로 넘기기 위한 올바른 타입 생성자 종류로 두개의 파라미터를 가진 Function1을 고쳐야 한다. 두가지 옵션이 있을 것이다.
(아까의 Int -> A -> Double 을 올바르게 적용하기 위해서 A -> B -> C의 셋 다 미정인 과정에서 미리 Fix된 타입을 정해버리기)
type F[A] = Int => A
type F[A] = A => Double
우리는 위의 Int => A로 변경해야 한다는 것을 안다. 그러나 예전 버전의 스칼라 컴파일러는 이 추론(inference)을 할 능력이 없었다. 이것이 서로 다른 arity들이 있는 타입 생성자를 합치는 것을 못하게 했다. 이제 이게 고쳐지기는 했다. "build.sbt"에 flag option 추가를 하기는 해야 하지만 말이다.
(Arity는 함수나 연산에서 사용되는 함수의 인자 또는 연산자의 개수를 의미한다)
3.8.2 Left-to-Right Elimination
스칼라 컴파일러에서의 부분 결합 함수는 파라미터를 왼쪽에서 오른쪽으로 고쳐나가면서 적용된다. 위의 예시를 가지고 설명하자면, Int => Double의 Int를 고쳤고, 함수를 위한 Functor 를 Int => ? 형태로 바꿔부렸다.
type F[A] = Int => A
val functor = Functor[F]
이 왼쪽에서 오른쪽으로 소거하는 작업이 많은 일반적인 상황에서 쓰인다. 이는 Function1이나 Either등의 Functor에도 마찬가지이다.
import cats.instances.either._ // for Functor
val either: Either[String, Int] = Right(123)
// either: Either[String,Int] = Right(123)
either.map(_ + 1)
// res2: scala.util.Either[String,Int] = Right(124)
그러나, 좌에서 우로 가는 소거가 항상 올바른 선택은 아닐 수 있다. 하나의 예시로 http://www.scalactic.org/ 에 있는 타입인 Or type인데, 이 친구는 좌 편향 되어 있는 Either과 같은 친구이다.
type PossibleResult = ActualResult Or Error
또 다른 예시는 Function1을 위한 Contravariant functor이다.
Function1을 위한 covariant Functor은 andThen-style 좌에서 우로 가는 함수 합성을 구현 하는데, Contravariant functor은 우에서 좌로 가는 함수 합성을 구현한다. 다른 말로, 아래의 표현은 동일하다는 것이다.
val func3a: Int => Double = a => func2(func1(a))
val func3b: Int => Double = func2.compose(func1)
// Hypothetical example. This won't actually compile:
val func3c: Int => Double = func2.contramap(func1)
맨 마지막 줄은 가상의 예시이다. 만약 진짜로 시도하려고 한다면, 컴파일이 안 될 것이다.
import cats.syntax.contravariant._ // for contramap
val func3c = func2.contramap(func1)
// <console>:27: error: value contramap is not a member of Double => Double
// val func3c = func2.contramap(func1)
// ^
여기에서의 문제는, Function1을 위한 Contravariant 는 리턴 타입에 대한 정보는 보존하면서, 파라미터 타입 변환은 보존한다. 이는 아래 그림 3.7에서 보이듯이, 오른쪽에서 왼쪽으로 가는 타입 파라미터 소거를 요구한다. (A 와 B=>A 의 결과가 B 이다!)
type F[A] = A => Double
컴파일러는 이를 실패하게 되는데, 그 이유는 왼쪽에서 오른쪽으로 가는 방향성 지향 때문이다. 이를 고쳐주려면 Function1의 타입 얼라이어스를 만들어서 파라미터를 뒤집으면 된다.
type <=[B, A] = A => B
type F[A] = Double <= A
만약 우리가 func2를 <= 대신으로 re-type해 주면, 우리는 소거의 방향성도 리셋 해 주며 contramap을 원하던 대로 쓸 수 있게 된다.
val func2b: Double <= Double = func2
val func3c = func2b.contramap(func1)
// func3c: Double <= Int = scala.runtime.AbstractFunction1$$Lambda$7404/929670420@3c871ca1
func2와 func2b의 차이는 방향성만 바뀌었을 뿐.
3.9 Summary
Functors는 연속적인 행위를 표현한다. 우리는 세 타입의 펑터를 다뤘다.
- Regular covariant Functors, with their map method, represent the ability to apply functions to a value in some context. Successive calls to map apply these functions in sequence, each accepting the result of its predecessor as a parameter.
-Contravariant functors, with their contramap method, represent the ability to “prepend” functions to a function-like context. Successive calls to contramap sequence these functions in the opposite order to map.
-Invariant functors, with their imap method, represent bidirectional transformations.
오역 / 틀린 정보 써놓은거 있으면 언제든지 지적 환영입니다~~~