Home 펑터 (Functor, 함자)
포스트
취소

펑터 (Functor, 함자)

Functor 펑터

펑터 (Functor, 함자)


  • 펑터는 매핑할 수 있는 것 이라는 행위를 선언한 타입 클래스
  • 다른 타입을 하나 받아서 구성되는 타입

ex)

1
fun <T, R> Iterable<T>.map(f: (T) -> R): List<R> {}
  • Iterable
    • T라는 타입이 Iterable 이라는 박스에 들어있는데
  • Iterable 박스에서 T를 꺼내서 R로 바꾸고 List 라는 박스에 담는다
    • f: (T) → R
  • List라는 박스에 R이 있다.
    • List
  • 즉, 리스트 같은 컨테이너형 타입의 값을 꺼내서 입력받은 함수를 적용한뒤 함수의 결과값을 컨테이너형 타입에 넣어서 반환하는 행위를 선언하는 타입 클래스 이다.
    • 주의, 펑터 자체는 추상화된 타입 클래스라서 List 같은 구체적 타입이 아니라 List 같이 일반화된 타입을 가져야한다.
    • 따라서 한개의 매개변수를 받는 타입 생성자다.

펑터 선언하기


펑터 타입 클래스를 선언하면 다음과 같다.

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

메이비 펑터 만들기


  • Maybe는 어떤 값이 있을 수도 있고 없을 수도 있는 컨테이녀형 타입이다.
  • 주로 어떤 함수의 반환값을 메이비로 선언함으로써, 함수의 실패 가능성을 포함하기 위한 목적으로 사용된다.
  • null 을 반환하는 대신 메이비를 사용하면 물필요한 if else를 통한 널처리나 예외를 피할 수 있다.
1
💡 자바8에서는 Optional, 코틀린에서는 ? 가 있어서 내부적으로 메이비와 같은 타입은 없다.

하지만 구현 하자면 다음과 같다.

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

Maybe도 펑터다

fmap의 반환 타입만 Maybe로 변경되었는데 이는 fmap 함수를 호출한 이후에 함수의 체이닝을 통해서 Maybe에 정의된 함수들을 사용할 수 있어야하기 때문이다.

Maybe는 값이 있거나 없는 두가지 상태를 가진다.

각 상태를 구현한 클래스를 작성해보자.

1
2
3
4
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)) // 함수의 합성
}

Just는 값을 반드시 포함한 상태

1
2
3
4
5
6
7
8
9
/**
 * [싱글턴]
 * 멤버변수로 상태값을 저장하지않고 메소드만 override 하여 사용하는 클래스의 경우 
 * object 로 상속을 받아 생성자없이 바로 사용가능하도록 만들수 있다.
 */
object Nothing : Maybe<kotlin.Nothing>() {
    override fun toString(): String = "Nothings"
    override fun <B> fmap(f: (kotlin.Nothing) -> B): Maybe<B> = Nothing
}

Nothing은 값이 없는 상태

그렇기 때문에 생성자로부터 어떤 입력도 받지 않는다.

그럼 Maybe 펑터를 사용해보자

1
2
3
4
5
6
7
8
fun main() {
    val result = Just(10).fmap { it + 10 }
    println(result)  // Just(20)

    // 싱글턴이라 생성자 없이 사용
    val result2 = Nothing.fmap { a: Int -> a + 10 }
    println(result2)  // Nothing
}

Just 상자안에 있던 10을 꺼내서 +10을 한뒤 Maybe 상자에 담고 반환했다. → 이것이 바로 펑터다!

Nothing은 fmap 함수에 적용할 값이 없기 때문에 f 함수의 내용에 관계없이 그대로 반환된다.

Maybe, List 이런것들은 결국 모두 어떤 값들을 담거나 비어 있는 컨테이너형 타입이다.

이더 펑터 만들기


  • 메이비는 타입 생성자의 매개변수가 한 개뿐인 타입이였다.
  • 만약 타입 매개변수가 두 개, 혹은 그 이상인 타입을 펑터의 인스턴스로 만드려면 어떻게 해야 할까?
  • Either 타입을 펑터의 인스턴스로 만들어보자

Either는 left 또는 right 타입만 허용하는 대수적 타입이다.

함수 호출이 성공하면 올바른 결과를 right에 담고, 실패하면 left에 담는다.

Either는 일반적으로 right의 값만 변경할 수 있다. 따라서 left값은 생성되는 시점에 고정된다.

1
2
3
sealed class Either<out L, out R> : Functor<R> {
    abstract override fun <R2> fmap(f: (R) -> R2): Either<L, R2>
}

여기서 fmap의 f 함수는 R에 대해서만 적용되고 L은 변경하지 않는다.

Either를 상속한 Left와 Right 값 생성자를 구현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
 * 실패 값 그대로 변환
 */
data class Left<out L>(val value: L) : Either<L, Nothing>() {
    override fun <R2> fmap(f: (Nothing) -> R2): Either<L, R2> = this
}

/**
 * 성공
 */
data class Right<out R>(val value: R) : Either<Nothing, R>() {
    override fun <R2> fmap(f: (R) -> R2): Either<Nothing, R2> = Right(f(value))
}
1
2
3
4
5
6
7
8
9
10
11
12
fun divideTenByN(n: Int): Either<String, Int> = try {
    Right(10 / n)
} catch (e: ArithmeticException) {
    Left("divide by zero ( error : ${e.message} )")
}

fun main() {
		println(divideTenByN(5))    // Right(value=2)
    println(divideTenByN(0))    // Left(value=divide by zero ( error : / by zero ))
    println(divideTenByN(5).fmap { r -> r * 2 })    // Right(value=4)
    println(divideTenByN(0).fmap { r -> r * 2 })    // Left(value=divide by zero ( error : / by zero ))
}

함수의 수행 결과가 Left일 때 fmap 함수는 Left를 그대로 반환하고, Right일 때만 변환 함수에 값을 적용한다.

Functor의 타입 생성자는 매개변수가 한개이기 때문에 타입이 다른 두개 이상의 매개변수를 가지는 타입을 Functor의 인스턴스로 만들기 위해서는 fmap 함수에 의해서 변경되는 매개변수를 제외한 나머지 값들을 고정해야한다.

단항 함수 펑터 만들기


함수형 언어에서는 함수도 Maybe, Either 처럼 타입이다. 그렇다면 함수도 펑터가 될 수 있지않을까?

Functor의 타입 생성자는 매개변수가 한개이기 때문에 타입이 다른 두개 이상의 매개변수를 가지는 타입을 Functor의 인스턴스로 만들기 위해서는 fmap 함수에 의해서 변경되는 매개변수를 제외한 나머지 값들을 고정해야한다.

이랬던 것과 다르게 함수의 타입 생성자는 입력과 출력만해도 두개의 매개변수가 필요하다. 뿐 아니라 하나 이상의 입력을 받을 수도 있다.

하지만 여기서는 매개변수가 한 개인 단항 함수에 대한 펑터를 먼저 만들어보자

단항 함수의 타입 생성자는 입력과 출력이 각각 하나씩 존재하므로 타입 매개변수가 두개이다.

따라서 Either와 마찬가지로 하나의 매개변수를 고정할 수 있다.

함수의 경우엔 입력값은 바꾸지 않고 출력값만 변경한다. (즉, 입력값이 고정값이다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
 * 매개변수가 한개인 단항 함수에 대한 펑터
 *
 * 함수의 타입 매개변수는 입력 T와 출력 R이다.
 * 함수의 입력은 고정하고, 출력만 매핑하기 위해서 펑터의 타입은 Functor<R>로 선언
 */
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)) }    // 함수 합성
    }

    // 함수를 호출한 결과를 받기 위해서 invoke함수 추가
    fun invoke(input: T): R = g(input)
}

함수의 경우에는 여러가지 타입을 가질 필요가 없기때문에 sealed class로 만들지 않았다.

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

함수 g에 함수 f를 적용하여 매핑한다는 것은 함수 합성을 의미

fmap의 구현은 결국 f와 g를 합성한 것 이라는것.!

이렇게 작성된 함수 함성을 이용하면 입력 매개변수가 여러 개인 함수도 만들 수 있다.

예를 들면 ((T) → R) → R2 이런식인데 이는 결국 매개변수가 한개인 함수의 체이닝인 (T) → ((R) → R2)로도 변경할 수 있다.

이건 결국 커링과 동일한 원리다.

이러한 원리를 이용하면 값을 감싸고 있는 펑터를 바꾸는 것 도 가능하다.

다음은 함수 펑터를 Maybe 펑터로 변경한것이다.

1
2
3
4
5
6
7
8
fun main() {
    val kg = UnaryFunction { a: Int -> a * 2 }.fmap { b: Int -> Just(b) }
		println(kg.invoke(5))   // Just(10)
    /**
     * Maybe(Just)를 반환함 함수 k에 따라서 아까 만든 Either나 Tree를 반환할 수 있다.
     * 이러한 함수를 승급함수라고 한다.
     */
}

위 예제에서 UnaryFunction은 숫자를 받아서 Maybe를 반환했다.

함수 k에 따라서 Either를 반환하게 될 수도 있다.

이러한 함수를 일반적으로 승급함수라고 한다.

승급함수 (리프팅)


리프팅은 특정 타입을 다루는 함수를 특정 타입과 관련된 다른 타입을 다루는 함수로 변화시키는 기술

즉, A타입의 값을 다루는 함수를 가지고 F<A> 타입의 값을 다루는 함수를 만드는 것

1
2
3
4
5
6
7
8
9
10
11
fun <P, R> transFunctionForArray(f: (P) -> R): (List<P>) -> List<R> {
    return { xList: List<P> ->
        val resultList: MutableList<R> = mutableListOf()

        xList.forEach { x: P ->
            resultList.add(f(x))
        }

        resultList.toList()
    }
}

transFunctionForArray 이 함수가 하는 일이 리프팅

1
2
3
4
5
6
7
8
9
10
11
12
fun main() {
    val result1 = transFunctionForArray<Int, Int> { it + 10 }
    val result2 = transFunctionForArray<String, String> { it + "10" }

    val result11 = result1(listOf(1,2,3))
    val result22 = result2(listOf("1","2","3"))

    println(result1)    // (kotlin.collections.List<P>) -> kotlin.collections.List<R>
    println(result2)    // (kotlin.collections.List<P>) -> kotlin.collections.List<R>
    println(result11)   // [11, 12, 13]
    println(result22)   // [110, 210, 310]
}

펑터의 법칙


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

  • 항등 함수(identity function)에 펑터를 통해서 매핑하면, 반환되는 펑터는 원래 펑터와 같다
  • 두 함수를 합성한 함수의 매핑은 각 함수를 매핑한 결과를 합성한 것과 같다.

펑터 제 1법칙


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

1
fmap(identity()) == identity()

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

여기서 항등 함수는 { x → x } 와 같이 입력받은 매개변수를 어떠한 가공 없이 그대로 반환하는 함수를 말한다.

fmap 함수의 변환 함수로 항등 함수를 넣었으므로 당연히 동일한 값이 반환될 것 이다.

이전에서 만든 Maybe, Either가 첫번째 법칙에 만족하는지 살펴보자.

1
2
// 항등함수
fun <T> identity(x: T): T = x
1
2
3
4
5
6
7
8
9
fun main() {
    // Maybe 1 laws
    println(Nothing.fmap { identity(it) } == identity(Nothing))     // true
    println(Just(5).fmap { identity(it) } == identity(Just(5)))     // true

    // Either
    println(Left("error").fmap { identity(it) } == identity(Left("error")))     // true
    println(Right(5).fmap { identity(it) } == identity(Right(5)))     // true
}

펑터 제 2법칙


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

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

compose 함수

1
2
3
4
5
6
infix fun <F, G, R> ((F) -> R).compose(g: (G) -> F): (G) -> R {
    val f = this
    return { x: G ->
        f(g(x))
    }
}
1
2
3
4
5
6
7
8
9
10
fun main() {
    val f = { a: Int -> a + 1 }
    val g = { b: Int -> b * 2 }

    val nothingLeft = Nothing.fmap(f compose g)
//    val nothingRight = Nothing.fmap(f) compose Nothing.fmap(g)    // 컴파일 오류

    val justLeft = Just(5).fmap(f compose g)
//    val justRight = Just(5).fmap(f) compose Just(0).fmap(g)    // 컴파일 오류
}

좌변은 간단한데 우변은 컴파일 오류가 발생한다.

compose는 입출력이 함수라서 Maybe로는 체이닝이 불가능해서다.

우선 아래처럼 하면 된다.

1
2
3
4
5
6
7
val nothingLeft2 = Nothing.fmap(f compose g)
val nothingRight2 = Nothing.fmap(g).fmap(f)
println(nothingLeft2 == nothingRight2)  // true

val justLeft2 = Just(5).fmap(f compose g)
val justRight2 = Just(5).fmap(g).fmap(f)
println(justLeft2 == justRight2)  // true

법칙을 만족해야하는 이유?


fmap 함수를 호출했을 때, 매핑하는 동작 외에는 어떤 것 도 하지 않는 다는 것을 알 수 있어야한다.

이러한 예측 가능성은 함수가 ㅇ나정적으로 동작할 뿐 아니라 더 추상적인 코드로 확장할 때 도움이 된다.

함수형 프로그래밍을 하다보면 항상 부수효과가 있고 순수한 함수가 깨지기 마련인데

안전한 컨테이너 타입으로 감싸고 이를 lifting 등.. 해서 다루는게 펑터

참고

  • 책 [코틀린으로 배우는 함수형 프로그래밍]
  • https://overcurried.com/%EA%B7%80%EC%B0%A8%EB%8B%88%EC%8A%A4%ED%8A%B8%EB%A5%BC%20%EC%9C%84%ED%95%9C%20%ED%8E%91%ED%84%B0/
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

맵 리듀스, 데이터 병렬성 - Hadoop

상위 호환성, 하위 호환성 차이

Comments powered by Disqus.