람다 표현식 (lambda expression) 람다란 무엇인가? 람다를 어디에서 어떻게 사용할까? 람다 활용 : 실행 어라운드 패턴 자바8의 함수형 인터페이스 메서드 레퍼런스 (Method Reference) 람다, 메서드 레퍼런스 활용 예제

Java8에서 새롭게 추가된 람다 표현식을 정리합니다.

람다란 무엇인가?

람다 표현식 (Lambda Expression) 은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 할 수 있다.
람다를 사용하면 더 쉽게 동작 파라미터 형식의 코드를 구현할 수 있으며, 이에 따라 코드가 더 간결하고 유연해진다.

람다의 특징

  • 익명 : 람다는 익명 메서드 처럼 이름이 없다.
  • 함수 : 람다는 메서드처럼 특정 클래스에 종속되지 않기 때문에 함수라고 부를 수 있다. 하시만 메서드처럼 “파라미터 리스트”, “바디”, “반환 형식”, “가능한 예외 리스트”를 포함한다.
  • 전달 : 람다 표현식을 메서드의 인수로 저장하거나 변수로 지정할 수 있다.
  • 간결성 : 익명 클래스처럼 자질구레한 코드 구현이 필요 없다.

람다의 구성요소

1
2
3
4
5
6
7
8
9
10

class implements Comparator<String> {
public int compare(String first, String second) {
return Integer.compare(first.length(), second.length());
}
}
Arrays.sort(strings, new LengthComparator());

// After
(String first, String second) -> Integer.compare(first.length(), second.length());
  • 파라미터 리스트 : Comparator의 compare 메서드의 파라미터 (두 개의 사과. first, second)
  • 화살표 : 화살표(->)는 람다의 파라미터 리스트와 바디를 구분한다.
  • 바디 : 두 사과의 무게를 비교한다. 람다의 반환값에 해당하는 표현식이다. 람다는 return이 함축되어 있기 때문에 명시적으로 사용하지 않아도 된다.

람다의 기본문법

(parameters) -> expression 또는 (parameters) -> { statements; }

람다를 어디에서 어떻게 사용할까?

람다 표현식은 함수형 인터페이스라는 문맥에서 사용할 수 있다.
“함수형 인터페이스”란 단 하나의 추상 메서드를 지칭하는 인터페이스를 의미한다. Comparator, Runnable, Callable, Predicate 등이 있다.

람다 표현식은 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있다. 따라서 전체 표현식을 함수형 인터페이스로 취급할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Runnable 함수형 인터페이스 (참고용)
public interface Runnable {
void run();
}

// 람다 사용
Runnable r1 = () -> System.out.println("Hello World 1");

// 익명 클래스 사용
Runnable r2 = new Runnable() {
public void run() {
System.out.println("Hello World 2");
}
}

public static void process(Runnable r) {
r.run();
}

process(r1); // 'Hello World 1' 출력
process(r2); // 'Hello World 2' 출력
process(() -> System.out.println("Hello World 3")); // 'Hello World 3' 출력

함수 디스크립터 (Function Descriptor)

  • 람다 표현식의 시그니처(Signature)를 가리키는 “함수형 인터페이스의 추상 메서드 시그니처”
  • 예를 들어 Runnable 인터페이스의 유일한 추상 메서드인 run은 인수와 반환값이 없으므로 Runnable 인터페이스는 인수와 반환값이 없는 시그니처로 생각할 수 있다. 즉, () -> void 라는 표기법으로 표현할 수 있다.

람다 활용 : 실행 어라운드 패턴

데이터베이스 연동, 파일 읽기/쓰기 등의 작업은 보통 다음과 같은 순환 패턴(Recurrent Pattern)로 이뤄진다.

  1. Resource Open (초기화 / 준비 코드)
  2. Processing (실제 처리 코드)
  3. Resource Close (정리 / 마무리 코드)

Resource Open/Close와 같이 실제 처리 코드를 둘러싸는 형식의 코드를 실행 어라운드 패턴(Execute Around Pattern) 이라고 부른다.

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
/**
* 1 단계 : 동작 파라미터화를 기억하라
* - 한 번에 한 줄만 읽을 수 있는 코드를 "동작 파라미터화"를 통해 다른 동작을 수행할 수 있도록 변경해 보자!
* - 즉, 아래 코드에서 "초기화 & 정리" 코드만 재사용하고 "처리" 코드는 동적으로 수행하도록 한다.
*/
public static String processFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return br.readLine(); // 실제 필요한 작업을 수행
}
}

/**
* 2 단계 : 함수형 인터페이스를 이용해서 동작 전달
* - (BufferedReader -> String)과 IOException 예외를 던질 수 있는 시그니처와 일치하는
* 함수형 인터페이스를 만든다.
*/

public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
public static String processFile(BufferedReaderProcessor p) throws IOException {
...
}

/**
* 3 단계 : 동작 실행!
* - 이제 BufferedReaderProcessor 함수형 인터페이스에 정의된 process 메서드의
* 시그니처(BufferedReader -> String)와 일치하는 람다를 전달할 수 있다.
* - 람다 표현식으로 함수형 인터페이스의 추상 메서드(process) 구현체를 직접 전달하여 처리할 수 있게 된다.
* (4단계 참고)
*/
public static String processFile(BufferedReaderProcessor p) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return p.process(br);
}
}

/**
* 4 단계 : 람다 전달
*/
String oneLine = processFile((BufferedReader br) -> br.readLine());
String twoLines = processFile((BufferedReader br) -> br.readLine() + br.readLine());

자바8의 함수형 인터페이스

자바8 라이브러리 설계자들은 java.util.function 패키지로 여러 가지 새로운 함수형 인터페이스를 제공한다.

Predicate

Predicate 인터페이스는 test라는 추상 메서드를 정의하며, test 메서드는 제네릭 형식 T의 객체를 인수로 받아 Boolean을 반환한다.
즉, T 형식의 객체를 사용하는 Boolean 표현식이 필요한 상황에서 Predicate 인터페이스를 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

public interface Predicate<T> {
boolean test(T t);
}

public static <T> List<T> filter(List<T> list, Predicate<T> p) {
List<T> results = new ArrayList<>();
for (T s: list) {
if (p.test(s)) {
results.add(s);
}
}
return results;
}

Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);

Consumer

Consumer 인터페이스는 제네릭 형식의 T 객체를 받아서 void를 반환하는 accept 라는 추상 메서드를 정의한다.
T 형식의 객체를 인수로 받아서 어떤 동작을 수행하고 싶을 때 Consumer 인터페이스를 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12

public interface Consumer<T> {
void accept(T t);
}

public static <T> void forEach(List<T> list, Consumer<T> c) {
for (T i: list) {
c.accept(i);
}
}

forEach(Arrays.asList(1, 2, 3, 4, 5), (Integer i) -> System.out.println(i));

Function

Function<T, R> 인터페이스는 제네릭 형식 T를 인수로 받아서 제네릭 형식 R 객체를 반환하는 apply라는 추상 메서드를 정의한다.
입력을 출력으로 매핑하는 람다를 정의할 때 활용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

public interface Function<T, R> {
R apply(T t);
}

public static <T, R> List<R> map(List<T> list, Function<T,R> f) {
List<R> result = new ArrayList<>();
for (T s : list) {
result.add(f.apply(s));
}
return result;
}

// [7, 2, 6]
List<Integer> l = map(Arrays.asList("lambdas", "in", "action"), (String s) -> s.length());

기본형 특화

자바는 “기본형”, “참조형” 2가지 형식이 존재하는데, 제네릭은 내부 구현상 참조형만 사용할 수 있다.

  • 기본형 (Primitive Type) : int, double, byte, char 등
  • 참조형 (Reference Type) : Byte, Integer, Object, List 등

기본형과 참조형을 혼합하여 사용할 경우 변환 과정이 필요하다. 자바에서는 이 과정이 자동으로 이뤄지며, 이를 *오토 박싱(Auto Boxing)”이라고 한다. 그러나 변환시 박싱한 값은 메모리를 더 많이 소모하며, 기본형을 가져올 때에도 메모리를 탐색하는 과정이 추가로 필요하다.

  • 박싱(Boxing) : 기본형 -> 참조형
  • 언박싱(Unboxing) : 참조형 -> 기본형

자바8에서는 기본형 타입을 사용할 때 오토박싱을 하지 않도록 특별한 함수형 인터페이스를 제공한다.
일반적으로 기본형 특화 함수형 인터페이스는 이름 앞에 DoublePredicate, IntConsumer, LongBinaryOperator, IntFunction 처럼 형식명이 붙는다.

자바8의 대표적인 함수형 인터페이스

함수형 인터페이스 함수 디스크립터 기본형 특화
Predicate T -> boolean IntPredicate, LongPredicate, DoublePredicate
Consumer T -> void IntConsumer, LongConsumer, DoubleConsumer
Function<T, R> T -> R IntFunction, IntToDoubleFunction, ToLongFunction…
Supplier () -> T BooleanSupplier, IntSupplier, LongSupplier, DoubleSupplier
UnaryOperator T -> T IntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator
BinaryOperator (T, T) -> T IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator
BiPredicate<L, R> (L, R) -> boolean
BiConsumer<T, U> (T, U) -> void ObjIntConsumer, ObjLongConsumer, ObjDoubleConsumer
BiFunction<T, U, R> (T, U) -> R ToIntBiFunction<T, U>, ToLongBiFunction<T, U>, ToDoubleBiFunction<T, U>

메서드 레퍼런스 (Method Reference)

메서드 레퍼런스는 특정 메서드만을 호출하는 람드 표현식의 축약형 이라고 볼 수 있다.
메서드 레퍼런스를 사용하면 경우에 따라 람다 표현식 보다 더 가독성이 좋으며, 메서드 명을 바로 사용하기 때문에 자연스러울 수도 있다.

1
2
3
4
5
// before
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));

// after
inventory.sort(comparing(Apple::getWeight));

메서드 레퍼런스 유형

  • 정적 메서드 레퍼런스
    • 람다 표현식을 메서드 레퍼런스로 바꾸는 방법
    • Integer의 parseInt 메서드는 Integer::parseInt로 표현 가능
  • 다양한 형식의 인스턴스 메서드 레퍼런스
    • 람다 표현식을 메서드 레퍼런스로 바꾸는 방법
    • String의 length 메서드는 String::length로 표현 가능
    • (String s) -> s.toUpperCase() 람다 표현식을 String::toUpperCase 로 줄여서 사용 가능
  • 기존 객체의 인스턴스 메서드 레퍼런스
    • 람다 표현식을 메서드 레퍼런스로 바꾸는 방법
    • Transaction 객체를 할당받은 expensiveTransaction 지역변수가 있고, Transaction 객체에는 getValue 메서드가 있다면, expensiveTransaction::getValue로 표현 가능
    • () -> expensiveTransaction.getValue() 람다 표현식을 expensiveTransaction::getValue 로 줄여서 사용 가능

생성자 레퍼런스

ClassName::new 처럼 클래스명과 new 키워드를 사용해서 생성자의 레퍼런스를 만들 수 있다.

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
/**
* 인수가 없는 생성자
*/
// before
Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();

// after
Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();

/**
* Integer 인수 1개를 갖는 생성자
*/
// before
Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);

// after
Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(110);

/**
* String, Integer 인수 2개를 갖는 생성자
*/
// before
BiFunction<String, Integer, Apple> c3 = (color, weight) -> new Apple(color, weight);
Apple c3 = c3.apply("green", 110);

// after
BiFunction<String, Integer, Apple> c3 = Apple::new;
Apple c3 = c3.apply("green", 110);

람다, 메서드 레퍼런스 활용 예제

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
// 1단계 : 동작 파라미터화를 통한 코드 전달
public class AppleComparator implements Comparator<Apple> {
public int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
}
inventory.sort(new AppleComparator());

// 2단계 : 익명 클래스 사용
inventory.sort(new Comparator<Apple>() {
public int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
});

// 3-1단계 : 람다 표현식 사용
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
// 3-2단계 : 람다 표현식 코드 줄이기 (자바 컴파일러가 람다의 파라미터 형식을 추론)
inventory.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight()));
// 3-3단계 : 정적 메서드 comparing 활용
import static java.util.Comparator.comparing;
inventory.sort(comparing((a) -> a.getWeight()));

// 4단계 : 메서드 레퍼런스 사용
import static java.util.Comparator.comparing;
inventory.sort(comparing(Apple::getWeight));