Как узнать, допускает ли изменения коллекция в Java?
Пишу эту статью, чтобы помочь Java-разработчикам понять, почему необходимо различать интерфейсы изменяемых (mutable) и неизменяемых (immutable) коллекций. Java — на редкость эффективный язык программирования с почти 30-летней историей. Java Collections Framework (JCF) — одна из наиболее активно используемых частей стандартной библиотеки Java — сыграл важную роль в успешном развитии языка. Сегодня Java продолжает совершенствоваться в соответствии с новыми требованиями, оставаясь в ряду лучших языков программирования. Однако, как и во многих других начинаниях, прошлые успехи не являются гарантией будущих достижений.
Мастика для натирки полов или начинка для десерта?
Долгое время в языке Java отдавалось предпочтение изменяемости в интерфейсах коллекций. После выхода Java 8 язык и библиотеки Java начали медленно, но неуклонно двигаться в сторону неизменяемости коллекций и других типов.
К сожалению, этот переход к неизменяемости происходил путем добавления новых реализаций и использования старых интерфейсов JCF, таких как Collection, List, Set и Map. Эти интерфейсы всегда были “условно” изменяемыми. Однако в Javadoc у изменяющих методов интерфейсов (таких как add и remove) нет никакой гарантии изменяемости.
Фрагмент Javadoc из метода add в интерфейсе Collection
Выбрасывание UnsupportedOperationException в ответ на изменяющий метод — это неудачный способ сообщить, что Collection Java может быть как воском для пола, так и начинкой для десерта. Использование Collection в качестве то ли десертной начинки, то ли чистящего средства может оказаться небезопасным. Но вы не узнаете об этом, пока не попробуете.
Какое же влияние оказал этот неоднозначный подход к дизайну на стандартную библиотеку Java за прошедшие годы? Рассмотрим несколько изменяемых и неизменяемых реализаций интерфейса List в стандартных библиотеках Java.
Приведенные ниже примеры кода создают экземпляры List, используя различные варианты реализации. Я укажу, какие из них являются изменяемыми, а какие — неизменяемыми. При этом заметьте: все они имеют один тип интерфейса — List.
ArrayList
ArrayList — изменяемая реализация List.
@Testpublic void arrayList(){ // Изменяемая реализация List<String> list = new ArrayList<>(); list.add(""); // Работает Assertions.assertEquals(List.of(""), list);}LinkedList
LinkedList — изменяемая реализация List.
@Testpublic void linkedList(){ // Изменяемая реализация List<String> list = new LinkedList<>(); list.add(""); // Работает Assertions.assertEquals(List.of(""), list);}CopyOnWriteArrayList
CopyOnWriteArrayList — изменяемая и потокобезопасная реализация List.
@Testpublic void copyOnWriteArrayList(){ // Изменяемая реализация List<String> list = new CopyOnWriteArrayList<>(); list.add(""); // Работает Assertions.assertEquals(List.of(""), list);}Arrays.asList()
Arrays.asList() возвращает изменяемую, но нерасширяемую реализацию List. Это означает, что можно задавать элементам List различные значения, но нельзя применять add или remove к List. Такая реализация List похожа на массив Java.
@Testpublic void arraysAsList(){ // Изменяемая, но нерасширяемая реализация List<String> list = Arrays.asList(""); list.set(0, ""); // Работает Assertions.assertThrows( UnsupportedOperationException.class, () -> list.add(0, "")); Assertions.assertEquals(List.of(""), list);}Collections.emptyList()
Collections.emptyList() возвращает неизменяемый пустой List.
@Testpublic void collectionsEmptyList(){ // Неизменяемый List<String> list = Collections.emptyList(); Assertions.assertThrows( UnsupportedOperationException.class, () -> list.add(0, "")); Assertions.assertEquals(List.of(), list);}Collections.singletonList()
Collections.singletonList() возвращает неизменяемый синглтон List.
@Testpublic void collectionsSingletonList(){ // Неизменяемый List<String> list = Collections.singletonList(""); Assertions.assertThrows( UnsupportedOperationException.class, () -> list.add("")); Assertions.assertEquals(List.of(""), list);}Collections.unmodifiableList(List)
Collections.unmodifiableList() возвращает “немодифицированное представление” List, но оборачиваемый им List можно модифицировать отдельно, как показано в приведенном ниже коде. Оборачиваемый List может быть изменяемым или неизменяемым, но если он будет неизменяемым, то представление будет излишним.
@Testpublic void collectionsUnmodifiableList(){ // Изменяемый List<String> arrayList = new ArrayList<>(); arrayList.add(""); // "Неизменяемый" (но arrayList все еще изменяем) List<String> list = Collections.unmodifiableList(arrayList); Assertions.assertThrows( UnsupportedOperationException.class, () -> list.add("")); Assertions.assertEquals(List.of(""), list); arrayList.add(""); Assertions.assertEquals(List.of("", ""), list);}Collections.synchronizedList(List)
Collections.synchronizedList возвращает “условно потокобезопасный” экземпляр List. Под “условно потокобезопасным” подразумевается, что такие методы, как iterator, stream и parallelStream, являются небезопасными и должны быть защищены разработчиком явной блокировкой.
@Testpublic void collectionsSynchronizedList(){ // Изменяемый List<String> arrayList = new ArrayList<>(); arrayList.add(""); // Изменяемый и "условно потокобезопасный" экземпляр List<String> list = Collections.synchronizedList(arrayList); Assertions.assertEquals(List.of(""), list); list.add(""); Assertions.assertEquals(List.of("", ""), list);}List.of()
List.of() возвращает неизменяемый List.
@Testpublic void listOf(){ // Неизменяемый List<String> list = List.of(""); Assertions.assertThrows( UnsupportedOperationException.class, () -> list.add("")); Assertions.assertEquals(List.of(""), list);}List.copyOf(List)
List.copyOf() копирует содержимое другого List и возвращает неизменяемый List.
@Testpublic void listCopyOf(){ // Неизменяемый List<String> list = List.copyOf(new ArrayList<>(List.of(""))); Assertions.assertThrows( UnsupportedOperationException.class, () -> list.add("")); Assertions.assertEquals(List.of(""), list);}Stream.toList()
Stream.toList() возвращает неизменяемый List.
@Testpublic void streamToList(){ // Неизменяемый List<String> list = Stream.of("").toList(); Assertions.assertThrows( UnsupportedOperationException.class, () -> list.add("")); Assertions.assertEquals(List.of(""), list);}Stream.collect(Collectors.toList())
Stream.collect(Collectors.toList()) вернет изменяемый List сегодня, но нет никакой гарантии, что он останется изменяемым в будущем.
@Testpublic void streamCollectCollectorsToList(){ // Изменяемый List<String> list = Stream.of("") .collect(Collectors.toList()); list.add(""); Assertions.assertEquals(List.of("", ""), list);}Stream.collect(Collectors.toUnmodifiableList())
Stream.collect(Collectors.toUnmodifiableList()) вернет сегодня немодифицируемый List, который фактически является неизменяемым, поскольку нельзя легко получить указатель на изменяемый List, который он оборачивает.
@Testpublic void streamCollectCollectorsToUnmodifiableList(){ // Изменяемый List<String> list = Stream.of("") .collect(Collectors.toUnmodifiableList());Assertions.assertThrows( UnsupportedOperationException.class, () -> list.add("")); Assertions.assertEquals(List.of(""), list);}List имеет множество потенциальных реализаций, при этом у вас нет возможности определить, какими они являются — изменяемыми или неизменяемыми.
Выбор Хобсона в дизайне
ВЫБОР ХОБСОНА — КАЖУЩАЯСЯ СВОБОДА ВЫБОРА ПРИ ОТСУТСТВИИ РЕАЛЬНОЙ АЛЬТЕРНАТИВЫ.
На протяжении последних 25 лет Java Collections Framework предпочитал простоту и минимализм в дизайне, основанном на иерархии наследования. JCF был чрезвычайно успешен, по крайней мере, по одному из критериев успеха: за последние 25 лет миллионы разработчиков смогли изучить и использовать JCF для создания полезных приложений. Это огромное достижение в истории Java.
Простой дизайн JCF позволяет разработчикам легко освоить четыре основных типа Collection. Эти типы, названные Collection, List, Set и Map, доступны начиная с JDK 1.2. Большинство разработчиков могут быстро освоить работу с коллекциями, изучив эти базовые типы.
В изменяемом мире эти и несколько других типов (таких как bag, sorted и ordered) удовлетворили бы большинство повседневных потребностей Java-разработчиков. Однако сейчас мы живем в гибридном мире, где изменяемость и неизменяемость должны сосуществовать. И если мы хотим, чтобы код доходил до будущих разработчиков в лучшем виде, необходимо различать изменяемые и неизменяемые типы.
Пространство List: расширение API с помощью Stream
С помощью Java Collections Framework и библиотеки Java Stream я продемонстрирую, как выглядит использование List без различения типов. Посмотрите на типы результатов filter, map и collect. Можно ли только по коду определить, изменяемыми или неизменяемыми являются возвращаемые типы используемых методов?
@Testpublic void landOfTheList(){ var mapping = Map.of("", "Leaves", "", "Leaf", "", "Pie", "", "Turkey"); // Изменяемый или неизменяемый? List<String> november = Arrays.asList("", "", "", ""); // Изменяемый или неизменяемый? List<String> filter = november.stream() .filter(""::equals) .toList(); // Изменяемый или неизменяемый? List<String> filterNot = november.stream() .filter(Predicate.not(""::equals)) .collect(Collectors.toList()); // Изменяемый или неизменяемый? List<String> map = november.stream() .map(mapping::get) .collect(Collectors.toUnmodifiableList()); // Изменяемый или неизменяемый? Map<Boolean, List<String>> partition = november.stream() .collect(Collectors.partitioningBy(""::equals)); // Изменяемый или неизменяемый? Map<String, List<String>> groupBy = november.stream() .collect(Collectors.groupingBy(mapping::get)); // Изменяемый или неизменяемый? Map<String, Long> countBy = november.stream() .collect(Collectors.groupingBy(mapping::get, Collectors.counting())); Assertions.assertEquals(List.of(""), filter); Assertions.assertEquals(List.of("", "", ""), filterNot); Assertions.assertEquals(List.of("Leaves", "Leaf", "Pie", "Turkey"), map); Assertions.assertEquals(filter, partition.get(true)); Assertions.assertEquals(filterNot, partition.get(false)); var expectedGroupBy = Map.of("Leaves", List.of(""), "Leaf", List.of(""), "Pie", List.of(""), "Turkey", List.of("")); Assertions.assertEquals(expectedGroupBy, groupBy); Assertions.assertEquals( Map.of("Leaves", 1L, "Leaf", 1L, "Pie", 1L, "Turkey", 1L), countBy);}При таком подходе к дизайну наиболее безопасным решением будет не доверять никому и создавать копии коллекций, прежде чем работать с ними. Такое решение чревато дополнительными затратами.
Ничто в этом мире не бывает бесплатным. Сегодня у Java-разработчиков, которым нужен гибридный фреймворк коллекций с четким различением интерфейсов изменяемых и неизменяемых коллекций, есть выбор следующих вариантов:
Дизайн с различением типов
Представьте себе мир Java, в котором существует три вида интерфейсов коллекций, обеспечивающих четкое разграничение типов на читаемые (Readable), изменяемые (Mutable) и неизменяемые (Immutable). В Eclipse Collections используются интерфейсы именно с такими названиями.
- Readable:
RichIterable,ListIterable,SetIterableиMapIterable. - Mutable:
MutableCollection,MutableList,MutableSetиMutableMap. - Immutable:
ImmutableCollection,ImmutableList,ImmutableSetиImmutableMap.
Утроение общего числа типов, необходимое для обеспечения их различия, означает, что разработчику потребуется больше времени на изучение таких фреймворков, как Scala Collections, Kotlin Collections и Eclipse Collections. По временным затратам это равносильно переходу от среднего к высшему образованию.
Посмотрим на примере Eclipse Collections, как на практике выглядит подобное различение типов и как оно, наряду с функциональным и “текучим” API, приводит к широкому использованию ковариантных типов возвращаемых значений. Стоит заметить, что ковариантные типы возвращаемых значений (Covariant Return Types) — замечательная возможность, доступная с Java 5.
Различение типов в Eclipse Collections
Приведенные ниже примеры построены на основе демонстрации, использованной в разделе “Пространство List” (“Land of List”). Они позволят сосредоточиться на двух основных типах коллекций Java — List и Set. Вы увидите примеры использования как изменяемых, так и неизменяемых типов с различением их в Eclipse Collections. В примерах будут рассмотрены MutableList, MutableSet, ImmutableList и ImmutableSet и в каждом случае будут показаны различаемые ковариантные типы возвращаемых значений при применении следующих методов.
select(также известный какfilter) — возвращает типRichIterable.reject(также известный какfilterNot) — возвращает типRichIterable.collect(также известный какmap) — возвращает типRichIterable.partition(также известный какpartitioningBy) — возвращает типPartitionIterable.groupBy(также известный какgroupingBy) — возвращает типMultimap.countBy(также известный какgroupingBy+counting) — возвращает типBag.
Нас будет интересовать один вопрос: изменяемыми или неизменяемыми будут типы возвращаемых значений при применении этих методов.
MutableList
Типы возвращаемых значений при применении методов в MutableList являются ковариантными переопределениями родительского интерфейса RichIterable. Изменяемыми или неизменяемыми будут типы возвращаемых значений при применении методов в MutableList?
@Testpublic void covariantReturnTypesMutableList(){ var mapping = Maps.immutable.of("", "Leaves", "", "Leaf", "", "Pie", "", "Turkey"); // Изменяемый или неизменяемый? MutableList<String> november = Lists.mutable.of("", "", "", ""); // Изменяемый или неизменяемый? MutableList<String> select = november.select(""::equals); // Изменяемый или неизменяемый? MutableList<String> reject = november.reject(""::equals); // Изменяемый или неизменяемый? MutableList<String> collect = november.collect(mapping::get); // Изменяемый или неизменяемый? PartitionMutableList<String> partition = november.partition(""::equals); // Изменяемый или неизменяемый? MutableListMultimap<String, String> groupBy = november.groupBy(mapping::get); // Изменяемый или неизменяемый? MutableBag<String> countBy = november.countBy(mapping::get); Assertions.assertEquals(List.of(""), select); Assertions.assertEquals(List.of("", "", ""), reject); Assertions.assertEquals( List.of("Leaves", "Leaf", "Pie", "Turkey"), collect); Assertions.assertEquals(select, partition.getSelected()); Assertions.assertEquals(reject, partition.getRejected()); var expectedGroupBy = Multimaps.mutable.list.with("Leaves", "", "Leaf", "", "Pie", "") .withKeyMultiValues("Turkey", ""); Assertions.assertEquals(expectedGroupBy, groupBy); Assertions.assertEquals( Bags.mutable.of("Leaves", "Leaf", "Pie", "Turkey"), countBy);}ImmutableList
Типы возвращаемых значений при применении методов в ImmutableList являются ковариантными переопределениями родительского интерфейса RichIterable. Изменяемыми или неизменяемыми будут типы возвращаемых значений при применении методов в ImmutableList?
@Testpublic void covariantReturnTypesImmutableList(){ var mapping = Maps.immutable.of("", "Leaves", "", "Leaf", "", "Pie", "", "Turkey"); // Изменяемый или неизменяемый? ImmutableList<String> november = Lists.immutable.of("", "", "", ""); // Изменяемый или неизменяемый? ImmutableList<String> select = november.select(""::equals); // Изменяемый или неизменяемый? ImmutableList<String> reject = november.reject(""::equals); // Изменяемый или неизменяемый? ImmutableList<String> collect = november.collect(mapping::get); // Изменяемый или неизменяемый? PartitionImmutableList<String> partition = november.partition(""::equals); // Изменяемый или неизменяемый? ImmutableListMultimap<String, String> groupBy = november.groupBy(mapping::get); // Изменяемый или неизменяемый? ImmutableBag<String> countBy = november.countBy(mapping::get); Assertions.assertEquals(List.of(""), select); Assertions.assertEquals(List.of("", "", ""), reject); Assertions.assertEquals( List.of("Leaves", "Leaf", "Pie", "Turkey"), collect); Assertions.assertEquals(select, partition.getSelected()); Assertions.assertEquals(reject, partition.getRejected()); var expectedGroupBy = Multimaps.mutable.list.with("Leaves", "", "Leaf", "", "Pie", "") .withKeyMultiValues("Turkey", "").toImmutable(); Assertions.assertEquals(expectedGroupBy, groupBy); Assertions.assertEquals( Bags.immutable.of("Leaves", "Leaf", "Pie", "Turkey"), countBy);}MutableSet
Типы возвращаемых значений при применении методов в MutableSet являются ковариантными переопределениями родительского интерфейса RichIterable. Изменяемыми или неизменяемыми будут типы возвращаемых значений при применении методов в MutableSet?
@Testpublic void covariantReturnTypesMutableSet(){ var mapping = Maps.immutable.of("", "Leaves", "", "Leaf", "", "Pie", "", "Turkey"); // Изменяемый или неизменяемый? MutableSet<String> november = Sets.mutable.of("", "", "", ""); // Изменяемый или неизменяемый? MutableSet<String> select = november.select(""::equals); // Изменяемый или неизменяемый? MutableSet<String> reject = november.reject(""::equals); // Изменяемый или неизменяемый? MutableSet<String> collect = november.collect(mapping::get); // Изменяемый или неизменяемый? PartitionMutableSet<String> partition = november.partition(""::equals); // Изменяемый или неизменяемый? MutableSetMultimap<String, String> groupBy = november.groupBy(mapping::get); // Изменяемый или неизменяемый? MutableBag<String> countBy = november.countBy(mapping::get); Assertions.assertEquals(Set.of(""), select); Assertions.assertEquals(Set.of("", "", ""), reject); Assertions.assertEquals( Set.of("Leaves", "Leaf", "Pie", "Turkey"), collect); Assertions.assertEquals(select, partition.getSelected()); Assertions.assertEquals(reject, partition.getRejected()); var expectedGroupBy = Multimaps.mutable.set.with("Leaves", "", "Leaf", "", "Pie", "") .withKeyMultiValues("Turkey", ""); Assertions.assertEquals(expectedGroupBy, groupBy); Assertions.assertEquals( Bags.mutable.of("Leaves", "Leaf", "Pie", "Turkey"), countBy);}ImmutableSet
Типы возвращаемых значений при применении методов в ImmutableSet являются ковариантными переопределениями родительского интерфейса RichIterable. Изменяемыми или неизменяемыми будут типы возвращаемых значений при применении методов в ImmutableSet?
@Testpublic void covariantReturnTypesImmutableSet(){ var mapping = Maps.immutable.of("", "Leaves", "", "Leaf", "", "Pie", "", "Turkey"); // Изменяемый или неизменяемый? ImmutableSet<String> november = Sets.immutable.of("", "", "", ""); // Изменяемый или неизменяемый? ImmutableSet<String> select = november.select(""::equals); // Изменяемый или неизменяемый? ImmutableSet<String> reject = november.reject(""::equals); // Изменяемый или неизменяемый? ImmutableSet<String> collect = november.collect(mapping::get); // Изменяемый или неизменяемый? PartitionImmutableSet<String> partition = november.partition(""::equals); // Изменяемый или неизменяемый? ImmutableSetMultimap<String, String> groupBy = november.groupBy(mapping::get); // Изменяемый или неизменяемый? ImmutableBag<String> countBy = november.countBy(mapping::get); Assertions.assertEquals(Set.of(""), select); Assertions.assertEquals(Set.of("", "", ""), reject); Assertions.assertEquals(Set.of("Leaves", "Leaf", "Pie", "Turkey"), collect); Assertions.assertEquals(select, partition.getSelected()); Assertions.assertEquals(reject, partition.getRejected()); var expectedGroupBy = Multimaps.mutable.set.with("Leaves", "", "Leaf", "", "Pie", "") .withKeyMultiValues("Turkey", "").toImmutable(); Assertions.assertEquals(expectedGroupBy, groupBy); Assertions.assertEquals(Bags.immutable.of("Leaves", "Leaf", "Pie", "Turkey"), countBy);}Доверяй, но проверяй: внутренние и внешние пользователи API
Одна из задач различения типов заключается в более четком донесении ваших намерений до разработчиков, которые будут читать и использовать код. Дело в том, что недобросовестные разработчики могут предоставить изменяемую реализацию неизменяемого интерфейса или, наоборот, неизменяемую реализацию изменяемого интерфейса. Оба варианта действительно возможны. Одно из решений, доступных сегодня в Java, — использование запечатанных типов (sealed) для ограничения альтернатив реализации в дизайне с иерархической системой наследования.
Если вы не доверяете разработчикам, использующим ваш API (например, неизвестным внешним пользователям), то у вас единственный выход — копировать данные для входящих параметров коллекции независимо от того, какой интерфейс вам передается. Различаемые типы возвращаемых значений должны быть безопасными и при этом более четко передавать намерения.
Если же вы доверяете разработчикам, использующим ваш API (внутренним пользователям), то различение типов сделает код намного яснее.