Как узнать, допускает ли изменения коллекция в 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 (внутренним пользователям), то различение типов сделает код намного яснее.