Lambda & Stream의 도입 배경과 원리, 최적화 전략! 알고 쓰자!!!
람다와 스트림은 원리를 모른 채 사용되는 경우가 많다. 인텔리제이 자동완성, Chat GPT와 코파일럿의 도움을 받는다면, 사실 개념조차 몰라도 사용할 수 있다.그런데 내가 그걸 왜? 알아야? 하지?
dwaejinho.tistory.com
글을 정리하기 이전에 이 글을 발견하게 되어 참조로 올린다. 여기 글을 들어가서 "느끼고" 다시 읽는걸 추천(나에게 추천..)
스트림
영어로 흐름이란 뜻. 선언형을 지원하여 함수형 프로그래밍을 지원한다는 둥 해당 내용은 더 공부해봤지만... 함수형 프로그래밍을 확실하게 더 공부하고나서 해당 내용을 이해 할수 있을거 같아서 포기하고, Stream API에 집중한다.
결론적으로는 컬렉션이나 여러 자료구조들에서 내부 요소들을 처리하기 위해 사용한다.
반복자랑 유사하다. iterator() 매서드를 통해 반복자를 생성하나 stream() 매서드를 통해 스트림을 생성하나 결국 내부적으로 요소들에 접근한다는 점에서 비슷. 하지만 큰 차이가 있으니
1. 스트림은 컬렉션의 내부 반복자를 사용한다. 즉 원본에 어떤 영향도 주지 않음.
2. 스트림에 선언되어 있는 함수들은 함수형 인터페이스를 사용하여 람다식 사용가능 -> 함수형 프로그래밍지원
3. 중간처리와 최종처리를 분리하여, 파이프라인 형성 -> 속도도 빠르고, 병렬 처리에도 효율적
대용량 어플리케이션으로 갈 수록 함수형 프로그래밍 학습곡선만 극복한다면 객체지향+함수형프로그래밍 으로 리펙토링하는걸 권장하니까.
Set<String> set = new HashSet<>();
set.add("a"); set.add("b"); set.add("c");
Stream<String> stream = set.stream();
stream.forEach(name -> System.out.println(name) );
//a
//b
//c
내부 반복자???
앞서 내부에서 따로 반복자를 사용한다고 하는데 외부반복자인 Iterator와 무슨 차이가 있나??
정말 많은 삽질이 있었고 정말 눈물이 날정도로 힘들었는데 결국 정리했다.
내부 반복자라는 이름은 무언가의 내부에 있다고 생각되게 만들었다. 실제로 무언가의 내부에 있긴하다. 그 내부가 어디일까? Stream 구현체 내부에 존재한다. 인터페이스에는 없고 인터페이스는 동작만을 정의하는데 동작을 호출할때 내부 구현에 의해 Spliterator 인스턴스에 의해 반복적인 행위들이 가능해지는 것.
근데 이것만으로 내부 반복자와 외부 반복자의 차이를 간단하게 설명하기엔 미약하다.
외부 반복자는 컬렉션에서 호출하여 만들어진 반복자를 어딘가에서 사용하여 실제로 구현하여 데이터 처리를 하는데 사용하는 소스 용으로 사용되어 외부 반복자라는 이름이 지어진거 같다. 실제 반복자는 반복적으로 next()를 직접 개발자가 호출해가며 컬렉션을 직접 조작하게 된다.
내부 반복자는 반면 철저히 숨겨져있다. 정말 찾기 어려웠는데 디버깅을 돌리며 내린 결론 StreamSupport, ReferencePipeline 클래스의 도움을 받아 Stream 인스턴스가 생성되고 중간 연산자와 최종 연산자의 조합으로 결국 반복이 돌아가는데 개발자는 next()에 따른 직접 조작을 구현하지 않는다. 그저 함수형 인터페이스를 이용한 일급객체다운 사용법을 통해 선언적 사용으로 What해라 What해라 명령해가며 Stream의 최종 동작 결과를 결정하게 된다. 최종 동작에 도달한 순간 내부 반복자가 비로소 돌아가며 컬렉션의 모든 요소를 순차적으로 탐색한다. limit(int)에 의해 혹은 에러에의해 중단되지 않는 이상 filter() 따위로는 반복자가 중단될일 없이 컬렌션 모든 요소를 탐색하는게 일반적일것.
Iterable 자체는 next()의 결과를 보장하지만, 개발자의 데이터 처리에 의해 컬렉션 자체의 내부 데이터가 변할 수 있는 외부 반복자 사용과 달리(이게 그냥 자바의 의도였다. 사실 상태를 완전히 배제하는 코딩만 해야되는건 아니니까)
내부 반복자란 개념을(실제가 아니라 개념이라고 결론을 내렸다. 어차피 둘다 정체는 Spliterator 클래스의 인스턴스야)사용하는 Stream()은 그저 반복하며 가져올뿐 데이터 반환 처리나 동작이 Collection 원본에 영향을 안주는게 Stream 설계의 방식이다. 의도다. 만약에 Stream을 통해 원본을 바꾸는게 목적이라면 "재정의" 하면 된다.
import java.util.ArrayList;
import java.util.List;
import java.util.Spliterator;
import java.util.stream.Stream;
public class StreamDebugTest {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
Stream<String> stream = list.stream();
stream.map(s -> {
System.out.println("Changing: " + s);
return s.toLowerCase();
})
.forEach(s -> {
System.out.println("last: " + s);
});
Stream<String> newStream = list.stream();
Spliterator<String> spliterator = newStream.spliterator();
}
}
스트림.. 만만하게 보면 안되네 상당한 고찰이 필요한 녀석
애초에 자바는 기존에 스트림 없이도 컬렉션 표준 인터페이스만으로도 객체지향에서 추구하는 객체를 가지고 반복 처리를 하는걸 상당히 잘 처리해 왔다. 그런데 문제는 점점 프로그램이 고도화 되면 될 수록 함수형, 선언형 프로그래밍을 해야한다는 수요가 증대하면서 고급개발자들이 함수형 언어를 지원하는 언어로 떠날 기세가보이자 자바8에서 큰맘 먹고 jdk를 갈아엎어서 결국 Stream, 람다식, 함수형 인터페이스 등의 개념을 도입하여 일급 객체로 전달가능한 함수를 만들어내고, 그걸 사용하기 쉽게 고차원적인 추상화가 담겨진 인터페이스인 Stream이 만들어진거다. 이미 개념적인 컨셉이 어려운 녀석이니까 한방에 이해 못한다고 좌절할건 없어보인다.
중간 처리 방식 + 최종처리 방식 => 파이프라인!
스트림은 어떻게 가독성을 증가시키는가? 바로 자체 파이프라인을 지원하기 때문. 레이지한 처리를 지원하는 메서드들과 최종적인 연산을 지원하는 매서드를 종합시켜 마치 메소드 체이닝만으로 우아한 연산?(사용자체는 너무 간편하다 원리를 파악하려고 하니까 너무 힘들었는데...)을 가능하게 만든다.
[햇갈릴 수 있는 설명들]
1.
중간처리 방식 == 스트림을 만드는방식
최종처리 방식 == 값을 만드는 방식
결론적으로는 중간처리 방식을 호출한다고 해서 새로운 스트림을 만들거나 하지는 않는다. 그저 처리 방식에 대해서 선언을 할뿐이다.
최종처리 방식은 값을 만드는 방식은 어느정도 합리적인 설명이라 생각한다.
2. 중간처리 방식은 최종 처리 방식으로 결론을 내리지 않는 이상 실행이 되질 않는다
이 설명도 어떻게 하면 쉽게 이해시킬까의 산물이지 않을까 싶은데 오히려 객체지향적으로 더욱 말이 안되는 설명이 된거 같다. 최종처리 방식을 호출할때서야 비로소 반복자가 돌면서 모든 처리가 일사분란하게 이루어진다고 한다면
"정렬" 연산은 어떻게 설명할것인가?? 처음부터 끝까지 단 한번의 요소탐색도 안하고 정렬이 과연 가능한가?
[간과 하고 넘어가는 것들]
1. 중간처리 방식은 더 나누자면 statless인지 stateful인지로 나눌 수 있다.
stateless concept methods: 조건, 매핑, 분할, 알림 등은 지금 요소와 다른 요소와 관련한 상태지배적이지 않다.
즉 그냥 하나 요소가지고도 모든걸 다 하는데 지장이 없다는 소리다.
filter() map() peek() 등이 stateless이다.
여기서 의문이 들어야함. peek()가 왜 stateless야? 반복호출하는데 어떻게 요소와 요소 사이 탐색이 필요 없는거야? 모든 요소 반복한다는건데?
-> peek() 만으로는 상태 지배적이지가 않다. 절대로. 그저 현재 요소를 반복 탐색할뿐이야. n^n 번 탐색하면서 뭔가를 처리하고자 할려고 peek()가 있는게 아니다. 실제로
.peek().peek().forEach();
이것의 결과는 놀랍게도 n번 탐색이다.
대체 왜이러는걸까? 스트림 리프퓨전기술이 들어간다. 그저 JDK는 경이롭다. 내부적으로
for(Element e : Elements){
//peek()코드
//peek()코드
//forEach()코드
}
로 변환되어 작동하게 된다.
2. stateful 방식들
stateful bounded operation: limit() skip() 같은 매서드가 딱이다. 어떤 경계값을 가지고 있으며 해당 경계에 도달했는지 체크하여 반복자 동작을 결정하는 녀석들이다. 반복자?? 다시 말하지만 아직 중간처리이다. 즉 얘네들 만으로 반복자는 시작도 안했다는 소리 ㅋㅋ
stateful unbounded operation: distinct() sort() 경계값을 저장하고 있는게 아니다. 예네는 독특하다 distinct()는 해당 요소에대한 내부적인 set()을 가지고 있어서 중복 체크를 진행하여 반복자가 뭘할지 말지를 결정하게 해주고 그런다 한들 끝까지 가는거다 일단. sort()는 더 복잡한데.... sort() 이후에 forEach() 호출되는 상황을 그려보자. forEach() 실행여부에 의해 중간처리 방식이 시작된다는 말에는 동의 하는가? 그럼 내부 요소 정렬이 안되어 있는 애가 반복자를 실행하고... 말이 안되자나. 즉 sort()는 여타 다른 중간처리 방식이 그저 선언만하고 내부적으로 반복을 안돌리는것과 달리 이 자식은 내부적으로 반복자를 먼저 돌려서 자체적인 "Buffer"개념에 요소를 저장하고 다음에 오는 매서드에게 my Buffer를 사용하게 만든다... JDK 대단해..
최종처리 방식들
리턴값이 Stream이 아니라 다른 녀석들이다. 실제로 그러하다.
여까지 도달하면 중간처리와 최종처리가 뒤섞인 예제를 마주하고 해당 코드의 출력값을 예측해보자. 난 다틀렸다.
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collector;
import java.util.stream.Collector.Characteristics;
public class StreamOperationTest {
public static void main(String[] args) {
List<String> data = Arrays.asList(
"A1", "A2", "B1", "B2", "A1", "C1", "C2", "B1"
);
System.out.println("=== 스트림 파이프라인 시작 ===");
List<String> result = data.stream()
// 1. Stateless 중간 연산
.filter(s -> {
System.out.println("[Stateless] filter 검사 C면 그냥 걸러!!: " + s);
return s.startsWith("A") || s.startsWith("B");
})
.map(s -> {
System.out.println("[Stateless] map 변환 Mapped 붙여!: " + s);
return s + "_mapped";
})
.peek(s ->
System.out.println("[Stateless] 1번 peek! distinct 전: " + s)
)
// 2. Stateful 중간 연산
.distinct() // 중복 제거
.peek(s ->
System.out.println("[Stateful] 2번peek! distinct로 상태 저장!!: " + s)
)
.sorted((s1, s2) -> {
System.out.println("[Stateful] 정렬 비교: " + s1 + " <-> " + s2 + "==" + s1.compareTo(s2));
return s1.compareTo(s2);
})
.peek(s ->
System.out.println("[Stateful] 3번peek! sorted 후: " + s)
)
//실제로는 이런식의 구현을 할필요가 없는데 Sysout 찍을려고 Collector를 만든것
//Collectors에 Collector 생성 정적 매서드 존재하고 그걸 쓰는게 맞다.
.collect(new Collector<String, List<String>, List<String>>() {
@Override
public Supplier<List<String>> supplier() {
return () -> {
System.out.println("[Final] 새로운 리스트 생성");
return new ArrayList<>();
};
}
@Override
public BiConsumer<List<String>, String> accumulator() {
return (list, item) -> {
System.out.println("[Final] 누적 처리: " + item);
list.add(item);
};
}
@Override
public BinaryOperator<List<String>> combiner() {
return (list1, list2) -> {
System.out.println("[Final] 리스트 병합");
list1.addAll(list2);
return list1;
};
}
@Override
public Function<List<String>, List<String>> finisher() {
return list -> {
System.out.println("[Final] 최종 처리 완료");
return list;
};
}
@Override
public Set<Characteristics> characteristics() {
return Collections.emptySet();
}
});
System.out.println("\n=== 최종 결과 ===");
result.forEach(System.out::println);
}
}
정말 어려운 코드이다. result.forEach()의 동작만 보자고 하면 사실 나름 따라갈만하다. 그런데 내부 동작의 순서를 정확히 예측해서 맞춘다면 Stream원리를 전부 이해한것. 나도 확인을 하기 위해서 일부러 디버깅용 Sysout을 막찍었다. 정답은
=== 스트림 파이프라인 시작 ===
[Stateless] filter 검사 C면 그냥 걸러!!: A1
[Stateless] map 변환 Mapped 붙여!: A1
[Stateless] 1번 peek! distinct 전: A1_mapped
[Stateful] 2번peek! distinct로 상태 저장!!: A1_mapped
[Stateless] filter 검사 C면 그냥 걸러!!: A2
[Stateless] map 변환 Mapped 붙여!: A2
[Stateless] 1번 peek! distinct 전: A2_mapped
[Stateful] 2번peek! distinct로 상태 저장!!: A2_mapped
[Stateless] filter 검사 C면 그냥 걸러!!: B1
[Stateless] map 변환 Mapped 붙여!: B1
[Stateless] 1번 peek! distinct 전: B1_mapped
[Stateful] 2번peek! distinct로 상태 저장!!: B1_mapped
[Stateless] filter 검사 C면 그냥 걸러!!: B2
[Stateless] map 변환 Mapped 붙여!: B2
[Stateless] 1번 peek! distinct 전: B2_mapped
[Stateful] 2번peek! distinct로 상태 저장!!: B2_mapped
[Stateless] filter 검사 C면 그냥 걸러!!: A1
[Stateless] map 변환 Mapped 붙여!: A1
[Stateless] 1번 peek! distinct 전: A1_mapped
[Stateless] filter 검사 C면 그냥 걸러!!: C1
[Stateless] filter 검사 C면 그냥 걸러!!: C2
[Stateless] filter 검사 C면 그냥 걸러!!: B1
[Stateless] map 변환 Mapped 붙여!: B1
[Stateless] 1번 peek! distinct 전: B1_mapped
[Stateful] 정렬 비교: A2_mapped <-> A1_mapped==1
[Stateful] 정렬 비교: B1_mapped <-> A2_mapped==1
[Stateful] 정렬 비교: B2_mapped <-> B1_mapped==1
[Final] 새로운 리스트 생성
[Stateful] 3번peek! sorted 후: A1_mapped
[Final] 누적 처리: A1_mapped
[Stateful] 3번peek! sorted 후: A2_mapped
[Final] 누적 처리: A2_mapped
[Stateful] 3번peek! sorted 후: B1_mapped
[Final] 누적 처리: B1_mapped
[Stateful] 3번peek! sorted 후: B2_mapped
[Final] 누적 처리: B2_mapped
[Final] 최종 처리 완료
=== 최종 결과 ===
A1_mapped
A2_mapped
B1_mapped
B2_mapped
??? 의문을 가져야 정상임 물론
1. ??? 최종 처리의 로그가 맨 마지막에 나오네? 아 앞에 stateless 처리가 먼저 다 되고 나서 최종 메서드가 호출되는구나~
-> 당빠 아니고, 디버깅 중단점을 찍으면 collect() 매서드 부분이 호출되기 전까지는 어떠한 로그도 안찍힌다.
게다가 "A1", "A2", "B1", "B2", "A1", "C1", "C2", "B1" 이 순서를 충실히 따르다가 sort() 로그가 빡빡 찍힌 시점 이후로 부터 최종 연산이 처리되는걸 확인 할 수 있다.
2. peek()는 총 3개 collect()는 1개 그러므로 총 반복횟수는 n^4?? 혹은 4n??
-> 결론적으로는 n + mlogm + m 이다. 뭔 개소리야 싶을 수 있는데
n: 초기 요소 개수
m: 필터링 결과 요소의 개수
mlogm: sort() 연산을 내부적으로 돌릴때 필요한 횟수
앞서 "Loop Fusion" 기술이 들어간다고 했는데,
for(element e: elements){
//1번 peek
//2번 peek
//3번 peek
//collect
}
//이런식으로 돌아가지 않는다
//==============실제 구현(의사 코드)======
for(elemet e: elements){
//1번 peek
//2번 peek
filter(e) ? sort().buffer=e : null;
}
newElements = sort().buffer
for(element e: newElements){
//3번 peek
//collect
}
collect() 호출시점에 모든게 결정되며 어떻게 반복문을 돌릴지 내부적으로 결정한다. 우리는 그저 사용할뿐이다. JDK 개발하려면 힘들다.. 암튼 중간에 sort가 없었다면 깔쌈하게 n번 반복으로 끝날거였지만 sort가 끼어 버린 이상 내부적으로 반복문을 2사이클로 나누게 되어지는것이다. 결과는 위에 출력결과를 확인해봐라. sort() 로그가 찍히기 전까진 peek 2개가 마치 하나의 반복문인것 처럼 로그를 찍는다. sort()로그가 끝난 시점에서 sort()내부엔 buffer에 저장된 요소를 하나씩 뱉어서 리스트에 저장하는 로그가 그다음에 peek()로그 저장로그가 번갈아가면서 찍히게 된다.
우리는 그저 사용할뿐이다. 그래도 이해는 하고 넘어가고 싶어서 탐색해보았다..여까지
스트림을 얻는 방법
그 전에 스트림의 종류부터
Stream<T> : 모든 오브젝트가 다 올수 있는 스트림이다.
IntStream, LongStream, DoubleStream : 프리미티브 타입에 대한 스트림도 제공한다!!
1. 컬렉션으로 부터 받기
List<MyColor> mlist = new ArrayList<>();
Set<Name> nset = new HashSet<>();
//생략
Stream<MyColor> mstream = mlist.stream();
//<-그냥 스트림이야!
Stream<Name> nstream = nset.stream().filter(x -> x != null);
//<-필터씌운 그냥 스트림이야!!
2. 배열로부터 받기
int[] iarray = {1, 2, 3};
//배열은 크기와 해석법이 명확히 정해져야함!!!
Mouse[] marray = nwe Mouse[5];
//중략
Stream<Mouse> mstream = Arrays.stream(marray);
InStream istream = Arrays.stream(iarray);
3. 범위로 스트림 생성 및 랜덤으로 스트림 생성
IntStream istream = IntStream.range(0, 10);
//range()의 결과 내부에 버퍼 꼬라지: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
LongStream lstream = LongStream.rangeClosed(0, 10);
//rangeClosed()의 결과 내부에 버퍼 꼬라지: 10까지 추가됨!
//double은 딱 떨어지지 않으므로 range 생성 지원안함..
IntStream irstream = Random.ints();
DoubleStream drstream = Random.doubles();
//여러 오버로딩이 존재하며 무한반복을 막기위해 limit()을 걸필요가 있을거다.
4.Files로 얻는 스트림
//텍스트 파일로부터 얻는 String Stream
Path tpath = Paths.get(this.class.getResource("data.txt").toURI());
Stream<String> tstream = Files.lines(tpath);
//디렉토리 패스로부터 얻는 Path Stream
Path dpath = Paths.get("").toAbsolutePath();
Stream<Path> dstream = Files.walk(dapth);
중간 연산 1 : 요소 걸러내기
distinct() : 중복 제거
stateful 매서드로 자체 set()으로 검사해가며 이하 매서드를 호출 아예 안해버린다.
Stream<T> filter(Predicate<? super T> predicate) : 트루면 이하 호출 펄스면 이하 건너뛰기
stateless 매서드
Predicate<T> : 함수형 인터페이스 T는 처리 매개변수 타입이다...
boolean test(T t); : 내부에 있는 추상 메서드로 boolean 결과를 반환하게 구현해주면 됨.
중간 연산 2 : 요소 변환
mapXxx(Function<T, R> action) : 요소를 만났을때 어떠한 처리를 해주고 해당 처리 결과 스트림화 시킴
Function<T, R>: 함수형 인터페이스 매개변수 T를 가지고 처리한 뒤에 R로 반환하는 매서드를 가지고 있음.
R apply(T t): 개발자가 선언해야할 함수의 동작 해당 동작의 결과가 Stream<R>을 만듬.
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
: 요소를 만났을때 해당 요소를 이용하여 Stream화 시키는 매서드임. 이걸 끝까지 쭉가는데 즉 분할이지
Function<? super T, ? extends Stream<? extends R>> mapper
이건 결국 Function<T, R> 이랑 똑같음. 단지 T는 T고 R은 Stream<R>이란 소리임.
이렇게 되면 apply()는 Stream<R> apply(T t)가 될뿐임.
중간 연산 3 : 요소 정렬
Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator);
아래놈이 더 우선적이고, 만약 인자로 comparator가 없는데 해당 요소에 compareTo가 정의되어 있지 않다면 에러겠지
public int compareTo(T o);
Comparable 클래스의 이 함수는 타겟과 자기 자신 this를 비교하여 같으면 0 작으면 음수 크면 양수를 반환하도록 구현되어 있어야함. like 빼기 연산
public interface Comparator<T>
int compare(T o1, T o2);
compareTo와 결과 로직은 같게 구현하면 된다. 다른건 매개변수끼리 비교하는거다. 객체 자체에 비교 기능이 없기에 Util 클래스인 Comparator를 빌려 쓴다고 생각해도 좋다.
중간연산 4: 반복
Stream<T> peek(Consumer<? super T> action);
호출될때마다 모든 요소를 탐색하여 취하는 액션을 정의하는게 아니라. 전체적인 요소가 반복될때 해당 요소에대한 로그를 찍기 위해 주로 사용한다. 그냥 사실상 "최종처리 연산" 안에 감싸진 println() 이라고 생각해도 좋다. 정말 그렇다....
Public interface Consumer<T>
void accept(T t);
아무 반환 책임 없는 단순 액션을 취하고자 할때 주로 사용하는 함수형 인터페이스이다. peek 연산에 아주 제격이다.
최종연산 1: 반복
비로소... Stream()의 모든 메서드를 실행시키는 연산이 시작이다. 반복이 있는데 얘가 사실상 찐 반복문 시작이다.(내부의 내부적으로 Sort()도 자체 반복을 제공하지만...)
void forEach(Consumer<? super T> action);
호출되고 나면 실제 내부 반복자가 돌기 시작한다!! 그때의 행동을 결정짓는 최종 action의 accept 매서드를 정의해주면 된다.
최종연산 2: 조건
boolean allMatch(Predicate<? super T> predicate);
boolean anyMatch(Predicate<? super T> predicate);
boolean noneMatch(Predicate<? super T> predicate);
가만보자 위에 filter랑 매개변수 자체는 아주 똑같다!! 그런데 반환 타입이 다르고, 실제 내부 구현도 다르다.
조건식을 정의하기 아주 안성 맞춤인 boolean test(T t)의 결과를 모든 요소를 탐색하는건 마찬가지 그런데
allMatch는 진짜 찐으로 모두 true나오면 true 하나라도 아니면 중단하고 false를 때려버린다.
anyMatch는 찐으로 다 도는데 하나라도 true 나오면 중단하고 true 때린다.
noneMath는 모두가 false 나올때까지 돌다가 true가 하나라도 나오면 중단하고 true 때린다.
최종 연산 3: 집계
long count();
Optional<T> findFirst();
Optional<T> max(Comparator<? super T> comparator);
Optional<T> min(Comparator<? super T> comparator);
OptionalDouble average(); //이건 DoubleStream 인터페이스만!!
sum() // 이건 primitive타입전용 Stream들 에서만!!
영어만 봐도 알 수 있잖아~~
public final class Optional<T>{
private static final Optional<?> EMPTY = new Optional<>(null);
private final T value;
}
Optional 은 함수형 인터페이스도 아니고 구체적인 추상 클래스이다. final class이므로 재정의 될수도 없다. 그냥 이대로 사용하는건데. 무엇이 있는가. 하면 비었는지 값은 뭔지 해시코드는 무엇인지 등등 그냥 래퍼 클래스의 기능을 넘어서 filter() 나 orElse() 등 스트림의 결과 null값이든 아니면 더 분할하거나 더 스트림을 만들 껀덕지가 있어보일때 사용할만한 매서드들이 있고, 해당 매서드들을 사용할때 일급객체인 우리 람다식을 이용하여 매개변수인 함수형 인터페이스를 구현해주면 된다.
OptionalDouble 이나 OptionalInt 도 있는데 null 대체값 구현, 형변환 등을 할 수 있는 메서드를 제공하는게 특징
최종연산 4 : 집계?
일반적인 상황에서의 집계는 위에서 다루었고, 일반적으로 정의할 수 없는 즉 간단한 조건식만으로 끝나는것도 아니라 구체적인 What 디테일 What을 지정해줘야되는 수요도 분명히 있다. 그리하여 등장한게 커스텀 집계 매서드
T reduce(T identity, BinaryOperator<T> accumulator);
Optional<T> reduce(BinaryOperator<T> accumulator);
<U> U reduce(U identity,
BiFunction<U, ? super T, U> accumulator,
BinaryOperator<U> combiner);
T reduce 는 디폴트값 T를 반환하고 반환방법에 대한 내용을 구현하기위한 accumulator를 매개변수로 사용 매개변수 2개짜리
Optional<T> reduce는 어차피 Optional로 반환하니까 그다음 처리 알아서 하라이고 매개변수 한개짜리
U reduce는 무려 매개변수 3개짜리인데
각각의 의미는 다음과 같다.
identity: 누적 축소 연산의 초기값
accumulator: 각 요소를 누적 축소하는 방법 정의
combiner : 병렬 처리시 각 쓰레드의 결과를 합치는 방법
List<String> words = Arrays.asList("Hello", "World", "Stream");
// 모든 단어의 길이 합 구하기
int totalLength = words.stream()
.reduce(
0, // 초기값
(sum, str) -> sum + str.length(), // 누적
Integer::sum // 결합
);
System.out.println(totalLength); // 출력: 14
//==========================
List<String> words = Arrays.asList("Hello", "World");
StringBuilder result = words.stream()
.reduce(
new StringBuilder(), // 초기값
(sb, str) -> sb.append(str), // 문자열 추가
(sb1, sb2) -> sb1.append(sb2) // StringBuilder 합치기
);
System.out.println(result.toString()); // 출력: HelloWorld
//====================
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
int sum = numbers.parallelStream()
.reduce(
0,
(acc, num) -> {
System.out.println("accumulator: " + acc + " + " + num +
" in " + Thread.currentThread().getName());
return acc + num;
},
(left, right) -> {
System.out.println("combiner: " + left + " + " + right +
" in " + Thread.currentThread().getName());
return left + right;
}
);
/* 가능한 출력 예시:
accumulator: 0 + 1 in ForkJoinPool.commonPool-worker-1
accumulator: 0 + 2 in ForkJoinPool.commonPool-worker-2
accumulator: 0 + 3 in ForkJoinPool.commonPool-worker-3
accumulator: 0 + 4 in ForkJoinPool.commonPool-worker-1
combiner: 1 + 2 in ForkJoinPool.commonPool-worker-1
combiner: 3 + 4 in ForkJoinPool.commonPool-worker-2
combiner: 3 + 7 in ForkJoinPool.commonPool-worker-1
*/
최종연산 5: 수집
<R> R collect(Supplier<R> supplier,
BiConsumer<R, ? super T> accumulator,
BiConsumer<R, R> combiner);
<R, A> R collect(Collector<? super T, A, R> collector);
총 2가지 방식
첫번째는 함수형 인터페이스의 추상매서드를 총3개 선언하여 어떻게 수집할지 결정하는거고
두번째는 추상 클래스를 사용하여 결정하는것이다. 그 추상클래스는 어떻게 가져오느냐??
Collector.java에 정적 매서드들이 구현되어 있다!!!
public static <T>
Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>(ArrayList::new, List::add,
(left, right) -> { left.addAll(right); return left; },
CH_ID);
}
매개변수 없이 그냥 호출만 하면 끝이고
public static <T, K, U>
Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper) {
return new CollectorImpl<>(HashMap::new,
uniqKeysMapAccumulator(keyMapper, valueMapper),
uniqKeysMapMerger(),
CH_ID);
}
매개변수 2개가 존재하는데 전부 Function<T, U>를 구현하면 된다. 즉 U apply(T t)를 구현하겠지
public static <T>
Collector<T, ?, Set<T>> toSet() {
return new CollectorImpl<>(HashSet::new, Set::add,
(left, right) -> {
if (left.size() < right.size()) {
right.addAll(left); return right;
} else {
left.addAll(right); return left;
}
},
CH_UNORDERED_ID);
}
매개변수 없음
public static <T, K> Collector<T, ?, Map<K, List<T>>>
groupingBy(Function<? super T, ? extends K> classifier) {
return groupingBy(classifier, toList());
}
여러 오버로딩이 있으나 groupingBy의 결과는 Collector<~, ~, Map> 이다!! 즉 Stream.collect(Collectors.groupingBy(~)) 의 결과는 항시 Map
요소 병렬 처리
동시성: 하나의 코어가 작업을 번갈아가면서 빠르게 처리! 작업1 다음에 무조건 작업2? 그건 보장 못해 여전히
병렬성: 여러 코어가 작업을 나눠서 처리! 작업의 크기마다 서로 끝나는 시간이 다를것
데이터 병렬성: 데이터를 분할하여 병렬처리 하게끔 하는것. 스트림이 이런 방식을 취한다.
작업 병렬성: 위에 설명한 병렬성이 이쪽 설명과 가깝다. 스레드 처리 방식을 의미
포크-조인 프레임웤
포크는 작업의 개수만큼 스트림 요소를 뚝뚝 나누는 개념 해당 포크 영역을 보고서 코어가 처리를 할것이다.
조인은 그렇게 된걸 합치는 개념적 영역
실제 구현애서는 병렬 작업은 즉 스레드는 스레드풀을 구현하여 처리되고, 작업이 끝날때마다 조인하여 결합되어 결과가 만들어진다.
이런 병렬처리를 지원하기에 사실 Spliterator를 사용하는 스트림만으로 어느정도 처리가 되지만, 더 우수한 parallel()을 지원함.
병렬이 무조건 빠르냐? 그렇진 않다. 왜냐하면 Fork 하고 Join 하기위한 영역을 사용할뿐더러 저런 내부 구현을 연산을 만들고 처리하는 비용도 들어가기 때문
특히! 스트림의 종류에 따라 병렬 스트림을 뚝뚝 끊을때 발생하는 비용이 다 다르다.
끝..
'Learn > 이것이 자바다' 카테고리의 다른 글
[이것이 자바다]네트워크 입출력 (0) | 2024.12.17 |
---|---|
[이것이 자바다 확인 문제] chapter 17 (0) | 2024.12.12 |
[이것이 자바다 확인문제] chapter 16 (0) | 2024.12.11 |
람다식, 함수형 프로그래밍, 메소드 참조, 생성자 참조 (1) | 2024.12.11 |
[이것이 자바다 확인문제] chapter 15 (0) | 2024.12.10 |