elevne's Study Note
Effective Java CH7: 람다와 스트림 본문
아이템 42. 익명 클래스보다는 람다를 사용하라
예전에는 자바에서 함수 타입을 표현할 때, 함수객체라고 불리는 추상 메소드를 하나만 담은 인터페이스 (드물게는 추상클래스) 를 사용했다. 이러한 함수객체를 만드는 주요 수단은 익명클래스였다.
public void test1(List<String> words) {
words.sort(new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
}
전략패턴처럼, 함수 객체를 사용하는 과거 객체지향 디자인 패턴에는 익명클래스면 충분했다. 하지만 익명 클래스 방식은 코드가 너무 길다. 이 대신에 람다식을 사용할 수 있다. 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결하다.
public void test2(List<String> words) {
words.sort((s1, s2) -> Integer.compare(s1.length(), s2.length()));
}
여기서 매개변수 s1, s2 그리고 반환 값의 타입 int 는 언급이 없다. 컴파일러가 문맥을 살펴 타입을 추론한다. (상황에 따라 컴파일러가 타입을 결정하지 못할 수도 있는데, 그럴 때는 프로그래머가 직접 명시해준다) 타입을 명시해야 코드가 더 명확할 때만 제외하고는 람다의 모든 매개변수 타입은 생략한다.
위 람다 자리에 비교자 생성 메소드를 사용하면 이 코드를 더 간결하게 만들 수 있다.
public void test3(List<String> words) {
words.sort(Comparator.comparingInt(String::length));
}
이전 챕터에서 작성했던 Operation enum 클래스도 아래와 같이 수정해볼 수 있다.
public enum Operation {
PLUS("+", (x, y) -> x + y),
MINUS("-", (x, y) -> x - y),
TIMES("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
@Override public String toString() { return symbol; }
public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
위에서 사용된 DoubleBinaryOperator 은 java.util.function 에서 제공하는 다양한 함수형 인터페이스 중 하나로, double 타입 인수 2 개를 받아 double 타입 결과를 리턴한다.
람다는 매우 편리하지만, 이는 이름이 없고 문서화할 수 없다는 단점이 있다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.
아이템 43. 람다보다는 메소드 참조를 사용하라
메소드 참조는 람다보다도 더 간결하다. 람다는 가끔 매개변수가 하는 일은 크게 없이 공간만 많이 차지할 때가 있다. 이럴 때는 메소드 참조로 코드를 제거할 수 있다. 람다로 할 수 없는 일이라면 메소드 참조로도 할 수 없다. 메소드 참조를 사용하면 기능을 잘 드러내는 이름을 지어줄 수 있고, 설명을 문서로 남길 수도 있다. IDE 들은 람다를 메소들 참조로 대체할 것을 권할 것이다. 그런데 때로는 람다가 메소드 참조보다 더 간결할 때도 있다. (예를 들어 클래스 이름이나 메소드 이름이 너무 길 때)
메소드 참조에는 다섯 가지 유형이 잇다. 가장 흔한 유형은 Integer::parseInt 처럼 정적 메소드를 가리키는 메소드 참조다. 그 다음으로는, 인스턴스 메소드를 참조하는 유형이 두 가지가 있다. 그 중 하나는 수신객체를 특정하는 한정적 인스턴스 메소드 참조, 다른 하나는 수신 객체를 특정하지 않는 비한정적 인스턴스 메소드 참조다. 한정적 참조는 정적 참조와 비슷하다. (함수 객체가 받는 인수와 참조되는 메소드가 받는 인수가 똑같다) 비한정적 참조에서는 함수 객체를 적용하는 시점에 수신 객체를 알려준다. 비한정적 참조는 주로 스트림 파이프라인에서의 매핑과 필터 함수에 사용된다. 그 다음으로는 클래스 생성자를 가리키는 메소드 참조, 배열 생성자를 가리키는 메소드 참조가 있다.
메소드 참조 유형 | 예 | 같은 기능을 하는 람다 |
정적 | Integer::parseInt | str -> Integer.parseInt(str) |
한정적 (인스턴스) | Instant.now()::isAfter | Instant then = Instant.now(); t -> then.isAfter(t); |
비한정적 (인스턴스) | String::toLowerCase | str -> str.toLowerCase() |
클래스 생성자 | TreeMap<K,V>::new | () -> new TreeMap<K,V>() |
배열 생성자 | int[]::new | len -> new int[len] |
아이템 44. 표준 함수형 인터페이스를 사용하라
java.util.function 패키지에는 총 43 개의 인터페이스가 담겨있다. 그 중 기본 인터페이스 6 개만 기억해두면 편하다. Operator 인터페이스는 인수가 1 개인 UnaryOperator, 2 개인 BinaryOperator 로 나뉘며, 반환값과 인수의 타입이 같은 함수를 뜻한다. Predicate 인터페이스는 인수 하나를 받아 boolean 을 반환하는 함수를 뜻하며, Function 인터페이스는 인수와 반환 타입이 다른 함수를 뜻한다. Supplier 인터페이스는 인수를 받지 않고 값을 반환하는 함수를, Consumer 인터페이스는 인수를 하나 받고 반환값은 없는 함수를 말한다.
인터페이스 | 함수 시그니처 | 예 |
UnaryOperator<T> | T apply(T t) | String::toLowerCase |
BinaryOperator<T> | T apply(T t1, T t2) | BigInteger::add |
Predicate<T> | boolean test(T t) | Collection::isEmpty |
Function<T, R> | R apply(T t) | Arrays::asList |
Supplier<T> | T get() | Instant::now |
Consumer<T> | void accept(T t) | System.out::println |
기본 인터페이스는 기본 타입인 int, long, double 용으로 각 3 개씩 변형이 생겨난다. (e.g., int 를 받는 Predicate 는 IntPredicate, long 을 반환하는 BinaryOperator 은 LongBinaryOperator) 이 변형들 중 유일하게 Function 의 변형만 매개변수화 한다. (e.g., LongFunction<int[]> 는 long 인수를 받아 int[] 를 반환) Function 인터페이스에는 기본 타입을 반환하는 변형이 총 9 개가 더 있다. 인수와 같은 타입을 반환하는 함수는 UnaryOperator 이므로, Function 인터페이스의 변형은 입력과 결과의 타입이 항상 다르다. 입력과 결과 타입이 모두 기본 타입이면 접두어로 SrcToResult 를 사용한다. (e.g., long 을 받아 int 를 반환하면 LongToIntFunction (총 6 개)) 또, 입력이 객체 참조이고 결과가 int, long, double 인 변형들도 있는데 이는 ToResult 접두어를 사용한다. (e.g., ToLongFunction<int[]> 는 int[] 를 받아 long 을 반환) 또, 기본 함수형 인터페이스 중 3 개에는 인수를 2 개씩 받는 변형이 있다. BiPredicate<T, U>, BiFunction<T, U, R>, BiConsumer<T, U> 다. (이 외에도 몇 개 더 있다)
대부분의 상황에서는 위와 같은 표준 함수형 인터페이스를 사용할 수 있다. 하지만, 예를 들어 매개변수 3 개를 받는 상황이라던지, 검사 예외를 던지는 경우에는 직접 함수형 인터페이스를 작성해야할 때가 있다. 이럴 때에는 항상 @FunctionalInterface 애노테이션을 통해 컴파일 단계에서 검사를 하도록 한다. (또, 이것이 람다용으로 설계됨을 쉽게 보여줄 수 있으며, 그 결과로 유지보수 과정에서 실수로 메소드를 추가하지 않도록 방지할 수 있다)
아이템 45. 스트림은 주의해서 사용하라
스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 말하며, 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다. 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있다. 각 중간 연산은 스트림을 어떠한 방식으로 변환한다. 중간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데, 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도, 다를 수도 있다. 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가한다.
스트림 파이프라인은 지연 평가된다. 평가는 종단 연산이 호출될 때 이루어지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 이러한 지연평가가 무한 스트림을 다룰 수 있게끔 해주는 열쇠이다.
다음은 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력한다. (아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말한다)
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);
}
}
for (Set<String> group : groups.values()) {
if (group.size() >= minGroupSize) {
System.out.println(group.size() + " : " + group);
}
}
}
public static String alphabetize(String s) {
char[] a= s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
computeIfAbsent 메소드는 맵 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환한다. 키가 없으면 건네진 함수 객체를 키에 적용하여 값을 계산해낸 다음 그 키와 값을 매핑해놓고, 계산된 값을 반환한다. 이제 위 코드와 같은 일을 하지만 스트림을 과하게 사용하는 예를 살펴본다.
private void test2(String[] args) {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(Collectors.groupingBy(
word -> word.chars().sorted().collect(
StringBuilder::new, (sb, c) -> sb.append((char) c), StringBuilder::append)
.toString()
)).values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
} catch (Exception e) {}
}
코드가 짧아지기는 했지만, 이해하기는 더 어려워졌다. 스트림을 적당히 사용하면 위 두 메소드의 절충안이 된다.
private void test3(String[] args) {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(Collectors.groupingBy(word -> alphabetize(word)))
.values()
.stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + " : " + g));
} catch (Exception e) {}
}
훨씬 이해하기 쉬워졌다. 위 스트림의 파이프라인에는 중간연산은 없으며, 종단 연산에서는 모든 단어를 수집해 맵으로 모은다. 이 맵은 단어들을 아나그램끼리 묶어둔 것으로, 앞선 두 프로그램이 생성한 맵과 실질적으로 동일하다. (alphabetize 메소드도 스트림으로 처리할 수 있지만, char 값들을 처리할 때는 스트림을 사용하지 않는 편이 좋다고 한다)
스트림 파이프라인은 되풀이되는 계산을 함수 객체로 표현한다. (주로 람다는 메소드참조) 그런데 함수 객체로는 할 수 없지만, 코드 블록으로는 할 수 있는 일들이 있다. 코드 블록에서는 범위 안의 지역변수를 읽고 수정하는 것이 가능하지만, 람다에서는 final 이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 것은 불가능하다. 또, 코드 블록에서는 return 문을 사용해 메소드를 빠져나가거나 break, continue 를 사용하여 블록 바깥의 반복문을 종료하거나 반복을 건너뛰거나, 메소드 선언에 명시된 검사 예외를 던질 수 있지만 람다에서는 전부 불가능하다.
스트림으로 처리하기 어려운 일도 있다. 대표적인 예로, 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기 어려운 경우다. (스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조) 다음은 처음 20 개의 메르센 소수를 출력하는 프로그램이다. (2**p - 1 형태의 수) 여기서 p 가 소수이면 해당 메르센 수도 소수일 수 있는데, 이 때의 수를 메르센 소수라고 한다.
public static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
public static void test1() {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
위 코드는 무한스트림을 반환하는 메소드를 사용하며, 처음 20 개의 메르센 소수를 출력한다. 여기에서 각 메르센 소수의 앞에 지수(p)를 출력하기를 원한다고 가정한다. 이 값은 초기 스트림에만 나타나므로 결과를 출력하는 종단 연산에서는 접근할 수 없다. 하지만, 다행히 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해 메르세 수의 지수를 쉽게 계산할 수 있다. (지수는 단순히 숫자를 이진수로 표현한 다음 몇 비트인지 세면 나온다)
public static void test1() {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(mp -> System.out.println(mp.bitLength()));
}
flatMap 을 사용하여 for 문을 스트림으로 바꿔볼 수도 있다. flatMap 은 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음, 그 스트림들을 다시 하나의 스트림으로 합친다.
public static List<Card> cardGenerate() {
return Stream.of(Mark.values())
.flatMap(mark ->
IntStream.range(1, 14)
.mapToObj(num -> new Card(mark, num)))
.collect(Collectors.toList());
}
private static class Card {
private Mark mark;
private int num;
Card(Mark mark, int num) {
this.mark = mark;
this.num = num;
}
@Override public String toString() {
return mark.name() + " : " + String.valueOf(num);
}
}
private enum Mark { SPACE, DIAMOND, HEART, CLOVER }
아이템 46. 스트림에서는 부작용 없는 함수를 사용하라
스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다. 이 때 각 변환 단계는 가능한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다. 순수 함수란 오직 입력만이 결과에 영향을 주는 함수를 말한다. 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다. 이렇게 하려면 스트림 연산에 건네는 함수 객체는 모두 부작용이 없어야 한다.
public void test1() throws Exception {
Map<String, Long> freq = new HashMap<>();
File file = new File("test");
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
}
위 코드는 스트림 코드라고 할 수 없다. 이 코드의 모든 작업이 종단 연산인 forEach 에서 일어나는데, 이 때 외부 상태를 수정하는 람다를 실행하고 있다. (forEach 가 그저 스트림이 수행한 연산 결과를 보여주는 일 이상을 하는 것은 좋지 않다) 이를 아래와 같이 수정한다.
public void test2() throws Exception {
Map<String, Long> freq;
File file = new File("test");
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words.collect(Collectors.groupingBy(String::toLowerCase, Collectors.counting()));
}
}
위 코드는 Collector 을 사용하는데, 스트림을 사용하려면 꼭 배워야한다. (java.util.Collectors 클래스에는 무려 39 개의 메소드가 있다) 콜렉터를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다. toList(), toSet(), toCollection(collectionFactory) 가 그 주인공들이다.
public void test3() throws Exception {
Map<String, Long> freq = new HashMap<>();
List<String> topTen = freq.keySet()
.stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(Collectors.toList());
}
위 코드는 빈도표에서 가장 흔한 단어 10 개를 뽑아낸다.
이 외의 다른 Collectors 의 메소드들도 알아본다.
가장 간단한 맵 수집기 toMap(keyMapper, valueMappper) 가 있다. 스트림 원소를 키에 매핑하는 함수와, 값에 매핑하는 함수를 인수로 받는다.
public void test4() throws Exception {
final Map<String, Operation> stringToEnum =
Stream.of(Operation.values()).collect(
toMap(Object::toString, e -> e)
);
}
더 복잡한 형태의 toMap 이나 groupingBy 는 충돌을 다루는 다양한 전략을 제공한다. toMap 에 키 매퍼와 값 매퍼는 병합 함수까지 제공할 수 있다. (병합 함수의 형태는 BinaryOperator<U> 이며, 여기서 U 는 해당 맵의 값 타입이다. 같은 키를 공유하는 값들은 이 병합 함수를 사용해 기존 값에 합쳐진다. 예를 들어 병합 함수가 곱셈이라면 키가 같은 모든 값을 곱한 결과를 얻는다) 인수 3 개를 받는 toMap 은 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들 때 유용하다. (e.g., 다양한 음악가의 앨범들을 담은 스트림을 가지고, 음악가와 그 음악가의 베스트 앨범을 연관짓는 코드)
public class Artist {
private String name;
public Artist(String name) {
this.name = name;
}
@Override
public String toString() {
return name;
}
}
public class Album {
private Artist artist;
private int sales;
public Album(Artist artist, int sales) {
this.artist = artist;
this.sales = sales;
}
public Artist artist() {
return artist;
}
public int sales() {
return sales;
}
@Override
public String toString() {
return Integer.toString(sales);
}
}
public static void main(String[] args) {
Artist a = new Artist("a");
Artist b = new Artist("b");
Artist c = new Artist("c");
List<Album> albums = List.of(
new Album(a, 21),
new Album(a, 11),
new Album(a, 30),
new Album(a, 9),
new Album(a, 49),
new Album(b, 37),
new Album(b, 12),
new Album(c, 45),
new Album(c, 65),
new Album(c, 28)
);
Map<Artist, Album> topHits = albums.stream().collect(
toMap(Album::artist,
album -> album,
maxBy(comparing(Album::sales))
)
);
System.out.println(topHits);
}
위에서는 비교자로 BinaryOperator 에서 정적 임포트한 maxBy 라는 정적 팩토리메소드를 사용한다. maxBy 는 Comparator<T> 를 입력받아 BinaryOperator<T> 를 돌려준다.
마지막 toMap 은 네 개의 인자를 받는데, 위에 추가로 맵 팩토리를 받는 것이다. 이 인수로는 EnumMap 이나 TreeMap 처럼 원하는 특정 맵 구현체를 지정할 수 있다. (이 세 가지 toMap 에 변종이 있다. toConcurrentMap 메소드로, 병렬 실행된 후 결과로 ConcurrentHashMap 인스턴스를 제공한다)
그 다음으로는 groupingBy 에 대해 알아본다. 이 메소드는 입력으로 분류함수를 받고, 출력으로는 원소들을 카테고리 별로 모아놓은 맵을 담은 수집기를 반환한다. 가장 간단한 groupingBy 는 분류 함수 하나만 받는다. 반환된 맵에 담긴 각각의 값은 해당 카테고리에 속하는 원소들을 담은 리스트다.
private void test3(String[] args) {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(Collectors.groupingBy(word -> alphabetize(word)))
.values()
.stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + " : " + g));
} catch (Exception e) {}
}
groupingBy 가 리스트가 아닌 다른 값을 갖는 맵을 생성하게 하려면, 분류함수와 함께 다운스트림 수집기도 명시해야 한다. 다운스트림 수집기의 역할은 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 일이다. 이 매개변수를 사용하는 가장 간단한 방법은 toSet() 을 이용하는 것이다. toSet() 대신에 toCollection(collectionFactory) 를 사용할 수도 있다. 원하는 컬렉션 타입을 선택할 수 있다. 다운스트림으로 couting() 을 건네는 방법도 있다. 이렇게 하면 각 카테고리(키)를 해당 카테고리에 속하는 원소의 개수와 매핑한 맵을 얻는다.
public void test2() throws Exception {
Map<String, Long> freq;
File file = new File("test");
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words.collect(Collectors.groupingBy(String::toLowerCase, Collectors.counting()));
}
}
groupingBy 의 세 번째 버전은 다운스트림 수집기에 더해 맵 팩토리도 지정하는 것이다. (이상의 총 3 가지 groupingBy 에 각각 대응하는 groupingByConcurrent 메소드들도 있다. ConcurrentHashMap 을 반환한다) 또, 많이 쓰이지는 않지만 분류함수 자리에 Predicate 를 받고 키가 Boolean 인 맵을 반환하는 partitioningBy 도 있다.
Stream 인터페이스의 min, max 메소드를 살짝 일반화한 java.util.function.BinaryOperator 의 minBy, maxBy 메소드도 알아본다. 이들은 인수로 받은 비교자를 이용해 스트림에서 가장 값이 작은/큰 원소를 찾아 반환한다.
마지막으로 Collectors 의 joining 은 문자열 등의 CharSequence 에만 사용할 수 있다. 이는 단순히 원소들을 연결해준다. 이 때, deimiter 을 받아서 구분문자로 사용할 수도, prefix 와 suffix 를 받아 접두, 접미 문자를 붙여주는 것도 가능하다.
아이템 47. 반환 타입으로는 스트림보다 컬렉션이 낫다
스트림은 Iteration 을 지원하지 않는다. 따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다. API 를 스트림만 반환하도록 짜놓으면 반환된 스트림을 for-each 로 반복하길 원하는 사용자는 불만을 갖게될 것이다. 그런데 Stream 인터페이스는 사실 iterable 인터페이스가 정의한 추상 메소드를 전부 포함할 뿐만 아니라, Iterable 인터페이스가 정의한 방식대로 동작한다. 그럼에도 Stream 은 Iterable 을 확장하지 않아서 for-each 로 활용할 수가 없다. 다음과 같은 코드로 for 문에 넣어볼 수 있긴 하다.
public static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
public void test1() {
Stream<BigInteger> s = primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20);
for (BigInteger i : (Iterable<? extends BigInteger>) s::iterator) {
System.out.println(i);
}
}
하지만 위 코드는 너무 난잡하고 직관성이 떨어진다. 따로 어댑터 메소드를 만들어서 사용하면 더 직관적이다.
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
그런데 Iterable 만 반환하면 이를 스트림 파이프라인에서 처리하려는 사용자가 불만을 갖게된다. 자바는 이를 위한 어댑터도 제공하지 않지만, 손쉽게 구현 가능하다.
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
Collection 인터페이스는 Iterable 의 하위타입이고 stream 메소드도 제공한다. 따라서 원소 시퀀스를 반환하는 공개 API 의 반환타입에는 Collection 이나 그 하위타입을 쓰는게 일반적으로 최선이다. 하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안된다. 반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구성하는 방안을 검토한다. 예를 들어, 주어진 집합의 멱집합 (한 집한의 모든 부분집한을 원소로 하는 집합) 을 반환하는 상황에서 원소 개수가 n 개면 멱집한의 원소 개수는 2 ** n 개가 된다. 그러니 멱집합을 표준 컬렉션 구현체에 저장하려는 생각은 위험하다. 이 때 AbstractList 를 이용하면 훌륭한 전용 컬렉션을 구현할 수 있다.
public class PowerSet {
public static void main(String[] args) {
Set<Integer> param = new HashSet<>();
param.add(1);
param.add(2);
param.add(3);
AbstractList<Set<Integer>> eg = (AbstractList<Set<Integer>>) PowerSet.of(param);
Set<Integer> first = eg.get(6);
System.out.println(first);
}
public static final <E> Collection<Set<E>> of(Set<E> s) {
List<E> src = new ArrayList<>(s);
if (src.size() > 30) throw new IllegalArgumentException("TOO MANY ELEMENTS");
return new AbstractList<Set<E>>() {
@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;
}
@Override
public int size() {
return 1 << src.size();
}
@Override
public boolean contains(Object o) {
return o instanceof Set && src.containsAll((Set) o);
}
};
}
}
입력리스트의 연속적인 부분리스트를 모두 반환하는 메소드도 작성해본다. 필요한 부분리스트를 만들어 표준 컬렉션에 담는 코드는 짧게 작성할 수 있지만, 이는 입력 리스트 크기의 거듭제곱만큼 메모리를 차지한다. 그렇다고 멱집합 클래스처럼 전용 컬렉션을 구현하는 것은 귀찮고 지루하다. 하지만 입력 리스트의 모든 부분리스트를 스트림으로 구현하기는 어렵지 않다. 첫 번째 원소를 포함하는 부분리스트를 그 리스트의 prefix, 마지막 원소를 포함하는 부분리스트를 suffix 라고 한다. 어떤 리스트의 부분리스트는 단순히 그 리스트의 프리픽스의 서픽스에 빈 리스트 하나만 추가하면 된다고 한다.
public class SubLists {
public static <E> Stream<List<E>> of(List<E> list) {
return Stream.concat(Stream.of(Collections.emptyList()), prefixes(list).flatMap(SubLists::suffixes));
}
private static <E> Stream<List<E>> prefixes(List<E> list) {
return IntStream.rangeClosed(1, list.size())
.mapToObj(end -> list.subList(0, end));
}
private static <E> Stream<List<E>> suffixes(List<E> list) {
return IntStream.range(0, list.size())
.mapToObj(start -> list.subList(start, list.size()));
}
}
Stream.concat 메소드는 반환되는 스트림에 빈 리스트를 추가, flatMap 메소드는 모든 프리픽스의 모든 서픽스로 구성된 하나의 스트림을 만든다. 프리픽스들과 서픽스들의 스트림은 IntStream.range, IntStream.rangeClosed 가 반환하는 연속된 정숫값들을 매핑해 만든다.
아이템 48. 스트림 병렬화는 주의해서 적용하라
아이템 45 에서 다루었던 메르센 소수를 생성하는 메소드를 다시 살펴본다. 해당 코드를 실행하면 즉각 소수를 찍기 시작해 약 10 초 이내에모든 결과가 나온다. 만약 속도를 높이고 싶어 스트림 파이프라인의 parallel() 을 호출하게 되면, 이 프로그램은 아무것도 출력하지 못하며 CPU 는 90% 이상 잡아먹는 상태가 무한히 계속된다. 이는 해당 파이프라인을 병렬화하는 방법을 찾지 못했기 때문이다. 환경이 아무리 좋더라도 데이터 소스가 Stream.iterate 거나 중간 연산으로 limit 을 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다. 스트림 파이프라인을 마구잡이로 병렬화하면 성능이 오히려 끔찍하게 나빠질 수 있다.
대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap 의 인스턴스이거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다. 이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋다는 특징이 있다. 또, 위 자료구조들은 원소들을 순차적으로 실행할 때의 참조 지역성이 뛰어나다. (이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻) 참조들이 가리키는 실제 객체가 메모리에서 서로 떨어져 있을 수 있는데, 그러면 참조 지역성이 나빠진다. 참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 대부분의 시간을 멍하니 보내게 된다. (따라서 참조지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용한다. 참조지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다)
스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 준다. 종단 연산 중 병렬화에 가장 적합한 것은 Reduction 이며, Stream 의 reduce 메소드 중 하나 혹은 min, max, count, sum 과 같이 완성된 형태로 제공되는 메소드 중 하나를 선택해 수행한다. anyMatch, allMatch, noneMatch 처럼 조건에 맞으면 바로 반환되는 메소드도 병렬화에 적합하다. 반면, 가변 축소를 수행하는 stream 의 collect 메소드는 병렬화에 적합하지 않다.
아래와 같은 코드를 병렬화해보고, 시간을 비교해본다.
public class Item48Test {
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
static long piParallel(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
public static void main(String[] args) {
long start1 = System.currentTimeMillis();
long res1 = pi(100000000L);
long end1 = System.currentTimeMillis();
System.out.printf("TIME SPENT FOR pi : %d%n", end1-start1);
long start2 = System.currentTimeMillis();
long res2 = piParallel(100000000L);
long end2 = System.currentTimeMillis();
System.out.printf("TIME SPENT FOR piParallel : %d%n", end2-start2);
}
}
Reference:
Effective Java
'Backend > Effective Java' 카테고리의 다른 글
Effective Java CH9: 일반적인 프로그래밍 원칙 (0) | 2023.07.30 |
---|---|
Effective Java CH8: 메소드 (0) | 2023.07.28 |
Effective Java CH6: 열거타입과 애노테이션 (0) | 2023.07.23 |
Effective Java CH5: 제네릭 (0) | 2023.06.27 |
Effective Java CH4: 클래스와 인터페이스 (0) | 2023.06.25 |