본문 바로가기
Book

자바의 정석 - 자바 기본기 정리하기 (13)람다, 스트림

by devLog by Ronnie's 2021. 12. 1.

들어가며


문제 구현에 있어서 자바에 대한 기본기의 부족함을 느껴서 오랜만에 자바의 기본 저서인 자바의 정석을 다시 피게 됐다. 그러면서 정말 신기한 경험을 하게 되었는데 바로 예전에 잘 이해가 안가서 읽고 넘어갔던 내용들이 이제는 내 머릿속에서 자연스럽게 그려지는 경험을 하게 되었다. 그동안에 시간들이 헛되지는 않았나보다.

 

어느 곳에서나 기본기는 중요하듯이 이번 기회를 통해 자바 기본기를 더 단단히 다지고자 챕터별로 글로 정리하면서 다시 한번 암기를 하고 좀 더 디테일하게 알아야 되는 곳은 챕터를 나눠서 자바의정석에 나온 내용 + 보강된 내용을 더해서 정리를 하고자 한다. 

 

정리


람다식
람다식은 간단히 말해서 메서드를 하나의 식으로 표현한 것이다. 람다식은 함수를 간략하면서도 명확하게 표현할 수 있게 해준다.
메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로 람다식을 익명 함수 라고도 한다.
Arrays.setAll(arr, (i) -> (int)(Math.random()*5)+1); 에서 (i) -> (int)(Math.random()*5)+1 이부분이 람다식이다.
이 람다식이 하는 일을 메서드로 표현하면 다음과 같음
int method(int i) {
return (int)(Math.random()*5) +1;
}
람다식을 사용하지 않고 다음과 같이 메서드를 만들어서 하면 모든 메서드는 클래스에 포함되어야 하므로 클래스도 새로 만들어야 하고, 객체도 생성해야만 비로소 이 메서드를 호출할 수 있다. 그러나 람다식은 이 모든 과정없이 오직 람다식 자체만으로도 이 메서드의 역할을 대신 할 수 있다.
게다가 람다식은 메서드의 매개변수로 전달되어지는 것이 가능하고, 메서드의 결과로 반환 될 수도 있다. 람다식으로 인해 메서드를 변수처럼 다루는 것이 가능해진 것이다.

람다식 작성법
메서드에서 이름과 반환 타입을 제거하고 매개변수 선언부와 몸통 사이에 -> 를 추가한다. (자바스크립트 화살표함수 => 혼동주의)
반환값이 있는 메서드의 경우는 return문 대신 식으로 대신할 수 있다. 식의 연산결과가 자동적으로 반환값이 된다. 이때 문장이 아닌 식이므로 끝에 ;를 붙이지 않는다. 람다식에 선언된 매개변수의 타입은 추론이 가능한 경우는 생략 가능하며 대부분은 생략 가능하다. 반환 타입이 없는 이유도 추론이 가능하기 때문
그리고 매개변수가 하나일때는 ()괄호 생략 가능, 마찬가지로 {} 안에 문장이 하나일때는 생략 가능. ; 이걸 붙이지 않는다.
{} 안에 return문일때는 {} 생략 불가능. 
정리!!
(int a, int b) -> { return a > b ? a : b; } =====> 반환타입,메서드명 지움+ ->작성 + {}안에 return일때는 {} 생략 불가능 및 ; 붙여줌
(int a, int b) -> a > b ? a : b ====> return 생략 시 {}로 생략 가능 + ; 붙이면 안됨
(a,b) -> a > b ? a : b ===> 타입 생략 가능
(int x) -> x * x
x -> x * x ====> 매개변수 하나이므로 괄호 생략 가능, 타입 생략 가능
() ->  ====> 매개변수 없을때는 () 로 사용

메서드참조
람다식이 하나의 메서드만 호출하는 경우에는 메서드 참조라는 방법으로 람다식을 간략히 할 수 있다.
예를 들어 문자열을 정수로 변환하는 람다식을 메서드 참조로 변경하면 다음과 같다.
(String s) -> Integer.parseInt(s); ===> Integer::parseInt;
메서드만 호출하는 람다식은 클래스이름::메서드이름 또는 참조변소::메서드이름으로 변경가능

생성자의메서드참조
() -> new MyClass(); 람다식
MyClass::new; 메서드 참조

스트림
많은 수의 데이터를 다룰때 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해 for문과 Iterator를 이용해서 코드를 작성해왔다.
그러나 이러한 방식은 코드가 너무 길고 알아보기 힘들다. 그리고 재사용성도 떨어진다.
또 다른 문제는 데이터 소스마다 다른 방식으로 다뤄야한다는 것이다. 예를 들어 List를 정렬할때는 Collections.sort()를 사용해야하고 배열을 정렬할때는 Arrays.sort()를 사용해야한다. 이러한 문제점들을 해결하기 위해 만든 것이 스트림이다. 스트림은 데이터 소스를 추상화하고 데이터를 다루는데 자주 사용되는 메서드들을 정의해 놓았다. 데디어소스를 추상화하였다는 것은 데이터 소스가 무엇이던 간에 같은 방식으로 다룰수 있게 되었다는 것과 코드의 재사용성이 높아진다는 것을 의미한다. 스트림을 이용하면 배열이나 컬렉션 뿐만 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있다. 
스트림을 사용하면 위에서 리스트와 배열의 sort()를 따로 쓰는 것이 아닌 동일한 방식으로 가능하다.
각 배열과 리스트의 객체에 대하여 스트림을 생성하고 생성된 스트림으로 제공해주는 sorted() 메서드와 forEach()로 출력한다.
두 스트림의 데이터 소스는 서로 다르지만 정렬하고 출력하는 방법은 완전히 동일하다.
strStream1.sorted().forEach(System.out::println); --> Stream<String> strStream1 = strList.stream();
strStream2.sorted().forEach(System.out::println); --> Stream<String> strStream2 = Arrays.stream(strArr);

스트림의 특징
1.스트림은 데이터 소스를 변경하지 않는다.
- 데이터 소스로 부터 데이터를 읽기만할 뿐 데이터 소스를 변경하지 않는다는 차이가 있다. 필요하다면 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수도 있다.
2.스트림은 일회용이다.
- 스트림은 Iterator처럼 일회용이다. Interator로 컬렉션의 요소를 모두 읽고 나면 다시 사용할 수 없는것처럼 스트림도 한번 사용하면 닫혀서 다시 사용할수 없다. 필요하면 다시 생성해야됨
3. 스트림은 작업을 내부 반복으로 처리한다.
- 내부반복이란 반복문을 메서드의 내부에 숨겼다는 것을 의미한다. forEach()는 스트림에 정의된 메서드 중의 하나로 매개변수에 대입된 람다식을 데이터 소스의 모든 요소에 적용한다.
4.지연된 연산
- 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다는것. 스트림에 대해 distinct()나 sort()같은 중간 연산을 호출해도 즉각적인 연산이 수행되지 않는다. 중간 연산을 호출하는 것은 단지 어떤 작업이 수행되어야하는지를 지정해주는 것일 뿐이고 최종 연산이 수행되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모된다.
5. Stream<Integer> 와 IntStream
요소의 타입이 T인 스트림은 기본적으로 Stream<T>이지만 오토박싱&언박싱으로 인한 비효율을 줄이기 위해 데이터 소스의 요소를 기본형으로 다루는 스트림, IntSream, LongStream, DoubleStream이 제공된다. 일반적으로 Stream<Integer>대신 IntStream을 사용하는 것이 더 효울적이고 IntStream에는 int타입의 값으로 작업하는데 유용한 메서드들이 포함되어 있다.
6.병렬스트림 
병렬 처리가 쉽다. 병렬 스트림은 내부적으로 Java에서 제공하는 fork&join프레임웍을 이용해서 자동적으로 연산을 병렬로 수행한다. 우리가 할일은 스트림에 parallel()이라는 메서드를 호출해서 병렬로 연산을 수행하도록 지시하면 될 뿐이다. 반대로 병렬로 처리되지 않게 하려면 sequential()을 호출하면된다. 모든 스트림은 기본적으로 병렬 스트림이 아니므로 sequential()을 호출할 필요는 없고 이 메서드는 parallel()을 호출한 것을 취소할때만 사용한다.

스트림만들기 - 컬렉션
스트림을 사용하려면 스트림이 필요하니 스트림을 생성하는 방법부터 알아야한다!
스트림의 소스가 될 수 있는 대상은 배열, 컬렉션, 임의의 수 등 다양하다.
컬렉션의 최고 조상인 Collection에 stream()이 정의되어 있다. 
forEach()는 지정된 작업을 스트림의 모든 요소에 대해 수행한다. 한가지 주의점은 forEach()가 스트림의 요소를 소모하면서 작업을 수행하므로 같은 스트림을 재사용 못하고 다시 스트림을 새로 생성해야되는 것이다. 이때 forEach()에 의해 스트림의 요소가 소모되는 것이지 소스의 요소가 소모되는 것은 아니기 때문에 같은 소스로부터 다시 스트림을 생성할 수 있는 것이다. 

 


스트림만들기 - 배열 
배열을 소스로 하는 스트림을 생성하는 메서드는 Stream과 Arrays에 static메서드로 정의 되어 있다.
Stream.of()
Arrays.stream()

스트림만들기 - 임의의 수
난수를 생성하는데 사용하는 Random클래스에는 아래아 같은 인스턴스 메서드들이 포함되 있다.
IntStream ints()
LongStream longs()
..
이 메서드들은 해당 타입의 난수들로 이루어진 스트림을 반환한다.
이 메서드들이 반환하는 스트림은 크기가 정해지지 않은 무한 스트림이므로 limit()도 같이 사용해서 스트림의 크기를 제한해 주어야한다. limit()은 스트림의 개수를 지정하는데 사용되며 무한스트림을 유한 스트림으로 만들어준다.
IntStream intStream = new Random().ints();
intStream.limit(5).forEach(System.out::println);
근데 만약 ints()에 매개변수를 넣어주게되면 크기를 정해주는 것이 되어 limit()를 안사용해도 된다 

스트림만들기 - 특정 범위의 정수
지정된 범위의 연속된 정수를 스트림으로 생성해서 반환해주는 range()와 rangeClosed()를 가지고 있다.
range()에 경우 경계의 끝인 end가 포함되지 않고 rangeClosed()를 사용하면 포함한다.
IntStream intStream = IntStream.rangeClosed(1,5); -> 1,2,3,4,5
지정된 범위에서 난수 발생시에는
IntStream ints(int begin, int end) -> 사이즈 정할시에는 (long streamSize, int begin, int end) 로 하면됨

스트림만들기 - 람다식 iterate(), generate()
이 둘은 람다식을 매개변수로 받아서 이 람다식에 의해 계산되는 값들을 요소로 하는 무한 스트림을 생성한다.
static<T> Stream<T> iterate(T seed, UnaryOperator<T> f)
iterate()는 씨앗값 seed으로 지정된 값부터 시작해서 람다식 f 에 의해 계산된 결과를 다시 seed값으로 해서 계산을 반복한다.
Stream<Integer> evenStream = Stream.iterate(0, n->n+2); // 0, 2, 4, 6 ...
generate()도 iterate()처럼 람다식에 의해 계산되는 값을 요소로 하는 무한 스트림을 생성해서 반환하지만 iterate()와 달리 이전 결과를 이용해서 다음 요소를 계산하지 않는다. 그리고 generate()에 정의된 매개변수 타입은 Supplier<T>이므로 매개변수가 없는 람다식만 허용된다. 
이때 주의점은 iterate()와 generate()에 의해 생성된 스트림은 기본형 스트림 타입의 참조변수로 다룰수 없다 (IntStream 에러, Stream<Integer>로사용)
굳이 필요하다면 mapToInt()와 같은 메서드로 변환해야함
IntStream evenStream = Stream.iterate(0, n->n+2).mapToInt(Integer::valueOf);
Stream<Integer> stream = evenStream.boxed(); // IntStream -> Stream<Integer>

빈스트림
요소가 하나도 없는 비어있는 스트림. 스트림에 연산 수행 결과가 하나도 없을때 null보다 빈스트림을 반환하는 것이 났다. 
Stream emptyStream = Stream.empty() -> empty() 빈스트림을 생성해서 반환
long count = emptyStream.count() -> count()는 스트림 요소의 개수를 반환함. 빈스트림이기 때문에 count값은 0임


스트림 연산
스트림이 제공하는 다양한 연산을 이용하면 복잡한 작업들이 간단히 처리할 수 있다.
스트림이 제공하는 연산은 중간 연산과 최종 연산으로 분류할 수 있다. 중간 연산은 연산결과를 스트림으로 반환하기 때문에 중간 연산을 연속해서 연결할 수 있다. 반면 최종연산은 스트림의 요소를 소모하면서 연산을 수행하므로 단 한번만 연산이 가능하다.

스트림 중간 연산
distinct() 중복제거 / filter() 조건에 안 맞는 요소 제외 / limit 스트림의 일부를 잘라냄 / skip() 스트림의 일부를 건너뜀
peek() 스트림의 요소에 작업수행 / sorted() 요소를 정렬 / map() mapToInt() .. flatMap() flatMapToDouble() ... 스트림의 요소를 변환한다.
중간 연산은 map()과 flatMap()이 핵심이나 나머지는 이해하기 쉽고 사용법도 간단하다.

skip(), limit()
skip(3) -> 처음 3개의 요소를 건너뛰고
limit(5) -> 스트림의 요소를 5개로 제한한다.
예를 들어 10개의 요소를 가진 스트림에 skip(3)과 limit(5)를 적용하면 4번째 요소부터 5개의 요소를 가진 스트림이 반환된다.

filter(), distinct()
dinstinct() 를 넣어주면 스트림의 요소 중 중복 요소들을 제거 후 반환해줌 
filter()는 매개변수로 Predicate를 필요로 하는데 아래와 같이 연산결과가 boolean인 람다식을 사용해도 된다.
intStream.filter(i -> i%2 == 0).forEach(System.out::println);
필요하다면 filter를 다른 조건으로 여러번 사용도 가능하다.
intStream.filter(i -> i%2 == 0).filter(i->i%3 != 0).forEach(System.out::println);

sorted()
sorted()는 지정된 Comparator로 스트림을 정렬하는데 지정하지 않으면 스트림 요소의 기본 정렬 기준으로 정렬한다. 
sorted() 기본 정렬 == sorted(Comparator.naturalOrder()) 기본정렬
sorted(Comparator.reverseOrder()) 기본정렬 역순

map()
스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할때가 있다. 이때 사용하는것이 바로 map()

flatMap()
스트림의 타입이 Stream<T[]>인경우 Stream<T>로 변환해야 작업이 더 편리할 때가 있다. 이때 사용한다.

peek()
연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶을때 사용
forEach()와 달리 스트림의 요소를 소모하지 않으므로 연산 사이에 여러번 끼워 넣어도 문제가 되지 않음.
filter()나 map()의 결과를 확인할때 유용하게 사용 가능. 연산이 많거나 중간에 값을 확인할때 filter()이후에 peek을 넣어 확인이 가능.

스트림 최종연산
forEach() 각 요소에 지정된 작업 수행 / count() 요소 개수 반환 / max(), min() 최대값 최소값 반환 / findAny() 아무거나 요소 하나 반환
findFirst() 첫번째 요소 하나 반환 / allMatch 모든 요소가 모두 만족하는지 아닌지 확인 boolean / anyMathch() 하나라도 만족하는지 / noneMatch() 모두 만족하지 않는지 / toArray() 모든 요소를 배열로 반환 / reduce() 스트림의 요소를 하나씩 줄여가면서(리듀싱) 계산 / collect() 스트림 요소를 수집, 주로 요소를 그룹화하거나 분할한 결과를 컬렉션에 담아 반환하는데 사용
최종 연산은 reduce()와 collect()가 핵심이다.

forEach()
반환 타입이 void이므로 스트림의 요소를 출력하는 용도로 많이 사용된다.

조건검사 allMatch, anyMatch, noneMatch
스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는지, 일부만 일치하는지 아니면 어떤 요소도 일치하지 않는지 확인 하는데 사용할 수 있다. 모두 매개변수로 Predicate를 요구하며 연산 결과로 boolean을 반환한다.
예를들어 학생들의 성적 정보 스트림에서 총점이 낙제점인 학생이 있는지 확인 하는 방법이다.
bool

Optional<T>
T타입의 객체를 감싸는 래퍼 클래스이다. 그래서 Optional타입의 객체에는 모든 타입의 객체를 담을 수 있다. 1.8부터 추가됨
최종 연산의 결과를 그냥 반환하는게 아니라 Optional객체에 담아서 반환을 하면 반환된 결과가 null인지 매번 if문으로 체크하는 대신 Optional에 정의된 메서드를 통해서 간단히 처리할 수 있다. 그리고 널체크를 위한 if문 없이도 NullPointerException이 발생하지 않는 보다 간결하고 안전한 코드를 작성하는 것이 가능.

Optional 객체 생성
of() 또는 ofNullable() 사용
만일 참조변수의 값이 null일 가능성이 있다면 of() 대신 ofNullable()을 사용해야한다. of()는 매개변수 값이 널이면 예외를 발생시키기 때문.

Optional 초기화
Optional<String> optVal = null도 가능하지만 바람직하지 않음
Optional<String> optVal = Optional.<String>empty(); // 빈객체로 초기화됨.

Optional 객체 값 가져오기
get() 사용한다. 값이 널일때는 NoSuchElementException이 발생한다. 이를 대비해 orElse()로 대채할 값을 지정할 수 있다.
optVal.get() -> optVal에 저장된 값을 반환 null이면 예외발생
optVal.orElse(""); -> 널일때 ""를 반환
orElse의 변형으로는 null값을 대체할 값을 반환하는 람다식을 지정할 수 있는 orElseGet()이 있고 null일때 지정된 예외를 발생시키는 orElseThrow()가 있다.
optVal.orElseGet(String::new); -> ""반환
optVal.orElseThrow(NullPointerException::new); 널이면 예외발생

isPresent(), ifPresent(Consumer<T> block)
isPresent()는 Optional 객체의 값이 null이면 false를 아니면 true를 반환
ifPresent는 값이 있으면 주어진 람다식을 실행하고 없으면 아무일도 하지 않는다.

댓글