본문 바로가기
Programming/Java, Spring

자바 함수형 프로그래밍 Scope, Closure&Curry, Lazy Evaluation, Function Composition

by Renechoi 2023. 6. 20.

Scope - Closure & Curry 

 

- 유효볌위, 즉 변수에 접근할 수 있는 범위를 의미한다.

- 블럭 밖과 안 => 변수 사용의 바운더리

- 마찬가지로 람다에서도 이와 같은 원리가 적용된다.

- 함수 안에 함수가 있을 때 내부 함수에서 외부 함수에 있는 변수에 접근이 가능하다(Lexical scope). 그 반대는 불가능하다. 

 

예를 들어 lexical scope 

public static Supplier<String> getStringSupplier() {
	String hello = "hello";
	Supplier<String> supplier = () -> {
   		Stirng world = "world"
        return hello + world; 
    };
    
    return supplier;
}

이때 hello는 supplier 안에서도 접근이 가능하다. 하지만 world라는 supplier 바깥에서 접근이 안된다. 

 

supplier는 자신 바깥에 있는 hello string과 자신 안의 world를 합쳐서 리턴한다. 

 

 

 

closure - 내부 함수가 존재하는 한 내부 함수가 사용한 외부 함수의 변수들 역시 계속 존재한다. 이렇게 lexical scope를 포함하는 함수를 closure라 한다. 

 

- 이때 내부 함수가 사용한 외부 함수의 변수들은 내부 함수 선언 당시로부터 변할 수 없기 때문에 final로 선언되지 않더라도 암묵적으로 final로 취급된다. 

 

- 이 개념을 응용한 것 중 하나가 Curry이다. 

 

- 여러 개의 매개변수를 받는 함수를 중첩된 여러 개의 함수로 쪼개어 매개 변수를 한 번에 받지 않고 여러 단계에 걸쳐 나눠 받을 수 있게 하는 기술 

 

- Closure의 응용 

BiFunction<Integer, Integer, Ineger> add = (x,y) -> x + y;

이와 같은 BiFunction을 두 개로 쪼갤 수 있다. 

 

Function<Integer, Function<Integer, Integer>> add = x -> y -> x + y;

이때 마지막 y를 받는 함수는 리턴되거나 다른 곳으로 가더라도 x의 값을 기억하고 있다 <- closure 

 

 

다음과 같은 코드를 보자. 

public class Closure {
   public static void main(String[] args) {
      Supplier<String> supplier = getStringSupplier();
      System.out.println(supplier.get());

   }

   public static Supplier<String> getStringSupplier(){
      String hello = "Hello";
      Supplier<String>  supplier = () -> {
         String world = "world";
         return hello + world;
      };
      return supplier;
   }
}

 

hello 라는 변수가 일반적으로는 리턴되고 나면 사라져야 하지만 supplier가 존재하는 한 계속 필요로 하기 때문에 사라지지 않는다. 따라서 get()함수를 통해 받을 수 있다. 

 

이를 응용한 것이 curry이다. 

 

일반적으로 BiFunction을 사용하면 아래와 같이 두 개의 인자를 받는다. 

 

BiFunction<Integer, Integer, Integer> add = (x, y) -> x+ y;

 

 

그러나 두 개를 한번에 받지 않고 나눠서 받고 싶은 것이다. 

 

Function<Integer, Function<Integer, Integer>> curriedAdd = x -> y -> x + y; 

 

이를 다음과 같이 살펴보면 

 

Function<Integer, Integer> addThree = curriedAdd.apply(3);

 

여기서 리턴된 함수는 apply를 했을 때 3을 더해주는 함수가 된다. 이 함수가 어디를 가던지 3을 기억을 하고 있기 때문에 

 

Function<Integer, Integer> addThree = curriedAdd.apply(3);

int result =addThree.apply(10);

System.out.println(result);

 

 

13을 리턴하게 된다. 

 

 

 

Lazy Evaluation 

 

- 말 그대로 지연된 계산이다. 

- 미리 전부 계산되던 값들을 필요할 때까지 미뤄서, 필요하지 않는다면 하지 않도록 할 수 있다. 

- 의도적으로 코드의 실행 순서를 미룰 수도 있다. 

 

아래와 같은 코드에서 or 연산 앞에 이미 true가 결과값으로 나와버리면 이후 연산은 진행하지 않는다. 

이렇게 최적화가 되어 있다. 

if (true || returnFalse()){ // 이때 뒤의 계산은 하지 않는다.
   System.out.println(true);
}
public static boolean returnTrue(){
   System.out.println("returning true");
   return true;
}

public static boolean returnFalse(){
   System.out.println("returning false");
   return false;
}

그런데 만약 다음과 같은 함수를 만들어서 논리상으로는 동일한 if 문을 구현해보면 어떨까 

public static boolean or(boolean x, boolean y){
   return x || y;
}
if (or(returnTrue(), returnFalse())){
   System.out.println("true");
}

 

이때는 당연히 두 연산 모두 계산이 된다. 

 

그 이유는 메서드 호출 전에 이미 메서드 인자값으로 받아오는 값들을 모두 알아야 메서드 자체가 호출되기 때문이다. 

 

이것을 lazy evaluation을 사용해서 특정 연산을 늦출 수 있다. 

 

public static boolean lazyOr(Supplier<Boolean> x, Supplier<Boolean> y){
   return x.get() || y.get();
}

 

이처럼 값을 받아올 때 바로 받아오는 것이 아니라 supplier를 한번 거쳐서 받아오게 된다. 

 

if(lazyOr( ()-> returnTrue(), ()-> returnFalse())){
   System.out.println("true");
}

 

논리상으로는 다를 바가 없지만 이 경우는 x.get()을 먼저 실행을 하고, true를 반환하였기 때문에 이 후 연산은 실행하지 않는다. 

 

 

이것과 같은 lazy Evaluation을 Stream에서도 확인할 수 있다. 

 

다음과 같은 코드를 보자.

 

Stream<Integer> integerStream = Stream.of(3, -2, 5, 67, 1, 34, -10)
   .filter(x -> x > 0)
   .peek(x -> System.out.println("peeking: " + x))
   .filter(x -> x % 2 == 0);

System.out.println("before collect ");

List<Integer> integers = integerStream.collect(Collectors.toList());

System.out.println("after collect ");

 

일반적으로 기대할 수 있는 바는 stream이 전부 종료 되고 나서 before collect문이 찍히고 collect를 한 이후 after collect 문이 찍히는 것이다. 

 

 

그런데 실제로는 before collect가 먼저 찍히고 그 이후 peeking이 찍히고 after collect가 찍힌다. 

 

왜 그런가면 종결처리가 이뤄지기 전까지 모든 계산을 미루기 때문이다. collect가 있을 때에야 계산을 미루다가 collect가 발생하면 그때서야 중간 연산이 진행되는 것이다. 

 

 

 

 

Function Composition 

함수 합성 

 

- f(x)와 g(x)를 합치는 것이다. => g(fx) 

- 마찬가지로 자바의 함수들도 합성이 가능하다 

 

<V> Function<V, R> compose(Function<? super V, ? extends T> before)

<V> Function<T, V) andThen(Function<? super R, ? extends V> after)

 

 

Function클래스는 compose와 andThen 인스턴스 메서드를 갖고 있다. 

 

compose는 파라미터로 들어온 함수를 먼저 실행하고, 그 다음 자기 자신을 실행하도록 합성한다. 

 

andThen은 자신을 먼저 실행하고 그 이후에 다음 function을 실행하도록 합성한다. 

 

compose와 andThen을 활용하는 예제를 살펴보자. 

 

다음과 같은 f(x)와 g(x)가 있다고 할 때 

Function<Integer, Integer> multiplyByTwo = x -> 2 *x;
Function<Integer, Integer> addTen = x -> x+ 10;

 

이를 다음과 같이 합성한다. 


Function<Integer, Integer> integerIntegerFunction = multiplyByTwo.andThen(addTen);
System.out.println(integerIntegerFunction.apply(3));

먼저 2 *x를 연산하고 이후 + 10을 해준다. 

 

 

Order를 받아서 가격을 프로세스해주는 함수들을 만들어보자. 

 

여러 개의 함수가 존재한다.

 

어떤 함수는 OrderLine들의 가격을 합쳐서 Order에 set해주는 기능, 어떤 함수는 현재 taxRate를 계산해서 가격을 조정해주는 price 프로세서가 있다. 

 

Order unprocessedOrder = new Order()
   .setId(1001L)
   .setOrderLines(Arrays.asList(
      new OrderLine().setAmount(BigDecimal.valueOf(1000)),
      new OrderLine().setAmount(BigDecimal.valueOf(2000))));

List<Function<Order, Order>> priceProcessors = getPriceProcessors(unprocessedOrder);

Function<Order, Order> mergedPriceProcessors = priceProcessors.stream()
   .reduce(Function.identity(), Function::andThen); // 초기값으로는 Order를 받아 그냥 Order를 넘기는 identity, 그리고 함수 두 개를 이어준다.

Order processedOrder = mergedPriceProcessors.apply(unprocessedOrder);  // 최종 합성된 processor가 가격을 처리한다.
System.out.println(processedOrder);
public static List<Function<Order, Order>> getPriceProcessors(Order order) {
   return Arrays.asList(new OrderLineAggregationPriceProcessor(),
      new TaxPriceProcessor(new BigDecimal("9.375")));
}
public class OrderLineAggregationPriceProcessor implements Function<Order, Order> {

   @Override
   public Order apply(Order order) {
      return order.setAmount(order.getOrderLines().stream()
            .map(OrderLine::getAmount)
            .reduce(BigDecimal.ZERO, BigDecimal::add));
   }

}
public class TaxPriceProcessor implements Function<Order, Order>{

   private final BigDecimal taxRate;
   
   public TaxPriceProcessor(BigDecimal taxRate) {
      this.taxRate = taxRate;
   }


   @Override
   public Order apply(Order order) {
      return order.setAmount(order.getAmount()
            .multiply(taxRate.divide(new BigDecimal(100)).add(BigDecimal.ONE)));   // 세율을 적용해준다.
   }

}

 

핵심은 mergedProcessor이다. 즉, 원하는 processor들만 모아서 합성하여 function을 가진 함수로 만들고 이를 한번에 적용할 수 있다. 

 

 


reference. https://fastcampus.co.kr/dev_red_lsh

 

 

 

반응형