이번 주는 지난 주에 이은 람다에 대한 마무리와 스트림에 대한 간단한 소개가 교재 내용의 주를 이룬다.
형식 검사, 형식 추론 제약
함수형 인터페이스가 존재한다면 람다 표현식을 구현할 수 있다. 그러나, 람다 표현식을 봤을때 어떤 함수형 인터페이스를 구현하는지는 알 수 없다. 어떻게 람다 표현식의 실제 형식을 파악할까?
1. 형식 검사
람다가 사용되는 컨텍스트를 통해 대상 형식을 확인할 수 있다. 대상 형식이란, 어떤 컨텍스트에서 기대되는 람다표현식의 형식을 의미한다. 아래 코드를 보자.
List<Apple> heavierThan150g = filter(inventory,(Apple apple) -> apple.getWeight()>150);
이러한 코드가 있을때 아래 5가지 과정을 거쳐 형식을 검사한다.
1. filter 메서드의 선언부를 확인한다.
2. 두번째 파라미터의 대상 형식 Predicate<Apple>을 기대한다.
3. Predicate<Apple>은 하나의 test라는 추상메서드를 정의한 함수형 인터페이스임을 확인한다.
4. test 메서드는 Apple -> boolean을 반환한다.
5. filter메서드의 모든 파라미터는 위 요구사항을 만족해야한다.
따라서, filter메서드의 두번째 파라미터(컨텍스트) --> 대상 형식을 검사할 수 있다.
함수 디스크립터가 같다면 같은 람다라도 다른 함수형 인터페이스를 사용할 수 있다.
() -> 42;
위 람다식은 void -> int를 반환하는 함수 디스크립터인데 Callable, PrivilegedAction의 함수형 인터페이스 둘 모두 구현이 가능하다.
형식 추론
자바 컴파일러는 람다 표현식의 컨텍스트를 이용해 람다 표현식과 관련된 함수형 인터페이스를 추론한다. 따라서, 함수형 디스크립터를 알 수 있고 파라미터에 명시적으로 형식을 나타내지 않아도 괜찮다.
아래 람다 식은 유효한 식이다.
Comparator<Apple> c = (a1, a2) -> a1.getWeight()compareTo(a2.getWeight());
정해진 규칙은 없고 개발자가 가독성을 위해 더 적절한 선택을 하면 된다.
람다의 지역 변수 사용
람다는 람다 내부에서의 인수를 자신의 바디안에서만 활용했다. 하지만, 람다도 파라미터로 넘겨진 값이 아닌 외부에서 정의된 외부 변수를 람다 캡쳐링을 통해 활용할 수 있다.
람다 캡쳐링이란?
명시적으로 final이라고 선언되거나 final과 다름 없이 한번만 할당되는 변수를 람다의 스레드로 복사 후 사용하는 과정을 의미함. 외부 변수는 Stack영역에 위치하여 서로 다른 스레드를 사용해 상호 공유가 불가능하다. 만약, 외부 스레드의 값을 참조했는데 해당 스레드가 사라진다면 더이상 참조가 불가능하기 때문이다.
따라서, 람다는 전달받은 값을 참조만 하고 값을 변경할 수 없다.
자세한 내용은 지난주에 배운 Effectively Final 글을 참조하자.
메서드 참조
람다표현식은 코드가 간결해진다는 장점이 있었다. 여기에 코드를 더 간결하게, 가독성을 올리는 방법으로 메서드 참조가 있다. 람다에서 어떤 메서드를 직접 호출하라고 명령을 보낼때 이에 대한 설명을 하는 것보다 명시적으로 어떤 메서드를 쓸지 메서드명을 나타내면 훨씬 가독성이 좋다. 이때 실제로 메서드를 호출하는 것이 아니기에 괄호가 필요하진 않다.
//람다
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
//메서드 참조
inventory.sort(comparing(Apple::getWeight);
메서드 참조를 만드는 방법은 세가지가 있다.
1. Static Method를 참조하기 ex: Integer의 parseInt
2. 다양한 형식의 인스턴스 메서드 참조하기 String::length
3. 기존 객체의 인스턴스 메서드 참조하기
특정 클래스를 할당 받은 인스턴스가 존재한다면 해당 인스턴스의 메서드를 직접 참조할 수 있다. 예를 들어 Apple a1객체가 있다면, 아래와 같이 인스턴스를 참조할 수 있다.
a1::getWeight
이렇게 파라미터로 메서드 참조가 되면, 자바 컴파일러는 주어진 함수형 인터페이스와 동일한지 람다와 같은 방식으로 형식 검사를 진행한다.
생성자 참조
같은 방법으로 생성자 참조 또한 가능하다. 함수형 시그니쳐에 따라 객체 생성이 가능하다. () -> Apple의 시그니처로 사과를 생성한다고 할때 () -> T의 함수형 시그니처를 갖고 있는 함수형 인터페이스 Supplier<T>를 통해 Apple을 생성할 수 있다.
SUppiler<Apple> c1 = Apple::new;
Apple a1 = c1.get()//Suppiler의 get메서드 활용
생성자에 파라미터가 필요하다면 T->R의 함수형 시그니처를 가지고 있는 Function<T,R>을 통해서 객체 생성이 가능하다.
Function<Integer,Apple> c2 = Apple:new;
Apple a2 = c2.apply(110);//파라미터로 Integer에 해당하는 값을 넣어주자.
생성자의 파라미터의 개수가 여러개가 필요하다면 적절한 메서드 시그니처를 갖는 함수형 인터페이스 활용하면 된다. 만약 존재하지 않는다면 직접 인터페이스를 만들어서 구현하면 된다.
활용 예시
지금까지 정리한 내용을 바탕으로 람다, 메서드 참조를 활용해보자. sort 메서드를 구현 과정을 생각해보자.
Sort 메서드는 파라미터롤 Comparator 객체를 인수로 전달한다. 여기서 우리는 sort 의 동작이 파라미터화 되어있다는 것을 알 수 있다. 우선, 해당 Comparator를 구현하는 구현체를 만들고 해당 구현체를 sort의 파라미터로 넘길 수 있다.(농부의 사과 비교 첫번째 예제)
동작파라미터화를 알았으니 이어서, 익명 클래스 -> 람다 -> 메서드 참조가 가능하다는 것을 알 수 있다.
익명 클래스 활용해 파라미터 안에 익명클래스를 선언할 수 있다.
inventory.sort(new Comparator<Apple>(){
public int compare(Apple a1,Apple a2){
return a1.getWeight().compareTo(a2.getWeight());
}
});
함수형 인터페이스를 참조 후 람다 표현식을 통해 코드를 전달할 수 있다. 따라서 아래처럼 간결하게 작성이 가능하다.
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
자바 컴파일러의 형식 추론을 통해 파라미터의 Apple은 선언하지 않아도 괜찮다.
이를 조금 더 간결하게 하기 위해선 Comparator의 비교 키를 받아 객체로 만드는 정적 메서드 Comparing을 활용할 수 있다.
inventory.sort(comparing(apple -> apple.getWeight()));
이제 메서드 참조를 통해 한번 더 코드를 간단하게 할 수 있다.
inventory.sort(comparing(Apple::getWeight));
람다 표현식 조합
자바 8 API의 함수형 인터페이스는 람다를 조합하기 위한 유틸 메서드를 지원한다. 해당 유틸 메서드는 디폴트 메서드임으로 추상메서드가 아니기에 구현되어 있다해서 함수형 인터페이스의 정의를 벗어나지 않는다. Compartor, Function, Predicate등에서 이 메서드를 제공하는데 이를 활용해 여러 람다 표현식을 조합할 수 있다.
예시: Comparator조합
만약, 내가 comparing을 사용해 정렬한 sort를 내림차순으로 정렬하고 싶다면 어떨까? 뒤에 유틸 메서드 reversed를 붙이면 된다.
inventory.sort(comparing(Apple::getWeight).reversed();
이어서, 무게로 내림차순 정렬했는데 무게가 동일할때 다른 속성으로 정렬하는 것은 어떨까? 이때는 thenComparing 메서드를 이어 붙이면 된다.
inventory.sort(comparing(Apple::getWeight)
.reversed
.thenComparing(Apple::getCountry));
스트림이란?
자바 8 API에 새로 추가된 기능으로, 컬렉션의 반복을 이전과 다르게 멀티 스레드 코드를 구현하지 않고 데이터를 투명하게 병렬처리할 수 있는 기능이다.
스트림이 등장하기 이전인 자바7에선 컬렉션을 처리할때는 for문을 돌며 누적자로 값을 처리하고 중간에서 컨테이너 역할을 하는 변수를 선언해 작업을 했으나, 스트림은 다음과 같이 세부 구현은 라이브러리 내에서 처리하는 방식으로 코드를 짜 가독성을 높아진다.
List<String> lowCaloricDishesName =
menu.stream()
.filter(d->d.getCalories()<400)
.sorted(comparing(Dish::getCalories))
.map(Dish::getName)
.collect(toList());
코드의 메서드만 읽어도 칼로리가 400이하인 메뉴를 칼로리 기준으로 정렬해 이름을 반환하는 리스트를 만드는 과정임을 알 수 있다. stream()대신 parallelStream()을 활용해 성능을 개선할 수 있다. 이렇게
이렇게 스트림은 멀티 코어 아키텍처를 최대한 투명하게 활용하도록 돕는다.
스트림의 특징은 다음과 같다.
- 선언형: 코드가 간결하고 가독성이 좋아진다.
- 조립 가능: 유연성이 높아짐
- 병렬화: 성능 개선
스트림의 명확한 정의
스트림의 정확한 정의는 '데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소'로 정의할 수 있다. 이 정의를 쪼개서 살펴보면 다음과 같다.
- 연속된 요소: 컬렉션과 같이 연속된 값의 집합이다. 순서가 있다는 점에서 컬렉션과 동일하나 컬렉션은 데이터에 어떻게 접근하고 저장할 것인가가 포인트인 반면 스트림은 계산을 어떻게 하느냐가 주요 포인트이다.
- 소스: 스트림은 컬렉션, 배열과 같은 데이터를 저장한 '소스'로부터 데이터를 제공받은 후 소비한다. 즉, 제공 받은 데이터를 그대로 유지하여 보관한다.
- 데이터 처리 연산: 스트림은 filter,map,reduce와 같은 함수형 프로그래밍에서 제공하는 연산과 DB연산을 제공하고 이를 병렬적으로 처리할 수 있다. (연산 결과 값이 stream이라서 아래와 같이 이어서 다른 메서드 호출이 가능함.)
stream()
.filter(~)//stream을 반환
.reduce(~);
즉, 스트림은 소스에서 데이터를 전달 받아 연속적으로 값을 처리하는 것이다.
또한 스트림의 주요 특징은 스트림 연산끼리 연결해서 파이프라인을 구성하도록 자신을 반환한다. 이를 통해 Laziness, short-circuiting같은 최적화가 가능하고, 명시적으로 반복하지 않고 내부에서 반복하는 내부 반복이라는 특징을 갖는다.
스트림과 컬렉션
스트림과 컬렉션 모두 '연속된' 형식의 요소를 저장하는 자료구조, 인터페이스를 제공한다. 여기서 '연속된'이라는 표현은 순서가 존재에 순서에 따라 순차적인 접근이 가능하다는 것을 의미한다.
둘의 차이가 뭔데?
계산 시점
책에서는 DVD를 컬렉션, 인터넷 스트리밍을 스트림으로 예를 든다. DVD는 모든 데이터가 구워져있어야 재생이 가능한데, 인터넷 스트리밍은 미리 받아진 프레임이 존재하면 해당 프레임부터 재생할 수 있기 때문이다. 즉, 컬렉션은 현재 자료구조가 포함하는 모든 값을 저장하는 자료구조로 저장이전에 계산을 끝내고 '저장'에 초점을 맞춘다. 반대로 스트림은 '계산'에 초점을 맞춰 요청할때만 필요한 요소만을 계산하는 자료구조다. 데이터의 계산 시점이 둘의 큰 차이이다.
단 한번만 탐색
스트림은 iterator와 동일하게 단 한번만 탐색이 가능하다. 한번 탐색해 어떤 연산이든 사용된 요소는 소비되어 사라진다.
즉, 아래의 코드는 첫줄은 유효하나 두번째 줄은 유효하지 않다.
Stream<String> s = title.stream();
s.forEach(System.out::println);//title의 단어들을 출력
s.forEach(System.out::println);//IllegalStateException 에러 발생
외부 반복과 내부 반복
컬렉션을 사용하면 내부의 값을 처리할때 사용자가 직접 요소를 꺼내서 for-each 구문등을 활용해 반복해야한다. 데이터를 어떻게 처리하는지 명시적으로 값을 가져와 외부에서 볼 수 있다는 이야기이다. 절차적으로 할일을 명시하고 다음 작업, 다음작업을 명시해 처리한다. 반대로 스트림은 내부 반복으로 여러 작업이 있을때 병렬적으로 처리하거나 조금 더 최적화된 순서로 처리할 수 있다는 장점이 있다.
스트림 연산
스트림은 이전에도 언급했듯 자기 자신을 반환해 여러 메서드를 연속적으로 사용할 수 있다. 연결할 수 있는 스트림 연산을 중간 연산, 스트림을 닫는 연산을 최종 연산이라고 한다.
중간 연산
병렬적으로 여러 요소를 연산하며 지속적으로 스트림을 반환한다. 이를 연결해 여러 연산을 할 수 있고 단말 연산을 파이프라인에 실행하기 전까진 아무 연산도 수행하지 않는다는 게으른 특징을 가지고 있다.
최종 연산
스트림 파이프라인에서 결과를 도출한다. 최종 연산은 보통 stream이 아닌 Integer, List, void와 같은 반환형을 띈다.
'PL > 모던 자바 인 액션' 카테고리의 다른 글
Effective Java - item 44 (0) | 2023.08.23 |
---|---|
Effective Java - item 42 (0) | 2023.08.23 |
HashMap의 동작 구조 (0) | 2023.08.16 |
Effectively final (0) | 2023.08.15 |
자바 컬렉션 프레임워크 (0) | 2023.08.15 |