펑터란 리스트나 옵션 등과 같은 컨텍스트(혹은 객체)에서 내부의 값에 연산자(함수)를 편하게 적용할 수 있도록 도와주는 컨텍스트입니다.
펑터 그 자체는 그닥 유용하지 않지만, 모나드 및 applicative functor는 Cats에서 가장 보편적으로 사용되는 추상화 계층입니다.
Functor를 이해하기 위한 예시
러프하게, 펑터란 map 메소드를 사용할 수 있는 '것'들을 의미합니다. (Option, List, Either 등을 생각하면 됩니다.)
우리는 보통 map 메소드를 Lists 내의 원소에 대한 반복 탐색을 수행할 때 처음 접하게 됩니다.
하지만, 펑터를 이해하기 위해서는 이 메소드를 다른 방법으로 생각해볼 필요가 있습니다. List 내의 원소를 순회하기 위해 사용하는 함수라 생각하지 말고, 내부의 값들을 한번에 변화시키기 위한 메소드라고 생각해 봅시다. 우리는 적용할 함수를 제공하고, map 메소드는 그 함수가 각각의 원소(아이템)에 적용되도록 합니다.
값은 바뀌지만, 리스트의 구조는 바뀌지 않습니다.
List(1, 2, 3).map(n => n + 1) // List[Int] = List(2, 3, 4)
즉, 이때 우리는 n ⇒ n+1 함수를 제공해 주었고, map 메소드는 해당 함수를 List 내의 각가의 원소에 적용되도록 해 준 것밖에 없습니다.
Scala with cats 책에 나온 그림을 가져왔습니다.
컨텍스트의 일부가 변경되거나 아예 변경되지 않음을 볼 수 있습니다.
같은 법칙이 각각의 Left, Right 컨텍스트에 대해서 Either에도 적용됩니다.
위 그림은 map 메소드가 컨텍스트의 구조는 변경하지 않고 각각의 다른 데이터 타입에 대해 어떻게 적용되는지 보여줍니다. 따라서 map 메소드가 iteration 패턴을 위해서만 사용된다는 생각을 떨쳐야 더 쉽게 Functor를 이해할 수 있습니다.
Functor의 정의
펑터는 연속적인 계산을 캡슐화(encapsulate)해 주는 클래스라고 생각할 수 있습니다.
일반적으로, 펑터는 (A ⇒ B) ⇒ F[B] 타입의 map 연산자를 가진 타입 클래스 F[A]로 정의됩니다.
Cats 라이브러리에서 Functor는 타입 클래스를 파라미터로 받는 타입 클래스 cats.Functor 로 정의되어 있습니다.
다음은 간소화된 버전의 Functor 정의입니다 :
package cats
import scala.language.higherKinds
trait Functor[F[_]] {
def map[A, B](fa: f[A])(f: A => B): F[B]
}
여기엔 생전 처음 보는 F[_] 라는 문법이 있습니다.
이 문법을 이해하기 위해서는 타입 생성자 및 higher kinded 타입에 대해서 먼저 알아야 합니다.
타입 생성자 Type Constructors 및 Higher Kinded Types
Kind는 타입을 위한 타입이며, 타입의 "빈 칸(holes)"을 의미합니다.
우선, 빈 칸이 없는 일반적인 타입(Regular type)과 타입을 생성하기 위해 빈 칸이 존재하는 타입 생성자(Type constructor)를 구분할 필요가 있습니다.
예를 들면, List는 하나의 빈 칸이 있는 타입 생성자입니다. 우리는 그 빈 칸을 일반적인 타입인 List[Int] 혹은 List[A]를 생성하기 위해 타입 파라미터를 넘겨줍니다.
즉, List는 타입 생성자(Type constructor)이고, List[A]는 일반적인 타입(Regular type)입니다.
List // 타입 생성자로, 하나의 타입 파라미터를 받습니다.
List[A] // 일반적인 타입이며, 타입 파라미터를 통해 생성되었습니다.
위 규칙은 함수와 값을 통해 비슷하게 유추해낼 수 있습니다. 함수들은 "값 생성자"로, 우리가 파라미터를 넘겨주었을 때 비로소 값을 만들어 냅니다.
math.abs // 하나의 파라미터를 받는 함수(값 생성자)
math.abs(x) // 값 파라미터를 통해 생성된 값 (일반적인 값)
스칼라에서는 타입 생성자를 언더스코어(_)를 통해 정의합니다.
한번 정의된 뒤에는 간단하게 식별자를 통하여 참조합니다.
def myMethod[F[_]] = {
val functor = Functor.apply[F] = ???
}
이 형식은 함수를 정의할 때 파라미터를 넣어주고 참조시에는 해당 파라미터를 생략한채로 사용할 수 있는 것과 유사합니다.
val f = (x: Int) => x *2
val f2 = f andThen f
Higher kinded type은 스칼라의 고급 언어 기능에 해당합니다. 타입 생성자를 A[_]와 같은 형식으로 정의하려면 이 기능을 활성화해야 합니다. 크게 두 가지 방법이 있는데, 하나는 language를 import해 주는 것이고 다른 하나는 sbt에서 옵션을 추가하는 방법입니다.
import scala.language.higherKinds
또는
scalacOptions += "-language:higherKinds"
Cats의 Functor
Functor 타입 클래스
펑터 타입 클래스는 cats.Functor 로 정의되어 있습니다. 동반객체에 Functor.apply 메소드를 사용함으로써 인스턴스를 가져올 수 있으며, cats.instances 패키지에 디폴트 인스턴스들이 정의되어 있습니다.
import scala.language.higherKinds
import cats.Functor
import cats.instances.list._
import cats.instances.option._
val list1 = List(1,2,3)
val list2 = Functor[List].map(list1)(_ * 2) //List(2,4,6)
val option1 = Option(123)
val option2 = Functor[Option].map(option1)(_.toString) //Option[String] = Some(123)
펑터는 타입 A⇒B 함수를 펑터를 통해 F[A] ⇒ F[B] 가 되도록 함수로 만들어주는 lift 메소드도 제공하고 있습니다.
val func = (x: Int) => x + 1
val liftedFunc = Functor[Option].lift(func)
liftedFunc(Option(1)) //Option[Int] = Some(2)
Functor Syntax
펑터가 제공하는 핵심적인 메소드는 map 입니다.
Option과 List는 내장 map 메소드를 이미 가지고 있으며, 스칼라 컴파일러는 내장 함수를 사용하는것을 선호하기에 이 둘로 설명하기는 약간 까다롭지만, 함수간의 맵핑을 시작으로 Functor가 제공하는 Syntax를 살펴봅시다.
스칼라의 Function1 타입은 map 함수를 가지고 있지 않습니다. (g(f(x) 기능을 수행하는 andThen 이름으로 같은 동작을 수행합니다.)
import cats.instances.function._
import cats.syntax.functor._
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) //String = 248!
다른 예제를 살펴보겠습니다.
이번에는 특정한 타입을 명시하지 않고 사용하기 위해 펑터를 추상화하여 사용합니다. 이러한 방법을 통해 어떠한 펑터 컨텍스트에 종속되지 않고 숫자에 방정식을 적용하는 메소드를 작성할 수 있습니다.
import cats.instances.option._
import cats.instances.list._
def doMath[F[_]](start: F[Int])
implicit functor: Functor[F]): F[Int] =
start.map(n => n + 1 * 2)
doMath(Option(20)) //Option[int] = Some(22)
doMath(List(1, 2, 3)) //List[Int] = List(3, 4, 5)
어떻게 이런 동작이 가능한지를 알아보기 위해 cats.syntax.functor의 map 메소드 정의를 확인하겠습니다.
다음은 간략한 버전의 코드입니다 :
implicit class functorOps[F[_], A](src: F[A]) {
def map[B](func: A => B)
(implicit functor: Functor[F]) F[B] =
functor.map(src)(func)
}
컴파일러는 확장 메소드를 내장 map 메소드가 없는 인스턴스에 사용합니다.
foo에 내장 map 메소드가 정의되지 않았다고 가정할 때, 컴파일러는 잠재적인 에러를 감지하고 FunctorOps를 사용합니다.
foo.map(value => value + 1)
new FunctorOps(foo).map(value => value + 1)
FunctorOps의 map 함수는 암시적은 Functor를 파라미터로 요구합니다. 즉, 이 코드는 F에 대한 펑터가 스코프에 정의된 경우에만 컴파일됨을 의미합니다. 그렇지 않다면 컴파일러 에러를 마주하게 됩니다.
Instances for Custom Types
우리는 펑터를 map 메소드를 정의함으로써 간단히 정의할 수 있습니다. 다음은 Ooption에 대한 Functor를 생성하는 간략한 예시입니다. (cats.instances에 이미 정의되어 있긴 합니다.)
implicit val optionFunctor: Functor[Option] =
new Functor[Option] {
def map[A, B](value: Option[A])(func A => B): Option[B] =
value.map(func)
}
가끔은 정의해 준 인스턴스에 대한 의존성을 주입해 주어야 할 필요가 있습니다.
예를 들어 만일 Future에 대한 Functor를 생성한다면 (cats.instances.future.map 에 이미 정의되어 있습니다.), future.map에 대한 암시적인 ExecutionContext 파라미터에 대한 고려가 필요합니다. functor.map 에는 추가적인 파라미터를 생성할 수 없으므로, 인스턴스를 생성할 때 추가적인 파라미터를 미리 고려해야 합니다.
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)
}
'Languages > Scala' 카테고리의 다른 글
[Scala Cats] 모노이드와 세미그룹 (0) | 2020.12.16 |
---|---|
[Scala Cats] 타입 공변성/반공변성 다루기 (0) | 2020.12.15 |
[Scala Cats] Cats의 타입 클래스 (0) | 2020.12.07 |
[Scala Cats] 스칼라 타입 클래스 기초 (0) | 2020.11.13 |
[스칼라] 함수 - 기초 (0) | 2020.07.21 |