Publish:

태그:

카테고리:

람다식이란?

함수형 인터페이스(추상 메소드를 한개만 가진 인터페이스)를 이용해 구현부를 축약해 표현하는 방식이다. 익명클래스를 이용해 구현을 하는 방식을 좀 더 줄여서 표현한 것이라고 보면된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@FunctionalInterface
interface Calculator {
  int compute(int a, int b);
}
    ...

// 익명클래스
Calculator c = new Calculator() {
  @Override
  public int compute(int a, int b) {
    return a + b;
  }
};

// 람다
Calculator cal = (a, b) -> a + b;
cal.compute(1,2);  // 3

매번 1회성 구현을 위해 인터페이스를 정의하는것이 귀찮다면 아래와같은 java에서 기본적으로 지원하는 함수형 인터페이스를 사용하면 된다. java 에서는 자주 구현이 될 만한 기능의 인터페이스를 미리 만들어서 제공하고 있다. 인터페이스 이름과 모양을 보면 어떤 역할을 하는지 명확히 알 수 있다. 이 밖에도 다양한 인터페이스를 지원한다.

Funtional Interface Description Method
Predicate T -> boolean boolean test(T t)
Consumer T -> void void accept(T t)
Supplier () -> T T get()
Function<T,R> T -> R R apply(T t)
Comparator (T, T) -> int int compare(T a, T b)
Runnable () -> void void run()
Callable () -> T V call()

예를 들어 java 에서 제공하는 인터페이스를 이용하면 아래와 같이 미리 정해진 인터페이스 타입으로 받을 수 있다.

1
2
3
// java.util.Comparator 사용
Comparator<Integer> co = (a, b) -> a + b;
System.out.println(co.compare(100, 2));  // 102

람다식의 사용 목적

람다를 사용하면 따로 클래스를 만들거나 인터페이스를 정의하지 않아도 된다.(java에서 기본 지원하는 함수형 인터페이스를 사용할 경우) 코드의 양을 줄일 수 있다.

람다의 사용 목적은 함수를 일급 객체로 다루겠다는 것에 있다. 일급 객체란 다른 함수의 인자로 전달가능 하고, 함수에서 반환값으로 사용 가능한 함수를 말한다. (javaScript 의 function처럼) 함수란, 식(expression)을 통해 값을 반환하는 역할을 한다. 이런 식 자체를 메소드의 인자로 넘겨 식의 통과 여부를 평가하게 된다.

람다식을 통해 다른 메소드의 파라미터로 간단하게 전달이 가능해지면서 메소드의 시작부터 끝까지 데이터가 흘러가는 것처럼 처리될 수 있다. java 에서는 람다와 메소드 체이닝(Method Chaining)을 잘 활용할 수 있는 Stream API 를 지원한다. 스트림을 이용해 데이터의 필터, 매핑, 집계 등의 작업을 직관적이고 일괄적으로 수행 가능하다. Stream은 람다식을 통해 지연평가(lazy evaluation)전략을 취하고, 단축평가(short-circuit evaluation)를 진행한다.

람다 활용

Lazy evaluation(지연평가)

지연 평가란 불필요한 연산을 피하기 위해 연산을 지연시키는 것을 말한다. 아래 예시코드와 결과를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class LazyEvaluationExample {
  public static void main(String[] args) {
    List<Integer> numbers = Arrays.asList(4, 15, 20, 7, 3, 13, 2, 20);

    List<Integer> chosen = numbers.stream()
      .filter(e -> {
        System.out.println("e < 10");
        return e < 10;
      })
      .limit(3)
      .toList();
    
    chosen.forEach(System.out::println);
  }
}

결과

1
2
3
4
5
6
7
8
e < 10
e < 10
e < 10
e < 10
e < 10
4
7
3

만약 위 연산이 Eager Evaluation 방식으로 동작한다면 8개 항목 모두 filter 연산을 하게 된다. eager evaluation

그러나 실제 결과에 찍힌 내용을 보면 filter함수의 실행은 5번만에 끝나고 최종 집계 처리 한다. 8번 연산해야 되는 작업을 5번만에 끝냈으므로 37.5% 의 성능 향상이 된 것이다. (3/8*100) 결과가 결정되자마자 평가가 중지된다. (Short-circuiting)

lazy evaluation

다른 예시도 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.function.Supplier;

public class DelayedEvaluationExample {
  public static void main(String[] args) {
    System.out.println("------ lazy evaluation ------");
    long start1 = System.currentTimeMillis();
    getLazyValue(true, DelayedEvaluationExample::computeValue);
    getLazyValue(false, DelayedEvaluationExample::computeValue);
    getLazyValue(false, DelayedEvaluationExample::computeValue);
    System.out.println("Time: " + (System.currentTimeMillis() - start1)/1000);

    System.out.println();
    
    System.out.println("------ eager evaluation ------");
    long start2 = System.currentTimeMillis();
    getEagerValue(true, computeValue());
    getEagerValue(false, computeValue());
    getEagerValue(false, computeValue());
    System.out.println("Time: " + (System.currentTimeMillis() - start2)/1000);
  }

  private static int computeValue() {
    System.out.println("Computing the value...");
    try {
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    return 42;
  }

  public static void getLazyValue(boolean valid, Supplier<Integer> supplier) {
    if (valid) {
      System.out.println("Value: " + supplier.get());
    } else {
      System.out.println("Invalid");
    }
  }

  public static void getEagerValue(boolean valid, int value) {
    if (valid) {
      System.out.println("Value: " + value);
    } else {
      System.out.println("Invalid");
    }
  }
}

결과

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
------ lazy evaluation ------
Computing the value...
Value: 42
Invalid
Invalid
Time: 1
  
------ eager evaluation ------
Computing the value...
Value: 42
Computing the value...
Invalid
Computing the value...
Invalid
Time: 3

위 결과를 보면 알 수 있듯이, eager evaluation 은 호출을 하는 그 즉시 실행된다. 반면에 lazy evaluation 은 식(expression)에 대한 평가가 실제 이루어지는 시점에 실행된다. 즉, 호출을 최대한 뒤로 늦춘다.

switch 문을 단순화 할 수 있다.

예를 들어 요일 별 할 일을 출력하는 프로그램을 작성한다고 가정해보자.

1
2
3
4
5
6
7
8
9
public enum Week {
   SATURDAY,
   SUNDAY,
   MONDAY,
   TUESDAY,
   WEDNESDAY,
   THURSDAY,
   FRIDAY
}

각 요일에 대한 enum을 정의하고 요일과 매핑되는 메소드를 정의한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class GoalPrinter {
   public void saturdayGoal() {
       System.out.println("Saturday: Relax and recharge for the upcoming week.");
   }


   public void sundayGoal() {
       System.out.println("Sunday: Set clear goals for the week ahead.");
   }


   public void mondayGoal() {
       System.out.println("Monday: Focus on improving a specific skill.");
   }


   public void tuesdayGoal() {
       System.out.println("Tuesday: Focus on improving a specific skill.");
   }


   public void wednesdayGoal() {
       System.out.println("Wednesday: Review progress and adjust your plans if needed.");
   }


   public void thursdayGoal() {
       System.out.println("Thursday: Wrap up tasks and prepare for the weekend.");
   }


   public void fridayGoal() {
       System.out.println("Friday: Work on a personal project.");
   }
}

클라이언트 코드에서 각 요일에 맞는 메소드로 분기처리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ClientApp {
   public static void main(String[] args) {
       Week week = Week.SATURDAY;
       GoalPrinter goalPrinter = new GoalPrinter();
       showWeekGoal(goalPrinter, week);
   }


   private static void showWeekGoal(GoalPrinter goalPrinter,Week week) {
       switch (week){
           case SATURDAY:
              goalPrinter.saturdayGoal();
               break;
           case SUNDAY:
               goalPrinter.sundayGoal();
               break;
           case MONDAY:
               goalPrinter.mondayGoal();
               break;
           case TUESDAY:
               goalPrinter.tuesdayGoal();
               break;
           case WEDNESDAY:
               goalPrinter.wednesdayGoal();
               break;
           case THURSDAY:
               goalPrinter.thursdayGoal();
               break;
           case FRIDAY:
               goalPrinter.fridayGoal();
               break;
       }
   }
}

위 소스코드는 요일을 enum으로 정의하고, switch 문을 통해 요일에 해당하는 함수를 실행하고 있다. 그러나 enum 에 요일이 추가되면, GoalPrinter함수도 바꿔야하고 switch 문에 케이스도 추가해야 한다. 이 중 어느하나라도 실수로 건너뛰면 에러가 발생할 것이다. 이런 방식은 SOLID 패턴의 OCP(Open-Closed Principle)를 위반하게 된다.

람다를 이용해 switch 제거하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public enum Week {
   SATURDAY(GoalPrinter::saturdayGoal),
   SUNDAY(GoalPrinter::sundayGoal),
   MONDAY(GoalPrinter::mondayGoal),
   TUESDAY(GoalPrinter::tuesdayGoal),
   WEDNESDAY(GoalPrinter::wednesdayGoal),
   THURSDAY(GoalPrinter::thursdayGoal),
   FRIDAY(GoalPrinter::fridayGoal);


   public final Consumer<GoalPrinter> consumer;


   Week(Consumer<GoalPrinter> consumer) {
       this.consumer = consumer;
   }
}

switch 에서 enum 타입에 따른 메소드로 분기처리 하지 않고, enum에서 바로 추상메소드를 구현해 enum 타입에 연결시킨다. 해당 예제에서는 미리 구현된 메소드를 참조(method reference)하는 방식으로 정의했다. 새로 인터페이스를 정의해도 되지만 java 에서 제공하는 Consumer 인터페이스를 사용했다. 클라이언트 호출은 아래와 같이 한다.

1
2
3
4
public static void main(String[] args) {
  Week week = Week.FRIDAY;
  week.consumer.accept(new GoalPrinter());
}

이 방식의 장점은 enum 에 상수를 추가할때 메소드 구현을 강제할 수 있어 코드를 분산되지 않게 관리 할 수 있고, 클라이언트 코드를 매번 수정할 필요도 없다.

다음 글에서는 Stream 에 대해 좀 더 자세히 알아본다. Stream 을 쓴다는 것의 의미(선언형 프로그래밍)와 스트림이 어떤식으로 데이터를 처리하는지 알아보고, 기존의 전통적은 for-loop 방식과 비교 및 주의해야 할 점을 알아본다.

reference

방문해 주셔서 감사합니다! 댓글,지적,피드백 언제나 환영합니다😊

댓글남기기