hanyugyeong 2023. 10. 5. 14:11
반응형
SMALL

이 장의 내용

  • 일급 시민, 고차원 함수, 커링, 부분 적용
  • 영속 자료구조
  • 자바 스트림을 일반화하는 게으른 평가와 게으른 리스트
  • 패턴 매칭, 자바에서 패턴 매칭을 흉내 내는 방법
  • 참조 투명성과 캐싱

이 장에서는 고급 함수형 프로그래밍 기법을 다룹니다. 이론적 지식뿐 아니라 실무에서 적용 가능한 기술을 배울 수 있습니다. 다루는 내용으로는 고차 함수, 커링, 영구 자료구조, 게으른 리스트, 패턴 매칭, 참조 투명성을 이용한 캐싱, 콤비네이터 등이 있습니다.

 

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

함수형 언어 프로그래머는 함수를 값으로 취급할 수 있음을 의미하는 일급 함수 개념을 폭넓게 사용합니다. 자바 8은 이전 버전과 구별되는 특징 중 하나로 일급 함수를 지원합니다. 이를 위해 메서드 참조나 람다 표현식으로 함숫값을 직접 표현하고, 함수를 인수나 결과로 사용할 수 있습니다. 예를 들어, 다음처럼 자바 8에서는 Integer.parseInt를 메서드 참조로 저장할 수 있습니다.

Function<String, Integer> strToInt = Integer::parseInt;

19.1.1 고차원 함수

지금까지 자바 8에서는 함숫값을 스트림 처리 연산이나 메서드 참조를 통해 동작 파라미터 화하는 용도로 활용해 왔습니다. 그러나 다음 코드처럼 Comparator.comparing과 같이 함수를 인수로 받아 다른 함수로 반환하는 정적 메서드를 사용하여 함숫값을 인수로 전달하고 반환하는 기능도 있습니다.

Comparator<Apple> c = comparing(Apple::getWeight);

// 함수를 조립해서 연산 파이프라인을 만들 때 위 코드와 비슷한 기능을 활용
Function<String, Integer> transformationPipeline = addHeader.andThen(Letter::checkSpelling)
                                                            .andThen(Letter::addFooter);

함수형 프로그래밍 커뮤니티에서는 Comparator.comparing과 같이 다음 중 하나 이상의 동작을 수행하는 함수를 고차원 함수(higher-order function)라고 부릅니다.

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

자바 8에서는 함수를 인수로 전달하고 결과로 반환할 뿐만 아니라 지역 변수로 할당하거나 구조체에 삽입할 수 있어서 고차원 함수라고 할 수 있습니다. 예를 들어, 문자열 'sin'을 Function <Double, Double>으로 매핑하는 Map <String, Function <Double, Double>>과 같은 코드가 가능합니다. 이와 비슷한 코드는 8장에서 팩토리 디자인 패턴을 설명할 때도 등장했습니다. 

19.1.2 커링

애플리케이션에서 국제화를 지원해야 할 때, 단위 변환 문제가 발생할 수 있습니다. 이때 변환 요소와 기준치 조정 요소가 단위 변환 결과를 결정하는 경우가 많습니다.

예를 들어 다음은 섭씨를 화씨로 변환하는 공식입니다.

CtoF(x) = x * 9 / 5 + 32

다음과 같은 패턴으로 단위를 표현할 수 있습니다.

  1. 변화 요소를 곱함
  2. 기준치 조정 요소를 적용

다음과 같은 메서드로 변환 패턴을 표현할 수 있습니다.

static double converter(double x, double f, double b) {
   return x * f + b;
}

단위 변환 문제를 해결하는 방법으로는 세 개의 인수로 받는 converter 메서드를 만들거나, 각각을 변환하는 메서드를 따로 만드는 방법이 있지만, 로직을 재활용하지 못하는 단점이 있습니다. 이때 커링(currying)이라는 개념을 활용해서 변환 함수를 생산하는 팩토리를 정의할 수 있습니다. 이 팩토리는 한 개의 인수를 갖는 변환 함수를 반환합니다. 다음은 커링이라는 개념을 활용해서 한 개의 인수를 갖는 변환 함수를 생산하는 팩토리를 정의하는 코드입니다.

static DoubleUnaryOperator curriedConverter(double f, double b) {
   return (double x) -> x * f + b;
}

위의 메서드에 변환 요소(f)와 기준치(b)를 넘겨주면 커링(currying)을 이용한 변환 함수를 반환하는 팩토리가 생성됩니다. 예를 들어, 아래 코드는 해당 팩토리를 이용하여 원하는 변환기를 생성하는 방법을 보여줍니다.

DoubleUnaryOperator convertCtoF = curriedConverter(9, 0/5, 32);
DoubleUnaryOperator convertUSDtoGBP = curriedConverter(0.6, 0);
DoubleUnaryOperator convertKmtoMi = curriedConverter(0.6214, 0);

DoubleUnaryOperator는 applyAsDouble이라는 메서드를 정의하므로 다음처럼 변환기를 사용할 수 있습니다.

double gbp = convertUSDtoCBP.applyAsDouble(1000);

위의 코드를 통해 기존의 변환 로직을 재활용하는 유연한 코드를 얻을 수 있습니다. 인수 x, f, b를 convert 메서드로 전달하는 대신, 함수를 요청할 때 f와 b 두 가지 인수만 전달하고 반환된 함수에 인수 x를 이용하여 변환 결과를 얻는 방식으로 코드를 작성합니다. 이러한 방식으로 다양한 변환 요소로 다양한 함수를 만들 수 있습니다. 이를 위해 커링(currying)이라는 기법을 활용합니다.

19.2 영속 자료구조

이 절은 함수형 프로그래밍에서 사용되는 자료구조에 대해 다루고 있습니다. 함수형 프로그래밍에서는 영속 자료구조라는 용어를 사용하며, 이는 불변 자료구조와 유사한 개념입니다. 이러한 자료구조는 함수형 메서드에서 전역 자료구조나 인수로 전달된 구조를 갱신할 수 없습니다. 이는 참조 투명성을 유지하기 위한 것입니다. 자료구조를 변경하면 같은 메서드를 두 번 호출했을 때 결과가 달라지며, 인수를 결과로 단순하게 매핑할 수 있는 능력이 상실됩니다. 따라서 함수형 프로그래밍에서는 영속 자료구조를 사용하여 참조 투명성을 유지하며, 안정적이고 예측 가능한 코드를 작성합니다.

19.2.1 파괴적인 갱신과 함수형

가변 TrainJourney 클래스는 A에서 B까지의 기차여행을 나타내며, 단방향 연결 리스트로 구현되어 있다. 각 여정에는 int 필드를 포함하여 상세 정보가 담겨있다. 다음 코드에서 보여주는 것처럼 기차여행에서는 여러 TrainJourney 객체를 연결할 수 있는 onward(이어지는 여정을 의미)라는 필드가 필요합니다. 직통열차나 여정의 마지막 구간에서는 onwardsk null이 됩니다.

class TrainJourney {
   public int price;
   public TrainJourney onward;
   public TrainJourney(int p, TrainJourney t) {
      price = p;
      onward = t;
   }
}

위 코드에서는 X에서 Y까지, 그리고 Y에서 Z까지의 여행을 나타내는 두 개의 TrainJourney 객체를 연결해서 하나의 여행을 만들 수 있다고 가정합니다. 이때, 기존의 명령형 메서드로는 TrainJourney 객체들을 연결할 수 있다.

static TrainJourney link(TrainJourney a, TrainJourney b) {
   if (a == null) return b;
   TrainJourney t = a;
   while (t.onward != null) {
      t = t.onward;
   }
   t.onward = b;
   return a;
}

위 코드에서는 두 개의 TrainJourney 객체를 연결하여 하나의 여행을 만드는 방법을 보여주지만, 파괴적인 갱신으로 인해 문제가 발생할 수 있다. 이를 해결하기 위해서는 자료구조를 바꾸면서 생기는 버그를 방지하는 방법을 고민해야 한다. 함수형 프로그래밍에서는 부작용을 제한하여 이러한 문제를 해결하며, 계산 결과를 표현할 자료구조가 필요하면 기존의 자료구조를 갱신하지 않고 새로운 자료구조를 만들어야 한다. 이를 통해 코드의 가독성과 유지보수성을 높일 수 있다. 주석을 과도하게 남용하는 것 대신 다음처럼 깔끔한 함수형 해결 방법을 사용하는 것이 좋다.

static TrainJourney append(TrainJourney a, TrainJourney b) {
   return a == null ? b : new TrainJourney(a.price, append(a.onward, b));
}

위 코드는 함수형 프로그래밍의 원칙을 따르면서도 기존 자료구조를 변경하지 않는다. TrainJourney 전체를 새로 만들지 않고, n 요소의 시퀀스와 m 요소의 시퀀스를 합쳐서 n+m 요소의 시퀀스를 반환한다. 이때 첫 번째 n 요소는 새로운 노드이며, 마지막 m 요소는 TrainJourney b와 공유된다. 주의해야 할 점은 append 함수의 결과를 갱신하지 말아야 하며, 만약 갱신하면 시퀀스 b로 전달된 기차 정보도 바뀔 수 있다는 것이다.

19.2.2 트리를 사용한 다른 예제

이진 탐색 트리는 HashMap과 같은 인터페이스를 구현하는 데 사용됩니다. 이 트리는 문자열 키와 int 값으로 구성된 요소를 가지며, 예를 들어 이름 키와 나이 정보 값으로 구성될 수 있습니다.

class Tree {
   private String key;
   private int val;
   private Tree left, right;
   public Tree(String k, int v, Tree l, Tee r) {
      key = k; val = v; left = l; right = r;
   }
}

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);
   }
   // 트리의 다른 작업을 처리하는 기타 메서드
}

주어진 키와 연관된 값을 갱신하려면 이진 탐색 트리에서 해당 키를 찾아 해당 노드의 값을 갱신하면 됩니다. 이진 탐색 트리는 탐색 시간이 매우 빠르므로 주어진 키를 찾아내는 데에는 효율적입니다. 따라서 키가 트리에 존재한다는 가정하에 해당 키를 찾은 후 값을 갱신할 수 있습니다.

public static 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 메서드가 탐색한 트리를 그대로 반환하는 것은 사용자가 트리를 직접 갱신해야 하며, 트리가 비어 있을 때는 새로운 노드가 반환될 수 있기 때문에 깔끔하지 않은 방법입니다. 사용자는 이러한 사실을 모두 기억해야 하므로 혼란스러울 수 있습니다.

public static 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 버전은 모두 기존 트리를 변경하므로, 트리에 저장된 맵의 모든 사용자가 변경에 영향을 받습니다. 따라서 다른 사용자들이 예기치 않게 변경된 값을 읽을 가능성이 있습니다.

 

19.2.3 함수형 접근법 사용

이 문제를 함수형으로 해결하려면 새로운 키/값 쌍을 저장할 새로운 노드를 생성하고, 해당 노드가 위치할 경로 상에 있는 모든 노드들도 새로 생성해야 합니다. 노드를 새로 만드는 과정은 일반적으로 그렇게 비용이 크지 않으므로, 트리의 크기가 2^d라면 재생성 과정은 일부 노드를 생성하는 과정에 불과합니다.

public static 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));
}

위 코드에서는 함수형 표현식을 사용하여 부작용이 없는 코드를 구현하고, fupdate를 사용하여 순수한 함수형 자료구조를 영속적으로 사용할 수 있습니다. 하지만 결과 자료구조를 바꾸지 말라는 조건은 모든 사용자에게 요구되며, 이를 무시하면 원치 않는 갱신이 발생할 수 있습니다.

fupdate는 일반적으로 기존 구조체를 변화시키지 않으므로 공통부분을 공유하여 효율적으로 사용할 수 있습니다. 하지만 final 필드에만 적용되므로 객체의 필드에 final을 적절하게 사용해야 합니다. 일부 사용자만 자료구조를 갱신하면서 다른 일부 사용자는 이를 알아차리지 못하도록 하기 위해서는 논리적으로 새로운 자료구조를 만들어 사용자에게 적절한 버전을 전달하는 API를 사용할 수 있습니다.

자바에서는 자료구조의 예전 버전이 자동으로 가비지 컬렉트 되므로, CD-R에 파일을 갱신하는 것과 비슷한 방식으로 여러 파일의 버전을 저장하고 적절한 파일 시작 주소 블록을 전달할 수 있습니다.

19.3 스트림과 게으른 평가

자바 8에서 추가된 스트림은 데이터 컬렉션 처리를 위한 편리한 도구입니다. 그러나 스트림은 단 한 번만 소비할 수 있는 제약 때문에 재귀적으로 정의할 수 없는 문제가 발생합니다. 이는 스트림을 재귀적으로 다룰 때 무한 반복이 일어날 수 있기 때문입니다. 따라서 이러한 문제를 해결하기 위해 스트림은 재귀적으로 정의될 수 없으며, 최종 연산 전까지 스트림을 소비하지 않도록 주의해야 합니다.

19.3.1 자기 정의 스트림

6장 예제에서는 소수를 생성하는 재귀 스트림을 살펴봅니다. 소수 스트림을 계산하는 코드는 아래와 같습니다.

public static Stream<Integer> primes(int n) {
   return Stream.iterate(2, i -> i + 1)
                .filter(MyMathUtils::isPrime)
                .limit(n);
}

public static boolean isPrime(int candidate) {
   int candidateRoot = (int) Math.sqrt((double) candidate);
   return IntStream.rangeClosed(2, candidateRoot)
                   .noneMatch(i -> candidate % i == 0);
}

다음은 소수로 나눌 수 있는 수를 제외하는 과정을 설명합니다.

  1. 소수를 선택할 숫자 스트림이 필요하다
  2. 스트림에서 첫 번째 수(스트림의 머리)를 가져온다. 이 숫자는 소수다(처음에 이 숫자는 2).
  3. 이제 스트림의 꼬리에서 가져온 수로 나누어 떨어지는 모든 수를 걸러 제외시킨다.
  4. 이렇게 남은 숫자만 포함하는 새로운 스트림에서 소수를 찾는다. 이제 1부터 다시 이 과정을 반복하게 된다. (재귀 알고리즘)

간단하지만 부족한 소수 생성 알고리즘을 스트림 API로 구현할 수 있습니다.

 

1단계 : 스트림 숫자 얻기

IntStream.iterate 메서드를 이용하면 2에서 시작하는 무한 숫자 스트림을 생성할 수 있습니다.

static IntStream numbers() {
   return IntStream.iterate(2, n -> n + 1);
}

2단계 : 머리 획득

IntStream은 첫 번째 요소를 반환하는 findFirst라는 메서드를 제공합니다.

static int head(IntStream numbers) {
   return numbers.findFirst().getAsInt();
}

3단계 : 꼬리 필터링

스트림의 꼬리를 얻는 대신 메서드를 정의합니다.

static IntStream tail(IntStream numbers) {
   return numbers.skip(1);
}

// 다음처럼 획득한 머리로 숫자를 필터링할 수 있다.
IntStream numbers = numbers();
int head = head(numbers);
IntStream filtered = tail(numbers).filter(n -> n % head != 0);

4단계 : 재귀적으로 소수 스트림 생성

다음 코드에서 보여주는 것처럼 반복적으로 머리를 얻어서 스트림을 필터링하려 할 수 있습니다.

static IntStream primes(IntStream numbers) {
   int head = head(numbers);
   return IntStream.concat{
      IntStream.of(head),
      primes(tail(numbers).filter(n -> n % head != 0))
   );
}

나쁜 소식

4단계 코드에서는 머리와 꼬리를 분리하기 위해 최종 연산인 findFirst와 skip을 사용했지만, 최종 연산은 스트림을 완전 소비하므로 "java.lang.IllegalStateException: stream has already been operated upon or closed." 에러가 발생합니다.

게으른 평가

위 코드에서는 재귀적으로 정의된 스트림을 처리하는데 발생하는 문제점과 자바 8에서 이를 해결하기 위해 제한을 두었음을 다룹니다. IntStream.concat 메서드에서 두 번째 인수를 primes를 직접 재귀적으로 호출하면 무한 재귀에 빠지는 문제가 발생합니다. 이러한 문제를 해결하기 위해서는 게으르게 평가하는 방식을 적용해야 합니다. 또한, 이와 같은 기능은 스칼라에서도 제공됩니다. 다음 예제는 스칼라로 알고리즘을 구현한 코드입니다. #:: 연산자는 게으른 연결을 담당합니다.

def numbers(n: Int): Stream[Int] = n #:: numbers(n+1)

def primes(numbers: Stream[Int]): Stream[Int] = {
   numbers.head #:: primes(numbers.tail filter (n -> n % numbers.head != 0))
}

위 코드는 자바와 다른 함수형 언어의 차이를 보여주는 것이므로 코드를 이해하기 어렵다는 걱정은 하지 않아도 된다. 자바에서는 메서드를 호출하면 인수가 모두 즉시 평가되지만, 스칼라에서는 #::를 사용한 연결식이 즉시 반환되고 필요한 시점에서만 각 요소가 평가되므로 게으른 리스트 평가를 구현할 수 있다.

19.3.2 게으른 리스트 만들기

자바 8의 스트림은 게으르게 동작하여 요청할 때만 값을 생성하며, 스트림에 연산을 적용한 경우에도 실제 계산은 최종 연산을 수행할 때만 이루어집니다. 이러한 특성으로 스트림에 여러 연산을 적용할 때 한 번에 처리할 수 있습니다. 이번 절에서는 스트림의 일반적인 형태인 게으른 리스트의 개념과 고차원 함수를 지원한다는 점을 살펴봅니다. 게으른 리스트는 함숫값을 자료구조에 저장하여 호출하지 않은 상태로 보관할 수 있으며, 필요한 시점에서 함숫값을 호출하여 더 많은 자료구조를 만들 수 있습니다.

기본적인 연결 리스트

다음 코드처럼 MyLinkedList라는 단순한 연결 리스트 형태의 클래스를 정의할 수 있습니다.

interface MyList<T> {
   T head();
   
   MyList<T> tail();
   
   default boolean isEmpty() {
      return true;
   }
}

class MyLinkedList<T> implements MyList<T> {
   private final T head;
   private final MyList<T> tail;
   public MyLinkedList(T head, MyList<T> tail) {
      this.head = head;
      this.tail = tail;
   }
   
   public T head() {
      return head;
   }
   
   public MyList<T> tail() {
      return tail;
   }
   
   public boolean isEmpty() {
      return false;
   }
}

class Empty<T> implements MyList<T> {
   public T head() {
      throw new UnsupportedOperationException();
   }
   
   public MyList<T> tail() {
      throw new UnsupportedOperationException();
   }
}

다음처럼 MyLinkedList 값을 만들 수 있습니다.

MyList<Integer> l = new MyLinkedList<>(5, new MyLinkedList<>(10, new Empty<>()));

기본적인 게으른 리스트

Supplier <T>를 이용하여 필요한 시점에만 데이터를 생성하는 게으른 리스트를 구현할 수 있으며, 이를 통해 꼬리가 모두 메모리에 존재하지 않도록 하여 메모리 사용을 최적화할 수 있습니다. 다음은 게으른 리스트를 만드는 코드입니다.

import java.util.function.Supplier;

class LazyList<T> implements MyList<T> {
   final T head;
   final Supplier<MyList<T>> tail;
   public LazyList(T head, Supplier<MyList<T>> tail) {
      this.head = head;
      this.tail = tail;
   }
   
   public T head() {
      return head;
   }
   
   public MyList<T> tail() {
      return tail.get(); // 위의 head와 달리 tail에서는 Supplier로 게으른 동작을 만들었다.
   }
   
   public boolean isEmpty() {
      return false;
   }
}

위 코드는 Supplier의 get 메서드를 호출하여 LazyList의 노드를 생성하는 방법을 보여주고, n으로 시작하는 무한히 게으른 리스트를 생성하는 방법을 제공합니다. 이를 위해 생성자에 tail 인수로 Supplier를 전달하며, 필요한 시점에만 데이터를 생성하는 게으른 리스트를 구현할 수 있습니다.

public static LazyList<Integer> from(int n) {
   return new LazyList<Integer>(n, () -> from(n + 1));
}

위 코드는 필요한 시점에만 데이터를 생성하는 게으른 리스트를 구현하는 방법을 보여줍니다. 코드를 실행하면 숫자는 요청했을 때 생성되며, 이를 확인하기 위해 System.out.println을 추가할 수 있습니다. 코드가 실행될 때 미리 모든 수를 계산하려 한다면, 프로그램이 영원히 종료되지 않을 수 있습니다.

LazyList<Integer> numbers = from(2);
int two = numbers.head();
int three = numbers.tail().head();
int four = numbers.tail().tail().head();

System.out.println(two + " " + three + " " + four);

소수 생성으로 돌아와서

지금 까지 만든 코드 LazyList를 이용하여 게으른 소수 리스트를 생성하는 방법을 제공하며, 스트림 API에 LazyList를 적용하여 필요한 시점에만 데이터를 생성하여 메모리 사용을 최적화할 수 있습니다.

public static MyList<Integer> primes (MyList<Integer> numbers) {
   return new LazyList<>(
      numbers.head(),
      () -> primes(
         numbers.tail()
                .filter(n -> n % numbers.head() != 0)
      )
   
   );
}

게으른 필터 구현

위 코드는 LazyList에 filter 메서드가 없기 때문에 컴파일 에러가 발생합니다. 이를 해결하기 위해 filter 메서드를 LazyList 클래스에 정의하여 사용할 수 있도록 합니다.

public MyList<T> filter(Predicate<T> p) {
   return isEmpty() ?
          this :
          p.test(head()) ?
             new LazyList<>(head(), () -> tail().filter(p)) :
             tail().filter(p);
}

위 코드에서 LazyList의 tail과 head 호출을 연결하여 처음 세 개의 소수를 계산할 수 있습니다.

LazyList<Integer> numbers = from(2);
int two = primes(numbers).head();
int three = primes(numbers).tail().head();
int five = primes(numbers).tail().tail().head();

System.out.println(two + " " + three + " " + five);

위 코드를 이용하여 모든 소수를 출력하는 방법을 제공하며, 이를 위해 head 메서드를 반복 호출하여 모든 소수를 출력할 수 있습니다.

static <T> void printAll(MyList<T> list) {
   while (!list.isEmpty()) {
      System.out.println(list.head());
      list = list.tail();
   }
}

printAll(primes(from(2)));

이 장에서는 함수형 프로그래밍을 설명하고 있으므로 다음처럼 재귀적으로 문제를 깔끔하게 해결할 수 있습니다.

static <T> void printAll(MyList<T> list) {
   if (list.isEmpty())
      return;
   System.out.println(list.head());
   printAll(list.tail());
}

위 코드는 자바에서 꼬리 호출 제거를 지원하지 않기 때문에, 스택 오버 플로우가 발생할 수 있어서 무한히 실행되지 않습니다.

드디어 완성!

자바 8에서 함수를 자료구조 내부로 추가하여 요청 시점에 실행되는 기능을 구현할 수 있다는 것을 확인하고, 이를 통해 게으른 리스트를 구현하여 게임 프로그램 등에서 활용할 수 있다는 것을 알게 되었다. 게으른 실행으로 인한 오버헤드 문제도 있지만, alreadyComputed 필드를 추가하여 해결할 수 있다. 패턴 매칭은 함수형 프로그래밍 언어에서 지원되는 기능이지만, 자바에서는 지원되지 않으므로 대안을 찾아야 한다.

19.4 패턴 매칭

패턴 매칭은 함수형 프로그래밍을 구분하는 또 하나의 중요한 특징이다.

19.4.1 방문자 디자인 패턴

방문자 디자인 패턴을 사용하면 자료형을 언랩 할 수 있으며, 특정 데이터 형식을 '방문'하는 알고리즘을 캡슐화할 수 있다.

방문자 클래스는 지정된 데이터 형식의 인스턴스를 입력으로 받고, 인스턴스의 모든 멤버에 접근한다.

class BinOp extends Expr {
  ...
  public Expr accept(SimpiftExprVisitor v) {
    return v.visit(this);
  }
}

public class SimpiftExprVisitor {
   ...
   public Expr visit(BinOp e) {
     if("+".equal(e.opname) && e.right instanceof Number && ...) {
       return e.left;
     }
     return e;
   }
 }

19.4.2 패턴 매칭의 힘

자바는 패턴 매칭을 지원하지 않지만 스칼라 언어에서는 패턴 매칭을 사용해 간결하고 명확한 코드를 구현할 수 있습니다.

def simplifyExpression(expr: Expr): Expr = expr match {
   case BinOp("+", e, Number(0)) => e  //0 더하기
   case BinOp("*", e, Number(1)) => e  //1 곱하기
   case BinOp("/", e, Number(1)) => e  //1 나누기
   case _ => expr //expr을 단순화할 수 없다.
}

패턴 매칭을 지원하는 언어의 가장 큰 실용적인 장점은 커다란 switch 문이나 if-then-else 문을 피할 수 있다는 것입니다.

자바로 패턴 매칭 흉내 내기

def simplifyExpression(expr: Expr): Expr = expr match {
   case BinOp("+", e, Number(0)) => e  //0 더하기
   ...

위 스칼라 코드와 달리 자바 8의 람다를 이용한 패턴 매칭은 단일 수준의 패턴 매칭만을 지원하며, 규칙을 정한 후 람다를 사용하고 if-then-else를 사용하지 않는 것이 요구됩니다.

myIf(conditition, () -> e1, () -> e2);

static <T> T myIf(boolean b, Supplier<T> truecase, Supplier<T> falsecase) {
   return b ? truecase.get() : falsecase:get();
}

BinOp와 Number 두 서브클래스를 포함하는 Expr 클래스의 패턴 매칭 값으로 돌아와서 patternMatchExpr이라는 메서드를 정의할 수 있습니다.

interface TriFunction<S, T, U, R> {
   R apply(S s, T t, U u);
}

static <T> T patternMatchExpr(
                        Expr e,
                        TriFunction<String, Expr, Expr, T> binopcase,
                        Function<Integer, T> numcase,
                        Supplier<T> defaultCase) {
   return
    (e instanceof Binop) ? 
      binopcase.apply(((BinOp)e).opname, ((BinOp)e).left,
                                         ((Binop)e).right) : 
    (e instanceof Number) ?
      numcase.apply(((NUmber)e).val) : 
      defaultcase.get();
}

다음 코드를 살펴봅니다.

patternMatchExpr(e, (op, 1, r) -> {return binopcode;},
                    (n) -> {return numcode;},
                    () -> {return defaultcode;});

람다를 이용한 패턴 매칭 흉내내기는 e가 BinOp인지, Number인지 확인하고 defaultCode를 실행합니다.

다음 예제는 PatternMatchExpr을 이용해서 덧셈과 곱셈 표현식을 단순화하는 방법을 보여줍니다.

public static Expr simplify(Expr e) {
   TriFunction<String, Expr, Expr, Expr> binopcase =
      (opname, left, right) -> {
         if ("+".equals(opname)) {
            if (left instanceof Number && ((Number) left).val == 0) {
               return right;
            }
            if (right instanceof Number && ((Number) right).val == 0) {
               return left;
            }
         }
         if ("*".equals(opname)) {
            if (left instanceof Number && ((Number) left).val == 1) {
               return right;
            }
            if (right instanceof Number && ((Number) right).val == 1) {
               return left;
            }
         }
         return new BinOp(opname, left, right);
      };
   Function<Integer, Expr> numcase = val -> new Number(val); //숫자 처리
   Supplier<Expr> defaultcase = () -> new Number(0);
  
   return patternMatchExpr(e, binopcase, numcase, defaultcase);
}

// 다음처럼 simplify 메서드를 호출할 수 있습니다.
Expr e = new BinOp("+", new Number(5), new Number(0));
Expr match = simplify(e);
System.out.println(match);

19.5 기타 정보

이 절에서는 함수형 프로그래밍에서 효율성과 같은 결과를 반환하는 것과 관련된 두 가지 세부 주제를 다룹니다. 또한, 두 개 이상의 함수를 인수로 받아 다른 함수를 반환하는 콤비네이터 개념도 살펴봅니다. 콤비네이터는 자바 8 API에 영감을 주는 중요한 기능 중 하나입니다.

19.5.1 캐싱 또는 기억화

노드의 수를 계산하는 computeNumberOfNodes(Range)라는 부작용 없는 메서드가 있다고 가정할 때, 이를 재귀적으로 탐색해야 하므로 노드 계산 비용이 많이 듭니다. 이때 참조 투명성을 유지하면서 추가 오버헤드를 피할 수 있는 기법으로는 기억화가 있습니다. 기억화는 메서드에 캐시를 추가하는 기법으로, 래퍼가 호출되면 인수, 결과 쌍이 캐시에 존재하는지 먼저 확인합니다. 캐시에 값이 존재하면 해당 값을 즉시 반환하고, 존재하지 않으면 계산 후 결과를 반환하고 인수, 결과 쌍을 캐시에 저장합니다. 이는 순수 함수형 해결방식은 아니지만, 감싼 버전의 코드는 참조 투명성을 유지할 수 있습니다. 다음은 캐싱을 사용하는 예제 코드입니다.

final Map<Range, Integer> numberOfNodes = new HashMap<>();
Integer computeNumberOfNodeUsingCache(Range range) {
   Integer result = numberOfNodes.get(range);
   if (result != null) {
      return result;
   }
   result = computeNumberOfNodes(range);
   numberOfNodes.put(range, result);
   return result;
}

메서드 computeNumberOfNodesUsingCache는 참조 투명성을 갖지만, numberOfNodes는 공유된 가변 상태를 포함하며 HashMap은 동기화되지 않았기 때문에 스레드 안전성이 없습니다. 따라서 다중 코어에서 동시에 호출할 경우 레이스 컨디션이 발생할 수 있습니다. 이를 해결하기 위해 가장 좋은 방법은 함수형 프로그래밍을 사용하여 동시성과 가변 상태가 만나는 상황을 완전히 없애는 것입니다. 이를 통해 코드의 동기화 문제를 해결할 필요가 없어지므로 코드를 함수형으로 구현했다면 동기화 등을 신경 쓸 필요가 없어집니다.

19.5.3 콤비네이터

함수형 프로그래밍에서는 함수를 인수로 받아 또 다른 함수를 반환하는 고차원 함수, 즉 콤비네이터를 많이 사용합니다. 자바 8 API에 추가된 많은 기능들도 이러한 콤비네이터의 영향을 받았습니다. 함수 조합이라는 개념을 살펴보면 함수형 프로그래밍에서 함수를 조합하여 새로운 함수를 만들 수 있다는 것을 알 수 있습니다.

static <A, B, C> Function<A, C> compose(Function<B, C> g, Function<A, B> f) {
   return x -> g.apply(f.apply(x));
}

함수 repeat는 n번 반복하여 함수 f를 실행하는 함수이다. compose 함수를 사용하여 repeat 함수를 정의하였다. repeat 함수를 호출할 때는 먼저 인수로 함수를 전달하고, 다음으로 실행할 횟수 n을 전달한다. 호출 결과로 f를 n번 실행한 결과가 출력된다.

repeat(3, (Integer x) -> 2 * x);

// 위 코드를 실행하면
// x -> (2 * (2 * (2 * x))) 또는 x -> 8 * x라는 결과가 나옵니다.

// 다음 코드로 결과를 확인할 수 있습니다. 코드를 실행하면 80이 출력됩니다.
System.out.println(repeat(3, (Integer x) -> 2 * x).apply(10);

// repeat 메서드를 다음처럼 구현할 수 있습니다. (루프를 한 번도 돌지 않은 상황은 예외적으로 처리함)
static <A> Function<A, A> repeat(int n, Function<A, A> f) {
   return n == 0 ? x -> x
                 : compose(f, repeat(n -1, f));
}

이 개념을 활용하면 반복 과정에서 전달되는 가변 상태 함수형 모델 등 반복 기능을 좀 더 다양하게 활용할 수 있습니다.

반응형
LIST