열렬히.뛰기

모던 자바 19. 함수형 프로그래밍 기법

Language > 프로그래밍 언어 > Java > 모던 자바 > 모던 자바 19. 함수형 프로그래밍 기법

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

함수가 입력값 + 함수 내 지역변수만으로 결과값을 내며,
전역 변수라는 개념이 없고
입력값을 변경하지 않는다면...

함수 자체가 일종의 변수 아닐까?

그래서 나온 개념이 바로 **일급함수**

자바는 어떻게 일급함수를 지원하는가?

1. 고차원 함수 (Higher-Order Function)

📌 정의 : 함수를 인자로 받거나, 함수를 반환하는 함수
이 두 가지 중 하나라도 만족하면 고차원 함수임.

1️⃣ 함수를 인자로 받는 경우
자바 8 이후에는 함수 자체를 넘길 수 있음.
(정확히는 함수형 인터페이스를 넘김)

Function<Integer, Integer> f = x -> x + 1;

public static int applyTwice(Function<Integer, Integer> f, int x) {
    return f.apply(f.apply(x));
}

✔ Function을 파라미터로 받는다 → 고차원 함수

💡 왜 중요하냐?
고차원 함수가 있으면

  • 로직을 재사용할 수 있음
  • 동작을 파라미터화할 수 있음
  • 조건/전략을 함수로 전달 가능

2. 커링 (currying)

다중 인자 함수를 인자 하나짜리 함수들의 체인으로 바꾸는 것

ex. “가격에 세율을 적용해서 최종 가격을 계산한다”

  • 커링이 적용된 경우
java
public static double calculate(double price, double taxRate) {
    return price * (1 + taxRate);
}

double result = calculate(100, 0.1); // 110
  • BiFunction 이라는 다중인자 전용 인터페이스를 사용하면?
  • (함수 정의를 아래처럼 선언과 함께 하고, apply라는 메서드를 써서 실행한다.)
java
BiFunction<Double, Double, Double> calculate =
        (price, taxRate) -> price * (1 + taxRate);

double result = calculate.apply(100.0, 0.1);

🚨 문제 : 세율이 항상 10%라면?
👉 계속 0.1을 넘겨야 함
👉 세율이 “설정값”인데 매번 전달해야 함
👉 함수 조합하기 애매함 (BiFunction은 Stream에서 바로 쓰기 불편)
그래서 커링을 써보면...

Function<Double, Function<Double, Double>> calculate =
        taxRate -> price -> price * (1 + taxRate);
        
Function<Double, Double> tax10Calculator = calculate.apply(0.1);

double r1 = tax10Calculator.apply(100.0); // 110
double r2 = tax10Calculator.apply(200.0); // 220

💡 정리 : 커링을 왜 쓰냐?

  • 일부 인자를 고정한 함수를 만들 수 있음
  • 함수 조합이 쉬워짐
  • 설정된 함수(설정값 고정) 만들기 좋음

예:

  • 세율 고정
  • 할인율 고정
  • 환율 고정

이게 “설정된 전략 함수”를 만드는 방식임.

영속 자료구조

  • 데이터를 수정할 때 기존 데이터를 변경하지 않고 새로운 버전을 만드는 자료구조를 영속 자료구조라고 한다.
  • 업데이트 → 새로운 자료구조 생성 이라는 뜻!

일반 방식

list = [1,2,3]

list.add(4)

list = [1,2,3,4]

영속 자료구조

list1 = [1,2,3]

list2 = add(list1, 4)

list1 = [1,2,3]
list2 = [1,2,3,4]

❖ 중요한 특징 (진짜 핵심)

영속 자료구조는 복사(=깊은 복사)를 많이 하지 않는다.

대신 구조 공유 (structural sharing)를 사용한다.

구조 공유란? 새 자료구조를 만들 떄 같은 구조를 공유(=얕은 복사)한 뒤

새로운 걸 추가.

특정 자료구조에서 이는 굉장히 유용

ex. 트리

  • 리스트에서는 차이가 작아 보이나, 트리에서는 엄청 커진다.
  • 예를 들어 아래와 같은 트리가 있다고 하자.
        8
      /   \
     3     10
    / \
   1   6
  • 여기서 6을 7로 업데이트한다고 하면,
    • 복사 (깊은 복사)면 모든 노드를 새로 만들지만,
    • 구조 공유 (structural sharing)는 일부만 새로 만든다.
  • 새로 만드는 건 다음과 같은 3개다 : 8', 3', 7
        8'
      /   \
     3'    10
    / \
   1   7

️❖ 중요한 포인트

자료구조의 진짜 장점은 성능이 아니라...

내가 만든 모든 자료구조를 동시에 사용할 수 있다.

version1
version2
version3

따라서,

  • concurrency
  • undo
  • snapshot
  • time travel debugging

같은 것들이 가능하다.

코드 예제 1

  • 코드 1
  • 코드 2
  • 링크리스트를 영속 자료구조로 만들기 vs 링크리스트를 파괴적 자료구조로 만들기
  • 영속 자료구조로 만들기에 쓴 함수가 append()
    • append로 appended, appended2를 만들었을 때 결과는 항상 같음.
    • 그러나 appended, appended2는 다른 자료구조. (메모리 주소가 다름)
  • 파괴적 자료구조로 만들기에 쓴 함수가 link()
    • link를 두 번 실행하면 문제가 생긴다; 순환구조 발생

코드 예제 2

  • 코드
  • 해당 트리는 이진 탐색 트리 (BST).
  • main에서 생성된 트리는 이렇게 생겼다
        Mary:22
       /      \
 Emily:20    Tian:29
   /    \       /
Alan:50 Georgie:23 Raoul:23
  • 영속 자료구조로 업데이트하면 : 기존 트리 t는 그대로 유지된다
  • 파괴적 자료구조로 업데이트하면 : 기존 트리 t를 수정.

스트림과 게으른 평가

우리가 자주 쓰던 Stream API도 함수형 언어의
무언가를 따온 기능이다.

예시 : 소수 생성기

소수 생성기를 스트림을 이용해 만들어보자.

ver.1

ver.2

  • Stream API를 이용해 좀 더 개선된 코드를 구현해보자

  • 에라스토테네스의 체를 구현한다고 보면 된다.

  • 개선된 코드

  • 그런데, 에러가 뜬다.

  • 왜? Java Stream의 구조 때문

  • Java Stream은 한번만 소비 가능한데, 여기서는 여러 번 소비하는 구조로 되어 있다.

public static void main(String[] args) {
    IntStream numbers = numbers();
    int head = head(numbers);
    IntStream filtered = tail(numbers).filter(n -> n % head != 0);
    primes(filtered).forEach(System.out::println);
    
    // head(), tail() 같은 것이 스트림에 없기에 이렇게 나눠서 써야 함.
}

ver.3

  • 따라서 새로운 대안이 필요하다.

  • 우선 인터페이스 MyList를 만드고

  • 이걸 활용한 Empty,

  • MyLinkedList 클래스,

  • LazyList 클래스를 만들어보자

  • main()에 해당하는 CalculatePrime3 도 만들자.

  • CalculatePrime3을 보면, MyLinkedList를 활용하는 부분과 LazyList를 활용하는 부분이 있음

  • MyLinkedList와 LazyList의 차이는?

  • MyLinkedList : 리스트 전체가 이미 만들어져 있음

  • LazyList : tail이 리스트가 아니라 "함수"다.

  • 필요할 때 tail.get()으로 생성하며, filter 역시 필요할 때 생성.

public class LazyList<T> implements MyList<T> {
    final T head;
    final Supplier<MyList<T>> tail;
    
    ....

    @Override
    public MyList<T> tail() {
        return tail.get();
    }

    @Override
    public MyList<T> filter(Predicate<T> p) {
        return isEmpty() ? this :
                p.test(head()) ?
                        new LazyList<>(head(), () -> tail().filter(p)) :
                        tail().filter(p);
    }
}
  • 왜? Supplier를 사용하니까.

  • Supplier = 값을 “나중에” 만들어주는 함수로 자바의 interface 구현체

  • 정리해보자면,

  1. CalculatePrime2 :
    • Stream을 이용해 에라토스테네스의 체를 구현하려고 한 코드
    • 하지만 재귀가 즉시 실행되기 때문에 실패함
    • Stream은 중간 연산은 lazy지만, 재귀 구조 자체를 lazy하게 만들 수는 없다.
  2. CalculatePrime3 + MyLinkedList :
    • Stream 대신 직접 만든 리스트 자료구조
    • 재귀 기반으로 구현
    • 리스트 전체가 한 번에 계산되어 무한히 지속 불가
  3. CalculatePrime3 + LazyList :
    • tail을 Supplier로 지연 계산하여 LazyList를 만들면 무한 리스트와 같은 함수형 자료구조를 구현할 수 있다.
    • Supplier를 사용해 tail 계산을 지연시키면 LazyList가 되고,
    • 이를 통해 무한 리스트와 같은 함수형 자료구조를 구현할 수 있다.
  • 이러한 계산 시점의 늦춰짐(lazy evaluation)은 병렬 처리에 도움을 준다.

    • 필요 없는 계산을 줄인다
    • pipeline 방식으로 element 단위 처리
    • data parallelism 가능
    • 공유 상태가 없다
  • 즉 자바의 스트림은 사실은 함수형 언어의 게으른 자료구조 아이디어를 가지고 온 것이다.

  • 하지만 자료구조 자체가 lazy한 것은 아니고, 연산(pipeline)만 lazy하다.

패턴 매칭

  • 데이터의 구조에 따라 다른 로직을 실행하는 방식
  • 게으른 자료구조처럼 원래 함수형 언어에 있는 개념이라고 보면 된다.
  • ex. 하스켈에서의 예시
haskell
eval (Number n) = n
eval (Add e1 e2) = eval e1 + eval e2
eval (Multiply e1 e2) = eval e1 * eval e2
  • 자바에서 이걸하면...
int eval(Expr e) {
    if (e instanceof Number) {
        return ((Number) e).value;
    }

    if (e instanceof Add) {
        Add a = (Add) e;
        return eval(a.left) + eval(a.right);
    }

    if (e instanceof Multiply) {
        Multiply m = (Multiply) e;
        return eval(m.left) * eval(m.right);
    }

    throw new IllegalArgumentException();
}
  • 문제점

    • 타입을 외부에서 일일이 검사함.
    • 다형성을 제대로 활용하지 못함. Expr라는 인터페이스를 만들었는데도 일일히 어떤 타입인지 묻고 있음
    • if문이 너무 많음.
  • 따라서 자바만을 위한 대안이 필요하다.

  • 책에서 먼저 언급하고 있는 것이 방문자 디자인 패턴이다.

1. 방문자 디자인 패턴

  • 객체 구조는 그대로 유지하면서 새로운 연산(기능)을 쉽게 추가할 수 있도록 하는 디자인 패턴이다.
  • 즉, 객체 내부에 기능을 계속 추가하는 대신 외부 Visitor 객체에게 작업을 위임한다.
  • 핵심 아이디어: 객체 구조(데이터)와 객체에 적용되는 연산(기능)을 분리한다

다음과 같은 객체 구조가 있다고 했을 때,

Shape (interface)
 ├─ Circle (class)
 └─ Rectangle (class)

기능을 넣고 싶으면, Visitor 객체에게 이를 맡긴다.

Shape (interface)
 ├─ Circle (class)
 └─ Rectangle (class)

Visitor (interface)
 ├─ AreaVisitor (class)
 └─ ChangeVisitor (class)

이걸로도 패턴 매칭을 흉내낼 수 있지만,
진짜 패턴매칭도 알아보자!

2. 패턴매칭

  • 작성 코드는 다음과 같다
  • 그런데 이것도 완벽한 패턴매칭은 아니다.
  • 왜? 계속 instanceof를 쓰고 함수가 커지는 구조이기 때문에...
  • 결국 Java는 전통적으로 패턴 매칭 언어가 아니기 때문에 생기는 일이다.
  • 즉, 자바라는 언어의 한계가 있다는 뜻이다.