Java

[모던 자바] 함수형 프로그래밍 기법

Beekei 2022. 4. 4. 13:21
반응형

함수는 모든 곳에 존재한다.

함수형 프로그래밍이란 함수나 메서드가 수학의 함수처럼 부작용 없이 동작함을 의미한다. 

함수형 언어 프로그래머는 함수형 프로그래밍이라는 용어를 좀 더 폭넓게 사용한다.

즉, 함수를 마치 일반 값처럼 사용해서 인수로 전달하거나, 결과로 반환받거나, 자료구조에 저장할 수 있음을 의미한다.

일반 값처럼 취급할 수 있는 함수를 일급 함수(first-class function)라고 한다.

자바 8에서는 :: 연산자로 메서드 참조를 만들거나 람다 표현식으로 직접 함숫값을 표현해서 메서드를 함수값으로 사용할 수 있다.

고차원 함수

함수형 프로그래밍 커뮤니티에 따르면 다음 중 하나 이상의 동작을 수행하는 함수를 고차원 함수(higher-order functions)라 부른다.

  • 하나 이상의 함수를 인수로 받음
  • 함수를 결과로 반환

Java8에서는 함수를 인수로 전달할 수 있을 뿐 아니라 결과로 반환하고, 지역 변수로 할당하거나, 구조체로 삽입할 수 있으므로 Java8의 함수도 고차원 함수라고 할 수 있다.

예를 들어 Function 함수형 인테페이스에 andThen 메서드로 연산 파이프라인을 만들 수 있다.

@FunctionalInterface
public interface Function<T, R> {
    ...
    
    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);
        return (T t) -> after.apply(apply(t));
    }
    
    ...
}

고자원 함수를 적용할 때도 부작용을 포함하는 함수를 사용하면 문제가 발생할 수 있다.

고차원 함수나 메서드를 구현할 때 어떤 인수가 전달될지 알 수 없으므로 인수가 부작용을 포함할 가능성을 염두에 두어야 한다.

함수를 인수로 받아 사용하면 서 코드가 정확히 어떤 작업을 수행하고 프로그램의 상태를 어떻게 바꿀지 예측하기 어려워지고 디버깅 또한 어려워질 것이다.

따라서 부작용을 포함하지 않는 것이 최선이고, 부작용을 포함한다면 최소한 인수로 전달된 함수가 어떤 부작용을 포함하게 될지 정확하게 문서화하는 것이 좋다.

콤비네이터
이처럼 함수를 조합하는 기능을 콤비네이터라고 부른다.

Java8 API에 추가된 많은 기능은 이 콤비네이터의 영향을 받았다.
예를 들어 CompletableFuture 클래스에는 CompletableFuture와 BiFunction 두 인수를 받아 새로운 CompletableFuture를 생성하는 thenCombine이라는 메서드가 추가되었다.
이 개념을 활용하면 반복 과정에서 전달되는 가변 상태 함수형 모델 등 반복 기능을 좀 더 다양하게 활용할 수 있다.

커링(currying)

커링은 x와 y라는 두 인수를 받는 함수 f를 한 개의 인수를 받는 g라는 함수로 대체하는 기법이다.

이때 g라는 함수 역시 하나의 인수를 받는 함수를 반환한다.

함수 g와 원래 함수 f가 최종적으로 반환하는 값은 같다. 즉, f(x,y) = (g(x))(y)가 성립한다.

 

예를 들어 섭씨를 화씨로 변환하는 공식은 CtoF(x) = x * 9 / 5 + 32이다.

이러한 패턴을 코드로 적용해보면 아래와 같다.

public double converter(double x, double f, double b) {
    return x * f + b;
}
double convertCtoF = converter(1000, 9.0/5, 32);
double convertUSDtoGBP = converter(1000, 0.6, 0);
double convertKmToMi = converter(1000, 0.6124, 0);

이러한 방법은 인수의 기준치를 사용할때마다 넣는 일은 귀찮은 일이며 오타도 발생하기 쉽다.

각각 변환하는 메서드를 따로 만드는 방법도 있지만 로직을 재활용하지 못한다.

 

이러한 상황에서 커링이라는 개념을 활용해 특정 상황에 적용할 수 있다.

한 개의 인수를 갖는 변환 함수를 생산하는 팩토리(factory)를 정의하는 것이다.

public DoubleUnaryOperator curriedConverter(double f, double b) {
    return (double x) -> x * f + b;
}
DoubleUnaryOperator convertCtoF = curriedConverter(9.0/5, 32);
DoubleUnaryOperator convertUSDtoGBP = curriedConverter(0.6, 0);
DoubleUnaryOperator convertKmToMi = curriedConverter(0.6124, 0);
convertCtoF.applyAsDouble(1000);
convertUSDtoGBP.applyAsDouble(1000);
convertKmToMi.applyAsDouble(1000);

위 메서드에 f와 b만 넘겨주면 우리가 원하는 작업을 수행할 함수가 반환된다.

이러한 방식으로 변환 로직을 재활용할 수 있으며 다양한 변환 요소로 다양한 함수를 만들 수 있다.


영속 자료구조

함수형 프로그램에서는 함수형 자료구조, 불변 자료구조 등의 용어도 사용하지만 보통은 영속 자료구조라고 부른다. (데이터베이스에 영속과는 다른 것이다.)

 

함수형 메서드에서는 전역 자료구조나 인수로 전달된 구조를 갱신할 수 없다.

자료 구조를 바꾼다면 같은 메서드를 두 번 호출했을 때 결과가 달라지면서 참조 투명성에 위배되고 인수를 결과로 단순하게 매핑할 수 있는 능력이 상실되기 때문이다.

파괴적인 갱신과 함수형

자료구조를 갱신할 때 발생할 수 있는 문제를 확인해보자.

A에서 B까지의 음식이 차례대로 나오는 코스요리가 있다고 가정해보자.

public class CourseDinner {
    public String dinnerName;
    public CourseDinner nextDinner;
    public CourseDinner(String dinnerName, CourseDinner nextDinner) {
        this.dinnerName = dinnerName;
        this.nextDinner = nextDinner;
    }
}

nextDinner로 다음 음식을 연결할 수 있는 구조를 가지고 있다.

단순한 명령형 메서드로 두 개의 CourseDinner 객체를 연결하려면 다음처럼 연결을 구현할 수 있다.

public CourseDinner link(CourseDinner a, CourseDinner b) {
    if (a == null) return b;
    CourseDinner t = a;
    while (t.nextDinner != null) {
        t = t.nextDinner;
    }
    t.nextDinner = b;
    return a;
}
CourseDinner courseDinner1 = new CourseDinner("아스파라거스 구이", new CourseDinner("대게라비올리", null));
CourseDinner courseDinner2 = new CourseDinner("전복구이", new CourseDinner("생선구이", null));
CourseDinner fullCourseDinner = link(courseDinner1, courseDinner2);
// CourseDinner{dinnerName='아스파라거스 구이', nextDinner=CourseDinner{dinnerName='대게라비올리', nextDinner=CourseDinner{dinnerName='전복구이', nextDinner=CourseDinner{dinnerName='생선구이', nextDinner=null}}}}

courseDinner1의 마지막 요리는 대게라비올리이지만 link 메서드로 두 개의 객체를 연결 후에는 생선구이로 바뀌게 된다.

즉 파괴적인 갱신(courseDinner1을 변경시킴)이 일어난다.

이렇게 되면 courseDinner1을 참조하고 있는 곳에서는 모두 영향을 미칠 것이고, 자료구조를 바꾸면서 생기는 버그를 어떻게 처리해야 할 것인지 문제를 해결해야 한다.

 

함수형에서는 이 같은 부작용을 수반하는 메서드를 제한하는 방식으로 문제를 해결한다.

계산 결과를 표현할 자료구조가 필요하면 기조의 자료구조를 갱신하지 않도록 새로운 자료구조를 만들어야 한다.

이는 표준 객체지향 프로그래밍의 관점에서도 좋은 기법이다. 이를 따르지 않으면 주석을 과도하게 남용할 수밖에 없다.

따라서 다음처럼 깔끔한 함수형 해결 방법을 사용하는 것이 좋다.

public CourseDinner append(CourseDinner a, CourseDinner b) {
    return a == null ? b: new CourseDinner(a.dinnerName, append(a.nextDinner, b));
}
CourseDinner courseDinner1 = new CourseDinner("아스파라거스 구이", new CourseDinner("대게라비올리", null));
CourseDinner courseDinner2 = new CourseDinner("전복구이", new CourseDinner("생선구이", null));
CourseDinner fullCourseDinner = append(courseDinner1, courseDinner2);
// CourseDinner{dinnerName='아스파라거스 구이', nextDinner=CourseDinner{dinnerName='대게라비올리', nextDinner=CourseDinner{dinnerName='전복구이', nextDinner=CourseDinner{dinnerName='생선구이', nextDinner=null}}}}

이 코드는 명확하게 함수형이며 기존 자료구조를 변경하지 않는다.

주의할 점은 사용자 역시 append의 결과를 갱신하지 말아야 한다는 것이다.

매번 새로운 객체를 만들어내는 것이 아니므로 결과를 변경하게 되면 시퀀스 b로 전달된 객체의 정보도 바뀔 수 있다.

트리를 사용한 다른 예제

HashMap 같은 인터페이스를 구현할 때는 이진 탐색 트리가 사용된다.

Tree는 문자열 키와 int값을 포함한다. 예를 들어 이름 키와 나이 정보 값을 포함할 수 있다.

class Tree {
    private String key;
    private int val;
    private Tree left, right;
    public Tree(String key, int val, Tree left, Tree right) {
        this.key = key;
        this.val = val;
        this.left = left;
        this.right = right;
    }
}
class TreeProcessor {
    public static int lookup(String k, int defaultVal, Tree t) {
        if (t == null) return defaultVal;
        if (k.equals(t.key)) return t.val;
        return lookup(k, defaultVal, k.compareTo(t.key) < 0 ? t.left : t.right);
    }
    // 트리의 다른 작업을 처리하는 기타 메서드
}

이제 이진 탐색 트리를 이용해서 문자열 값으로 int를 얻을 수 있다.

주어진 키와 연관된 값을 어떻게 갱신할 수 있는지 살펴보자.

public void update(String k, int newVal, Tree t) {
    if (t == null) { /* 새로운 노드를 추가해야 함 */ }
    else if (k.equals(t.key)) t.val = newVal;
    else update(k, newVal, k.compareTo(t.key) < 0 ? t.left : t.right);
} 

새로운 노드를 추가할 때는 주의해야 한다. 

가장 쉬운 방법은 update 메서드가 탐색한 트리를 그대로 반환하는 것이다.

(새로운 노드를 추가하지 않으면 기존 트리가 그대로 반환된다.)

 

하지만 이 방법은 그리 깔끔하지 못하다.

사용자는 update가 즉석에서 트리를 갱신할 수 있으며, 전달한 트리가 그대로 반환된다는 사실, 원래 트리가 비어있으면 새로운 노드가 반환될 수 있다는 사실을 모두 기억해야 하기 때문이다.

public Tree update(String k, int newVal, Tree t) {
    if (t == null)
        t = new Tree(k, newVal, null, null);
    else if (k.equals(t.key)) 
        t.val = newVal;
    else if (k.compareTo(t.key) < 0) 
        t.left = update(k, newVal, t.left);
    else 
        t.right = update(k, newVal, t.right);
    return t;
}

두 가지 update 버전 모두 기존 트리를 변경한다. 즉, 트리에서 저장된 맵의 모든 사용자가 변경에 영향을 받는다.

함수형 접근법 사용

위 트리의 문제를 함수형으로 어떻게 처리할 수 있을까?

우선 새로운 키/값 쌍을 저장할 새로운 노드를 만들어야 한다.

또한 트리의 루트에서 새로 생성된 노드의 경로에 있는 노드들도 새로 만들어야 한다.

public Tree fupdate(String k, int newVal, Tree t) {
    return (t == null) ?
        new Tree(k, newVal, null, null) :
        k.equals(t.key) ?
            new Tree(k, newVal, t.left, t.right) :
            k.compareTo(t.key) < 0 ? 
                new Tree(t.key, t.val, fupdate(k, newVal, t.left), t.right) :
                new Tree(t.key, t.val, t.left, fupdate(k, newVal, t.right));
}

위 코드에서는 if-then-else 대신 하나의 조건문을 사용했는데 이렇게 해서 위 코드가 부작용이 없는 하나의 표현식임을 강조했다. 하지만 취향에 따라 if-then-else 문으로 코드를 구현할 수 있다.

이전 update 메서드는 모든 사용자가 같은 자료구조를 공유하며 프로그램에서 누군가 자료구조를 갱신했을 때 영향을 받는 반면에 fupdate는 순수한 함수형이다.

fupdate에서는 새로운 Tree를 만든다. 하지만 인수를 이용해서 가능한 한 많은 정보를 공유한다.

 

이와 같은 함수형 자료구조를 영속(persistent)이라고 하며 따라서 프로그래머는 fupdate가 인수로 전달된 자료구조를 변화시키지 않을 것이라는 사실을 확신할 수 있다.

"결과 자료구조를 바꾸지 말라"는 것이 자료구조를 사용하는 모든 사용자에게 요구되는 단 한 가지 조건이다.

이를 지키지 않으면 의도치 않은, 원치 않는 갱신이 일어날 가능성이 있다.

 

어떤 사람은 "나는 일부 사용자만 볼 수 있게 트리를 갱신하면서도 다른 일부 사용자는 이를 알아차릴 수 없게 하고 싶다"라고 말한다.

이는 어떤 값을 갱신할 때 먼저 복사해야 하는지 주의 깊게 확인하는 고전적인 자바 해법과 변화가 일어나지 않도록 구현하는 함수형 해법 두 가지 방법이 있다.

즉, 갱신을 수행할 때마다 논리적으로 새로운 자료구조를 만든 다음에 변화가 일어나지 않도록 사용자에게 적절한 버전의 자료구조를 전달할 수 있다.

 

 

반응형