Пишу эту статью, чтобы помочь Java-разработчикам понять, почему необходимо различать интерфейсы изменяемых (mutable) и неизменяемых (immutable) коллекций. Java — на редкость эффективный язык программирования с почти 30-летней историей. Java Collections Framework (JCF) — одна из наиболее активно используемых частей стандартной библиотеки Java — сыграл важную роль в успешном развитии языка. Сегодня Java продолжает совершенствоваться в соответствии с новыми требованиями, оставаясь в ряду лучших языков программирования. Однако, как и во многих других начинаниях, прошлые успехи не являются гарантией будущих достижений.
Мастика для натирки полов или начинка для десерта?
Долгое время в языке Java отдавалось предпочтение изменяемости в интерфейсах коллекций. После выхода Java 8 язык и библиотеки Java начали медленно, но неуклонно двигаться в сторону неизменяемости коллекций и других типов.
К сожалению, этот переход к неизменяемости происходил путем добавления новых реализаций и использования старых интерфейсов JCF, таких как Collection
, List
, Set
и Map
. Эти интерфейсы всегда были “условно” изменяемыми. Однако в Javadoc у изменяющих методов интерфейсов (таких как add
и remove
) нет никакой гарантии изменяемости.
Выбрасывание 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-разработчиков, которым нужен гибридный фреймворк коллекций с четким различением интерфейсов изменяемых и неизменяемых коллекций, есть выбор следующих вариантов:
- Scala Collections.
- Kotlin + ImmutableCollections.
- 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 (внутренним пользователям), то различение типов сделает код намного яснее.
Читайте также:
- Обнаружение и предотвращение утечек памяти в Java
- Сложные вопросы на собеседовании для тех, кто 7 лет работал с Java. Часть 2
- 3 способа мониторинга изменений лог-файлов в Java
Читайте нас в Telegram, VK и Дзен
Перевод статьи Donald Raab: How do you know if a Java Collection is Mutable or Immutable?