본문 바로가기

Languages/Scala

[Scala Cats] 펑터 Functors, Higher Kinded Types

반응형

이미지 출처 : Pixabay

펑터란 리스트나 옵션 등과 같은 컨텍스트(혹은 객체)에서 내부의 값에 연산자(함수)를 편하게 적용할 수 있도록 도와주는 컨텍스트입니다.

펑터 그 자체는 그닥 유용하지 않지만, 모나드 및 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 책에 나온 그림을 가져왔습니다.

출처 : Scala with Cats

컨텍스트의 일부가 변경되거나 아예 변경되지 않음을 볼 수 있습니다.

같은 법칙이 각각의 Left, Right 컨텍스트에 대해서 Either에도 적용됩니다.

위 그림은 map 메소드가 컨텍스트의 구조는 변경하지 않고 각각의 다른 데이터 타입에 대해 어떻게 적용되는지 보여줍니다. 따라서 map 메소드가 iteration 패턴을 위해서만 사용된다는 생각을 떨쳐야 더 쉽게 Functor를 이해할 수 있습니다.

Functor의 정의

펑터는 연속적인 계산을 캡슐화(encapsulate)해 주는 클래스라고 생각할 수 있습니다.

일반적으로, 펑터는 (A ⇒ B) ⇒ F[B] 타입의 map 연산자를 가진 타입 클래스 F[A]로 정의됩니다.

출처 : Scala with Cats

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)
	}

 

 

반응형