Stream API를 이해하기 위한 순서에 따라 그 과정을 처음부터 낱낱이 열거해보겠다.
대략적인 순서는 다음과 같다.
1. 함수형 인터페이스(Functional Interface)
2. 익명 객체(Anonymous Object)
3. 람다 표현식(Lambda Expression)
4. 메서드 레퍼런스(Method Reference)
5. 자바 제공 함수형 인터페이스
6. 메서드 체이닝(Method Chaning)
7. Stream API
메서드가 단 하나만 존재하는 인터페이스
@FunctionalInterface //어노테이션 생략가능 interface MyInterface{ int howLong(String s); }Q. 이런게 대체 무슨 의미가 있느냐?
다음에 소개할 람다 표현식을 통해, 편리한 프로그래밍이 가능해짐
개발자들은 다음과 같이, 메인함수에서 인터페이스의 구현과 사용을 한 번에 하고 싶은 욕구가 생김!
public static void main(String[] args){ MyInterface mi = new class MyInterfaceImpl implements MyInterface{ @Override public int howLong(String s){ return s.length(); } }; int n = mi.howLong("Hello World!"); }(어차피 메서드 하나만 구현하는데, 그 때문에 구현클래스를 따로 만들고 나서 사용하기가 귀찮으니까)
이를 다음과 같이 Anonymous Object로 표현 가능함!public static void main(String[] args){ MyInterface mi = new MyInterface(){ @Override public int howLong(String s){ return s.length(); } }; int n = mi.howLong("Hello World!"); }
- 구현할 클래스의 이름은 필요 없으므로 생략 (익명클래스)
- 구현할 클래스의 내용은 중괄호를 열어 작성
개발자들은 함수형 인터페이스를 익명객체로 사용하는 경우, 다음과 같이 불필요한 부분을 생략하여 작성하고 싶음!
public static void main(String[] args){ MyInterface mi = (s){ //파라미터가 존재하자 않는다면, ()로 표현 return s.length(); }; int n = mi.howLong("Hello World!"); }
- 인터페이스 명 생략
* 변수에 인터페이스 타입이 명시되어 있으므로, 어떤 인터페이스를 구현할지 뻔함- 메서드 시그니처 생략
* 함수형 인터페이스이므로 메서드가 하나 뿐이니까, 어떤 메서드를 구현할지도 뻔함
* 메서드 시그니처 : Visibility, Return Type, 메서드이름, 파라미터타입, 파라미터이름
(파라미터 이름은 일단 남겨둠)
이를 다음과 같이 Lambda Expression으로 표현 가능함!public static void main(String[] args){ MyInterface mi = (s) -> { return s.length(); }; int n = mi.howLong("Hello World!"); }여기서 불필요한 괄호 + "return"을 없애주면 다음과 같이 더 간단히 표현 가능!
public static void main(String[] args){ MyInterface mi = s -> s.length(); int n = mi.howLong("Hello World!"); }=> 다시 정리하면, 메서드의 파라미터 이름과 함수 바디(구현부)를 제외하고 모두 생략
메서드 레퍼런스 : 최종적으로 적용될 메소드의 레퍼런스를 지정해주는 표현 방식
개발자들은 위 람다표현식도, 더 간단하게 표현하고 싶음!
아래의 람다식을 보자 (주석도 같이)MyInterface mi = (s) -> { s.length(); } //String타입의 s를 받아서, 바로 s의 메서드를 실행함 //-> 해당 변수타입의 메서드를 실행하는 것과 똑같음따라서 다음과 같이 사용 가능
MyInterface mi = String::length; //String : 파라미터 타입, length : 해당 타입의 멤버 함수 //또는 다음과 같은 관점으로도 볼 수 있음 //String : 메소드의 레퍼런스, length : 최종적으로 적용될 메소드따라서 메서드 레퍼런스를 이용해 다음과 같이 사용 가능!
public static void main(String[] args){ MyInterface mi = String::length; //파라미터 타입::해당타입의 멤버함수 int n = mi.howLong("Hello World!"); }Q. 메서드 레퍼런스의 여러 예시를 보았는데 다음 예시에서 의문이 생김
MyConsumer c = System.out::println; //System.out : 메소드의 레퍼런스, println : 최종적으로 적용될 메소드 //parameter가 println 메서드의 parameter에 들어감메서드 레퍼런스로 전달되는 paremeter가
위 설명에서는 (S) -> S.length() 와 같이 receiver객체로 쓰였는데,
왜 이 예시에서는 (S) -> System.out.println(S) 와 같이 메서드의 parameter로 쓰이는가?
이를 어떻게 구분하고 사용하는가?
메소드 레퍼런스 타입은 사실 4가지 유형으로 분류됨
1. 인스턴스 메소드 레퍼런스 (InstanceType::instanceMethodName)
매개변수가 메소드의 리시버 객체로 사용됨
(String s) -> s.length() 로 사용됨
ex) MyInterface mi = String::length;
2. 특정 객체의 인스턴스 메소드 레퍼런스 (ClassName::instanceMethodName)
매개변수가 멤버함수의 매개변수로 전달됨
(x) -> System.out.println(x)로 사용됨
ex) MyInterface mi = System.out::println
3. 스태틱 메서드 레퍼런스 (ClassName::staticMethodName)
매개변수가 스태틱 메소드의 매개변수로 사용됨
(input) -> String.valueOf(input) 으로 사용됨
ex) MyInterface mi = String::valueOf;
4. 생성자 레퍼런스 (ClassName::new)
매개변수가 생성자의 매개변수로 전달 됨
() -> new MyObject()
따라서 질문에 대한 답은, receiver객체로 쓰인 경우는 1번 유형에 해당하는 경우이고
parameter로 쓰인 경우는 위 2번과 3번 중, 2번에 해당하기 때문임
* 역시 나중에 Stream API에서 여러가지 유형이 사용될 수 있음
여기서 잠깐 다음과 같이 자바가 제공하는 여러 종류의 함수형 인터페이스들을 보고 넘어가자
Supplier T get()
: 데이터를 공급함 ex) generate( () -> "Infinite Stream!")
Function<T,R> R apply(T t)
: 매개값을 받아 타입반환해 반환값으로 리턴 ex) mapToInt(i -> (int)i)
Operator<T> T apply(T t)
: 매개값을 받아 연산해 리턴 ex) reduce(x, y -> x * y)
Consumer void accept(T t)
: 매개값을 받아 소비함 ex) foreach(n -> System.out.println(n)
Predicate boolean test(T t)
: 매개값을 받아 검사후 boolean리턴 ex) filter(n -> n > 100)
+ 기타
Comparator int compare(T o1, T o2)
Runnable () -> void void run()
Callable () -> T V call()
메서드 체이닝 : 객체의 메서드가 객체 자신을 반환함으로써 연속적인 메서드 호출을 가능하게 하는 프로그래밍 기법
다음과 같은 예시가 있다. (조금 복잡하지만, stream과 이어지는 내용임)
1. 다음과 같이 MyCollection이라는 컬렉션 객체 클래스가 존재한다.public class MyCollection<T>{ private List<T> list; //Consumer인터페이스 구현객체를 받아서, list의 각 데이터들에 대해 Consumer의 메서드를 실행 public void forEach(Consumer<T> consumer){ for(int i = 0; i < list.size(); i++){ T data = list.get(i); consumer.accept(data); } } //Predicate인터페이스 구현객체를 받아서, //위 forEach문을 이용해 receiver객체의 list의 각 데이터들에 대해 //Predicate의 메서드를 실행하여, true가 나온 값들을 newList에 add()하여 newList를 리턴 //결과적으로 elements중, 구현된 Predicate 메서드의 조건에 맞는 데이터들만 //다시 컬렉션객체로 모아 리턴 public MyCollection<T> filter(Predicate<T> predicate){ List<T> newList = new ArrayList<>(); forEach(d -> { if(predicate.test(d)) newList.add(d); }); return new MyCollection<>(newList); } //Function인터페이스 구현객체를 받아서, //위 forEach문을 이용해 receiver객체의 list의 각 데이터들에 대해 //Fucntion메서드를 실행하여 나온 데이터를 newList에 add()하여 newList를 리턴 //결과적으로 elements에 대해 Function메서드로 변경한 값을 //다시 컬렉션객체로 모아 리턴 public <U> MyCollection<U> map(Function<T, U> function){ List<U> newList = new ArrayList<>(); forEach(d -> newList.add(function.apply(d))); return new MyCollection<>(newList); } //이 메서드의 경우, public 뒤 <U>는 리턴타입이 아님, U라는 타입을 해당 메서드에서 사용하겠다는 문법 }* 참고
위 메서드들에서 파라미터로 넘어오는 인터페이스들의(Consumer, Predicate, Fucntion) 구현객체는, 보통 람다식으로 바로 정의되어 넘어옴
ex) myCollection.forEach(s -> System.out.println(s));
ex) myCollection.map(m -> m.getName());
-> 이처럼 함수형인터페이스를 이용해, 함수가 인자로 들어가는 함수를 고차함수 라고 함
2. 위 메서드들로 리턴되는 Collection객체 또는 데이터에 대해, 다음과 같이 연쇄적으로 연산을 이어나갈 수 있음public static void main(String[] args){ MyCollection<Integer> collection = new MyCollection<>(List.of(1,2,3,4,5)); collection.filter(n -> n<3) .map(s -> s+1) .foreach(System.out::println); }
Q. Method Chaining은 Demeter's law를 위반하는 것이 아닌가?
Demeter's law
- 자신이 직접 참조하는 객체들과만 상호작용해야 하며, 간접적으로 참조되는 객체와는 상호작용하지 말아야 한다는 법칙
- 객체 간의 의존성을 줄여서 결합도를 낮추고 모듈화를 촉진하는 것이 목적
ex) Demeter's law 위반
order.getCustomer().getAddress().getCity().getName();
// order객체가 Customer객체를 반환, Customer객체가 address를 반환, address가 City를 반환, ...
ex) Demeter's law에 따라 수정
orderDTO.setCustomer().setAddress().setCity().setName();
//orderDTO객체의 메서드들만 연쇄적으로 호출됨

결론
Java의 Stream API는 메서드 체이닝을 많이 사용하지만, 이는 Demeter's Law를 위반하지 않습니다. 이는 Stream API의 메서드들이 같은 객체(Stream 객체)에 대한 메서드를 연속적으로 호출하기 때문입니다.
Stream : 데이터의 연속, 요소들이 하나씩 흘러가면서 처리된다는 의미를 가짐
* System.in도 PrintStream
자바8부터 Collection객체를 Stream으로 사용하는 API를 제공
- 어떠한 입력 데이터(생성된 스트림)를 가지고 필요한 과정을 거치면서 아웃풋 데이터를 생성해냄
- 원본을 변경하지는 않고 원하는 데이터를 생성해내는 과정
- 여러번 재사용 하지 않는 일회용
- 내부 반복으로 작업을 처리함
<Stream 생성하기> (기능 : 예시)
배열을 Stream으로 : Arrays.stream(arr)
Collection객체를 Stream으로 : list.stream()
<Stream 연산하기>
다중 스레드로 연산 병렬처리 : parallel()
* 연산 앞에 사용하여 ParallelStream으로 변환
* ParallelStream : 병렬스트림, 기존 : 순차스트림
중복제거 : distinct()
데이터 n개 추출 : limit(n)
정렬 (PrimitiveType & String 배열) : sorted()
정렬 (객체 배열 & List) : sorted((o1, o2)->o1.getName().compareTo(o2.getName()))
데이터 필터링 : filter(s->s.getScore()>80)
모든 Element들에 대해 조건이 하나라도 맞는지 : anyMatch(s -> s instanceof Student)
모든 Element들에 대해 조건이 모두 맞는지 : allMatch(s -> s instanceof Student)
각 Element에 대해 실행 : forEach(System.out::println)
각 Element를 교체하기(요소변환) : map(s -> s.getName())
Integer타입을 int타입으로 변환 : mapToInt(Integer::intValue)
* 이렇게도 사용가능 : mapToInt(student -> student.getScore())
* IntStream.range : p1부터 p2이전까지의 integer를 차례대로 스트림으로 방출ex) IntStream.range(1, 11).forEach(System.out::println); //1부터 10까지 출력
<Stream 연산 결과 리턴하기>
* 잠깐 알고 가기Collection Stream에는 2가지 유형이 존재 1. Generic Type Stream : Stream<T> - Collection객체 or 객체 배열로 만든 Stream (List, Stack, Object[]) 2. Primitive Type Stream : IntStream, LongStream, DoubleStream - Primitive Type 배열로 만든 Stream (int[], long[], double[])Generic Type Stream을 배열로 리턴 : toArray(Student[]::new)
Generic Type Stream을 List로 리턴 : collect(Collectors.toList())
Primitive Type Stream을 배열로 리턴 : toArray();
Primitive Type Stream을 List로 리턴 : boxed().collect(Collectors.toList())
* boxed() : primitive타입을 rapper클래스로 boxing해야 하므로
첫 번째 값 : findFirst()
갯수 : count() *long 리턴
합계 : sum() *int 리턴
최댓값 : max() *Optional 리턴 -> getAsInt() 사용
최솟값 min() *Optional 리턴 -> getAsInt() 사용
평균 : average() *Optional 리턴 -> getAsDouble() 사용
* 주의 및 참고 사항- reduce() 연산 활용 ex) 모든 Elements 곱한 값 : reduce((x, y) -> x * y) //getAsInt() - sum, max, min, average 연산은, 실행전에 mapToInt()로 바꾸고 사용 - collect(Collectors.toList()) 와 toList()의 차이 : 전자는 ArrayList가, 후자는 read_only버전 List상속객체가 리턴 됨 - Collection객체를 배열로 : books.toArray(Book[]::new);* 또 참고로 Stream API의 기능은 6번 예시 코드와 다소 차이가 있음(JPA의 조회기능처럼 lazy기법이 적용 & 병렬처리가 가능해 더 효율적으로 작동함)
사용 예시
List<Student> students = Arrays.(students)
.sorted((o1, o2)->o1.getName().compareTo(o2.getName()))
.collect(Collectors.toList());
함수형 프로그래밍의 특징
1. 순수함수
- 동일한 입력 -> 동일한 결과
- 입력만이 결과에 영향을 준다
2. 불변성
- 기존 데이터를 변경하지 못함 (새로운 걸 만들어 냄)
=> 위 특징들로 인해 병렬처리 가능 (여러 스레드에서 접근해도 바뀔 염려x)
참고 : [10분 테코톡] 이상의 Java Stream API
(기준)
1. 가독성
: stream이 함수형 프로그래밍으로써 일반적으로 가독성이 좋은 장점이 있지만, 상황에 따라 for문이 더 좋은 상황도 있음. 그리고 개인취향에 따라서도 다름
-> stream = for
2. 디버깅
: stream은 내부로직이 복잡해 exception이 직관적이지 않고, 디버깅이 어려움
-> stream < for
3. 병렬처리
: stream은 병렬처리로 생기는 이점 존재
-> stream > for
4. 물리적 성능
: 일반적으로 for문이 stream보다 빠름 (for문이 오래됐기 때문에 jvm 최적화가 많이 이루어짐),
하지만 요즘엔 하드웨어의 발전 때문에 별 차이 안남
따라서 유지보수관점에서 결정하는 편이 좋음
-> stream = for
참고 : [10분 테코톡] 크리스, 로마의 stream vs for
stream을 사용하기 좋은 경우
안좋은 경우
또한 7-4에서 제시한 네 가지 기준까지 참고하여 시의적절히 사용하는 것이 좋다!
끝