Java
February 3

Как узнать, допускает ли изменения коллекция в 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-разработчиков, которым нужен гибридный фреймворк коллекций с четким различением интерфейсов изменяемых и неизменяемых коллекций, есть выбор следующих вариантов:

  1. Scala Collections.
  2. Kotlin + ImmutableCollections.
  3. Eclipse Collections.

Дизайн с различением типов

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

Источник