19장: 함수형 프로그래밍 기법

고차원 함수

  • 함수형 프로그래밍에서는 함수를 일반값처럼 취급하여 인수로 전달하거나 결과로 반환하거나 자료구조에 저장할 수 있다. 이러한 행위가 가능한 함수를 일급 함수라고 부른다.

  • 자바 8은 이전 버전들과 달리 일급 함수를 지원하여 :: 연산자로 메서드 참조를 만들거나 람다 표현식을 이용해 일급 함수로 사용할 수 있다.

Function<String, Integer> strToInt = Integer::parseInt;
  • 고차원 함수 함수를 인수로 받아 다른 함수를 반환하는 함수이다.

  • 아래와 같이 Comparator의 comparing 메서드는 Function 형태의 함수를 입력받아 Comparator 형태의 함수를 반환한다.

Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
  • 부작용을 포함하는 함수를 사용한다면 일관되지 않은 결과나 race condition이 발생할 수 있다. 따라서 고차원 함수 역시 인수가 부작용을 포함하는 함수일 가능성을 대비하여 정확히 문서화하는 것이 좋다.

커링

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

  • 즉, f(x, y) = (g(x))(y) 가 성립해야 한다.

  • 예를 들어, 애플리케이션에서 국제화를 지원하면서 단위 변환 문제가 발생할 수 있다.

  • 기존 자바 버전에서 사용하던 방식으로 아래와 같은 메서드를 정의해 사용할 수 있다. x는 구체적인 변수 값, f, b는 변환을 위한 공식 값이다.

static double converter(double x, double f, double b) {
   return x * f + b;
}
  • 다음은 커링을 이용해 단위 변환 전용 메서드를 쉽게 만들 수 있는 방식이다. 변환 공식에 의한 f, b 값을 입력받는 팩토리 메서드를 만들고, 이 메서드에 의해 나온 변환기를 사용하는 방식이다.

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

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

void test() {
   DoubleUnaryOperator convertCtoF = curriedConverter(9, 0/5, 32);
   DoubleUnaryOperator convertUSDtoGBP = curriedConverter(0.6, 0);
   DoubleUnaryOperator convertKmtoMi = curriedConverter(0.6214, 0);
   double gbp = convertUSDtoGBP.applyAsDouble(1000);
}

영속 자료구조

  • 함수형 메서드에서는 참조 투명성에 위배되지 않고 인수를 결과로 단순히 매핑하기 위해 전역 자료구조나 인수로 전달된 구조를 갱신할 수 없게 되어있다.

  • 만약 함수 내에서 외부의 자료구조를 갱신하게 된다면 해당 자료구조를 참조하던 다른 함수에서 혼란에 빠질 것이다.

  • 따라서 함수형 프로그래밍에서는 이러한 함수가 생기지 않도록 제한한다.

  • 아래 예제에서는 TrainJourney라는 클래스가 다음 행선지인 TrainJourney 클래스를 이어서 갖고 있다. append 메서드는 입력 인자(기존 자료구조)를 변경하는 형태가 아니라 새로운 객체를 만들어 반환값을 만드는 형태로 동작한다.

class TrainJourney {
   public int price;
   public TrainJourney next;
   public TrainJourney(int price, TrainJourney next) {
      price = price;
      next = next;
   }
   
   static TrainJourney append(TrainJourney a, TrainJourney b) {
      return a == null ? b : new TrainJourney(a.price, append(a.next, b));
   }
}
  • 이렇게 저장된 값이 외부에 영향을 받지 않는 영속 자료구조를 다루는 함수를 사용해 기존 구조를 변화시키지 않을 수 있다.

  • 주의할 점은 함수에서 반환된 자료구조를 바꾸지 않아야 한다는 것이다. 만약 변경하게 된다면 의도치 않은 갱신이 발생할 수 있다.

LazyList

  • 스트림은 단 한번만 소비할 수 있으므로 재귀적으로 정의할 수 없다.

  • 스트림에 일련의 연산을 적용하면 바로 수행되지 않고 최종 연산을 적용해 실제 계산을 해야할 때 수행된다.

  • 게으른 특성으로 인해 각 연산 별로 스트림을 탐색할 필요 없이 한 번에 여러 연산을 처리할 수 있다.

  • 이를 이용해 함수를 자료구조에 저장해 사용하지 않은 상태로 저장하는 LazyList를 구현할 수 있다. LinkedList의 원소는 메모리에 존재하지만, LazyList의 원소는 함수가 호출되어야 생성될 것이다.

  • 아래는 LazyList 구현 코드이다.

class LazyList<T> {
   final T head;
   final Supplier<LazyList<T>> tail;
   public LazyList(T head, Supplier<LazyList<T>> tail) {
      this.head = head;
      this.tail = tail;
   }
   
   public T head() {
      return head;
   }
   
   public LazyList<T> tail() {
      return tail.get();
   }
   
   public boolean isEmpty() {
      return false;
   }
   
   public MyList<T> filter(Predicate<T> p) {
   return isEmpty() ?
          this :
          p.test(head()) ?
             new LazyList<>(head(), () -> tail().filter(p)) :
             tail().filter(p);
   }
}
  • 아래와 같이 계속해서 증가하는 수를 반환하는 Supplier를 입력하여도 모든 수가 미리 계산되는 것이 아닌 필요한 시점에 생성되는 것을 확인할 수 있다.

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

void test() {
   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); // 2 3 4
}
  • 아래와 같이 소수를 담는 LazyList를 생성하는 메서드를 만들고, LazyList의 tail 메서드가 호출될 때마다 새로운 소수가 계산되도록 할 수 있다.

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

void test() {
   LazyList<Integer> list = primes(from(2)); // 2 이상의 소수를 담는 리스트

   while (!list.isEmpty()) {
      System.out.println(list.head());
      list = list.tail();
   }
}

패턴 매칭

  • 아래와 같이 숫자를 나타내는 Number 클래스가 있고 연산을 나타내는 BinOp 클래스가 있다고 한다. 이 때 new Bin("+", new Number(5), new Number(0))Number(5) 로 단순화할 수 있다.

class Expr { ... }
class Number extends Expr { ... }

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

  • 방문자 디자인 패턴이란 지정된 데이터 형식의 인스턴스를 입력받고 모든 멤버에 접근하는 방식이다.

  • BinOp 클래스에 SimpiftExprVisitor라는 방문자 클래스를 입력받는 accept 메서드를 추가해두고, 해당 방문자에게 본인 클래스를 전달한다. 이를 통해 방문자 클래스는 BinOp 객체를 unwrap하여 사용할 수 있다.

  • 방문자 디자인 패턴의 자료형을 unwrap하는 형태가 패턴 매칭에서도 사용된다.

public class SimpiftExprVisitor {
   ...
   public Expr visit(BinOp e) {
     if("+".equal(e.opname) && e.right instanceof Number && ...) {
       return e.left;
     }
     return e;
   }
 }
  • 아래는 BinOp에 0을 더하거나 1을 곱하고 나눌 때 단순화하는 스칼라의 패턴 매칭 코드이다.

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을 단순화할 수 없다.
}
  • 위 코드와 비슷한 동작을 자바로 구현하려면 아래와 같이 람다를 함수의 인자로 입력하는 방식으로 구현해야 한다.

    • patternMatch 메서드의 첫 번째 인자는 Expr가 BinOp일 때 수행되는 판별 메서드이고, 두 번째 인자는 Expr가 Number 타입일 때 수행되는 메서드이고, 세 번째 인자는 두 경우 이외일 경우에 수행되는 메서드이다.

    • BinOp일 경우에는 0을 더하거나 1을 곱하고 나눌 때 단순화하는 메서드가 수행되어야 한다.

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

static <T> T patternMatch(
                        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();
}

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 patternMatch(e, binopcase, numcase, defaultcase);
}

void test() {
   Expr e = new BinOp("+", new Number(5), new Number(0));
   Expr match = simplify(e);
   System.out.println(match);
}

참조 투명성을 이용한 캐싱

  • 비용이 비싼 함수를 호출하는 경우 데이터를 캐싱하여 추가 오버헤드를 피하고 참조 투명성을 유지할 수 있다.

  • 아래는 HashMap 자료구조에 데이터를 캐싱하는 예제이다.

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;
}
  • 캐싱 데이터가 공유된 가변 상태인 경우 데이터를 추가하거나 찾는 과정에서 동기화 문제가 발생할 수 있다. 이 때 Concurrent 자료구조를 사용할 수 있지만, 동시에 접근할 경우 성능이 크게 저하될 수 있다.

  • 가장 좋은 방법은 함수형 프로그래밍을 사용해 동시성과 가변 상태가 만나지 않도록 하는 것이지만 성능 문제가 발생할 수 있다.

콤비네이터

  • 두 개 이상의 함수를 인자로 받아 다른 함수를 반환하는 메서드를 의미한다.

  • CompletableFuture 클래스의 thenCombine 메서드는 CompletableFuture와 BiFunction 을 입력받아 새로운 CompletableFuture를 생성한다.

  • Function 인터페이스에서 제공하는 compose 메서드를 통해 두 함수를 순차적으로 적용할 수 있다.

default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
    Objects.requireNonNull(before);
    return (V v) -> apply(before.apply(v));
}
  • 입력 인자에 2를 곱하는 작업을 n 만큼 반복하는 repeat 메서드를 구현하여 내부 반복을 수행하는 동작을 정의할 수 있다.

static <A> Function<A, A> repeat(int n, Function<A, A> f) {
   return n == 0 ? x -> x
                 : compose(f, repeat(n - 1, f));
}

void test() {
   System.out.println(repeat(3, (Integer x) -> 2 * x).apply(10); // 80
}

Last updated