모던 자바 인 액션 2

Daniel_Yang·2023년 8월 13일
0

3장 람다 표현식

3장 람다

3장 람다 표현식

  • 대부분의 레퍼런스

https://velog.io/@heoseungyeon/람다-표현식

https://highlighter9.tistory.com/41

https://catsbi.oopy.io/dc24ed56-6ce8-4ed6-b270-cc2b97f4ad5a

중요한 부분

  • 람다 표현식 특징
  • 람다 표현식과 함수형 인터페이스
  • 람다 캡쳐링 및 제약조건

개요


Java8에서 새롭게 추가된 기능 중 대표적인 기능은 람다 표현식입니다.

람다 표현식은 함수형 프로그래밍을 위한 필수 요소입니다.

람다 표현식란?

1. 람다 표현식의 특징

  • 람다 표현식은 간결한 방식으로 익명 함수를 표현할 수 있게 해줘서
    • 코드의 가독성을 향상
    • 작성하는 코드의 양을 줄인다
  • 람다는 특히 함수형 인터페이스와 함께 사용될 때 매우 유용하게 활용됩니다.
  • 람다 표현식은 자바에서 함수형 프로그래밍 패러다임을 채택하는데 큰 역할
  • 자바 컴파일러의 추론에 의존

요약

1) 익명성

  • 보통의 메서드와 달리 이름이 없는게 람다의 특징입니다. "이름이 없다"라는 것은 익명이라고 표현합니다.

2) 함수

  • 람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 부릅니다. 하지만 메서드처럼 파라미터, 바디, 반환 형식, 가능한 예외를 포함하게 됩니다.

3) 전달

  • 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있습니다.

4) 간결성

  • 익명 클래스처럼 불필요한 코드(컴파일러의 추론으로 해결가능한 코드)를 구현할 필요가 없습니다.

구성요소

(Item i1, Item i2) → i1.getTime.compareTo(i2.getTime)
(Item i1, Item i2) // 파라미터 리스트// 화살표
i1.getTime.compareTo(i2.getTime)  // 람다 바디
// 파라미터 리스트, 화살표, 람다 바디로 구성된다.
  • 파라미터 리스트
  • 화살표 : 파라미터 리스트와 바디를 구분한다.
  • 바디 : 람다의 반환값에 해당하는 표현식

어디에, 어떻게 람다를 사용할까?

  • 함수형 인터페이스는 정확히 하나의 추상 메서드를 지정하는 인터페이스이다.
    • @FunctionalInterface 어노테이션을 붙여 주면 메서드가 2개 이상일 경우 컴파일 단계에서 에러를 발생 시킬 수 있다.
  • 람다 표현식을 이용해서 함수형 인터페이스의 추상 메서드를 즉석으로 제공(구현 객체 생성 - 전달)할 수 있으며, 람다 표현식 전체가 함수형 인터페이스의 인스턴스로 취급한다.
  • 함수 디스크립터
    • 함수형 인터페이스의 추상 메서드 시그니처는 람다 표현식의 시그니처를 가르킨다. 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 부른다.

용어: 시그니처, 함수 디스크립터(chatGPT)

시그니처

시그니처(signature)는 프로그래밍 언어와 관련하여 주로 메서드나 함수의 이름과 
그것의 매개변수 타입들을 나타냅니다., 함수나 메서드의 "서명"이라고 볼 수 있습니다.
시그니처는 오버로딩(overloading)과 관련 있습니다. 자바 같은 언어에서 두 메서드가 
같은 이름을 가지지만 다른 매개변수 타입 또는 개수를 갖는다면, 그 두 메서드는 다른 시그니처를 갖습니다. 
이러한 시그니처의 차이로 인해 메서드 오버로딩이 가능합니다.
예를 들어,
void print(String message) { ... }
void print(int number) { ... }

위의 두 print 메서드는 다른 시그니처를 가집니다. 첫 번째는 String 타입의 매개변수를 받고, 
두 번째는 int 타입의 매개변수를 받습니다. 그러나 반환 타입은 시그니처에 포함되지 않기 때문에 
반환 타입만 다른 메서드는 오버로드할 수 없습니다.
람다 표현식의 문맥에서 "시그니처"는 람다의 매개변수 타입들을 지칭하며, 
해당 람다가 사용될 함수형 인터페이스의 추상 메서드의 시그니처와 일치해야 합니다.

------

함수 디스크립터와 람다 표현식의 시그니처는 Java 8의 함수형 프로그래밍에 관련된 중요한 개념들입니다. 

함수 디스크립터 (Function Descriptor):

함수형 인터페이스의 추상 메서드의 시그니처를 의미합니다.
함수형 인터페이스는 정확히 하나의 추상 메서드만을 가진 인터페이스를 말합니다. 
그러나 default 메서드나 static 메서드는 여러 개 있을 수 있습니다.
예를 들어, java.util.function.Predicate<T> 함수형 인터페이스는 
boolean test(T t)라는 하나의 추상 메서드를 가지므로, 
이 메서드의 시그니처가 해당 인터페이스의 함수 디스크립터가 됩니다.

람다 표현식의 시그니처 (Lambda Expression Signature):

람다 표현식의 시그니처는 람다의 파라미터와 그 결과로 대응되는 함수 디스크립터와 일치해야 합니다.
예를 들어, 위에서 언급한 Predicate<T>의 test 메서드를 구현하는 
람다는 다음과 같은 형태를 가져야 합니다: (T t) -> boolean 표현식
람다 표현식을 사용할 때, 그 람다가 대입되는 대상 타입(target type)의 
함수형 인터페이스의 함수 디스크립터와 람다의 시그니처가 일치해야 합니다. 
이 일치하는 과정을 타입 검사(type checking)라고 하며, 
이를 통해 Java 컴파일러는 람다 표현식이 올바른 시그니처를 가지고 있는지 확인합니다.

2. 람다 표현식의 장단점

장점

  • 코드의 간결성 - 람다를 사용하면 불필요한 반목문을 삭제할 수 있고 복잡한 식을 단순하게 표현할 수 있습니다.
  • 지연연상 수행 - 람다는 지연연상을 수행함으로써 불필요한 연산을 최소화할 수 있습니다.
  • 병렬처리 가능 - 멀티쓰레드를 활용하여 병렬처리할 수 있습니다.

단점

  • 람다식의 호출이 어렵습니다.
  • 불필요하게 너무 많이 사용하면 오히려 가독성을 떨어 뜨릴 수 있습니다.

3. 람다 표현식의 규칙

람다의 기본 문법 및 사용 방법

1) 싱글 파라미터 : 파라미터가 하나인 경우, 괄호가 필요 없습니다.

2) 중괄호 선택 : 한 문장일 경우 중괄호가 필요 없습니다.

3) return 키워드 선택 : 한문장일 경우 생략이 가능합니다. 다만 중괄호를 포함한 경우 무조건 return 키워드를 포함해야합니다.

4) 매개변수 화살표 (→) : 매개변수 화살표를 통해 함수 몸체를 가리킬 수 있습니다.

1️⃣ 싱글 파라미터 예시

(param) -> param+1
// 괄호 생략 가능
param -> param+1

2️⃣ 중괄호 선택 예시

param -> { param+1 }
// 중괄호 생략 가능
param -> param+1

3️⃣ return 키워드 선택 예시

param -> { return param+1; }
// return 키워드 생략 가능
param -> param+1

4️⃣ 매개변수 화살표 (→)

// 함수 몸체를 가리키는 (->) 화살표 사용
param -> param+1

메서드 참조(레퍼런스)

  • 메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다.
  • 메서드 레퍼런스는 특정 메서드만을 호출하는 람다의 축약형이다. 메서드 레퍼런스를 새로운 기능이 아니라 하나의 메서드를 참조하는 람다를 편리하게 표현할 수 있는 문법으로 간주 할 수 있다.
기존의 코드 inventer.sort((Apple a1, Apple a2) 
			→ a1.getWeight().compareTo(a2.getWeight))) 를 메서드 참조형으로 전환한다면
inventer.sort((Apple a1, Apple a2)comparing(Apple::getWeight)) 형으로 사용가능
람다메서드 레퍼런스 단축 표
(Apple a) → a.getWeight()Apple::getWeight
() → Thread.currentThread().dumpStack()Thread.currentThread()::dumpStack
(str, i) ⇒ str.substring(i)String::substring
(String s) → System.out.println(s)System.out::println

**메서드 참조가 왜 중요한가?**

가독성과 깊은 관련이 있다. 우리가 많이 사용하는 화살표 → 를 이용하는 방법도 좋다 근데 이방법을 풀어보자면 "메서드를 이렇게 호출하고 이렇게 사용해라" 가 된다. 하지만 메서드 참조의 경우 "이 메서드를 사용해"가 된다. 놀라울 정도로 축약형을 만들 수 있다.

**메서드 참조를 만드는 방법**

  • 정적 메소드 참조 → Integer::parseInt 의 형식 Integet class 내부의 정적 메서드 parseInt() 참조
  • 다양한 형식의 인스턴스 메서드 참조→ String::length String class의 인스턴스 메서드 lenght() 참조
  • 기존 객체의 인스턴스 메서드 참조→ Transaction expensiveTransaction = new Transaction();
    Value v = expensiveTransaction::getValue; 형태로 가능

람다, 메소드 참조 활용하기

class Orange{

    public Integer weight;

    public Orange(Integer weight)
        this.weight = weight;
    }

    public Integer getWeight() {
        return weight;
    }
}

**1단계 : 코드 전달**

// 동작은 파라미터화
public static void main(String[] args) {
    oranges.forEach(s -> System.out.print(" "+s.getWeight()));
    System.out.println();
    oranges.sort(new OrangeComparator()); // 동작 파라미터화
    oranges.forEach(s -> System.out.print(" "+s.getWeight()));

}

class OrangeComparator implements Comparator<Orange> {
//파라미터화된 코드
    public int compare(Orange o1, Orange o2) {
        return o1.getWeight().compareTo(o2.getWeight());
    }
}

**2단계 : 익명 클래스 사용**

public static void main(String[] args) {
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));
        System.out.println();
        oranges.sort(new Comparator<Orange>(){
            public int compare(Orange o1, Orange o2) {
                return o1.getWeight().compareTo(o2.getWeight());
            }
        });
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));

    }
}

**3단계 : 람다 표현식 사용**

public static void main(String[] args) {
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));
        System.out.println();
        oranges.sort((Orange o1, Orange o2) -> o1.getWeight().compareTo(o2.getWeight()));
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));
    }

형식 추론 간결화

public static void main(String[] args) {
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));
        System.out.println();
        oranges.sort((Orange o1, Orange o2) -> o1.getWeight().compareTo(o2.getWeight()));
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));
    }

comparing을 이용한 간결화

public static void main(String[] args) {
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));
        System.out.println();
        Comparator<Orange> c = Comparator.comparing((Orange o) -> o.getWeight());
				oranges.sort(c); // 1번 방식
        oranges.sort(Comparator.comparing(o -> o.getWeight())); // 2번방식
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));
    }

**4단계 : 메서드 참조 사용**

public static void main(String[] args) {
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));
        System.out.println();
        oranges.sort(Comparator.comparing(Orange::getWeight));
        oranges.forEach(s -> System.out.print(" "+s.getWeight()));
    }

어라운드 패턴(대강)

  • 람다가 활용되는 대표적인 예는 ‘실행 어라운드 패턴’이다.
  • 파일처리는 자원을 열고(OPEN) 자원을 처리하고(PROCESS) 자원을 닫는 과정(CLOSE)을 반복한다. 이를
    순환패턴이라 한다. 그렇다면 파일을 처리하는 코드는 자원을 설정(SetUp)하고 자원을 처리(Process)하고
    자원을 정리(CleanUp) 하는데 이를 실행어라운드 패턴이라 부른다
  • 람다는 람다표현식을 사용하여 함수형 인터페이스의 추상메소드를 묘사한 구현객체를 생성하여 반환하여
    파라미터로 넘긴다.
    - 이렇게 언제든 변화 가능한 Process 코드를 파라미터로 분리하여 언제든 대응가능 하도록 구성한 패턴이 바로, 실행어라운드 패턴이다. 그리고 실행 어라운드 패턴에서 코드를 넘길 때, 사용되는 기술이 람다!

형식 검사, 형식 추론

형식 검사

  • 람다가 사용하는 콘텍스트를 이용해 람다의 형식을 추론할 수 있다.
  • 어떤 콘텍스트에서 제공될꺼라 기대되는 람다 표현식의 형식을 대상 형식(Target Type)
List<Company> companiesHasCafeteria= filter(list, (Company company)-> company.hasCafeteria());
  1. 람다가 사용된 콘텍스트 확인을 해야하니 filter 메소드의 정의 확인
  2. 메소드의 두 번쨰 파라미터인 Predicate<Company> p가 기대하는 대상 형식(target type)을 나타낸다.
  3. Prediate p는 boolean test(T t) 라는 하나의 추상 메서드를 정의하는 함수형 인터페이스이다.
  4. test 메서드는 Company를 받아 boolean을 반환하는 함수 디스크립터를 묘사한다.
  5. filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 한다.
    그리고 전달되는 인수인 (Company company)→ company.hasCafeteria() 는 Company → boolean의 형태를 띄고있기에 유효하며 이렇게 형식 검사는 성공적으로 완료된다.

형식 추론

  • 자바 컴파일러는 람다 표현식이 사용된 콘텍스트를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다. 즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 람다의 시그니처로 추론 가능
    //Before
    List<Company> companiesHasCafeteria= filter(list, (Company company)-> company.hasCafeteria());
    
    //After
    List<Company> companiesHasCafeteria= filter(list, company-> company.hasCafeteria());

4. Java8 이전 방식과 람다 표현식

기존의 익명 내부 클래스와 람다의 차이점

람다 표현식을 이용하려면 오버라이드 할 메서드가 포함된 함수형 인터페이스가 필요합니다.

함수형 인터페이스란?

함수형 인터페이스(Functional Interface)는 함수를 하나만 가지는 인터페이스를 의미합니다. 함수형 인터페이스는 @FunctionalInterface 어노테이션을 붙여 표현합니다.

준비

1) 함수형 인터페이스 생성

@FunctionalInterface
public interface UserPredicate{
		boolean test(User user);
}

2) 클래스 생성

public class EmailPredicate implements UserPredicate{
    private String email;

    public EmailPredicate(String email){
        this.email = email;
    }

    @Override
    public boolean test(User user){
        return email.equals(user.getEmail());
    }

    public String getEmail() {
        return email;
    }
}

자바 8 이전 방식 - 익명 클래스

  • override 구현과 인스턴스화를 동시에
UserPredicate userPredicate = new EmailPredicate("dia0312@naver.com") {
    @Override
    public boolean test(User user) {
        return this.getEmail().equals(user.getEmail());
    }
};

람다 표현식 사용

  • override 구현과 인스턴스화를 동시에
UserPredicate userPredicate = (user) -> "dia0312@naver.com".equals(user.getEmail());

5. 람다 캡쳐링

람다의 바디(구현부)에는 파라미터를 제외하고도 바디 외부에 있는 변수를 참조할 수 있습니다.

이렇게 람다 시그니처의 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수를 자유 변수(Free Variable)이라고 부릅니다.이런 자유 변수를 참조하는 행위를 람다 캡쳐링(Lambda Capturing)이라고 합니다.

람다 캡쳐링의 제약 조건

  1. 지역 변수는 final로 선언되어 있어야 한다.
  2. final로 선언되지 않은 지역변수는 final처럼 동작해야 한다.
    • 값의 재할당이 일어나면 안됩니다.

1️⃣ "지역 변수는 final로 선언되어 있어야 한다" 예시 코드

final String email = "dia0312@naver.com";
int age4 = getAge(users, user -> email.equals(user.getEmail()));

2️⃣ "final로 선언되지 않은 지역변수는 final처럼 동작해야 한다." 예시 코드

**// 값이 한번 초기화되고 재할당 되지 않음 -> final 처럼 동작함.**
String email = "dia0312@naver.com";
int age4 = getAge(users, user -> email.equals(user.getEmail()));

❌ "final로 선언되지 않은 지역변수는 final처럼 동작해야 한다." 예시 실패 코드

String email = "dia0312@naver.com";
**// 값을 재할당하였기 때문에 -> final 처럼 동작하지 않음.**
email = "sonny@naver.com";
int age4 = getAge(users, user -> email.equals(user.getEmail()));

다음과 같이 "Variable used in lambda expression should be final or effectively final"에러 메시지를 확인할 수도 있습니다.

Untitled

이유(chatGPT)

  1. (스레드) 안전성: Java의 람다는 다른 스레드에서 실행될 수 있습니다. 변수가 변경 가능하면 (즉, 변경될 수 있다면) 하나의 스레드에서 변수를 수정하는 동안 다른 스레드에서 사용되는 문제가 발생할 수 있습니다. 이로 인해 예측할 수 없는 결과가 발생할 수 있습니다. 변수가 final 또는 유효하게 final인지 확인함으로써 Java는 이 문맥에서 스레드 안전성을 보장합니다.
  2. 함수형 프로그래밍 패러다임: 함수형 프로그래밍의 핵심 원칙 중 하나는 불변성입니다. 불변의 데이터를 가지고 있으면 (람다 포함) 함수의 부작용이 없어져 코드에 대한 추론이 쉬워집니다.
  3. 변수의 수명: 람다 표현식은 전달되어 나중에 실행될 수 있기 때문에 (람다를 생성한 메서드가 실행을 마친 후에도 가능하게) 그들이 포착하는 변수가 일관성을 유지하도록 보장하는 것이 중요합니다. 이러한 변수가 변경 허용될 경우 람다가 실제로 실행될 때의 동작을 예측하기가 어렵게 됩니다.
  4. 간단함과 예측 가능성: 람다의 액세스를 final 또는 유효하게 final 변수만 허용함으로써 그 동작을 단순화하며 더 예측 가능하게 만듭니다. 이는 람다의 동작이 외부 요인에 따라 변경될 수 있는 복잡한 상황을 피합니다.

이 제한은 주로 지역 변수와 매개 변수에 관련되어 있습니다. 인스턴스 변수나 정적 변수는 이러한 제한이 없습니다. 그러나 람다 내의 인스턴스나 정적 변수를 사용할 때, 특히 스레드 안전성을 고려하여 주의해야 합니다.


  1. Heap vs Stack 영역
  • JVM에서 지역변수는 스택이라는 영역에 생성 ex) 멀티버스의 로키
    • 실제 메모리와는 달리 JVM에서 스택 영역은 스레드마다 별도의 스택이 생성됩니다. 따라서 지역 변수는 스레드끼리 공유가 안됩니다. 람다는 별도의 스레드에서 어떻게 지역변수를 참조할 수 있는가? ⇒ 그 이유는 람다에서는 지역변수에 직접적으로 접근하는 것이 아닌 지역 변수를 자신의 스레드의 스택에 복사하기 때문입니다. 그렇기 때문에 별도의 스레드의 스택에 있는 지역변수와 동일한 값을 참조할 수 있는 것 ⇒ 하지만 복사하는 값을 계속 바뀐다면 람다 스레드에서 해당 값에 대한 불신이 생길 것
  • JVM에서 인스턴스 변수는 힙 영역에 생성 ex) 멀티버스에서 유일하게 존재하는 위처
    • 인스턴스 변수는 스레드끼리 공유가 가능
    • 별도로 복사할 필요도 없고, 직접 힙에 접근해서 사용하면 되기 때문

다루지 않은 부분

함수형 인터페이스 사용

  • 함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 한다. 하지만 우리가 매번 함수형 인터페이스의 형태로 람다를 사용하고자 한다면 매번 함수형 인터페이스를 선언해줘야 할 것이다.
  • 이러한 점에서 자바 API는 공통 시그니처를 편하게 사용할 수 있는 인터페이스를 지원한다.
    • Predicate, Consumer, Function

람다 표현식을 조합할 수 있는 유용한 메서드

  • **comparator 조합**

  • **predicate 조합**

  • **Function 조합**

    4장 스트림 소개

https://www.tomatodeveloper.blog/2c5e8373-0b73-4da9-b933-bab52eb6d650

https://incheol-jung.gitbook.io/docs/study/java-8-in-action/2020-03-10-java8inaction-chap4

https://highlighter9.tistory.com/42

  • 가장 많은 참고

https://velog.io/@dongvelop/Moder-Java-in-Action-4장.-스트림-소개

스트림이란?

필요성

  • 많은 요소를 포함하는 커다란 컬렉션을 어떻게 처리해야 할까?
    • 멀티 코어 아키텍처를 활용한 병렬 방식? → 단순 반복 처리에 의해 복잡하고 어렵다.

    • 또한 복잡한 코드는 디버깅도 어렵다.

      ⇒ 스트림으로 이를 쉽고 간단하게 처리할 수 있다.

스트림

  • 스트림은 Java 8 API에서 새로 추가된 기능이다.
    • 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다.
    • 선언형 : 데이터를 처리하는 임시 구현 코드 대신 질의로 표현
  • 또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬 처리할 수 있다.
  • stream()을 parallelStream()으로 바꾸면 이 코드를 멀티코어 아키텍처에서 병렬 실행할 수 있다!
  • 자바 8 이전 vs 자바 8 이후
    • 자바 8 이전: 컬렉션은 데이터를 담는 컨테이너 역할만
    • 자바 8 이후: 세부 구현을 라이브러리 내에서 모두 처리한다.
      • 선언형으로 코드를 구현할 수 있다!
        • 루프와 조건문 등의 제어 블록을 사용해서 어떻게 동작을 구현할 지 지정할 필요가 없다.
        • 선언형 코드와 동작 파라미터화를 이용하면 변하는 요구사항에 쉽게 대응할 수 있다!
      • 여러 빌딩 블록 연산을 연결해서 복잡한 데이터 처리 파이프라인을 만들 수 있다.
        • 가독성과 명확성을 유지할 수 있다!

        • 이때, 빌딩 블록 연산은 filter , sorted , map , collect를 말한다.

          Untitled

        • 결과적으로 우리는 데이터 처리 과정을 병렬화하면서 스레드와 락을 걱정할 필요가 없다

자바 8 이전

List<Dish> lowCaloricDishes = new ArrayList<>();
for(Dish d: menu) {
    `if(d.getCalories() < 400) {
        lowCaloricDishes.add(d);
    }
}

Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
    public int compare(Dish d1, Dish d2) {
        return Integer.compare(d1.getCalories(), d2.getCalories());
    }
});

List<String> lowCaloricDishesName = new ArrayList<>();
for(Dish d: lowCaloricDishes) {
    lowCaloricDishesName.add(d.getName());
}

vs

자바 8 이후

List<String> lowCaloricDishesName = menu.stream()
    .filter(d -> d.getCalories() < 400)
    .sorted(comparing(Dishes::getCalories))
    .map(Dish::getName)
    .collect(toList());

Java 8의 스트림 API의 특징

  • 선언형 : 더 간결하고 가독성이 좋아진다.
  • 조립할 수 있음 : 유연성이 좋아진다.
  • 병렬화 : 성능이 좋아진다.

스트림 API를 사용하는 방법

스트림이란 정확히 뭘까?

스트림이란 '데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소'로 정의할 수 있다.

  • 연속된 요소
    • 컬렉션과 마찬가지로 스트림은 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공한다. 컬렉션의 주제는 데이터이고 스트림의 주제는 계산이다. 추후 이 차이에 대해 더 살펴보겠다.
  • 소스 스트림은 컬렉션배열I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비(consume)한다.
  • 데이터 처리
    • 연산스트림 연산은 순차적으로 또는 병렬로 실행할 수 있다.

스트림 시작하기

  • Java 8 컬렉션에는 스트림을 반환하는 stream() 메서드가 추가되었다.
  • 스트림은 ‘데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소’로 정의할 수 있다.

스트림의 두 가지 주요 특징

  1. 파이프라이닝
    • 대부분의 스트림 연산은 스트림 연산끼리 연결해 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다!
    • 덕분에 게으름(laziness), 쇼트서킷(short-circuiting)같은 최적화도 얻을 수 있다.
  2. 내부 반복
    • 반복자를 이용해 명시적으로 반복하는 컬렉션과 달리, 스트림은 내부 반복을 지원한다.

스트림과 컬렉션

  • 차이점(chatGPT)
    1. 용도:

      • 컬렉션: 주로 데이터를 저장하고, 관리하며, 일반적인 데이터 조작을 위해 사용됩니다. 컬렉션은 고정된 크기의 데이터 구조입니다.
      • 스트림: 데이터 처리 연산(변환, 필터링, 그룹화 등)을 위한 구조입니다. 스트림은 데이터의 연속적인 흐름을 나타냅니다.
    2. 저장 및 변경:

      • 컬렉션: 데이터의 저장과 관리에 중점을 둡니다. 컬렉션 내의 데이터는 추가, 제거, 수정이 가능
      • 스트림: 데이터의 변환과 처리에 중점을 둡니다. 스트림 자체는 데이터를 저장하거나 변경할 수 없다.
    3. 재사용성:

      • 컬렉션: 재사용이 가능합니다. 같은 컬렉션에 여러 번 접근할 수 있습니다.
      • 스트림: 한 번 사용되면 재사용할 수 없습니다. 연산이 완료되면 해당 스트림은 닫히게 됩니다.
    4. 컨슈머:

      • 컬렉션: 여러 컨슈머에 의해 동시에 사용될 수 있습니다.
      • 스트림: 단 한 번만 소비될 수 있습니다. 데이터가 처리되면 해당 스트림은 소비된 것으로 간주
    5. 성능:

      • 컬렉션: 기본적으로 연산이 병렬적으로 실행되지 않습니다.
      • 스트림: 병렬 스트림을 사용하여 연산을 병렬적으로 실행할 수 있습니다.
    6. 무한성:

      • 컬렉션: 항상 고정된 크기 또는 한정된 데이터 세트를 가집니다.
      • 스트림: 무한한 데이터 소스를 다룰 수 있습니다 (예: Stream.iterate 또는 Stream.generate).
    7. 메모리 소비:
      - 컬렉션: 모든 데이터가 메모리에 저장됩니다.
      - 스트림: 데이터는 필요할 때 처리되며, 모든 데이터가 메모리에 저장되지 않을 수 있다(지연 계산)
      - 가장 큰 차이점은 데이터를 언제 계산하느냐다. 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야 하나 스트림은 이론적으로 요청할 때만 요소를 계산하는 고정된 자료 구조다.
      - 즉, 컬렉션은 삽입 삭제가 가능하지만 스트림의 경우는 불가하다. 스트림 관점에서 메모리 저장 영역이 어디에 저장될지 모르기에

      이러한 차이점들을 통해 컬렉션은 데이터의 저장과 관리에 중점을 둔 반면, 스트림은 데이터의 연속적인 처리에 중점을 둔다는 것을 알 수 있습니다.

  • 딱 한번만 탐색할 수 있다.

    • 반복자와 마찬가지로 한 번만 탐색할 수 있다. → 즉, 탐색된 스트림의 요소는 소비된다!
    • 또한 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다.
  • 외부 반복과 내부 반복

    • 컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야 한다.
      • 이를 외부 반복이라고 한다.
        List<String> names = new ArrayList<>();
        for(Dish dish: menu) {
        		names.add(dish.getName());
        }
        List<String> names = new ArrayList<>();
        Iterator<String> iterator = menu.iterator();
        while (iterator.hasNext()) { //명시적 반복
        		Dish dish = iterator.next();
        		names.add(dish.getName());
        }
    • 반면에 스트림 라이브러리는 내부 반복을 사용한다.
      • 함수에 어떤 작업을 수행할지만 지정하면 모든 것이 알아서 처리된다.
        List<String> names = menu.stream()
        						  .map(Dish::getName)
        					 	  .collect(toList());
    • 내부 반복이 외부 반복보다 좋은 이유 2가지!
      1. 작업을 투명하게 병렬로 처리하거나, 더 최적화된 다양한 순서로 처리할 수 있다.
      2. 스트림의 내부 반복은 데이터 표현과 하드웨어를 활용한 병렬성 구현을 자동으로 선택한다.
        • 반면, for-each와 같은 외부 반복에서는 병렬성을 스스로 관리해야 한다. (synchronized)

스트림 연산

List<String> names = menu.stream()
    .filter(d -> d.getCalories() > 300) // 중간 연산
    .map(Dish::getName) // 중간 연산
    .limit(3) // 중간 연산
    .collect(toList()); // 최종 연산
  • 스트림의 연산은 크게 두 가지로 구분할 수 있다.
    • 스트림을 이어서 연결할 수 있는 중간 연산

      중간 연산의 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지는 아무 연산도 하지 않는다는 것. 즉, 게으르게 처리한다. → 합쳐진 중간 연산을 최종 연산으로 한 번에 처리한다!

      • 람다가 현재 처리중인 요리를 출력하기
        ```java
        public class HighCaloriesNames {
            public static void main(String[] args) {
                List<String> names = menu.stream()
                        .filter(dish -> {
                            System.out.println("filtering " + dish.getName());
                            return dish.getCalories() > 300;
                        })
                        .map(dish -> {
                            System.out.println("mapping " + dish.getName());
                            return dish.getName();
                        })
                        .limit(3)
                        .collect(toList());
                System.out.println(names);
            }
        }
        ```
        
    • 스트림을 닫는 최종 연산

      최종 연산은 스트림 파이프라인에서 결과를 도출한다.

      보통 최종 연산에 의해 List, Integer, void 등 스트림 이외의 결과를 반환한다.

      • 스트림의 모든 요리 출력 예제
        ```java
        menu.stream().forEach(System.out::println);
        ```
        

스트림에 존재하는 메소드 이용 방법

  • 필터링
  • 스트림 슬라이싱
  • 스트림 축소
  • 요소 건너뛰기
  • 매핑
  • 스트림 평면화
  • 검색과 매칭
  • 리듀싱

스트림 이용하기

스트림 이용 과정은 아래 세 가지로 요약할 수 있다.

  • 질의를 수행할 데이터 소스(like 컬렉션)
  • 스트림 파이프라인을 구성할 중간 연산 연결
  • 스트림 파이프라인을 실행하고 결과를 만들 최종 연산
  • 스트림 파이프라인의 개념은 빌더 패턴과 비슷하다!
    • 빌터 패턴에서는 호출을 연결해서 설정을 만든다(= 중간 연산)
    • 그리고 준비된 설정에 build() 메서드를 호출한다. (= 최종 연산)

기본형 특화 스트림

무한 스트림

스트림 API가 왜 비싼 연산(비효율적 동작)이 될 수 있는가?(chatGPT)

  • 사용 방식과 구성에 따라 비효율적으로 동작할 수 있으니 주의하자
  1. 중간 연산: 스트림은 여러 중간 연산(intermediate operations)을 포함할 수 있습니다. 이러한 연산들은 스트림의 각 요소에 대해 수행되며, 많은 중간 연산이 체인으로 연결되면 각 연산이 요소마다 반복적으로 실행됩니다. 이것은 특히 큰 데이터 세트에 대해 비효율적일 수 있습니다.
  2. 불필요한 연산: 스트림의 연산은 필요한 것보다 더 많은 연산을 수행할 수 있습니다. 예를 들어, 최종 연산(terminal operation)이 findFirstfindAny와 같은 메서드일 경우, 스트림의 모든 요소를 처리할 필요가 없을 수도 있지만 잘못된 스트림 구성으로 인해 그렇게 동작할 수 있습니다.
  3. 박싱 및 언박싱: 스트림 API는 기본형 데이터에 대한 특화된 버전을 제공하지만, 이러한 특화된 버전을 사용하지 않을 경우 자동 박싱 및 언박싱 오버헤드가 발생할 수 있습니다.
  4. 병렬 스트림: 스트림 API는 병렬 처리를 쉽게 할 수 있는 parallel() 메서드를 제공합니다. 그러나 모든 작업이 병렬 처리에 적합한 것은 아닙니다. 데이터 분할 및 병합 비용, 스레드 관리 오버헤드 등으로 인해 항상 성능 향상을 가져오는 것은 아닙니다.
  5. 외부 데이터 소스: 스트림 연산의 비용은 데이터 소스에 따라 다를 수 있습니다. 예를 들어, 메모리 내 컬렉션에서의 스트림 연산은 데이터베이스 쿼리나 네트워크에서 데이터를 가져오는 것보다 빠를 수 있습니다.

1개의 댓글

comment-user-thumbnail
2023년 8월 13일

정보 감사합니다.

답글 달기