peaches-book-study / effective-java

이펙티브 자바 3/E
0 stars 2 forks source link

Item 47. 반환 타입으로는 스트림보다 컬렉션이 낫다 #44

Open Lainlnya opened 3 months ago

Lainlnya commented 3 months ago

Chapter : 7. 람다와 스트림

Item : 47. 반환 타입으로는 스트림보다 컬렉션이 낫다

Assignee : Lainlnya


🍑 서론

java 7이전

원소시퀀스를 반환하는 메서드의 반환타입 ** 원소시퀀스: 일반적으로 배열이나 리스트와 같은 데이터 구조에서 요소(element)들의 연속적인 나열을 의미

  1. 컬렉션 인터페이스
  2. iterable 인터페이스: for-each 문에서만 쓰이거나 반환된 원소가 일부 collection 메서드를 구현할 수 없을 때
  3. 배열: 반환 원소들이 기본 타입이거나 성능에 민감한 상황

하지만 java 8이후 스트림이 등장하며 선택이 복잡해졌다.

🍑 본론

스트림이란 ?

java 8 API에 추가된 기능. 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다.

또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있다.

java 7이전 3000만원 이하의 차량을 조회하는 코드

@ToString
@Getter
@AllArgsConstructor
public class Car {
    private String name; // 자동차 이름
    private Integer price; // 가격
}

// 차량 목록
List<Car> cars = Arrays.asList(
    new Car("아반테", 20000000),
    new Car("쏘나타", 30000000),
    new Car("그랜저", 40000000)
);

// 3000만원 이하 차량 필터링
List<Car> lowPriceCars = new ArrayList<>();
for (Car car : cars) {
    if (car.getPrice() <= 30000000) {
        lowPriceCars.add(car);
    }
}

// 비싼 차량부터 정렬
Collections.sort(lowPriceCars, new Comparator<Car>() {
    @Override
    public int compare(Car c1, Car c2) {
        return Integer.compare(c2.getPrice(), c1.getPrice());
    }
});

// 자동차 이름 필터링
List<String> lowPriceCarNames = new ArrayList<>();
for (Car lowPriceCar : lowPriceCars) {
    lowPriceCarNames.add(lowPriceCar.getName());
}

System.out.println(lowPriceCarNames);
// 출력 결과 : [쏘나타, 아반테]

java 8 환경에서 스트림을 사용했을 때

// 차량 목록
List<Car> cars = Arrays.asList(
    new Car("아반테", 20000000),
    new Car("쏘나타", 30000000),
    new Car("그랜저", 40000000)
);

List<String> lowPriceCarNames = cars.stream()
    .filter(c -> c.getPrice() <= 30000000) // 3000만원 이하 차량 필터링
    .sorted((c1, c2) -> c2.getPrice().compareTo(c1.getPrice())) // 비싼 차량부터 정렬
    .map(Car::getName) // 자동차 이름 필터링
    .collect(Collectors.toList());

System.out.println(lowPriceCarNames);
// 출력 결과 : [쏘나타, 아반테]

로직을 선언형으로 처리할 수 있고, 가독성 좋은 코드로 구현할 수 있다. ** 선언형 프로그래밍이란 원하는 결과를 묘사하는 방식으로 코드를 작성하는 프로그래밍 패러다임

stream을 parallelStream()으로 변경하면 멀티코어 아키텍쳐에서 병렬로 실행할 수 있다.

List<String> lowPriceCarNames = cars.parallelStream()
.filter(c -> c.getPrice() <= 30000000) // 3000만원 이하 차량 필터링
.sorted((c1, c2) -> c2.getPrice().compareTo(c1.getPrice())) // 비싼 차량부터 정렬
.map(Car::getName) // 자동차 이름 필터링
.collect(Collectors.toList());

✅ 원소를 반환할 때는 당연히 스트림을 사용해야 하지만, 스트림은 반복(iteration)을 지원하지 않는다. => 스트림과 반복을 알맞게 조합해야 한다.

✅ stream 인터페이스는 Iterable 인터페이스가 정의한 방식대로 동작하지만, Iterable을 확장(extend)하지 않아서 for-each로 스트림을 반복할 수 없다.

    for (ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses()::iterator) {

    }

위의 경우 실전에 쓰기에 너무 난잡하고 직관성이 떨어지기 때문에, 어댑터 메서드를 사용하면 상황이 나아진다.

public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;
}

for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
    // 프로세스 처리
}

어댑터를 사용하면 어떤 스트림도 for-each문으로 반복할 수 있다.

Collection 인터페이스는 Iterable의 하위 타입이로 stream 메서드도 제공하니 반복과 스트림을 동시에 지원한다. => 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는게 일반적으로 최선이다.

반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 ArrayList나 HashSet 같은 표준 컬렉션 구현체를 반환하는 것이 최선일 수 있다.

하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안된다.

❗ 람다식 메소드::참조 메소드 참조 (Method Reference)는 말 그대로 메소드를 참조해서 매개 변수의 정보 및 리턴 타입을 알아내어, 람다식에서 굳이 선언이 불필요한 부분을 생략하는 것

(x, y) -> Math.max(x, y)
// 중복되는 매개변수를 없애고, 화살표를 없애고, 클래스가 메소드를 참조하는 기호인 `.`를 `::`로 변환하면 아래와 같이 표현 가능하다.
Math::max;

전용 컬렉션

반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현하는 것이 좋다.

집합의 부분집합을 반환할 때 해당 집합의 원소 개수는 2^n개가 되며, 표준 컬렉션 구현체에 저장하는 것은 위험하다. 하지만 AbstractList를 사용하면 손쉽게 구현할 수 있다.

✅ 부분집합을 구성하는 각 원소의 인덱스를 비트 벡터로 사용하는 것

public class PowerSet {
    public static final <E> Collection<Set<E>> of (Set<E> s) {
        List<E> src = new ArrayList<>(s);

        if (src.size() > 30)
            throw new IllegalArgumentException("집합에원소가너무많습니다(최대30개).: " + s);

        return new AbstractList<Set<E>>() {
            @Override 
            public int size () {
                // 부분집합의 크기는 2를 원래 집합의 원소 수만큼 거듭제곱 한것과같다. 
                return 1 << src.size();
            }
            @Override
            public boolean contains (Obiect o) {
                return o instanceof Set & src.containsAll((Set) o);
            }
            @Override
            public Set<E> get (int index) {
                Set<E> result = new HashSet<>();
                    for (int i = 0; index != 0; i++, index >>= 1)
                        if ((index & 1) == 1)
                            result.add(src.get(i));
                    return result;
                }
            };
    }
}

👆 입력 집합의 원소 수가 30을 넘으면 PowerSet.of가 예외를 던진다. 다시 말해, Collection의 size 메서드가 intㄹ르 반환하므로 PowerSet.of가 반환되는 시퀀스의 최대 길이는 2^31 - 1로 제한된다. Stream이나 Iterable이 아닌 Collection을 반환 타입으로 쓸 때의 단점을 보여준다.

AbstarctCollection을 활용해서 Collection 구현체 작성

  1. Iterable용 메서드 외에 contains와 size를 구현할 것
  2. 반복이 시작되기 전에는 시퀀스의 내용을 확정할 수 없는 등의 사유로 contains와 size를 구현하는게 불가능할 때는 컬렉션보다는 스트림이나 Iterable을 반환하는 것이 좋다.
  3. 두 방식을 모두 제공해도 된다.

만약 부분리스트를 스트림으로 구현한다면?

  1. for 반복문을 중첩해서 사용할 때
    for (int start = 0; start < src.size(); start++) {
    for (int end = start + 1; end <= src.size(); end++) {
        System.out.println(src.subList(start, end));
    }
    }
  2. 스트림으로 구현할 때

입력 리스트의 모든 부분리스트를 스트림으로 반환

import java.util.stream.IntStream;

public static <E> Stream<List<E>> of(List<E> list) {
    return IntStream.range(0, list.size())
            .mapToObj(start ->
                IntStream.rangeClosed(start + 1, list.size())
                        .mapToObj(end -> list.subList(start, end)))
            .flatMap(x -> x);
}

=> 반복을 사용하는게 더 자연스러운 상황에서도 사용자는 스트림을 쓰거나 Stream을 Iterable로 변환해주는 어댑터를 이용해야 한다. => collection을 사용하면 스트림을 활용한 구현보다 1.4배 빨랐다.

🍑 결론

원소를 반환하는 메서드의 반환타입

  1. 컬렉션을 반환할 수 있다면 컬렉션으로 반환하라
  2. 반환 전부터 이미 원소를 컬렉션에 담아 관리하고 있거나, 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 적다면 ArrayList와 같은 표준 컬렉션에 담아 반환하라.
  3. 그렇지 않다면 부분 집합의 예처럼 전용 컬렉션을 구현해도 된다.
  4. 컬렉션을 반환하는게 불가능하면 스트림과 Iterable 중 더 자연스러운 것을 반환하라.

Referenced by