이 글은 코틀린으로 배우는 함수형 프로그래밍을 읽고 간략하게 내용과 소감(?)을 정리하려고 쓴 글이다.
함수형 프로그래밍은 자료 처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나이다.[from wiki..] 함수형 프로그래밍은 크게 아래의 4가지 특징을 갖고 있다.
불변성(immutable)
입력을 수정하지 않고 결과를 새로 생성하여 반환을 한다. 이를 불변성이라고 한다. 함수형 프로그래밍에서 데이터는 기본적으로 불변으로 취급한다.
참조 투명성(referential transparency)
순수한 함수(pure function)은 부수효과가 없다. 입력이 동일하다면 같은 값을 출력해야 한다. 즉 1+1은 2로 대체가 가능해야 한다. 개발자의 입장에서 이해하기 쉽게 이야기 하면 함수 내부 이외의 요소에 영향을 받지 않는다고 보면된다.(전역변수를 사용한다던가...)
일급 함수
함수를 매개변수로 넘길 수 있으면 함수를 반환값으로 사용할 수 있고 이를 자료구조에 담을 수 있다.
게으른 평가
명령형 언어의 코드는 즉시 값이 평가 된다. 함수형 언어는(물론 코틀린은 아니지만..) 필요한 시점까지 값의 평가를 미룰 수 있다. 즉 게으른 평가를 사용하여 무한대라는 요소를 표현할 수 있다는 의미이다.
tailrec fun tailFunction(status: Int) {
when(status) {
0 -> return 0
else -> tailFunction(status - 1)
}
}
kotlin에선 tailrec
이라는 예약어로 꼬리재귀를 구현할 수 있다. 물론 위 처럼 자신을 호출하는 함수가 로직의 마지막 연산이어야 한다.
고차함수
고차함수는 함수를 매개변수로 받거나 반환값으로 사용하는 함수를 의미한다. 이를 활용하여 코드의 재사용성을 높이고 기능을 확장하기 쉽게 만들 수 있다.
컨테이너
흔히 컬렉션이라고도 하며 가장 쉬운 예시로 List를 들 수 있을 것이다. 함수형 프로그래밍에서 이러한 컨테이너의 단위로 데이터를 처리한다.
함수형 타입 시스템
함수형 프로그래밍에서 타입은 일반적인 언어의 타입시스템보다 넓은 범위를 의미한다.(코틀린의 데이터 타입과는 조금 다르다) 함수형 타입 시스템은 대수적 타입을 근간으로 하는데 이는 이형의 여러가지 타입을 묶어서 하나의 새로운 타입을 정의하는 것으로 kotlin에선 enum과 sealed class를 예시로 들수 있다. 실제로 kotlin에서 함수형 프로그래밍을 구현하기 위해 이 sealed class가 매우 유용하게 사용된다.
sealed class Maybe<out T> {
object Nil : maybe<Nothing>
data class Just<out T>(value : T) : maybe<out T>
}
tailrec fun tailFunction(status: maybe<Int>) {
when(status) {
Maybe.nil-> return 0
is Maybe.Just -> tailFunction(status)
}
}
위는 가장 단순하게 대수적 타입을 표현한 코드이다.(몇가지 로직이 빠져있는 것은 감안을..) Maybe라는 컨테이너가 비어있다는 상태를 타입으로 구분할 수 있다.
함수형 프로그래밍은 카테고리 이론이라는 수학적 원리를 토대로 만들어졌다. 이 개념들을 구현체로 구현한 함수형 프로그래밍의 4가지 구현체에 대해서 알아보도록 하자.
펑터는 매핑할 수 있는 것 이라는 행위를 선언한 타입 클래스를 의미한다.
interface Functor<out A> {
fun <B> fmap(f: (A) -> B): Functor<B>
}
위 코드는 펑터를 인터페이스로 선언한 것이다. 상태를 sealed class로 나타냈다면 행위는 인터페이스로 구현할 수 있다. 펑터는 매핑하는 행위를 갖는 타입이므로 인터페이스로 정의할 수 있다. 컨테이너에 내용물(A)을 꺼내서 다른 타입(B)으로 변환하고 컨테이너에 다시 집어 넣는다. 간단하게 List의 map을 생각하면 된다.
만약 펑터에 함수가 담겨져 있다면 어떻게 될까?
Functor({ x -> x * 2}).fmap{ it(Functor(2)) }
구현체가 있다는 가정하에 위 코드가 동작을 할까? Functor의 원형을 살펴보면 알수있겠지만 f: (A) -> B 로 Functor의 내부에 값과 함수가 존재하면 이를 적용할 방법이 없다. 이를 보완하기 위해 어플리케이티브 펑터를 활용한다.
infix fun Functor<(A)->B>.apply(f: Maybe<A>): Functor<B>
// Functor({x: Int -> x * 2}) apply Functor(2) apply Functor(3)
위와 같은 원형을 가진 함수를 만들어 사용하면 주석에 표시한 것 처럼 활용이 가능하다. 다만 매개변수가 하나인 경우만 처리가 가능하게 되는데 이는 커링으로 해결을 한다.
모노이드는 연관 바이너리 함수와 항등값을 가진 대수적 타입으로 정의할 수 있다. 이게 뭔 개소리냐 하면 예를 들어 x + y 라는 함수가 있을 때 x나 y에 0을 넣으면 무조건 나머지 한쪽의 값이 그대로 나오게 된다. 이때 0이 항등값이 되고 x + y는 연관 바이너리 함수가 된다.
interface Monoid<T> {
fun mempty(): T // 항등값을 반환
fun mappend(m1: T, m2: T): T //연관 바이너리 함수
}
// Collection<Int>.fold(monoid.mempty(), (acc, v) -> { monoid.mappend(acc, v) })
모노이드의 연산의 대표적인 예시가 바로 fold인데 이때 initial이 항등값이 되고 사용되는 연산이 연관 바이너리 함수라고 보면 될 것이다.(물론 실제 입력으로 제대로 된 항등값과 연관 바이너리 함수가 안들어 갈 수도 있는데 이러면 제대로된 연산이 되진 않을 것이다)
사실 모나드까지 제대로 이해하기 위해선 카테고리 이론이라는 수학적인 지식이 필요한데 개발자의 입장에서 거기까진 필요없다고 한다. 자세히 알고싶다면 따로 공부를 해야할 듯 싶다. 모나드에 대해서 매우 단순하게 이야기 하면 Functor에 flatMap이 추가된 타입이다.
interface Monad<out A> : Functor<A> {
fun <B> flatMap(f: (A) -> Monad<B>): Monad<B>
}
위 원형을 보면 알수 있듯이 map과 기능적으론 동일하나 컨테이너에 감싼 형태로 반환을 하는 함수를 인자로 받는다. 즉 컨테이너에 감싼 형태로 반환하는 함수가 있다면 이를 flatMap에서 활용할 수 있다는 의미가 된다. 그러면 flatMap이 의미하는 바는 무엇일까? 펑터, 어플리케이티브 펑터, 모노이드 전부 결국 함수의 합성을 표현하기 위한 프로그래밍에서의 타입이고 이러한 타입끼리 유연하게 합성을 하는 것이 결국 함수형 프로그래밍의 정수라고 할 수 있다.(아님 말고..) 결국 모나드 끼리 함수를 통해 다양한 합성을 하여 로직을 구현할 수 있게 된다는 의미가 된다.
함수형 프로그래밍의 기원을 이해하기 위해선 상당히 많은 양의 이론을 요구하는 것 같다. 하지만 실무에서 사용할 땐 map, fold, flatMap과 같은 함수들의 사용방법만 알아도 크게 문제가 될 것 같진 않다. 물론 하스켈과 같은 함수형 언어를 사용하겠다고 한다면 다를지도 모르겠고 kotlin에 꼭 함수형 프로그래밍을 적용할 필요가 있는지도 잘 모르겠지만 적당하게 적용하면 코드를 간결하게 만들어준다는 측면에선 확실히 효과가 있을 것 같다.