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

2021-07-07

Maybe: 멋있지만 살짝 모자란 친구

함수 컴포지션에서 에러를 다룰때 깔끔한 처리를 도와주는 함수자였다.

map을 이용해서 함수 컴포지션을 할때 Nothing이 되는 순간 그 뒤로 실행해야할 map을 모두 무시할 수 있었기 때문이다.

그리고 마지막엔 Maybe가 들고 있는 값(just)을 뽑아내거나 Nothing인 경우 기본값을 지정해줄 수 있었다.

Maybe의 한계

마지막에 기본값을 리턴해 주는게 아니라 특별한 처리를 하고 싶은 경우에는 어떻게 해야 할까?

posts에서 id로 특정 post를 찾아 글쓴이를 로그로 찍어보자.

여기서 글쓴이가 manhyuk인 경우에는 success 값을 출력하고 아닌경우 error를 출력해보자

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

val posts: List<Post> = listOf(
    Post(id = 1, title = "kotlin is coooool", author = "manhyuk"),
    Post(id = 2, title = "java is dead", author = "sujin")
)

게시글의 구조는 위와 같다.

그리고 게시글을 찾아 게시글을 반환해주는 함수를 만든다.

val findPostById = {id: Int, posts: List<Post> -> posts.find { it.id == id }}

게시글의 글쓴이가 manhyuk인 경우 Just를 반환하고 그렇지 않으면 Nothing을 반환하는 함수를 만든다

fun validatePostAuthor(post: Post?): Maybe<Post?> {
    if (post == null) {
        return Maybe.of(null)
    }
    val isContained = post.author.contains("manhyuk")
    if (isContained) {
        return Maybe.of(post)
    }
    return Maybe.of(null)
}

그리고 위 두 함수를 조합한 후에 게시글의 글쓴이를 로그로 찍어주는 함수를 만든다.

fun logBookAuthor(id: Int, posts: List<Post>): Maybe<Post?> {
    val findPost = findPostById(id, posts)
    return validatePostAuthor(findPost)
}
// println("logBookAuthor(posts = posts) = ${logBookAuthor(id = 1, posts = posts)}") -> Just
// println("logBookAuthor(posts = posts) = ${logBookAuthor(id = 3, posts = posts)}") -> Nothing

validatePostAuthor 함수의 결과의 상태를 기준으로 분기하여 로그를 출력하는 함수를 하나 더 만든다

fun logByMaybeStatus(maybePost: Maybe<Post?>) {
    if (maybePost.isNothing()) {
        println("error")
    } else {
        println("success = $maybePost")
    }
}

위 함수들을 조합하면 아래와 같이 만들 수 있다.

fun logBookAuthorWithHandling(id: Int, posts: List<Post>) {
    val findPost = findPostById(id, posts)
    val validatedPost = validatePostAuthor(findPost)
    logByMaybeStatus(validatedPost)
}

fun main() {
    logBookAuthorWithHandling(id = 1, posts = posts) // success = Maybe()
    logBookAuthorWithHandling(id = 3, posts = posts) // error
}

의도한대로 예외처리가 잘 되었다. 하지만 갑자기 요구 사항이 변경되어 Nothing인 경우에 글쓴이를 같이 출력해야한다면

어떻게 해야할까? Nothing 상태에는 값이 null 이기 때문에 출력할 데이터가 없다!!

Either: Maybe 상위호환

Maybe는 에러 상황에 기본 값만 지정하는게 최선의 방법이다. 하지만 Nothing 상태일때도 참조할만한 값을 가지고 있다면 갑자기 요구사항이 변경되는 상황에서도 큰 영향없이 수정이 가능할것이다.

즉 에러 처리시에 참조할 값을 들고 있는 함수자가 Either이다.

Either 는 Left와 Right의 상태를 갖는다.

Left인 경우 Nothing이 매치되고, Right인 경우 Just가 매치된다고 생각하면 된다.

Either를 구현해보자


class Either<A, B>() {
    companion object {
        fun<A> left(_value: A): Left<A> {
            return Left(_value)
        }
        fun<B> right(_value: B): Right<B> {
            return Right(_value)
        }
    }


    class Left<A>(val value: A) {

        fun isRight(): Boolean {
            return false
        }
        fun isLeft(): Boolean {
            return true
        }


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

    class Right<B>(val value: B) {
        fun isRight(): Boolean {
            return true
        }
        fun isLeft(): Boolean {
            return false
        }

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

Maybe와 크게 다르지 않게 구현할 수 있다.

다른점은 Right는 인자로 받은 함수를 실행해 새로운 Right를 반환하지만

Left는 함수를 받지만 실행하지않고 Left를 새로 만들어 반환한다

하지만 Maybe까진 직접 구현한걸 사용해왔지만 Either는 arrow kt를 사용하겠다

class Either<out A, out B> {
   al isLeft: Boolean

    fun isLeft(): Boolean = isLeft

    fun isRight(): Boolean = isRight

    data class Left<out A> constructor(val value: A) : Either<A, Nothing>() {
        override val isLeft = true
        override val isRight = false

        override fun toString(): String = "Either.Left($value)"
    }
    data class Right<out B> constructor(val value: B) : Either<Nothing, B>() {
        override val isLeft = false
        override val isRight = true

        override fun toString(): String = "Either.Right($value)"

    }
}

구현은 동일하고 아래와 같이 사용이 가능하다

val concat = {str1: String, str2: String -> "$str1$str2"}
println(Either.Right("kotlin").map { concat(it, " is cool")}) // kotlin is cool
println(Either.Left("java").map { concat(it, " is dead") }) // java is dead

Either로 해결해보자

Maybe로 해결할 수 없었던 부분을 Either로 다시 해결해보자

Maybe 코드

val findPostById = {id: Int, posts: List<Post> -> posts.find { it.id == id }}

fun validatePostAuthor(post: Post?): Maybe<Post?> {
    if (post == null) {
        return Maybe.of(null)
    }
    val isContained = post.author.contains("manhyuk")
    if (isContained) {
        return Maybe.of(post)
    }
    return Maybe.of(null)
}

fun logBookAuthor(id: Int, posts: List<Post>): Maybe<Post?> {
    val findPost = findPostById(id, posts)
    return validatePostAuthor(findPost)
}

fun logByMaybeStatus(maybePost: Maybe<Post?>) {
    if (maybePost.isNothing()) {
        println("error")
    } else {
        println("success = $maybePost")
    }
}

fun logPostAuthorWithHandling(id: Int, posts: List<Post>) {
    val findPost = findPostById(id, posts)
    val validatedPost = validatePostAuthor(findPost)
    logByMaybeStatus(validatedPost)
}

fun main() {
    println("logBookAuthor(posts = posts) = ${logBookAuthor(id = 1, posts = posts)}") -> Just
    println("logBookAuthor(posts = posts) = ${logBookAuthor(id = 3, posts = posts)}") -> Nothing

    logPostAuthorWithHandling(id = 1, posts = posts) // success = Maybe(id=1, ...)
    logPostAuthorWithHandling(id = 3, posts = posts) // error
}

Either 로 변경한 코드

val findPostById = {id: Int, posts: List<Post> -> posts.find { it.id == id }}

fun validatePostAuthorWithEiter(post: Post?): Either<Post?, Post> {
    if (post == null) {
        return Either.Left(post)
    }
    val isContained = post.author.contains("manhyuk")
    if (isContained) {
        return Either.Right(post)
    }
    return Either.Left(post)
}


fun logByEitherStatus(eitherPost: Either<Post?, Post>) {
    if (eitherPost.isLeft()) {
        println("error = $eitherPost")
    } else {
        println("success = $eitherPost")
    }
}

fun logPostAuthor(id: Int, posts: List<Post>) {
    val findPost = findPostById(id, posts)
    val validated = validatePostAuthorWithEiter(findPost)
    logByEitherStatus(validated)
}

fun main() {
    logPostAuthor(id = 1, posts = posts) // success = Either.Right(Post(id=1, title=kotlin is coooool, author=manhyuk))
    logPostAuthor(id = 2, posts = posts) // error = Either.Left(Post(id=2, title=java is dead, author=sujin))
}

Maybe를 사용했을때 처리하기 힘들었던 부분을 Either 함수자를 이용해 더 쉽게 해결할 수 있게 되었다.

요약

  • Either은 Left, Right 상태를 갖고 있다.
  • Left는 에러 처리에 필요한 값을 저장
  • Right는 Just와 같이 정상적인 처리의 값을 가짐