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

@Test
public void arrayList()
{
// Изменяемая реализация
List<String> list = new ArrayList<>();
list.add("✅"); // Работает

Assertions.assertEquals(List.of("✅"), list);
}

LinkedList

LinkedList  —  изменяемая реализация List.

@Test
public void linkedList()
{
// Изменяемая реализация
List<String> list = new LinkedList<>();
list.add("✅"); // Работает

Assertions.assertEquals(List.of("✅"), list);
}

CopyOnWriteArrayList

CopyOnWriteArrayList  —  изменяемая и потокобезопасная реализация List.

@Test
public 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.

@Test
public 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.

@Test
public void collectionsEmptyList()
{
// Неизменяемый
List<String> list = Collections.emptyList();

Assertions.assertThrows(
UnsupportedOperationException.class,
() -> list.add(0, "⛔️"));
Assertions.assertEquals(List.of(), list);
}

Collections.singletonList()

Collections.singletonList() возвращает неизменяемый синглтон List.

@Test
public 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 может быть изменяемым или неизменяемым, но если он будет неизменяемым, то представление будет излишним.

@Test
public 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, являются небезопасными и должны быть защищены разработчиком явной блокировкой.

@Test
public 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.

@Test
public 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.

@Test
public 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.

@Test
public 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 сегодня, но нет никакой гарантии, что он останется изменяемым в будущем.

@Test
public 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, который он оборачивает.

@Test
public 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. Можно ли только по коду определить, изменяемыми или неизменяемыми являются возвращаемые типы используемых методов?

@Test
public 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?

@Test
public 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?

@Test
public 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?

@Test
public 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?

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Donald Raab: How do you know if a Java Collection is Mutable or Immutable?

Предыдущая статьяПродвинутое применение «select» в Ruby
Следующая статьяПрограммирование будущего: беспилотный автомобиль, управляемый JavaScript и ИИ