NullPointerException в Collectors.toMap при наличии нулевых значений в записях
Вопрос: 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 ответ(ов)
Вам нужно заменить вызов 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
значениями и дублирующими ключами.
Вот несколько более простых способов собрать коллекцию, чем предложенный @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
значениями в конечной карте и делает код более дружелюбным к обработке.
Да, поздно отвечаю, но думаю, это поможет понять, что происходит "под капотом", если кто-то захочет реализовать другую логику 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:
Реализовал идею Холгера ниже, добавил метод тестирования.
Извините, что поднимаю старый вопрос, но поскольку его недавно отредактировали и указали, что "проблема" все еще актуальна в 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
как значение при поиске идентификатора, для которого нет ответа в карте.
Надеюсь, это будет понятно всем.
Если значение является строкой, то это может сработать:
map.entrySet().stream().collect(Collectors.toMap(e -> e.getKey(), e -> Optional.ofNullable(e.getValue()).orElse("")))
В этом коде мы используем поток stream()
для обработки множества пар "ключ-значение" из map
. Мы собираем их обратно в карту, при этом для каждого значения проверяем, не является ли оно null
, и заменяем его на пустую строку, если это так. Это позволит избежать NullPointerException
при работе с null
-значениями в вашей карте.
Java 8: Преобразование List<V> в Map<K, V>
Почему поле с @Autowired в Spring оказывается null?
Найти первый элемент по предикату
Есть ли краткий способ итерации по стриму с индексами в Java 8?
Java 8 Iterable.forEach() против цикла foreach: что выбрать?