All Posts

함수형 코틀린 스터디 2회차

챕터 2에서 열띤 토론(?)은 아니고 질의응답을 하다 보니 2시간이 증발해버려서 이번 주는 챕터 2 밖에 못했다.

챕터 2의 주요 내용은 함수형 프로그래밍의 기초적인 내용을 주로 소개한다. 다들 함수형 프로그래밍 기초 개념이 부족하여 고생한 듯하다.

아래는 스터디 시간에 주로 거론됐던 내용이다.

함수형 프로그래밍이란

함수형 프로그래밍의 핵심은 을 통해서 데이터를 변환하는 것이다. 여기서 식은 본질적으로 값이다. 값은 확정적이다. 그런데 어떻게 데이터를 변환하는가? 변하지 않는 값과 달리 식은 변화 요소(정의역)를 가질 수 있다.

변화 요소를 가지는데 어떻게 값이라 볼 수 있을까? y = x + 3과 같이 x에 따라 결국 값을 확정할 수 있기 때문에 식은 본질적으로 값이다.

보통 순수 함수의 정의에 대해서 찾아보면 수학적으로 정의된 함수에 가장 가까운 함수를 순수 함수라고 한다. 그러나 컴퓨터 세계에서 순수 함수는 사실 불가능하다. 순수 함수는 상태가 없어야 하는데 컴퓨터는 함수조차 메모리에 올라가기 때문에 상태가 존재할 수밖에 없다.

컴퓨터 세계에서 순수 함수는 본질적으로 부수 효과가 없는 함수를 말한다. 즉, 값만 사용해야 하는 함수다. 참조를 사용하는 순간 잠정적 오류 가능성을 내포하기 때문이다.

보통 객체지향에선 각 객체가 자신에 대한 상태 관리자이기 때문에 명시적으로 상태 관리자임이 외부로 드러나진 않지만 함수형 프로그래밍에선 펑터, 모나드(I/O를 담당한다든지)와 같은 형태로 명시적으로 나타난다.

함수형 프로그래밍과 객체지향 둘 중 어느 것이 우월하고 정답이냐는 없지만 확실한 건 값지향 vs 레퍼런스 지향 둘 중에 하나로 정하여 일관된 코드 작성이 중요하다.

순수 함수

순수 함수에는 부수 효과나 메모리(다른 변수, 함수, 객체 등…), I/O가 없다. 코틀린에서 순수 함수를 작성하는 것은 가능하지만, 컴파일러는 다른 함수형 언어처럼 해석하여 실행하진 않는다.

재귀 함수

fun sum(n: Int): Int {
  // ...
  return n + sum(n - 1)
}

위와 같은 재귀 함수가 있을 때 왜 함수는 call stack에 쌓여야만 하는 걸까? 바로 + 연산 때문이다. 아직 끝나지 않은 연산 때문에 stack에 있는 함수가 사라져선 안 되는 것이다.

fun sum(n: Int, acc: Int): Int {
  // ...
  return sum(n - 1, n + acc)
}

// 꼬리 재귀 최적화
tailrec fun sum(n: Int, acc: Int): Int {
  // ...
  return sum(n - 1, n + acc)
}

하지만 위와 같이 누적된 연산 결과를 다음 함수 call에 인자로 보낸다면 이번 call에서 더 이상 진행할 연산이 없기 때문에 stack에 함수는 필요가 없어진다. 코틀린에선 tailrec을 키워드를 붙이면 컴파일러가 꼬리 재귀 최적화 여부를 검사한 뒤 최적화한다. 코틀린의 경우 실제론 컴파일러가 loop로 변환한 뒤 실행된다고 한다.

각 언어마다 꼬리 재귀 최적화 연산자가 정해져 있고 코틀린은 if, when과 같은 연산자들이 그 대상이다.

함수형 프로그래밍은 왜 재귀를 사용하는 걸까? 원래 함수형 프로그래밍은 for-loop가 존재하지 않는다. for와 같은 반복문은 본래 상태를 제어하기 위해 나온 제어문인데 함수형 프로그래밍에선 상태가 존재하지 않기 때문이다.

지연 계산법

일부 함수형 언어는 지연 계산을 제공한다. Haskell은 기본적으로 지연 계산법을 사용한다. 그러나 코틀린은 이와 반대로 엄격한(조급한) 계산법을 사용한다. 다만 코틀린 표준 라이브러리와 델리게이트 속성이라는 언어 기능의 일부로 지연 계산이 가능하다.

fun main(args: Array<String>) {
  val i by lazy {
    println("지연 계산법")
    1
  }

  println("i 사용 전")
  println(i)
}

lazy의 동작은 람다를 전달받아 저장한 Lazy<T> 인스턴스를 반환한다. 즉, 미리 i에 객체가 할당된다. 그리고 i에 최초 접근 시에 저장해놨던 람다가 실행되면서 그 결과를 그때 기록한다.

사실 위의 예제에서 lazy를 사용하는 건 좀 오바스러운데 저 경우엔 lazy로 인해 할당되는 Lazy 객체가 더 비용이 크기 때문이다. 하지만 lazy하게 초기화 되는 대상이 비트맵 이미지와 같이 비용이 큰 객체라면 실제론 큰 이득을 볼 수 있다.

fun main(args: Array<String>) {
  val size = listOf(2 + 1, 3 * 2, 1 / 0, 5 - 4).size
}

위의 예제는 실행하면 ArithmeticException이 일어나는데 listOf 실행이 끝나기 전에 내부 요소에 대한 연산에서 이미 에러가 발생하기 때문이다. 아래 코드를 보자.

fun main(args: Array<String>) {
  val size = listOf({ 2 + 1 }, { 3 * 2 }, { 1 / 0 }, { 5 - 4 }).size
}

반면 위의 코드가 무슨 차이가 있는가 싶을 것이다. 실행해보면 문제 없이 실행된다. 위의 코드에서 구하고 싶은 것은 list의 size이기 때문에 실제로 요소로 들어있는 { 1 / 0 } 를 실행하기 전까진 프로그램은 아무 문제 없이 동작한다. { 1 / 0 } 때문에 다른 연산들이 실패하지 않는데 그 의의가 있다.

즉, 함수에 가둔 코드는 개발자가 호출하기 전까진 실행되지 않기 때문에 lazy한 연산이 가능하다.

참고 서적

  • 함수형 코틀린