모던 자바 인 액션 3장을 학습하고 정리한 내용입니다.
동작 파리미터화를 통해 요구사항 변화에 효과적으로 대응 가능
다음의 문제가 발생
이러한 문제를 자바 8의 람다 표현식을 통해 개선할 수 있다.
람다 표현식 : 메서드를 전달할 수 있는 익명 함수를 단순화 한 것
익명: 보통의 메서드와 달리 이름이 없음
함수: 메서드와 달리 특정 클래스에 종속되지 않음 (not method, function)
전달: 람다 표현식을 메서드 인수로 전달하거나, 변수로 저장 가능
간결성: 익명 클래스처럼 많은 부가적인 코드를 구현할 필요 없음
람다 표현식은 세 부분으로 이루어진다.
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
| 람다 파라미터 |화살표| 람다 바디 |
파라미터 리스트: 메서드 파라미터(위 코드에서는 Apple 객체 2개가 해당)
화살표: 람다의 '파라미터 리스트'와 '람다 바디'를 구분
람다 바디: 람다의 반환값에 해당하는 표현식
자바 8에서 지원하는 다섯 가지 람다 표현식 예제
// String 형식의 파라미터 한 개를 가지며, int를 반환 -> return이 함축되어 있음
(String s) -> s.length()
// Apple 형식의 파라미터 한 개를 가지며, boolean을 반환
(Apple a) -> a.getWeight() > 150
// int 형식의 파라미터 두 개를 가지며, 리턴값이 없다 (void 리턴), 여러 행 포함 가능
(int x, int y) -> {
System.out.println("result:");
System.out.println(x+y);
}
// 파라미터가 없으며, int 42를 반환
() -> 42
// Apple 형식의 파라미터 두 개를 가지며, int를 반환
(Apple a1, Apple 2) -> a1.getWeight().compareTo(a2.getWeight())
함수형 인터페이스
라는 문맥에서 람다 표현식을 사용할 수 있다.
함수형 인터페이스
: 단 하나의 추상 메서드만을 지정하는 인터페이스
public interface Comparator<T> {
int compare(T o1, T o2);
}
public interface Runnable {
void run();
}
Comparator
, Runnable
인터페이스는 함수형 인터페이스다.
디폴트 메서드가 있더라도, 추상 메서드가 오직 하나면 함수형 인터페이스다.
람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로, 전체 표현식을 함수형 인터페이스의 인스턴스로 취급할 수 있다.(기술적으로 따지면, 함수형 인터페이스를 구현한 클래스의 인스턴스인 것이다.)
Runnable r1 = () -> sout("Hello 1"); // 람다 사용
// sout(ra.getClass()) -> class Main$$Lambda$14/0x0000000800c01408
Runnable r2 = new Runnable() { // 익명 클래스 사용
@Override
public void run() {
sout("Hello 2");
}
}
public void process(Runnable r) {
r.run();
}
process(r1);
process(r2);
process(() -> sout("Hello 3")); // 직접 전달된 람다 표현식
함수형 인터페이스의 추상 메스드 시그니처(메서드 이름 + 파라미터 리스트)는 람다 표현식의 시그니처를 가리킨다.
람다 표현식의 시그니처를 서술하는 메서드를 Function Descriptor
라고 부른다. (3.4에서 추가 설명)
public void process(Runnable r) {
r.run();
}
process(() -> sout("Hello 3"));
Runnable 인터페이스의 run 메서드와 람다 함수의 시그니처가 같기 때문에 실행 가능
컴파일러가 람다 표현식의 유효성을 확인한다.
@FunctionalInterface
Annotation을 통해 컴파일 타임에 검증을 할 수 있다.
execute around pattern
이 적용된 코드에 람다가 어떻게 활용되는지 살펴보자
execute around pattern
은 다음과 같은 루틴이 반복되는 구조를 의미한다.
초기화/준비 코드
작업 A
정리/마무리 코드
public String processFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return br.readLine(); // 실제 필요한 작업 수행
}
}
현재 코드는 파일에서 한 번에 한 줄만 읽을 수 있다.
동작 파라미터화를 적용해서 실제 필요한 작업
을 파라미터를 통해 변경할 수 있다. 람다를 적용해보자
// 두 줄을 읽는 작업을 수행하는 람다 코드를 파라미터로 던진다.
String result = processFile((BufferedReader br) -> br.readLine() + br.readLine());
이렇게 사용하기 위해선 위 람다에 대응하는 함수형 인터페이스가(시그니처) 필요하다.
(BufferedReader) -> String
에 대응하는 시그니처를 위해 함수형 인터페이스를 만들자.
@FunctionalInterface
public interface BufferedReaderProcessor {
String process(BuffuredReader b) throws IOException;
}
processFile()
가 파라미터를 사용하도록 변경하자
public String processFile(BufferedReaderProcessor p) throws IOException {...}
앞선 단계를 통해 람다와(실행 하려는 작업), 이에 대응하는 메서드 시그니처를 만들었다. 이제 사용해보자
public String processFile(BufferedReaderProcessor p) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return p.process(br);
}
}
위 처럼 실행하려는 메서드(processFile)에 동작 파라미터화가 적용되었으므로, 쉽게 수행하려는 작업(한 줄 읽기, 두 줄 읽기 등)을 전달할 수 있게 되었다.
String oneLine = processFile((BufferedReader br) -> br.readLine());
String twoLine = processFile((BufferedReader br) -> br.readLine() + br.readLine());
그런데 내가 사용하고 싶은 람다를 위해, 매번 함수형 인터페이스(시그네처)를 만들어 줘야하는걸까? 불편하지 않나?
함수형 인터페이스
는 오직 하나의 추상 메서드
를 가진다.
함수형 인터페이스의 추상 메서드
는 람다 표현식의 시그니처를 묘사한다.
함수형 인터페이스의 추상 메서드의 메서드 시그니처
를 함수 디스크립터
라고 한다.
람다 표현식을 사용하기 위해선, 그에 대응하는 함수 디스크립터가 필요하다.
그런데 람다 표현식을 사용할 때 마다 함수 디스크립터를 정의하기는 귀찮다.
자바는 java.util.function
패키지로 여러 가지 함수형 인터페이스를 제공한다.
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
...
}
Predicate
인터페이스는 test
라는 추상 메서드를 제공한다. test
는 boolean을 반환한다.
public <T> List<T> filter(List<T> list, Predicate<T> p) {
List<T> results = new ArrayList<>();
for (T t : list) {
if(p.test(t)) {
results.add(t);
}
}
return results;
}
Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
List<String> nonEmptyStrings = filter(strings, nonEmptyStringPredicate);
// 또는 바로 사용:
List<String> nonEmptyStrings = filter(strings, (String s) -> !s.isEmpty());
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
...
Consumer
인터페이스는 accept
라는 추상 메서드를 제공한다. accept
는 void를 반환한다.
단순히 어떤 동작을 수행하고 싶을 때 Consumer
인터페이스를 사용한다.
public <T> void forEach(List<T> list, Consumer<T> c) {
for(T t : list) {
c.accept(t);
}
}
forEach(numbers, (Integer i) -> System.out.println(i));
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
...
Function
인터페이스는 apply
라는 추상 메서드를 제공한다. apply
는 파라미터 T을 사용하고, R 을 반환한다.
입력을 출력으로 매핑하는 람다를 정의할 때 Function
인터페이스를 사용한다.
public static <T, R> List<R> map(List<T> list, Function<T, R> f) {
List<R> results = new ArrayList<>();
for (T t : list) {
results.add(f.apply(t));
}
return results;
}
List<String> map = map(numbers, (Integer i) -> Integer.toString(i * 2));
제네릭은 Reference Type만 사용할 수 있다.
Boxing: Primitive Type -> Reference Type
Unboxing: Reference Type -> Primitive Type
Autoboxing: 자동으로 박싱, 언박싱이 이뤄지는 상황? 기능?
이러한 변환 과정은 비용이 소모된다.
Boxing한 값은 Reference Type이므로 Heap 영역에 저장된다. 즉, 메모리를 더 차지한다.
Reference Type이 Boxing한 값을 찾는 작업은 메모리 탐색을 수반한다. 즉, 작업 시간이 생긴다.
자바 8은 Primitive Type사용 할 때, autoboxing을 피할 수 있는 특별한 함수형 인터페이스를 제공한다.
public interface IntPredicate {
boolean test(int value);
...
}
IntPredicate oddNumbers = (int i) -> i % 2 == 1;
oddNumbers.test(1); // autoboxing 발생 X
IntPredicate EvenNumbers = (Integer i) -> i % 2 == 0;
EvenNumbers.test(2); // autoboxing 발생
그 외에도 여러 가지 기본형 특화 함수형 인터페이스
가 존재한다.
함수형 인터페이스 | 함수 디스크립터 |
---|---|
Predict<T> | T -> boolean |
Consumer<T> | T -> void |
Function<T, R> | T -> R |
Supplier<T> | () -> T |
UnaryOperator<T> | T -> T |
BinaryOperator<T> | (T, T) -> T |
BiPredicate<L, R> | (T, U) -> boolean |
BiConsumer<T, U> | (T, U) -> void |
BiFunction<T, U, R> | (T, U) -> R |
자세한 내용은 책 참고
함수형 인터페이스는 CheckedExcpetion을 던질 수 없다.
public interface MyInterface {
void test() throws IOException;
}
함수형 인터페이스를 직접 만들게 되면 자바가 제공하는 함수형 인터페이스와 호환되지 않을 수 있다.
그래서 try~catch을 사용한다,
Function<BufferedReader, String> f = (BufferedReader b) -> {
try {
return b.readLine();
} catch(IOException e) {
throw new RuntimeException(e);
}
}
람다가 사용되는 Context를 이용해서 람다의 Type을 추론할 수 있다.
어떤 Context에서 기대되는 람다 표현식의 형태를 target Type(대상 형식)
이라고 한다.
List<Apple> heavierThan150g = filter(inventory, (Apple apple) -> apple.getWeight() > 150);
위 코드는 다음의 순서로 형식 확인 과정이 진행된다.
1. filter 메서드의 선언을 확인한다.
2. filter 메서드는 두 번째 파라미터로 'Predicate<Apple>' 형태의 target Type을 기대한다.
3. Predicate<Apple>은 test라는 한 개의 추상 메서드를 정의하는 함수형 인터페이스다.
4. test 메서드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사한다.
5. filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 한다.
target Type
이라는 특징 때문에, 같은 람다 표현식이라도 호환되는 추상 메서드를 가진 다른 함수형 인터페이스로 사용될 수 있다.
자바 컴파일러는 람다 표현식이 사용된 Context(target Type)을 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다.
즉, target Type을 통해 함수 디스크립터를 알 수 있으므로, 컴파일러는 람다의 시그니처도 추론할 수 있다.
컴파일러가 람다 표현식의 파라미터 형식 (시그니처)에 접근할 수 있으므로, 람다 문법에서 이를 생략할 수 있다.
// 기본 형태
List<Apple> greenApples = filter(apples, (Apple apple) -> apple.getColor == Color.GREEN));
// 형식 추론을 이용한 생략 형태
List<Apple> greenApples = filter(apples, apple -> apple.getColor == Color.GREEN)
// 기본 형태
Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
// 형식 추론을 이용한 생략 형태
Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
람다 표현식에서도 외부에서 정의된 변수 (자유 변수, free varaible)을 사용할 수 있다. 이와 같은 동작을 람다 캡처링(capturing lambda)
라고 한다.
자유 변수
instance variable
static varaible
local variable
...
// sout == System.out.println
int PORT_NUMBER = 1234;
Runnable r = () -> sout(PORT_NUMBER);
자유 변수 사용에는 제약이 있다.
람다는 instance varaible과 static variable을 자신의 바디에서 사용할 수 있다.
그러려면 local variable는 명시적으로 final로 선언되어 있거나, 실질적으로 final(effectively final)처럼 사용되어야 한다.
책 113, 114(closure) 참고
메서드 참조
를 이용하여 기존의 메서드 정의를 재활용해서 람다처럼 사용할 수 있다.
메서드 참조
는 특정 메서드만을 호출하는 람다의 축약형이라고 생각할 수 있다.
메서드 참조는 말 그대로 메서드를 참조하는 것일 뿐, 실제로 메서드를 호출하는 것이 아니다.
고로 중괄호를 사용하지 않는다.
// 메서드 호출
(Apple a) -> a.getWeight();
// 메서드 참조
Apple::getWeight
메서드 참조는 세 가지 유형으로 구분할 수 있다.
Integer
의 parseInt
메서드는 Integer::parseInt
로 표현할 수 있다.public static void main(String[] args) {
List<String> stringNumbers = List.of("1", "2", "3", "4");
// 함수형 인터페이스를 사용
Function<String, Integer> toIntFunction = (String s) -> Integer.parseInt(s);
List<Integer> filter1 = filter(stringNumbers, toIntFunction);
// 바로 람다식을 전달하기
List<Integer> filter2 = filter(stringNumbers, (String s) -> Integer.parseInt(s));
// 메서드 참조 이용하기
List<Integer> filter3 = filter(stringNumbers, Integer::parseInt);
}
private static <T, R> List<R> filter(List<T> stringNumbers, Function<T, R> f) {
List<R> numbers = new ArrayList<>();
for (T stringNumber : stringNumbers) {
numbers.add(f.apply(stringNumber));
}
return numbers;
}
String
타입 인스턴스의 length
메서드는 String::length
로 표현할 수 있다.(String s) -> s.toUpperCase(); == String::toUpperCase;
-----------------------------------------------------------------------------
String myString = "aBcDeF";
// myString = change(myString, (String s) -> s.toUpperCase());
myString = change(myString, String::toUpperCase);
private static <T, R> R change(T t, Function<T, R> f) {
return f.apply(t);
}
getValue
메서드를 가진Transaction
객체를 지역 변수 a가 할당 받았다면, a::getValue
로 표현할 수 있다.() -> transactionA.getValue(); == transactionA::getValue;
-----------------------------------------------------------------------------
// EX: 유효한 이름만 필터링 하기
private boolean isValidName(String string) {
return Character.isUpperCase(String.charAt(0));
}
filter(names, this::isValidName)
List<String>
정렬하기import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> numbers = Arrays.asList("a", "E", "B", "d", "C");
// 1. Comparator로 정렬하기
sortWithComparator(numbers);
// 2. 람다 사용하기
numbers.sort((String s1, String s2) -> s1.compareToIgnoreCase(s2));
// 3. 람다 타입 추론 사용하기
numbers.sort((s1, s2) -> s1.compareToIgnoreCase(s2));
// 4. 메서드 참조 사용하기
numbers.sort(String::compareToIgnoreCase);
System.out.println(numbers);
}
private static void sortWithComparator(List<String> numbers) {
numbers.sort(new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o1.compareToIgnoreCase(o2);
}
});
}
}
sort
메서드는 파라미터로 Comparator
를 사용한다.Comparator
는 compare
라는 (T, T) -> int
형식의 함수 디스크립터를 갖는다.컴파일러는 람다 표현식의 형식을 검사하는 방식과 유사한 방법으로 메서드 참조가 함수형 인터페이스와 일치하는지를 검증한다.
함수형 인터페이스를 사용하면, 정적 메서드 참조를 사용하는 것과 유사한 형식으로 생성자도 참조할 수 있다.
Supplier<Apple> as = Apple::new;
// Supplier<Apple> appleSupplier = () -> new Apple();
Apple a1 = as.get();
Apple a2 = as.get();
// Apple 클래스의 생성자가 int 타입의 weight인스턴스를 초기화 한다면
Function<Integer, Apple> af = Apple::new;
// Function<Integer, Apple> af = (Integer weight) -> new Apple(weight);
Apple a3 = af.apply(100);
Apple a4 = af.apply(70);
// 2개의 instance varaible을 가지는 경우
BiFunction<Color, Integer, Apple> abf = Apple::new;
// BiFunction<Color, Integer, Apple> abf = (Color color, Integer weight) -> new Apple(color, weight);
Apple a5 = abf.apply(Color.GREEN, 150);
지금까지 배웠던 순서대로 List<Apple>
을 정렬하는 코드를 발전시켜보자.
public class AppleComparator implements Comparator<Apple> {
@Override
int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
}
apples.sort(new AppleComparator());
apples.sort(new Comparator<Apple>() {
@Override
int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
});
apples.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
// 타입 추론 사용
apples.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight()));
// Comparator의 comparing 메서드 사용 -> 9장에서 자세히 설명
import java.utils.Comparator.comparing
apples.sort(comparing(apple -> apple.getWeight()));
apples.sort(comparing(Apple::getWeight));
// 참고: 단순 List<Integer>를 정렬하는 상황이면
numbers.sort(Integer::compare);
Integer
, String
등의 class에 compare
메소드와 compareTo
메소드가 있다.
// Integer class의 compare, compareTo 메서드
public int compareTo(Integer anotherInteger) {
return compare(this.value, anotherInteger.value);
}
public static int compare(int x, int y) {
return (x < y) ? -1 : ((x == y) ? 0 : 1);
}
compare를
사용하든 compareTo
를 사용하든 둘 다 오름차순 정렬이 된다.
List<Integer> numbers = Array.asList(1,5,2,4,3);
numbers.sort(Integer::compare); // 1,2,3,4,5
// numbers.sort((i1, i2) -> Integer.compare(i1, i1));
numbers.sort(Integer::compareTo); // 1,2,3,4,5
// numbers.sort((i1, i2) -> i1.compareTo(i2));
자바 컴파일러가 사용된 메서드 참조식을 분석하고, 함수 디스크립터 유효성을 검증한 뒤, 알아서 메서드 사용 방법(?)에 맞춰 실행시키는 듯 하다.
자바 8 API의 몇몇 함수형 인터페이스는 다양한 유틸리티 메서드를 제공한다.
이를 통해 람다 표현식을 조합하여 복잡한 람다 표현식을 만들 수 있다.
이는 함수형 인터페이스의 default method
가 있기에 가능한 것이다. (9장에서 설명)
static method comparing
을 통해 비교에 사용할 key를 추출할 수 있다.
Comparator<Apple> c = Comparator.comparing(Apple:getWeight);
내림차순을 지원하는 별도의 Comparator를 만들 필요 없이, reverse
메서드를 사용하면 된다.
apples.sort(comparing(Apple::getWeight).reversed());
thenComparing
메서드를 통해 comparator를 여러 개 연결하여 세부적인 비교를 할 수 있다.
apples.sort(comparing(Apple::getWeight) // 무게를 기준으로
.reversed() // 내림차순 정렬
.thenComparing(Apple::getCountry) // 같은 무게인 경우에는 country기준 오름 차순 정렬
);
복잡한 조건을 검증하기 위해 negate
, and
, or
메서드를 제공한다.
기존에 존재하는 predicate를 반전시키는 predicate를 반환
public static void main(String[] args) {
// 빨강 2개, 초록 1개
List<Apple> apples = Arrays.asList(
new Apple(140, "R"),
new Apple(150, "G"),
new Apple(125, "R")
);
// 빨강 검증 predicate
Predicate<Apple> redApplePredicate = (Apple a) -> a.color.equals("R");
// !빨강 검증 predicate
Predicate<Apple> notRedApplePredicate = redApplePredicate.negate();
}
기존에 존재하는 predicate에 And조건을 추가한 predicate를 반환
// 빨강 && 무게 130 초과 predicate
Predicate<Apple> redAndOver130Predicate = redApplePredicate.and((Apple a) -> a.weight > 130);
기존에 존재하는 predicate에 Or조건을 추가한 predicate를 반환
// 빨강 || 무게 145 초과 predicate
Predicate<Apple> redOrOver145Predicate = redApplePredicate.or((Apple a) -> a.weight > 145);
andThen
, compose
두 가지 디폴트 메서드를 제공한다.
주어진 함수를 먼저 적용한 결과를 다른 함수의 입력을 전달하는 함수를 반환
Function<Integer, Integer> f = (x) -> x + 1;
Function<Integer, Integer> g = (x) -> x * 2;
Function<Integer, Integer> h = (x) -> f.andThen(g); // g(f(x))
int result = h.apply(2) // 6
파라미터로 주어진 함수를 먼저 실행 한 다음에, 그 결과를 외부 함수의 파라미터로 제공
Function<Integer, Integer> f = (x) -> x + 1;
Function<Integer, Integer> g = (x) -> x * 2;
Function<Integer, Integer> h = (x) -> f.compose(g); // f(g(x))
int result = h.apply(2) // 5
이를 통해 어떤 일들을 순차적으로 수행하는 파이프라인을 만들 수 있다.
EX) 입력값에 header를 추가한 다음에, 철자 검사를 수행하고, footer를 붙이고, ...
람다를 통해 적분 계산을 할 수 있다. 자세한 내용은 생략
람다 표현식
은 익명 함수의 일종이다. 람다 표현식
은 이름은 없지만, 파라미터 리스트, 바디, 리턴 타입을 가진다. 람다 표현식
은 예외를 던질 수 있다.함수형 인터페이스
는 단 하나의 추상 메서드만을 정의하는 인터페이스다.함수형 인터페이스
는 디폴트 메소드는 가질 수 있다.함수형 인터페이스
를 기대하는 곳에서만 람다 표현식
을 사용할 수 있다.람다 표현식
을 통해 함수형 인터페이스
의 추상 메서드를 즉석에서 제공할 수 있다.람다 표현식
전체가 함수형 인터페이스
의 인스턴스로 취급된다.java.util.function
패키지에는 다양한 함수형 인터페이스
가 있다.오토박싱
으로 인한 성능 문제를 해결하기 위한 기본형 특화 함수형 인터페이스
가 있다.람다 표현식
을 통해 실행 어라운드 패턴
을 개선할 수 있다.메서드 참조
를 이용하면 기존의 메서드 구현을 재사용하고 직접 전달할 수 있다.Comparator, Predicate, Function
등의 함수형 인터페이스는 디폴트 메서드를 통해 확장성을 제공한다.