함수형 프로그래밍 - functor(Maybe)
Functor
영어로는 Functor 한글로는 함자 또는 함수자 라고 불리운다.
함수형 프로그래밍 글을 보면 Fucntor
와 monad
는 빠지지 않고 나온다
이 중에서 오늘은 함자가 무엇인지 살펴보고 함자를 통해 Maybe라는 함자를 만들어 보겠다
이 내용을 보기전에 함수 합성(composition)과 커링(currying)을 알면 더욱 좋겠지만
몰라도 볼 수 있도록 쉽게 표현해 보았다
이 글은 velog - FP in JS 시리즈를 코틀린으로 변경한 글입니다
상자 만들기
함수형 프로그래밍에 관심이 많은 사람들은 함수형 프로그래밍에서는 값을 박스(또는 컨테이너)안에 넣는다고 표현하는것을 많이 봤을것이다.
나도 많이 봤는데 솔직히 무슨말인지 잘 이해가 안됐다.
간단하게 생각하면 값이 있는 객체 정도라고 보면 된다
코드를 보면 더 쉽게 이해할 수 있다.
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는 Just
와 Nothing
이라는 상태를 가지고 있다.
따라서 값이 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 으로 잘 나온다 이렇게 되면 각 함수에서 예외처리를 하지 않고도 예외를 다룰 수 있게된다.
요약
-
함수자는 같은 타입을 반환하는 map 함수를 구현한 구현체이다.
-
함수자 특성을 사용한 Maybe라는 구현체는 예외를 우아하게 다룰 수 있다
-
Maybe는 Nothing과 Just라는 상태를 갖는다.
-
함수자가 이것만 있을까? ㅎㅎㅎ Either 등등 더 있다~~
-
함수자 이후에는 모나드, 애플리케이터 펑터 등 많은 개념들이 더 존재한다
-
타입 맞추기 너무 어렵다
전체 코드
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)}")
}
참고