5

NullPointerException в Collectors.toMap при наличии нулевых значений в записях

13

Вопрос: NullPointerException при использовании Collectors.toMap с null-значениями

Я столкнулся с проблемой, когда метод Collectors.toMap бросает исключение NullPointerException, если одно из значений равно null. Мне непонятно такое поведение, ведь карты могут содержать null-значения без каких-либо проблем. Есть ли хорошая причина, по которой значения не могут быть null для Collectors.toMap?

Также, существует ли удобный способ решения данной проблемы в Java 8, или мне стоит вернуться к использованию обычного цикла for?

Вот пример моей проблемы:

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

class Answer {
    private int id;
    private Boolean answer;

    Answer() {
    }

    Answer(int id, Boolean answer) {
        this.id = id;
        this.answer = answer;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public Boolean getAnswer() {
        return answer;
    }

    public void setAnswer(Boolean answer) {
        this.answer = answer;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Answer> answerList = new ArrayList<>();

        answerList.add(new Answer(1, true));
        answerList.add(new Answer(2, true));
        answerList.add(new Answer(3, null));

        Map<Integer, Boolean> answerMap =
                answerList
                        .stream()
                        .collect(Collectors.toMap(Answer::getId, Answer::getAnswer));
    }
}

Стек вызовов:

Exception in thread "main" java.lang.NullPointerException
    at java.util.HashMap.merge(HashMap.java:1216)
    at java.util.stream.Collectors.lambda$toMap$168(Collectors.java:1320)
    ...

Эта проблема все еще сохраняется в Java 11.

5 ответ(ов)

0

Вам нужно заменить вызов Collectors.toMap() на свой собственный метод, который вы написали, чтобы избежать падения при наличии null значений. Вот как выглядит ваш код:

public static <T, K, U>
        Collector<T, ?, Map<K, U>> toMap(Function<? super T, ? extends K> keyMapper,
                Function<? super T, ? extends U> valueMapper) {
    return Collectors.collectingAndThen(
            Collectors.toList(),
            list -> {
                Map<K, U> result = new HashMap<>();
                for (T item : list) {
                    K key = keyMapper.apply(item);
                    if (result.putIfAbsent(key, valueMapper.apply(item)) != null) {
                        throw new IllegalStateException(String.format("Duplicate key %s", key));
                    }
                }
                return result;
            });
}

Эта реализация собирает элементы в список, а затем создает Map, добавляя элементы с помощью keyMapper и valueMapper. Важно, что ваш метод не вызывает исключение при наличии null значений — вместо этого он просто игнорирует их.

Чтобы использовать этотCollector, просто замените вызов стандартного Collectors.toMap() на ваш метод. Например:

Map<KeyType, ValueType> myMap = myList.stream()
        .collect(toMap(yourKeyMapper, yourValueMapper));

Таким образом, вы избежите проблем с null значениями и дублирующими ключами.

0

Вот несколько более простых способов собрать коллекцию, чем предложенный @EmmanuelTouzery. Используйте его, если хотите:

public static <T, K, U> Collector<T, ?, Map<K, U>> toMapNullFriendly(
        Function<? super T, ? extends K> keyMapper,
        Function<? super T, ? extends U> valueMapper) {
    @SuppressWarnings("unchecked")
    U none = (U) new Object();
    return Collectors.collectingAndThen(
            Collectors.<T, K, U> toMap(keyMapper,
                    valueMapper.andThen(v -> v == null ? none : v)), map -> {
                map.replaceAll((k, v) -> v == none ? null : v);
                return map;
            });
}

В этом решении мы просто заменяем null на некоторый пользовательский объект none, а затем выполняем обратную операцию в финализаторе. Это позволяет избежать проблем с null значениями в конечной карте и делает код более дружелюбным к обработке.

0

Да, поздно отвечаю, но думаю, это поможет понять, что происходит "под капотом", если кто-то захочет реализовать другую логику Collector.

Я постарался решить задачу более нативным и простым способом. Думаю, это максимально прямолинейно:

public class LambdaUtilities {

  /**
   * В отличие от {@link Collectors#toMap(Function, Function)} результат может содержать
   * значения null.
   */
  public static <T, K, U, M extends Map<K, U>> Collector<T, M, M> toMapWithNullValues(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper) {
    return toMapWithNullValues(keyMapper, valueMapper, HashMap::new);
  }

  /**
   * В отличие от {@link Collectors#toMap(Function, Function, BinaryOperator, Supplier)}
   * результат может содержать значения null.
   */
  public static <T, K, U, M extends Map<K, U>> Collector<T, M, M> toMapWithNullValues(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, Supplier<Map<K, U>> supplier) {
    return new Collector<T, M, M>() {

      @Override
      public Supplier<M> supplier() {
        return () -> {
          @SuppressWarnings("unchecked")
          M map = (M) supplier.get();
          return map;
        };
      }

      @Override
      public BiConsumer<M, T> accumulator() {
        return (map, element) -> {
          K key = keyMapper.apply(element);
          if (map.containsKey(key)) {
            throw new IllegalStateException("Дублирующийся ключ " + key);
          }
          map.put(key, valueMapper.apply(element));
        };
      }

      @Override
      public BinaryOperator<M> combiner() {
        return (left, right) -> {
          int total = left.size() + right.size();
          left.putAll(right);
          if (left.size() < total) {
            throw new IllegalStateException("Дублирующие ключи");
          }
          return left;
        };
      }

      @Override
      public Function<M, M> finisher() {
        return Function.identity();
      }

      @Override
      public Set<Collector.Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));
      }
    };
  }
}

И тесты с использованием JUnit и assertj:

@Test
public void testToMapWithNullValues() throws Exception {
  Map<Integer, Integer> result = Stream.of(1, 2, 3)
      .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null));

  assertThat(result)
      .isExactlyInstanceOf(HashMap.class)
      .hasSize(3)
      .containsEntry(1, 1)
      .containsEntry(2, null)
      .containsEntry(3, 3);
}

@Test
public void testToMapWithNullValuesWithSupplier() throws Exception {
  Map<Integer, Integer> result = Stream.of(1, 2, 3)
      .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null, LinkedHashMap::new));

  assertThat(result)
      .isExactlyInstanceOf(LinkedHashMap.class)
      .hasSize(3)
      .containsEntry(1, 1)
      .containsEntry(2, null)
      .containsEntry(3, 3);
}

@Test
public void testToMapWithNullValuesDuplicate() throws Exception {
  assertThatThrownBy(() -> Stream.of(1, 2, 3, 1)
      .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null)))
          .isExactlyInstanceOf(IllegalStateException.class)
          .hasMessage("Дублирующийся ключ 1");
}

@Test
public void testToMapWithNullValuesParallel() throws Exception {
  Map<Integer, Integer> result = Stream.of(1, 2, 3)
      .parallel() // это приведет к вызову .combiner()
      .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null));

  assertThat(result)
      .isExactlyInstanceOf(HashMap.class)
      .hasSize(3)
      .containsEntry(1, 1)
      .containsEntry(2, null)
      .containsEntry(3, 3);
}

@Test
public void testToMapWithNullValuesParallelWithDuplicates() throws Exception {
  assertThatThrownBy(() -> Stream.of(1, 2, 3, 1, 2, 3)
      .parallel() // это приведет к вызову .combiner()
      .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null)))
          .isExactlyInstanceOf(IllegalStateException.class)
          .hasCauseExactlyInstanceOf(IllegalStateException.class)
          .hasStackTraceContaining("Дублирующийся ключ");
}

И как это использовать? Просто используйте его вместо toMap(), как показано в тестах. Это делает код, который вызывает данное решение, максимально чистым.

EDIT:

Реализовал идею Холгера ниже, добавил метод тестирования.

0

Извините, что поднимаю старый вопрос, но поскольку его недавно отредактировали и указали, что "проблема" все еще актуальна в Java 11, я хотел бы обратить на это внимание:

answerList
        .stream()
        .collect(Collectors.toMap(Answer::getId, Answer::getAnswer));

Вызывает исключение NullPointerException, поскольку карта не допускает null в качестве значения. Это логично, так как, если вы ищете в карте ключ k, и его нет, то возвращаемое значение уже равно null (смотрите документацию Java). Если бы вам удалось добавить в k значение null, это выглядело бы так, будто карта ведет себя странно.

Как кто-то отметил в комментариях, это довольно легко решить, используя фильтрацию:

answerList
        .stream()
        .filter(a -> a.getAnswer() != null)
        .collect(Collectors.toMap(Answer::getId, Answer::getAnswer));

Таким образом, в карту не будут вставляться значения null, и при этом вы все равно получите null как значение при поиске идентификатора, для которого нет ответа в карте.

Надеюсь, это будет понятно всем.

0

Если значение является строкой, то это может сработать:

map.entrySet().stream().collect(Collectors.toMap(e -> e.getKey(), e -> Optional.ofNullable(e.getValue()).orElse("")))

В этом коде мы используем поток stream() для обработки множества пар "ключ-значение" из map. Мы собираем их обратно в карту, при этом для каждого значения проверяем, не является ли оно null, и заменяем его на пустую строку, если это так. Это позволит избежать NullPointerException при работе с null-значениями в вашей карте.

Чтобы ответить на вопрос, пожалуйста, войдите или зарегистрируйтесь