뜌릅

7장 펑터란 무엇인가? 본문

잡글

7장 펑터란 무엇인가?

TwoCastle9 2023. 1. 8. 18:12
반응형

펑터란?

펑터는 매핑할 수 있는 것이라는 행위를 선언한 타입 클래스를 말한다. 

여기서 "매핑 할 수 있는 것"이라는 것은 list에서 사용한 map과 동일하다. 

 

fun <T, R> Iterable<T>.map(f: (T) -> R): List<R>

map함수는 Iterable한 객체가 가진 T타입의 값을 f 함수에 적용하여 R타입의 값을 얻은후, 이 값을 List객체 안에 넣어서 List<R>을 반환하는 함수이다.

 

즉 펑터는 "리스트 같은 컨테이너형 타입의 값을 꺼내서 입력받은 함수를 적용한 후, 함수의 결괏값을 컨테이너형 타입에 넣어서 반환하는 행위를 선언한 타입 클래스"를 의미한다.  

interface Functor<out A> {
    fun <B> fmap(f : (A) -> B): Functor<B>
}

(코틀린에서 타입 클래스는 인터페이스로 정의한다.)

오버라이드 하는 클래스에서 컨테이너형 타입으로 반환 할 수 있다. fmap 함수는 입력받은 f 함수를 사용해서 A 값을 B로 변환한 후, 펑터에 담아서 Functor<B>를 반환한다. 

 

Maybe Functor

펑트를 왜 어떻게 사용하는 것일까?

펑터를 통하여 메이비(Optional 이라고도 불린다. 어떤값이 있을 수도 없을 수도 있는 컨테이너형 타입이다.) 

주로 어떤 함수의 반환값을 메이비(Optional)로 선언함으로써, 함수의 실패 가능성을 포함하기 위한 목적으로 사용된다 .

sealed class Maybe<out A>: Functor<A> {
	abstract override fun toString(): String
    abstract override fun <B> fmap(f: (A) -> B): Maybe<B>
}

 

Just는 무조건 값을 포함해야 하는 상태를 의미한다. 

fmap함수는 입력받은 함수 f에 적용해서 변환하고 다시 컨테이너형 타입인 Just에 넣어서 반환한다.

data class Just<out A> (val value:A) : Maybe<A>(){
    override fun toString(): String  = "Just($value)"
    override fun <B> fmap(f: (A) -> B): Maybe<B> = Just(f(value))
}

 

Nothing은 값이 없는 상태이다. fmap해도 그대로 Nothing을 호출시킨다. 

object Nothing: Maybe<kotlin.Nothing>() {
    override fun <B> fmap(f: (Nothing) -> B): Maybe<B> = Nothing
    override fun toString(): String = "Nothing"
}

출력은 다음과 같이 일어난다. 

println(Just(10).fmap { it + 10 }) //"Just(20)" 출력
println(Nothing.fmap{a: Int -> a + 10})//"Nothing" 출력

결론적으로 메이비와 리스트는 모두 어떤 값들을 담거나 비어 있는 컨테이너형 타입이다. 

펑터는 타입 생성자에서 컨테이너형 타입을 요구한다. 따라서 어떤 값을 담을 수 있는 타입은 항상 펑터로 만드는 것을 생각해 볼 수 있다. 

펑터의 fmap은 프로그래밍에서 매우 유용하게 사용된다고 한다. 

 

단항 함수 펑터 만들기

펑터 타입 클래스의 타입 생성자는 하나의 매개변수만 가진다. 하지만 함수의 타입은 함수의 매개변수가 여러 개인 경우, 하나 이상의 타입 매개변수를 가질 수 있다. 하지만 특성상, 변경할 수 있는 타입 한개를 제외한 나머지는 고정해야 한다. 왜냐면 매개변수가 한 개인 단항 함수에 대한 펑터를 만드는 것으로 제한하기 때문이다.

 

단항 함수의 타입 생성자는 입력과 출력이 각각 하나씩 존재하므로 타입 매개변수가 두 개이다. 따라서 함수의 경우 입력값은 바꾸지 않고, 출력값만 변경한다. 즉, 입력값이 고정값이다. 

data class UnaryFunction<in T, out R>(val g: (T) -> R):Functor<R>{
    override fun <R2> fmap(f: (R) -> R2): UnaryFunction<T,R2> {
        return UnaryFunction{ x: T -> f(g(x))}
    }
    fun invoke(input: T): R = g(input)
}

sealed가 아닌 이유는 함수의 경우 여러가지 타입을 가질 필요가 없기 때문이다. 

val f = { a: Int -> a + 1 }
val g = { b: Int -> b * 2 }
val fg = UnaryFunction(g).fmap(f)
println(fg.invoke(5)) //11

사실 함수 g에 함수 f를 적용하여 매핑한다는 것은 함수의 합성을 의미한다. fmap이 결국 f와 g를 합성한 것이라는게 보일 것이다.  이를 잘 활용하면 함수 합성을 활용하여 매개변수가 여러 개인 함수도 만들 수 있다.

펑터의 법칙

펑터가 되기 위해서는 두 가지 법칙을 만족해야 한다. 이것을 펑터의 법칙이라 한다. 

모든 펑터의 인스턴스는 다음 두 가지 법칙을 지켜야 한다. 

 

1. 항등 함수(identity function)에 펑터를 통해서 매핑하면, 반환되는 펑터는 원래의 펑터와 같다.

2. 두 함수를 합성한 함수의 매핑은 각 함수를 매핑한 결과를 합성한 것과 같다.

 

펑터 제 1법칙

fmap(identity()) == identity()

1 법칙을 코드로 표현하면 다음과 같다. 

fmap을 호출할 때 항등 함수 id를 입력으로 넣은 결과는 반드시 항등 함수를 호출한 결과와 동일해야 한다. 

항등 함수는 { x -> x } 을 의미한다. 

 

fmap(f compose g) == fmap(f) compose fmap(g)

2 법칙을 코드로 표현하면 다음과 같다. 

함수 f와 g를 먼저 합성하고 fmap 함수의 입력으로 넣은 것과 함수 f을 fmap에 넣어서 얻은 함수와 g를 fmap에 넣어서 얻은 함수를 합성한 결과와 같아야 한다. 

그렇다면 펑터는 왜 펑터의 법칙을 만족하도록 만들어야 할까? fmap 함수를 호출했을 때, 매핑하는 동작 외에 어떤 것도 하지 않는다는 것을 보장한다면, 이러한 예측 가능성은 함수가 안정적으로 동작할 뿐만 아니라 더 추상적인 코드로 확장할 때도 도움이 된다. 

 

 

 

 

 

반응형