[아이템 46] 스트림에서는 부작용 없는 함수를 사용하라

2023. 5. 30. 22:36Java/Effective Java

스트림 패러다임

  • 스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다
  • 이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수이다.
    • 순수 함수란 오직 입력만이 결과에 영향을 주는 함수를 말한다.
    • 다른 가변상태 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
  • 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)가 없어야 한다.

 

예제) 텍스트 파일에서 단어별 수를 세어 빈도표를 생성하는 코드

// 스트림 API를 적절히 사용하지 못하는 코드
public static void main(String[] args) {
    Map<String, Long> freq = new HashMap<>();
    try (Stream<String> words = new Scanner("file").tokens()) {
			  // freq.merge 외부 상태를 수정
        words.forEach(word -> freq.merge(word.toLowerCase(), 1L, Long::sum));
    }
}

// 스트림 API를 제대로 사용하는 코드
public static void main(String[] args) {
    Map<String, Long> freq = new HashMap<>();
    try (Stream<String> words = new Scanner("file").tokens()) {
        words.collect(groupingBy(String::toLowerCase, counting()));
    }
}

 

 

수집기(Collector)

  • java.util.stream.Collectors 클래스에 있는 메서드들을 사용
  • 축소(reduction) 전략을 캡슐화한 블랙박스 객체
    • 축소는 스트림의 원소들을 객체 하나에 취합한다는 뜻
  • 수집기가 생성하는 객체는 일반적으로 컬렉션이며, collector 라는 이름을 쓴다.
  • 종류
    • toList()
    • toSet()
    • toCollection(collectionFactory)

 

1) toMap

  • toMap(keyMapper, valueMapper)
    • 스트림 원소를 키에 매핑하는 함수와 값에 매핑하는 함수를 인수로 받는다.
  • toMap((keyMapper, valueMapper, mergeFunction)
    • 같은 키를 공유하는 값들은 병합 함수를 사용해 기존 값에 합쳐진다.
class Artist {
	private String name;
	private int age;

	public String getName() {
		return name;
	}

	public int getAge() {
		return age;
	}

	public Artist(String name, int age) {
		this.name = name;
		this.age = age;
	}

	@Override
	public String toString() {
		return "Artist{" +
			   "name='" + name + '\\'' +
			   ", age=" + age +
			   '}';
	}
}

class Album {
	Artist artist;
	String name;
	Integer sales;

	public Artist getArtist() {
		return artist;
	}

	public String getName() {
		return name;
	}

	public Integer getSales() {
		return sales;
	}

	public Album(Artist artist, String name, Integer sales) {
		this.artist = artist;
		this.name = name;
		this.sales = sales;
	}

	@Override
	public String toString() {
		return "Album{" +
			   "artist=" + artist +
			   ", name='" + name + '\\'' +
			   ", sales=" + sales +
			   '}';
	}
}

@Test
public void toMap_테스트() {
	List<Album> albums = new ArrayList<>();

	Artist artist = new Artist("ARTIST", 10);
	Artist artist2 = new Artist("ARTIST2", 20);
	Artist artist3 = new Artist("ARTIST3", 30);

	Album album = new Album(artist, "ALBUM", 10);
	Album album2 = new Album(artist2, "ALBUM2", 20);
	Album album3 = new Album(artist3, "ALBUM3", 30);

	albums.add(album);
	albums.add(album2);
	albums.add(album3);

	Map<Artist, String> collect = albums.stream()
										  .collect(toMap(Album::getArtist, Album::getName));

	for (Artist a : collect.keySet()) {
		System.out.println(a.toString());
	}

	-- 출력 결과 --
	Artist{name='ARTIST', age=10}
	Artist{name='ARTIST3', age=30}
	Artist{name='ARTIST2', age=20}
}

@Test
public void toMap_병합함수_테스트() {
	List<Album> albums = new ArrayList<>();

	Artist artist = new Artist("ARTIST", 10);
	Artist artist2 = new Artist("ARTIST2", 20);

	Album album = new Album(artist, "ALBUM", 10);
	Album album2 = new Album(artist2, "ALBUM2", 20);
	Album album3 = new Album(artist2, "ALBUM3", 30);
	Album album4 = new Album(artist2, "ALBUM4", 40);
	Album album5 = new Album(artist2, "ALBUM5", 50);

	albums.add(album);
	albums.add(album2);
	albums.add(album3);
	albums.add(album4);
	albums.add(album5);

	Map<Artist, Album> collect = albums.stream()
										.collect(toMap(Album::getArtist, Function.identity(), maxBy(comparing(Album::getSales))));

	for (Artist a : collect.keySet()) {
		System.out.println(collect.get(a));
	}

	-- 출력 결과 --
	Album{artist=Artist{name='ARTIST', age=10}, name='ALBUM', sales=10}
	Album{artist=Artist{name='ARTIST2', age=20}, name='ALBUM5', sales=50}
}

 

 

2) groupBy

  • 분류함수(classifier)를 입력받아서 출력으로 원소들을 분류기가 분류한 카테고리별로 모아놓은 맵을 반환한다.
// 알파벳화 결과가 같은 단어들의 리스트로 매핑하는 맵을 반환
words.collect(groupingBy(words -> alphabetize(word)))
  • 인자로 다운스트림 수집기(down stream collector)를 같이 전달해주면 원소 리스트를 다른걸로 바꿀 수 있음
// 결과로 각 카테고리에 속하는 원소의 개수를 매핑한 맵을 얻을 수 있음
words.collect(groupingBy(String::toLowerCase, counting())

 

 

3) joining

  • CharSequence의 인스턴스 스트림에만 적용 가능한 메서드로, 원소들을 연결(concat)함.
    1. joining() : 단순 concat
    2. joining(delimiter) : delimiter를 넣어서 연결
    3. joining(prefix, delimiter, suffix) : 접두문자, 구분자, 접미문자를 넣어서 연결

 

 

정리

  • 스트림파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다.
  • 스트림을 올바로 쓰려면 collector(수집기) 함수 객체를 잘 알아두고 사용하자.