이 포스팅은 모던 자바 인 액션 책에 나와있는 내용을 바탕으로 작성하였습니다.
이미 어느 정도 익숙하고, 많이 사용해왔던 모던 자바의 기법이지만 언제나 그렇듯 기본과 원리가 중요하다는 사실을
인지하면서 프로젝트가 마무리 되는 이 시점에 한 번 더 정리해보려고 한다.
기존에는 책을 대충 훑고 난 다음 사용했기 때문에 이번에는 좀 정독을 하면서 중요 부분은 포스팅해보려고 한다.
람다란 무엇인가?
- 메서드로 전달할 수 있는 익명함수를 단순화한 것 (이름없음)
- 기본 문법
- (parameters) -> expression
- (parameters) -> { statements; }
어디에 어떻게 람다를 사용할까?
- 함수형 인터페이스를 인수로 받는 메서드에 람다 표현식을 사용할 수 있다.
- 함수형 인터페이스는 정확히 하나의 추상 메서드를 지정하는 인터페이스이다.
- 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급
// 함수형 인터페이스 예시
public interface Predicate<T> {
boolean test(T t);
}
public interface Comparator<T> {
int compare(T o1, T o2);
}
public interface Runnable {
void run();
}
public void execute(Runnable r) {
r.run();
}
execute(() -> {});
람다를 어떻게 활용할 수 있을까?
- 실행어라운드 패턴에 활용할 수 있다.
- 예를 들어 데이터베이스의 파일 처리를 보면 자원 열고 처리하고 자원 닫는 순서로 이루어 진다. 이 때 실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 갖는데 이런 형태를 바로 실행 어라운드 패턴이라고 부른다.
-
초기화/준비 코드 작업 A 정리/마무리 코드 -
초기화/준비 코드 작업 B 정리/마무리 코드 - 중복되는 준비 코드와 정리 코드가 작업 A와 작업 B를 감싸고 있다.
// 한 줄씩 읽는 기존 코드
public String work() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("hi.txt"))) {
return br.readLine();
}
}
// 근데 상황에 따라 두 줄을 읽어야 된다는 요구사항이 들어왔다.
// 따라서 br.readLine(); 이 부분의 동작을 함수형 인터페이스를 사용하는 것으로 바꾼다.
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
public String work(BufferedReaderProcessor p) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("hi.txt"))) {
return p.process(br);
}
}
String result = work((BufferedReader br) -> br.readLine());
String result2 = work((BufferedReader br) -> br.readLine() + br.readLine());
함수형 인터페이스와 람다의 활용
- 다양한 람다 표현식을 사용하려면 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다.
- 함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터(fuction descriptor)라고 한다.
- 자바 API는 Comparable, Runnable, Callable, Predicate, Consumer, Function 등의 함수형 인터페이스를 제공하고 있고, 이 인터페이스에 대한 자세한 설명은 이 포스팅에서 생략하도록 한다. (이와 관련된 포스팅은 따로 있음)
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
}
@FunctionalInterface
public interface Function<T,R> {
R apply (T t);
}
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
// Predicate 활용 예시
Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
List<String> nonEmpty = filter(listOfString, nonEmptyStringPredicate);
// Consumer 활용 예시
public <T> void forEach(List<T> list, Consumer<T> c) {
for (T t: list) {
c.accept(t);
}
}
forEach(Arrays.asList(1,2,3,4,5), (Integer i) -> System.out.println(i));
@FunctionalInterface
public interface Function<T> {
R apply (T t);
}
public <T,R> List<R> map(List<T> list, Function<T,R> f) {
List<R> result = new ArrayList<>();
for (T t: list) {
result.add(f.apply(t));
}
return result;
}
List<Integer> list = map(Arrays.asList("lamdas", "in", "action"),
(String s) -> s.length()
);
기본형 특화
자바의 모든 형식은 참조형(Object, Integer, List) 아니면 기본형(int, double, byte, char)가 있다.
제네릭 파라미터 Consumer<T> 의 T 같은 데에는 참조형만 사용할 수 있다는
제네릭의 내부 구현 방식으로 인해 자바에서는 기본형을 참조형으로 박싱하는 기능을 제공한다.
그리고 박싱과 언박싱이 자동으로 이뤄지는 오토박싱 기능도 제공하는데 (int가 Integer로)
이러한 변화과정은 비용이 소모된다. 박싱한 값은 기본형을 감싸는 Wrapper이며 이것은 Heap에 저장된다.
따라서 박싱한 값이 메모리를 더 소비하기 때문에 기본형을 가져올 때에도 메모리를 탐색해야 하는 과정을 거친다.
이런 이유로 오토박싱 동작을 피할 수 있도록 특별한 함수형 인터페이스를 제공한다.
Predicate<Integer> -> IntPredicate 이 외 DoublePredicate, IntConsumer, LongBinaryOperator, IntFuntion가 있다.
public interface IntPredicate {
boolean test(int i);
}
형식 추론
자바 컴파일러는 람다 표현식이 사용된 콘텍스트를 이용해서 관련된 함수형 인터페이스를 추론해낸다!
그리고 이런 추론은 개발자가 람다 문법에서 파라미터 형식을 생략하더라도 추론이 가능하기 때문에
아래와 같이 두 가지 형태로 사용이 가능하며 개발자 스스로 어떤 코드가 가독성을 향상 시킬 수 있는지 결정해야 한다.
Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
람다 캡처링
람다 표현식은 인수를 자신의 바디 안에서만 사용했다. 하지만 자유변수(외부에서 정의된 변수)를 활용할 수 있고,
이것을 람다캡처링(capturing lambda)라고 부른다.
int portNumber = 8289;
Runnable r = () -> System.out.println(portNumber);
하지만 이에는 제약사항이 있는데
지역 변수가 명시적으로 final로 선언되거나, final은 아니지만 실제론 final처럼 사용되어야 한다는 것이다.
위에서 portNumber가 다시 portNumber=8080으로 두 번 값을 할당하면 컴파일 에러가 발생하게 될 것이다.
왜 이런 제약사항이 있는가?
인스턴스 변수는 힙에 저장되고, 지역 변수는 스택에 위치하는데 람다가 만약 스레드에서 실행된다고 하면 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드는 해당 변수에 접근하려 할 것이다. 따라서 자바 구현에서는 원래 변수에 접근을 허용하지 않고, 자유 지역 변수의 복사본을 제공하게 되었고 이 복사본의 값이 바뀌지 않아야 하므로 지역변수에는 값을 한 번만 할당해야 하는 것이다. (병렬화에도 관련이 있음)
클로저
함수의 비지역 변수를 자유롭게 참조할 수 있는 함수의 인스턴스
람다와 익명 클래스 모두 메서드의 인수로 전달될 수 있으며 자신의 외부 영역의 변수에 접근할 수 있다. 다만 람다와 익명클래스는 람다가 정의된 메서드의 지역 변수의 값은 바꿀 수 없다.
메서드 참조
메서드를 어떻게 호출해야 하는지 설명을 참조하기 보다는 메서드명을 직접 참조해서 가독성을 높인다. 예를 들어 Apple::getWeight는 Apple클래스에 정의된 getWeight의 메서드 참조이다. 결과적으로 메서드 참조는 람다 표현식
(Apple a) -> a.getWeight()을 축약한 것이다.
방법
1. 정적 메서드 참조 -> Integer::parseInt
2. 다양한 형식의 인스턴스 메서드 참조 -> String::length
3. 기존 객체의 인스턴스 메서드 참조 -> t::getValue (Transaction 객체를 할당받은 t 지역변수의 getValue 메서드)
예시
ToIntFuntion<String> stringToInt = (String s) -> Integer.parseInt(s);
-> Funtion<String, Integer> stringToInteger = Integer::parseInt;
BiPredicate<List<String>, String> contains = (list, element) -> list.contains(element);
-> Predicate<List<String>, String> contains = List::contains;
Predicate<String> startsWithNumber = (String string) -> this.startsWithNumber(string);
-> Predicate<String> startsWithNumber = this::startsWithNumber;
생성자 참조
ClassName::new처럼 클래스명과 new 키워드를 이용해 기존 생성자의 참조를 만들 수 있다.
Supplier의 () -> Apple과 같은 시그니처를 갖는 생성자가 있다고 가정하면 아래와 같이 사용 가능하다.
Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();
Function을 이용하면 아래와 같이 사용할 수 있다.
Function<Integer, Apple> c2 = Apple::new;
Apple a1 = c2.apply(110);
인스턴스화하지 않고도 생성자에 접근할 수 있는 기능을 다양한 상황에 응용할 수 있다.
static Map<String, Function<Integer, Fruit>> map = new HashMap<>();
static {
map.put("apple", Apple::new);
map.put("orange", Orange::new));
}
public static Fruit giveMeFruit(String fruit, Integer weight) {
return map.get(fruit).apply(weight);
}
람다 표현식을 조합할 수 있는 유용한 메서드
자바 8 API의 몇몇 함수형 인터페이스는 디폴트 메서드를 활용해 다양한 유틸리티 메서드를 제공하고 있다.
Comparator
역정렬 reversed(), 두번째 비교자 thenComparing 이 있다.
inventory.sort(comparing(Apple::getWeight)
.reversed()
.thenComparing(Apple::getCountry));
Prediacte
negate, and, or 세가지 메서드 제공한다.
negate는 특정 프레디케이트를 반전시킬 때 예를 들어 '빨간색이 아닌 사과' 같은..
Predicate<Apple> notRedApple = redApple.negate();
and와 or은 말그대로이다.
Predicate<Apple> redAndHeavyAppleOrGreen =
redApple.and(apple -> apple.getWeight() > 150)
.or(apple -> GREEN.equals(a.getColor()));
Function
Function인터페이스를 반환하는 andThen, compose 두 가지 디폴트 메서드를 제공한다.
andThen은 주어진 함수를 먼저 적용한 결과를 다음 함수의 입력으로, compose메서드는 인수로 주어진 함수를 먼저 실행한 다음에 그 결과를 외부함수의 인수로 제공한다. 즉, f.andThen(g)에서 andThen 대신에 compose를 사용하면 g(f(x))가 아니라 f(g(x))라는 수식이 된다.
Funtion<Integer, Integer> f = x -> x + 1;
Funtion<Integer, Integer> g = x -> x * 2;
Funtion<Integer, Integer> h = f.componse(g);
int result = h.apply(1); // 3을 반환한다.
'책 > 모던자바인액션' 카테고리의 다른 글
Java 함수형 프로그래밍에 대해 알아보자. (0) | 2022.06.16 |
---|