18장: 함수형 프로그래밍

함수형 프로그래밍

side effect

  • 공유 가변 데이터 구조를 사용하면 프로그램 전체에서 데이터가 갱신되었다는 사실을 추적하기 어려워진다.

  • 예를 들어 여러 클래스에서 공유하는 가변 리스트가 있다면, 어느 클래스가 소유자인지 구분하기 어렵고 어느 클래스가 데이터를 추가/수정했는지 알기 어렵다.

  • 자료구조의 데이터를 수정하거나 필드에 값을 할당하는 것, 예외, 파일에 I/O 동작을 수행하는 것 등등을 side effect라고 부른다.

  • 이러한 side effect를 없앨 수 있도록 불변 객체를 사용할 수 있으며, side effect 없는 시스템 컴포넌트는 메서드가 서로 간섭하는 일이 없으므로 lock 없이도 멀티코어 병렬성을 사용할 수 있다.

프로그래밍 기법

  • 선언형 프로그래밍

    • 무엇을 어떻게 수행할 것인지에 대해 집중하는 방식이다.

    • 문제를 어떻게 푸는지 명확히 보여주는 스트림의 내부 반복 방식도 선언형 프로그래밍에 해당한다.

  • 함수형 프로그래밍

    • 선언형 프로그래밍을 따르는 대표적인 방식이며, side effect가 없는 방식을 지향한다.

    • 여러 연산을 연결해 복잡한 질의를 표현하고 자연스럽게 읽고 쓸 수 있는 코드 구현에 적합하다.

  • 객체 지향 프로그래밍

    • 모든 것을 객체로 간주하여 프로그램이 객체의 필드를 갱신하거나 메서드를 호출하는 방식을 지향한다.

    • 자바 프로그래머는 객체 지향과 함수형 방식을 혼합한다. Iterator 객체를 통해 내부 상태를 포함하는 자료구조를 탐색하며 함수형 방식으로 자료구조에 들어있는 값의 합을 계산할 수 있다.

특징

  • 함수형 프로그래밍에서 함수란 0개 이상의 인수를 가지고 한개 이상의 결과를 반환해야 하며, 이 때 다른 객체의 필드를 고치는 등 시스템의 다른 부분에 영향을 미치는 side effect를 가지면 안된다.

  • 지역 변수만을 변경해야 한다.

  • 함수나 메서드에서 참조하는 객체는 불변 객체(final)여야 한다.

  • 메서드 내에서 생성한 객체의 필드를 갱신할 수는 있지만 외부에 노출되면 안된다.

  • 예외를 일으키지 않아야 하기 때문에 Optional을 반환하는 방법을 사용할 수도 있고, 다른 컴포넌트에 영향을 미치지 않도록 지역적으로 예외를 사용하는 방법도 사용할 수 있다.

  • 비함수형 동작을 감출 수 있는 상황에서만 side effect를 포함하는 함수를 사용해야 한다.

참조 투명성

  • 함수를 호출할 때 어떤 인자를 주든 항상 같은 결과를 반환하면 참조 투명성을 가진 함수이다.

  • 프로그램 이해에 큰 도움을 주며, 비싸거나 오래걸리는 연산을 캐싱하는 최적화 기능도 사용할 수 있다.

  • 반환한 결과가 가변 객체가 아닌 불변 객체여야 참조 투명성을 가질 수 있다.

재귀와 반복

  • 순수 함수형 프로그래밍 언어에서는 while, for 같은 반복문을 포함하지 않는다.

  • 아래와 같이 반복문 루프 내부에서 외부의 stats 객체 상태를 변화시키는 경우 함수형과 상충하는 side effect가 발생하게 된다.

public void searchGold(List<String> list, Stats stats) {
    for(String s : list) {
        if("gold".equals(s)) {
            stats.incrementFor("gold");
        }
    }
}
  • 반복문을 사용하는 코드는 재귀를 사용해 구현할 수 있다.

  • 하지만 반복문보다 재귀문을 사용하면 리소스가 많이 든다. 왜냐하면 함수를 호출할 때마다 호출 스택에 호출 정보를 저장할 스택 프레임이 쌓이기 때문이다. 이로 인해 입력 값에 비해 메모리 사용량이 증가하게 된다.

  • 함수형 언어에서는 이를 해결하기 위해 tail-call optimization을 제공하여 컴파일러가 하나의 스택 프레임을 재활용할 수 있도록 한다.

  • 아래는 factorial 을 구하는 로직을 재귀 방식으로 구현한 것이다.

static long factorial(long n) {
    return n==1 ? 1: n * factorial(n-1);
}
  • 아래는 factorial을 구하는 로직을 tail-call optimization이 가능하도록 구현한 것이다. 위 재귀 방식과 가장 큰 차이점은 이전 함수로부터 값을 받아 곱할 필요 없이 그대로 값을 반환(tail-call)하면 된다는 것이다. 이로 인해 특정 함수 정보를 담고 있는 스택 프레임을 생성할 필요가 없어 컴파일러가 tail-call optimization을 해줄 수 있다.

static long factorial(long n) {
    return factorialHelper(1, n);
}

static long factorialHelper(long acc, long n) {
    return n==1 ? acc: factorialHelper(acc*n, n-1);
}
  • 자바에서는 tail-call optimization을 지원하고 있지 않으므로, 반복을 스트림으로 대체하여 함수형을 유지할 수 있다.

Last updated