[ODOP:07]Java 함수형 프로그래밍 - 07. 스트림 활용
벤 바이디히 의 'A Functional Approach to Java' 를 읽고 정리 한 내용입니다 박싱 스트림, 무한 스트림, 저수준 스트림, 시간 처리 등을 다루고 있습니다
박싱 스트림
Stream 에서는 원시 타입을 사용할 수 없다. (JEP:218)
원시 타입의 사용을 위해서는 아래의 두가지 옵션을 사용 가능
- 오토박싱
- 스트림의 특화된 변형
Stream<Long> longStream = Stream.of(5L, 23L, 42L);
스트림 에서는 위와 같은 형태로 자동 형 변환을 지원한다
일반적으로 이러한 형 변환 오버헤드는 무시할 수 있는 수준이다
하지만 데이터 처리 파이프라인에서 래퍼 타입의 지속적인 생성으로 인해 오버헤드가 누적되어 성능 저하를 일으킬 수 있다
원시 스트림
박싱 스트림은 원시 타입을 그대로 사용 할 수 있게 도움을 준다
원시 타입 | 원시 스트림 | 박싱된 스트림 |
---|---|---|
int |
IntStream |
Stream<Integer> |
long |
LongStream |
Stream<Long> |
double |
DoubleStream |
Stream<Double> |
우리가 위에서 사용했던 map 함수를 살펴보자
@Override
public final IntStream map(IntUnaryOperator mapper) {
Objects.requireNonNull(mapper);
return new StatelessOp<Integer>(this, StreamShape.INT_VALUE,
StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
@Override
Sink<Integer> opWrapSink(int flags, Sink<Integer> sink) {
return new Sink.ChainedInt<Integer>(sink) {
@Override
public void accept(int t) {
downstream.accept(mapper.applyAsInt(t));
}
};
}
};
}
여기서 특이한 부분을 보면 IntUnaryOperator
를 볼 수 있는데, 이건 primitive 타입의 int 를 사용 할 수 있게 해주는 FunctionalInterface
이다
Stream 들
원시 Stream -> 박싱 스트림 변환
원시 스트림과 전환을 위해 사용
Stream<Integer> boxed()
Stream<U> mapToObj(IntFunction<? extends U> mapper)
반복 스트림
루프와 같은 반복을 사용 할 수 있으며 느긋한 함수형 파이프라인의 이점을 얻을 수 있음
<T> Stream<T> iterate(T seed, UnaryOperator<T> f)
IntStream iterate(int seed, IntUnaryOperator f)
반복 스트림 + 종료 조건
<T> Stream<T> iterate(T seed, Predicate<T> hasNext, UnaryOperator<T> next)
IntStream iterate(int seed, IntPredicate hasNext, IntUnaryOperator next)
반복 범위가 확정 된 스트림
iterate 와 비슷하게 동작 하지만 주요 차이점은 Spliterator 에 있다
반복 스트림 생성 방식은 반복 과정에 대해 더 큰 유연성을 제공한다. 하지만 병렬 스트림 에서는 초적화 가능성을 제공하는 스트림 특성을 잃을 수 있다
IntStream range(int startInclusive, int endExclusive)
IntStream rangeClosed(int startInclusive, int endInclusive)
LongStream range(long startInclusive, long endExclusive)
LongStream rangeClosed(long startInclusive, long endInclusive)
반환되는 스트림은 여러 특성을 가질 수 있다
ORDERED
순서SIZED
크기 지정SUBSIZED
부분 크기 지정IMMUTABLE
불변NOTNULL
널이 아님DISTINCT
고유SORTED
정렬
무한 스트림
JDK 의 모든 스트림 인터페이스(Stream<T>
, IntStream
, LongStream
, DoubleStream
등) 은 무한한 스트림 생성을 위해 정적 편의 메서드를 제공 한다
<T> Stream<T> generate(Supplier<T> s)
IntStream generate(IntSupplier s)
LongStream generate(LongSupplier s)
DoubleStream generate(DoubleSupplier s)
- ...
초기 값이 없는 형태의 스트림은UNORDERED
(순서가 없는) 상태가 된다(병렬 처리에서 유리 -> [[08-스트림을 활용한 병렬 데이터 처리]])
UNORDERED 특성은 랜덤 값과 같이 상호 의존성이 없는 요소의 시퀀스에서 유용하다
ex:
Stream<UUID> createStream(long count) {
return Stream.generate(UUID::randomUUID)
.limit(count);
}
비순서 스트림은 병렬 환경에서 limit 연산자를 사용 하더라도 처음 n 개는 보장되지 않는다는 단점이 있다
ex:
Stream.generate(new AtomicInteger()::incrementAndGet)
.parallel()
.limit(1_000L)
.mapToInt(Integer::valueOf)
.max()
.ifPresent(System.out::println); // 테스트 결과 : 1037
이러한 동작은 비순서 특성을 가진 스트림에서 일어나는 문제 이다.
이러한 상황이 대부분의 상황에서 문제가 되지는 않지만 최대 성능을 내야 하는 작업 에서는 적절한 스트림 타입과 유리한 특성을 선택하는 것이 좋다
랜덤 숫자
스트림 API 는 랜덤 숫자 스트림을 생성 할 때 특별한 기능을 제공한다.
java.util.Random 인스턴스와 Stream.generate 를 통해서도 스트림 생성이 가능하지만 이 기능을 사용하면 더욱 간단하게 만들 수 있다
java.util.Random
java.util.concurrent.ThreadLocalRandom
java.util.SplittableRandom
// Random
Random random = new Random();
// 무한 랜덤 스트림 (스트림 길이: Long.MAX_VALUE = 920경)
IntStream randomIntStream = random.ints();
LongStream randomLongStream = random.longs();
DoubleStream randomDoubleStream = random.doubles();
// 범위 지정 랜덤 스트림
IntStream boundedRandomIntStream = random.ints(1, 100); // 1~99
LongStream boundedRandomLongStream = random.longs(1L, 100L); // 1~99
DoubleStream boundedRandomDoubleStream = random.doubles(0.0, 1.0); // 0.0~1.0
// 개수 지정 랜덤 스트림
IntStream limitedRandomIntStream = random.ints(10);
LongStream limitedRandomLongStream = random.longs(10);
DoubleStream limitedRandomDoubleStream = random.doubles(10);
// ThreadLocalRandom (멀티 스레드 환경에서 최적화를 위해 사용)
// 무한 랜덤 정수 스트림
IntStream randomIntStream = ThreadLocalRandom.current().ints();
// 10개의 랜덤 정수
IntStream limitedRandomIntStream = ThreadLocalRandom.current().ints(10);
배열 <-> 스트림
Array -> Stream
String[] fruits = new String[] {"Banana", "Melon", "Orange"};
String[] result = Arrays.stream(fruits)
.filter(fruit -> fruit.contains("a"))
.toArray(String[]::new);
class Arrays
<T> Stream<T> stream(T[] array)
<T> Stream<T> stream(T[] array, int startInclusive, int endExclusive)
class Stream
Object[] toArray()
<A> A[] toArray(IntFunction<A[]> generator)
toArray 에서 생성 된 배열을 사용 할 때는 정적 타입 검사가 이루어지지 않음
타입 호환이 정상적이지 못할 경우 런타임에서 ArrayStoreException 발생 할 수 있음
원시 타입 -> Stream
int[] fibonacci = new int[] {0, 1, 1, 2, 3, 5, 8, 13, 21, 34};
int[] evenNumbers = Arrays.stream(fibonacci)
.filter(value -> value % 2 == 0)
.toArray();
Arrays.stream 은 IntStream 등의 원시 타입 특화 스트림을 만들어 준다
따라서 위와 같은 형태의 스트림 사용이 가능하다
IntStream stream(int[] array)
IntStream stream(int[] array, int startInclusive, int endExclusive)
저수준 스트림
보조 클래스 중 java.util.Stream-Support 는 Spliterator 에서 직접 스트림을 생성하기 위한 여러 저수준의 정적 편의 메서드를 제공 한다
Stream<T> stream(Spliterator<T> spliterator, boolean parallel)
순차 스트림이나 병렬 스트림을 간단하게 생성 가능Stream<T> stream(Supplier<? extends Spliterator<T>> supplier, int characteristics, boolean parallel)
Spliterator 를 직접 사용하는 대신 Supplier 는 스트림 파이프라인 최종 연산이 호출 된 후 한 번만 호출
이 방식을 통해 자료 구조의 원본과의 잠재적인 간섭을 최소화.MUTABLE(가변성)
또는non-CONCURRRENT(비동시성)
스트림에 대해서도 보다 안정적으로 처리 가능
동적 바인딩
이 외에도 동적 바인딩
방식의 Spliterator 를 사용할 수도 있다
이 방식 에서는 Spliterator 를 생성 할 때 요소들이 고정 되어 있지 않다
대신 최종 연산을 호출 한 후 스트림 파이프라인이 요소를 처리하기 시작 할 때 처음으로 바인딩 된다
Iterator<T>
-> Spliterator<T>
java.util.Spliterators
클래스는 Spliterator 를 생성하기 위한 다양한 편의 메서드를 제공 한다
이 중 Iterator 를 위한 두가지 메서드가 있다
public static <T> Spliterator<T> spliterator(
Iterator<? extends T> iterator,
long size,
int characteristics
) {
return new IteratorSpliterator<>(
Objects.requireNonNull(iterator),
size,
characteristics
);
}
public static <T> Spliterator<T> spliteratorUnknownSize(
Iterator<? extends T> iterator,
int characteristics
) {
return new IteratorSpliterator<>(
Objects.requireNonNull(iterator),
characteristics
);
}
파일 I/O 사용
스트림은 컬렉션 기반의 순회만을 위한 것이 아니다java.nio.file.Files
클래스의 도움을 받아 파일 시스템을 순회 하는 데에도 훌륭한 방법을 제공 한다
- 위에서 살펴 본 스트림들과는 달리 I/O 관련 스트림은 사용이 끝난 후 명시적인
Stream#close()
를 해 주어야 한다 Stream#close()
는java.lang.AutoCloseable
인터페이스를 준수 하므로,try-with-resources
블록을 사용할 수 있다
디렉토리 내용 읽기
디렉토리의 항목들을 확인할 때 Files.list 메서드를 호출하여 주어진 경로에 대한 Stream<Path>
를 생성할 수 있다
class Files
static Stream<Path> list(Path dir) throws IOException
- 이 메서드의 인수는 디렉터리 여야 한다. 그렇지 않으면 NotEirectoryException 발생
- 검색 된 내용은 순서가 보장되지 않는다
깊이 우선 디렉터리 순회
class Files
static Stream<Path> walk(Path start, int maxDepth, FileCisitOption... options) throws IOException
static Stream<Path> walk(Path start, FileVisitOption... options) throws IOException
두 함수의 차이는 탐색 할 디렉터리의 최대 깊이 이다
ex:
var startPath = Paths.get("./");
try (var stream = Files.walk(startPath)) {
stream.map(Path::toFile)
.filter(file -> file.getName().startsWith("."))
.filter(Predicate.not(File::isFile))
.sorted()
.forEach(System.out::println);
} catch (Exception e) {
e.printStackTrace();
}
결과:
.
./.git
./.gradle
./.idea
./frontend/.idea
./frontend/node_modules/.bin
./frontend/node_modules/.cache
./frontend/node_modules/@humanwhocodes/object-schema/.github
./frontend/node_modules/@soda/get-current-script/.github
./frontend/node_modules/@typescript-eslint/eslint-plugin/node_modules/.bin
./frontend/node_modules/@typescript-eslint/typescript-estree/node_modules/.bin
파일 시스템 탐색
walk 메서드로 특정 경로에 있는 파일을 읽을 수 있겠지만 find 메서드가 더욱 특화된 방법을 제공한다
이 메서드는 현재 요소의 BasicFileAttribute 에 접근 할 수 있는 BiPredicate 를 스트림 생성에 직접 포함시켜 작업 요구 사항에 스트림을 더 집중 시킨다
class Files
static Stream<Path> find(
Path start,
int maxDepth,
BiPredicate matcher,
FileVisitOption... options) throws IOException
ex:
var start = Paths.get("./");
BiPredicate<Path, BasicFileAttributes> matcher =
(path, attr) -> path.toString().endsWith(".java");
try (var stream = Files.find(start, Integer.MAX_VALUE, matcher)) {
stream.forEach(System.out::println);
} catch (Exception e) {
e.printStackTrace();
}
파일 한 줄 씩 읽기
class Files
static Stream<String> lines(Path path, Charset cs) throws IOException
static Stream<String> lines(Path path) throws IOException
- 기본적으로
StandardCharsets.UTF_8
사용
- 기본적으로
원하는 Charset 을 사용 해도 좋지만 병렬 처리 시 성능에 차이가 있을 수 있다
lines 메서드는UTF-8
,US_ASCII
,ISO_8859_1
에 최적화 되어 있다
ex: 저자 Github link - 7-4
예제 실행 명령어:
./gradlew example-7-4
날짜와 시간의 처리
Java 8 에서 날짜와 시간에 대한 API 가 추가되었다
불변 특성을 갖는 이 API 를 지원하기 위해 Stream 에도 몇 개의 기능이 추가 되었는데, 한번 알아보자
시간 타입 질의
날짜와 시간의 데이터 중 우리가 사용하고자 하는 데이터를 추출하고 싶을 때가 있다
그 때 사용 할 수 있는 도구가 바로 java.time.temporal.TemporalQueries
이다
ex:
boolean isItTeaTime = LocalDateTime.now()
.query(temporal -> {
var time = LocalTime.from(temporal);
return time.getHour() >= 16;
});
LocalTime time = LocalDateTime.now().query(LocalTime::from);
System.out.println("time = " + time);
System.out.println("isItTeaTime = " + isItTeaTime);
여기서 주의깊게 봐야 하는 항목은 2번째 줄의 query
이다
해당 구현체를 잘 보면 아래와 같이 되어 있는데
![[스크린샷 2024-08-11 오전 12.04.22.png]]
내부적으로 해당 쿼리를 구현하고 있음을 확인 할 수 있다
LocalDate 범위 스트림
Java 9 에서는 JSR 310 타입인 java.time.LocalDate 에 Stream 기능을 도입 하여 연속적인 LocalDate 요소 범위를 생성할 수 있게 되었다
class LocalDate
public Stream<LocalDate> datesUntil(LocalDate endExclusive)
public Stream<LocalDate> datesUntil(LocalDate endExclusive, Period step)
날짜에 대한 상세한 처리가 필요하면 해당 기능을 사용할 수 있을 것 같다
JMH 를 활용한 스트림 성능 측정
JVM 내부 JIT(Just in time) 컴파일러는 실제 성능 측정에 어려움이 있다
스트림 파이프라인의 정확한 성능 측정을 하기 위해서는 아래의 도구를 사용 해 보자