Map의 콘텐츠 소비하기

Map 인터페이스에는 Iterable 인터페이스의 forEach() 메서드와 동일한 방식으로 작동하는 forEach() 메서드가 있습니다. 차이점은 이 forEach() 메서드는 단순한 Consumer 대신 BiConsumer를 인수로 받는다는 점입니다.

간단한 Map을 만들고 그 내용을 인쇄해 보겠습니다.

Map<Integer, String> map = new HashMap<>();
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
 
map.forEach((key, value) -> System.out.println(key + " :: " + value));

이 코드는 다음과 같은 결과를 생성합니다:

1 :: one
2 :: two
3 :: three

 

값 바꾸기

Map 인터페이스는 키에 바인딩된 값을 다른 값으로 대체하는 세 가지 메서드를 제공합니다.

첫 번째는 기존 값을 새 값으로 맹목적으로 대체하는 replace(key, value)입니다. 이것은 put-if-present 연산과 동일합니다. 이 메서드는 Map에서 제거된 값을 반환합니다.(맵에 키가 존재할 때만 수행됨)

보다 세밀한 제어가 필요한 경우 기존 값을 인수로 사용하는 이 메서드의 오버로드를 사용할 수 있습니다: replace(key, existingValue, newValue). 이 경우 기존 값은 새 값과 일치하는 경우에만 대체됩니다. 이 메서드는 대체가 성공하면 true를 반환합니다.

Map 인터페이스에는 BiFunction을 사용하여 Map의 모든 값을 대체하는 메서드도 있습니다. 이 BiFunction은 키와 값을 인자로 받아 기존 값을 대체할 새 값을 반환하는 리매핑 함수입니다. 이 메서드를 호출하면 내부적으로 Map의 모든 키/값 쌍에 대해 반복됩니다.

다음 예제는 이 replaceAll() 메서드를 사용하는 방법을 보여줍니다:

Map<Integer, String> map = new HashMap<>();
 
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
 
map.replaceAll((key, value) -> value.toUpperCase());
map.forEach((key, value) -> System.out.println(key + " :: " + value));

이 코드를 실행하면 다음과 같은 결과가 생성됩니다:

1 :: ONE
2 :: TWO
3 :: THREE

 

값 계산하기

Map 인터페이스는 맵에 키-값 쌍을 추가하거나 맵의 기존 값을 수정하는 세 번째 패턴을 제공합니다: compute(), computeIfPresent(), computeIfAbsent()의 메서드 형태가 그것들입니다.

이 세 가지 메서드는 다음과 같은 인수를 사용합니다:

compute()의 경우, 두 개의 인자로 리매핑 bi-function이 호출됩니다. 첫 번째 인수는 키이고, 두 번째 인수는 키가 있으면 기존 값, 없으면 null입니다. 리매핑 바이함수는 널 값으로 호출할 수 있습니다.

computeIfPresent()의 경우, 해당 키에 바인딩된 값이 있고 null이 아닌 경우 리매핑 함수가 호출됩니다. 키가 널 값에 바인딩되어 있으면 리매핑 함수가 호출되지 않습니다. 리매핑 함수는 null 값으로 호출할 수 없습니다.

computeIfAbsent()의 경우 해당 키에 바인딩된 값이 없으므로 리매핑 함수는 실제로 키를 인수로 받는 단순한 Function입니다. 이 함수는 키가 Map에 존재하지 않거나 null 값에 바인딩된 경우 호출됩니다.

모든 경우에 bifunction(또는 function)가 null 값을 반환하면 해당 키에 대한 매핑이 생성되지 않고 해당 키가 Map에서 제거됩니다. 이 세 가지 방법 중 하나를 사용하여 null 값을 가진 키/값 쌍을 Map에 넣을 수는 없습니다.

모든 경우에 반환되는 값은 Map에서 해당 키에 바인딩된 새 값 또는 리매핑 함수가 null을 반환한 경우 null입니다. 이 의미는 put() 메서드와는 다르다는 점을 지적할 필요가 있습니다. put() 메서드는 이전 값을 반환하는 반면, compute() 메서드는 새 값을 반환합니다.

computeIfAbsent() 메서드의 매우 흥미로운 사용 사례는 List를 값으로 하는 Map을 생성하는 것입니다. 다음과 같은 문자열 List가 있다고 가정해 보겠습니다: [one two three four five six seven]입니다. 여기서 키는 해당 목록의 단어 길이이고 값은 이 단어들의 목록인 Map을 만들어야 합니다. 생성해야 하는 것은 다음과 같은 Map입니다:

3 :: [one, two, six]
4 :: [four, five]
5 :: [three, seven]

compute() 메서드가 없으면 이렇게 작성할 수 있습니다:

List<String> strings = List.of("one", "two", "three", "four", "five", "six", "seven");
Map<Integer, List<String>> map = new HashMap<>();
for (String word: strings) {
    int length = word.length();
    if (!map.containsKey(length)) {
        map.put(length, new ArrayList<>());
    }
    map.get(length).add(word);
}
 
map.forEach((key, value) -> System.out.println(key + " :: " + value));

이 코드를 실행하면 예상되는 결과가 생성됩니다:

3 :: [one, two, six]
4 :: [four, five]
5 :: [three, seven]

참고로, putIfAbsent()를 사용하여 이 반복을 단순화할 수 있습니다:

for (String word: strings) {
    int length = word.length();
    map.putIfAbsent(length, new ArrayList<>());
    map.get(length).add(word);
}

하지만 computeIfAbsent()를 사용하면 이 코드를 더욱 개선할 수 있습니다:

for (String word: strings) {
    int length = word.length();
    map.computeIfAbsent(length, key -> new ArrayList<>())
       .add(word);
}

이 코드는 어떻게 작동하나요?

  • 키가 Map에 없으면 Mapping 함수가 호출되어 빈 List를 생성합니다. 이 List는 computeIfAbsent() 메서드에 의해 반환됩니다. 이 목록은 코드가 word를 추가한 빈 목록입니다.
  • 키가 Map에 있으면 매핑 함수가 호출되지 않고 해당 키에 바인딩된 현재 값이 반환됩니다. 이것은 word를 추가해야 하는 부분적으로 채워진 List입니다.

이 코드는 필요한 경우에만 빈 목록이 생성되기 때문에 putIfAbsent() 코드보다 훨씬 효율적입니다. putIfAbsent() 호출에는 키가 Map에 없는 경우에만 사용되는 기존 빈 목록이 필요합니다. Map에 추가하는 객체를 필요에 따라 생성해야 하는 경우에는 computeIfAbsent()putIfAbsent() 보다 우선적으로 사용해야 합니다.

 

값 병합하기

Map에 다른 값의 집계인 값이 있는 경우 computeIfAbsent() 패턴이 잘 작동합니다. 하지만 이 집계를 지원하는 구조에는 변경 가능해야 한다는 제한이 있습니다. 이는 ArrayList의 경우이며, 작성하신 코드가 ArrayList에 값을 추가하는 것과 같은 역할을 합니다.

단어의 List를 만드는 대신 단어의 연결을 만들어야 한다고 가정해 보겠습니다. 여기서 String 클래스는 다른 문자열의 집합으로 보이지만 변경 가능한 컨테이너가 아니므로 computeIfAbsent() 패턴을 사용하여 이를 수행할 수 없습니다.

이때 merge() 패턴이 구출됩니다. merge() 메서드는 세 개의 인수를 받습니다:

  • 해당 키에 바인딩해야 하는 값입니다.
  • 리매핑 BiFunction.

키가 Map에 없거나 널 값에 바인딩되어 있지 않으면 해당 키에 값이 바인딩됩니다. 이 경우 리매핑 함수는 호출되지 않습니다.

반대로 키가 이미 널이 아닌 값에 바인딩되어 있는 경우 기존 값과 새 값을 인수로 전달하여 리매핑 함수가 호출됩니다. 이 리매핑 함수가 null을 반환하면 키가 Map에서 제거됩니다. 그렇지 않으면 생성된 값이 해당 키에 바인딩됩니다.

다음 예제에서 이 merge() 패턴이 실제로 작동하는 모습을 확인할 수 있습니다:

List<String> strings = List.of("one", "two", "three", "four", "five", "six", "seven");
Map<Integer, String> map = new HashMap<>();
for (String word: strings) {
    int length = word.length();
    map.merge(length, word, 
              (existingValue, newWord) -> existingValue + ", " + newWord);
}
 
map.forEach((key, value) -> System.out.println(key + " :: " + value));

이 경우 length 키가 맵에 없으면 merge() 호출은 이를 추가하고 word에 바인딩하기만 합니다. 반면에 length 키가 이미 Map에 있으면 기존 값과 word를 가지고 바이함수를 호출합니다. 그러면 바이함수의 결과가 현재 값을 대체합니다.

이 코드를 실행하면 다음과 같은 결과가 생성됩니다:

3 :: one, two, six
4 :: four, five
5 :: three, seven

두 패턴, computeIfAbsent()merge()에서 왜 이 람다의 문맥에서 항상 사용할 수 있고 해당 문맥에서 캡처할 수 있는 인수를 사용하는지 궁금할 수 있습니다. 정답은 성능상의 이유로 캡처하는 람다보다 캡처하지 않는 람다를 선호해야 한다는 것입니다.