함수형 프로그래밍 - functor(Maybe)

2021-06-23

Functor

영어로는 Functor 한글로는 함자 또는 함수자 라고 불리운다.

함수형 프로그래밍 글을 보면 Fucntormonad는 빠지지 않고 나온다

이 중에서 오늘은 함자가 무엇인지 살펴보고 함자를 통해 Maybe라는 함자를 만들어 보겠다

이 내용을 보기전에 함수 합성(composition)과 커링(currying)을 알면 더욱 좋겠지만

몰라도 볼 수 있도록 쉽게 표현해 보았다

이 글은 velog - FP in JS 시리즈를 코틀린으로 변경한 글입니다

상자 만들기

함수형 프로그래밍에 관심이 많은 사람들은 함수형 프로그래밍에서는 값을 박스(또는 컨테이너)안에 넣는다고 표현하는것을 많이 봤을것이다.

나도 많이 봤는데 솔직히 무슨말인지 잘 이해가 안됐다.

box

간단하게 생각하면 값이 있는 객체 정도라고 보면 된다

코드를 보면 더 쉽게 이해할 수 있다.

class Box<T>(val value: T) {

    companion object {
        fun<T> of(_value: T): Box<T> {
            return Box(_value)
        }

    }

    override fun toString(): String {
        return "Box($value)"
    }
}

val box1 = Box.of(1) // Box(1)
val box2 = Box.of(listOf("a", "b")) // Box(["a", "b"])

박스는 말 그대로 값을 감싸고있는 객체이다.

왜 쓸데없이 값을 감싸고있을까? 그건 이제 차차 알아가보자 ㅎㅎ

박스안에 값 변경해보기

박스에 값을 넣는것은 간단하다, 하지만 평생 박스에 값만 넣을 수는 없으니 간단한 예제와 함께 값도 변경해보자

여러가지 post중에서 특정 id의 title을 찾고 title의 첫 문자를 대문자로 바꿔보자

data class Post(val id: Int, val title: String)

// 우선 posts 라는 변수에 post들을 담는다
val posts = listOf(Post(1, "kotlin is cool"), Post(2, "java is not cool"))


// 첫번째 문자를 대문자로 바꾸는 함수
val startCase = { str: String -> str.get(0).toUpperCase() + str.substring(1) }
// post 객체에서 title을 가져오는 함수 (함수로 뺄 필요는 없지만 그냥 그러려니 넘어가기)
val getTitle = { post: Post -> post.title }
// id를 기준으로 post를 검색하는 함수
val findPostById = { id: Int, posts: List<Post> -> posts.find { post -> post.id == id } }


// 위 순수함수들을 합성한 함수
fun getUpperPostTitleById(id: Int, posts: List<Post>) {
        val box = Box.of(posts)
        val post = findPostById(id, box.value)
        val title = post?.let { getTitle(it) }
        return title?.let { startCase(it) }
}

println(getUpperPostTitleById(1, posts)) // Kotlin is cool
println(getUpperPostTitleById(2, posts)) // Java is not cool

posts를 Box안에 넣고 box.value 를 통해 값에 직접 접근하여 함수를 실행시켰다

근데 뭔가 좀 비효율적으로 보인다. 기껏 박스에 값을 넣었는데 강제로 꺼내서 값을 사용한다? (그러면 애초에 넣질말아야지 ㅎㅎ;)

값을 직접 다루다보면 값이 중간에 변경될 수 있고, 갑자기 사라지거나 하면 우리가 그토록 혐오하는 side effect가 발생하게 된다

이를 해결하기 위해서 값에 직접 접근하는것이 아니라 박스 안에 값이 든 채로 변경해보자

값이 든 채로 변경하기 위해서는 map이라는 함수를 구현해야 한다.

아까 만들어둔 Box 클래스에 map 함수를 추가한다.

class Box<T>(val value: T) {

    companion object {
        fun<T> of(_value: T): Box<T> {
            return Box(_value)
        }

    }

    override fun toString(): String {
        return "Box($value)"
    }

    fun <T, R> map(fn: (T) -> R): Box<R> {
        return Box(fn(this.value as T))
    }
}

박스에 추가한 map 함수는 인자로 함수를 받아 함수를 적용한 값을 바탕으로 새로운 박스를 생성한다

val addFive = { num: Int -> num + 5 }
Box.of(1).map<Int, Int> { addFive(it) } // Box(6)
Box.of(1).map<Int, Int> { addFive(it) }.map<Int, Int> { addFive(it) } // Box(11)

이렇게 만들게 되면 박스안에 값을 유지한채로 값을 변경할 수 있다.

사실 값을 변경했다기보다는 새로운 박스를 만들었다고 볼 수 있다 (immutable)

즉, 함수자는 map 함수를 구현한 구현체인것이다.

풀어 말하면 함수자는 map의 결과가 새로운 박스를 반환하는 함수이다.

이렇게 보면 함수자는 정말 특별할것 없이 간단한 함수이다.

너무 간단하다보니 왜 이걸 알아야하고 어떻게 쓰이는지가 의문일 수 있다.

눈치 빠른 사람들은 이미 알겠지만 map이라는 함수는 Array에서 가장 흔히볼 수 있는 함수이다

Array또한 함수자의 조건을 만족하기 때문에 함수자라고 할 수 있다.

예제를 더 보면서 함수자에 대해 더 알아보자

fun getUpperPostTitleById2(id: Int, posts: List<Post>): Box<String> {
    return Box.of(posts)
        .map<List<Post>, Post?> { findPostById(id, it) }
        .map<Post?, String> { getTitle(it!!)}
        .map<String, String> { startCase(it)}
}
println("getUpperPostTitleById2() = ${getUpperPostTitleById2(2, posts)}") // Box("Java is not cool")

위 코드를 보면 박스에 만들어둔 map 함수를 통해서 글의 제목을 찾을 수 있었다. 그럼에도 우리는 의문이 든다. 왜 함수자를 쓰는걸까?

예외처리를 위한 함수자

함수를 합성해 함수형 프로그래밍 코드를 작성할때 한가지 문제점이 있다. 예외처리가 힘들다는 점인데, id가 3인 글의 제목을 찾고 싶을때 어떻게 될까?

println("getUpperPostTitleById2() = ${getUpperPostTitleById2(1, posts)}") // Box("Kotlin is cool")
println("getUpperPostTitleById2() = ${getUpperPostTitleById2(2, posts)}") // Box("Java is not cool")
println("getUpperPostTitleById2() = ${getUpperPostTitleById2(3, posts)}") // Exception

에러가 난다

왜냐하면 findPostById 함수 내에서 id가 3인 post를 찾을 수 없기 때문이다. 따라서 빈 배열을 리턴할것이고

getTitle 함수에서 post가 null이기 때문에 에러가 난다.

물론 이를 해결하기 위해서 if문 분기 또는 Nullable 즉 ? 키워드를 사용할 수 있지만, 무분별하게 사용하게 된다면 모든 코드에 ? 와 let이 무한증식 될 수 있다. 함수 합성이 3개가 아니라 몇십개가 된다면 정말 끔찍하다

val getTitle = { post: Post? -> post?.title }

이러한 노가다성 방법은 우아하지 못한 해결방식이다.

우아한 해결책 (Maybe)

이러한 상황을 우아하게 해결하기 위한 함수자 Maybe가 존재한다.

open class Maybe<T>(val value: T) {

    companion object {
        fun<T> of(_value: T): Maybe<T> {
            return Maybe(_value)
        }
    }

    override fun toString(): String {
        return "Maybe($value)"
    }

    fun isNothing(): Boolean {
        return this.value == null
    }

    fun <T, R> map(fn: (T) -> R): Maybe<R> {
        if (isNothing()) {
            return Maybe("Nothing" as R)
        }
        return Maybe(fn(this.value as T))
    }
}

이전에 만든 박스와 다른점은 Maybe는 JustNothing이라는 상태를 가지고 있다.

따라서 값이 null인경우에는 Nothing, 그 외 값이 있는 경우에는 Just 상태를 갖게 된다.

그리고 box와 또 다른점은 Box의 map은 함수가 호출되면 무조건 map함수내에 함수를 적용해서 새로 리턴했지만

Maybe는 조건에 따라 Maybe 객체를 리턴한다.

fun getUpperPostTitleById3(id: Int, posts: List<Post>): Maybe<String> {
    return Maybe.of(posts)
        .map<List<Post>, Post?> { findPostById(id, it) }
        .map<Post?, String> { getTitle(it!!)}
        .map<String, String> { startCase(it)}
}
println("getUpperPostTitleById3() = ${getUpperPostTitleById3(3, posts)}") // Nothing

Box를 Maybe로 변경했을 뿐인데 예외가 발생하지않고 잘 실행된다 물론 값은 Nothing 으로 잘 나온다 이렇게 되면 각 함수에서 예외처리를 하지 않고도 예외를 다룰 수 있게된다.

요약

  1. 함수자는 같은 타입을 반환하는 map 함수를 구현한 구현체이다.

  2. 함수자 특성을 사용한 Maybe라는 구현체는 예외를 우아하게 다룰 수 있다

  3. Maybe는 Nothing과 Just라는 상태를 갖는다.

  4. 함수자가 이것만 있을까? ㅎㅎㅎ Either 등등 더 있다~~

  5. 함수자 이후에는 모나드, 애플리케이터 펑터 등 많은 개념들이 더 존재한다

  6. 타입 맞추기 너무 어렵다

전체 코드

class Box<T>(val value: T) {

    companion object {
        fun<T> of(_value: T): Box<T> {
            return Box(_value)
        }

    }

    override fun toString(): String {
        return "Box($value)"
    }

    fun <T, R> map(fn: (T) -> R): Box<R> {
        return Box(fn(this.value as T))
    }
}


open class Maybe<T>(val value: T) {

    companion object {
        fun<T> of(_value: T): Maybe<T> {
            return Maybe(_value)
        }
    }

    override fun toString(): String {
        return "Maybe($value)"
    }

    fun isNothing(): Boolean {
        return this.value == null
    }

    fun <T, R> map(fn: (T) -> R): Maybe<R> {
        if (isNothing()) {
            return Maybe("Nothing" as R)
        }
        return Maybe(fn(this.value as T))
    }
}


data class Post(
    val id: Int,
    val title: String
)

fun main() {
    val posts = listOf(
        Post(id = 1, title = "kotlin is cool"),
        Post(id = 2, title = "java is not cool")
    )

    val startCase = { str: String -> str.get(0).toUpperCase() + str.substring(1) }
    val getTitle = { post: Post -> post.title }
    val findPostById = { id: Int, posts: List<Post> -> posts.find { post -> post.id == id } }


    fun getUpperPostTitleById(id: Int, posts: List<Post>): String? {
        val box = Box.of(posts)
        val post = findPostById(id, box.value)
        val title = post?.let { getTitle(it) }
        return title?.let { startCase(it) }
    }

    println(getUpperPostTitleById(1, posts))
    println(getUpperPostTitleById(2, posts))
//    println(getUpperPostTitleById("book3", posts))

//    val addFive = { num: Int -> num + 5 }
//    println(Box.of(1)
//        .map<Int, Int> { addFive(it) })

    fun getUpperPostTitleById2(id: Int, posts: List<Post>): Box<String> {
        return Box.of(posts)
            .map<List<Post>, Post?> { findPostById(id, it) }
            .map<Post?, String> { getTitle(it!!)}
            .map<String, String> { startCase(it)}
    }

    println("getUpperPostTitleById2() = ${getUpperPostTitleById2(2, posts)}")
    println("getUpperPostTitleById2() = ${getUpperPostTitleById2(2, posts)}")

    fun getUpperPostTitleById3(id: Int, posts: List<Post>): Maybe<String> {
        return Maybe.of(posts)
            .map<List<Post>, Post?> { findPostById(id, it) }
            .map<Post?, String> { getTitle(it!!)}
            .map<String, String> { startCase(it)}
    }
    println("getUpperPostTitleById3() = ${getUpperPostTitleById3(3, posts)}")


}

참고