Вступление
Название поста действительно «холиварное», но на мой взгляд и у Java, и у популярной библиотеки Guava есть ряд архитектурных проблем, которые в перспективе приводят к двусмысленностям и разногласиям в команде.
Java Collections Framework и иммутабельность на доверии
Изначально Collections Framework разрабатывался без оглядки на то, что там могут появиться иммутабельные контейнеры. В интерфейсе java.util.Collection
присутствуют методы add
, clear
, remove
и.т.д. В последующих версиях добавились всеми любимые утилиты: Collections.emptyList()
, Collections.singletonList<T>(T t)
, Arrays.asList<T>(T.. values)
и прочие. Вот здесь и начинаются проблемы. Давайте рассмотрим следующий код:
List<Integer> list = Arrays.asList(21, 22, 23);
list.add(24) // О нет! UnsupportedOperationException
Как же так? Ведь интерфейс java.util.List
наследуется от java.util.Collection
, в котором точно есть метод add
. Дело в том, что Arrays.asList<T>(T... t)
возвращает имплементацию, в которой методы add
и remove
выбрасывают исключение. Здесь получается странная ситуация: метод в интерфейсе присутствует, но вызывать мы его не можем. Однако ситуация становится еще более странной, если мы попытаемся изменить значение какого-нибудь элемента.
List<Integer> list = Arrays.asList(21, 22, 23);
list.set(0, 121);
System.out.println(list); // [121, 22, 23]
В данном случае все обрабатывается без ошибок. То есть коллекция является иммутабельной лишь частично. Почему было принято такое решение, не ясно. Это может показаться надуманной проблемой, ведь если мы используем полученный список здесь и сейчас, то мы точно знаем, что пытаться добавлять в него новые элементы не стоит. Однако давайте рассмотрим другую ситуацию.
List<Integer> filtered = filter(List<Integer> list);
Метод filter
принимает интерфейс List<Integer>
и возвращает тот же тип. Можем ли мы изменять переданный список? Откуда мы знаем, какая имплементация была передана? Писать try {} catch(Exception e) {}
на каждый вызов add
или remove
— не лучший вариант. А какой список вернул метод? Новый объект или измененный старый? Если мы изменим list
, повлияет ли это на filtered
? Простейший фрагмент кода уже породил такое огромное количество неясностей. Я лично видел в коде проекта два подобных метода, написанных разными разработчиками. Один из них менял переданный список и возвращал тот же самый инстанс, второй — создавал новый. Получается, что, написав в одном месте list.clear()
, ничего бы не произошло, в другом же это могло повлиять на дальнейшую работоспособность системы. Подобная двусмысленность нарушает один из принципов SOLID, а именно букву L — принцип подстановки Барбары Лисков, который утверждает, что имплементации интерфейсов должны быть заменяемыми без нарушения корректности программы. Если же мы не уверены, выбросит ли исключение вызов метода add
или нет, принцип не соблюдается.
Кто-то может заметить, что в данном случае можно использовать Stream API
. Это правда, стримы в Java хороши, к тому же, они не меняют переданные коллекции, а лишь создают новые. Но ведь это не отменяет той проблемы, которая есть, а лишь скрывает ее.
Самое печальное то, что корень всех зол проистекает не из java.util.Collection
, а из java.util.Iterable
, который является предком для Collection
. Iterable
определяет метод, возвращающий Iterator
, который позволяет писать элегантные for-each циклы. Давайте посмотрим на код этого интерфейса.
public interface Iterator<E> {
boolean hasNext();
E next();
default void remove() {
throw new UnsupportedOperationException("remove");
}
default void forEachRemaining(Consumer<? super E> action) {
Objects.requireNonNull(action);
while (hasNext())
action.accept(next());
}
}
Здесь присутствует метод remove
. То есть, если я захочу объявить итерируемый класс с возможностью его обхода по for-each, который не предполагает удаления элементов, мне придется мириться с тем, что в его публичном API будет метод, который не должен быть вызван, так как в моей имплементации он всегда выбрасывает исключение. По моему мнению ООП и строго-типизированные языки были придуманы как раз для того, чтобы ограничить набор функций, доступных пользователю, но сейчас выходит так, что разработчики языка Java пытаются добавить иммутабельные сущности там, где они по определению не могут быть.
Guava и иммутабельность на доверии
Но что насчет Guava? Рассмотрим один из классов этой библиотеки — ImmutableList
.
ImmutableList<String> people = ImmutableList.of("Michael", "Simon", "John")
...
Неужели проблема решена? Ведь даже сам класс имеет в названии слово «Immutable». Но дело в том, что ImmutableList
имплементирует java.util.List
, а значит обладает методами add
, clear
и.т.д. Да, они выбрасывают исключения. Да, они определены как final
. Но проблема здесь остается та же самая: мы не знаем и главное не должны знать, какая имплементация была передана на List
или Collection
. Конечно, можно условиться во всем проекте ссылаться непосредственно на ImmutableList
, что в теории устранит двусмысленность, ведь никто не будет пытаться изменять объект, который буквально называется как «неизменяемый список». Но в таком случае отказ от Guava приведет к необходимости огромного количества исправлений в уже работающем и оттестированном коде.
Kotlin и иммутабельность без доверия
Каково же решение данной проблемы? Что же, один из подходов был реализован JetBrains в их языке Kotlin. Вместо того, чтобы определять все методы в одном интерфейсе, мы строим иерархию, в которой мутационные методы расширяют иммутабельные интерфейсы. В упрощенном виде это выглядит так:
Подробнее объяснение того, почему такой подход может привести к проблемам, приведено в этой статье, но здесь оставлю краткий пример.
val list: List<Int> = listOf(1, 2, 3)
val mutableList: MutableList<Int> = list as MutableList<Int>
mutableList.clear() // О нет! Снова UnsupportedOperationException
Из-за того, что MutableList
расширяет List
и при этом по факту используется единственная имплементация от MutableList
, нам ничего не стоит скастовать иммутабельный список, к мутабельному. И здесь мы сталкиваемся с той же самой проблемой, которая была и в Java Collections Framework, и в Guava: метод есть, а вызывать его нельзя. Конечно, в данном случае такое поведение логично, ведь список изначально задумывался как неизменяемый. Да и в целом такой вариант лучше того, что нам предлагает Java, ведь здесь все-таки присутствует разделение интерфейсов, но, к сожалению, полностью это не решает проблемы.
Настоящая иммутабельность
Так как же быть? Отказаться от иммутабельных коллекций вовсе? Это тоже плохой подход, ведь работать с иммутабельными сущностями намного проще: их можно передавать в другие методы, сервисы и даже потоки, не опасаясь того, что их содержимое поменяется в неподходящий момент. Поэтому здесь я предлагаю свой вариант. Необходимо создать зеркальную иерархию Java Collections Framework с приставкой Immutable. Выглядеть это будет примерно так:
У изменяемых и неизменяемых коллекций не должно быть общих интерфейсов, это исключит возможность каста и связанных с этим неожиданных исключений или изменений тех объектов, которые объявлены как «Immutable». Также можно заметить, что не нужно копировать иерархию один в один. Например, нет никакого смысла в интерфейсе ImmutableQueue
, так как сама суть очереди подразумевает то, что она будет меняться. Помимо этого можно исключить и некоторые имплементации. LinkedList
имеет преимущество только при условии того, что мы будем часто добавлять или удалять элементы из него, так как это связный список (а точнее, двусвязный). ImmutableLinkedList
даст только замедление при обращении по индексу и никаких преимуществ взамен.
Кто-то может справедливо заметить, что при таком решении мы должны выбирать, с какими коллекциями работаем. И нет никакой возможности передать в функцию List
если она принимает ImmutableList
. Это правда, но это решается путем добавления методов toMutableList
и toImmutableList
для ImmutableList
и List
соответственно. Однако, помимо всего прочего, появляется огромное количество дублирующего кода. Например, надо будет реализовывать ArrayList
и ImmutableArrayList
отдельно. Здесь можно немного схитрить. Если ArrayList
уже написан, мы можем использовать композицию и применять его внутри ImmutableArrayList
, то есть ImmutableArrayList
будет являться оберткой для ArrayList
. Так как в публичном API не доступны методы для изменения содержимого коллекции, проблем это не вызовет.
Положа руку на сердце, даже в таком подходе есть камень преткновения — java.util.Iterable
. Нам придется имплементировать этот интерфейс в обоих типах коллекций, так как он предоставляет for-each цикл, который является очень удобным синтаксическим сахаром. java.util.Iterator
, как мы увидели выше, определяет метод remove
, который противоречит концепции неизменяемых сущностей. Есть несколько вариантов, решения этой проблемы.
- Добавляем новый интерфейс
ImmutableIterable
, который в свою очередь возвращаетImmutableIterator
. Для того чтобы люди им пользовались, необходимо, чтобы Java позволяла использовать for-each как сIterable
, так и сImmutableIterable
. - Пишем свой
ImmutableIterator
, которой имплементируетjava.util.Iterator
и объявляет методremove
, какfinal
, который всегда выбрасывает исключение. В этом случае придется смириться с тем, что из интерфейса иммутабельных коллекций будет доступен один неприкаянный метод. - Удаляем метод
remove
изIterator
, что поломает обратную совместимость и приведет к большому количество рефакторинга как со стороны тех, кто разрабатывает Java, так и тех, кто ее использует.
Заключение
Сложно сказать, как будет дальше развиваться Java и пойдет ли JСP на такое. Но я убежден в том, что вышеописанное решение — это правильный шаг, хоть и вызовет определенные дискуссии в Java-сообществе. Я понимаю, что затронутая мной тема спорная, поэтому готов выслушать комментарии и критику. Спасибо за внимание!
P.S.
В данный момент я как раз работаю над open-source библиотекой, которая предоставляют полностью иммутабельные Java-коллекции. Желающие могут ознакомиться по ссылке на Github.
UbuRus
В очередной раз в интернете не разобрались в Kotlin коллекциях:
Дока на listOf:
read-only это не immutable, а интерфейс который запрещает изменять, реализация же может быть основана на mutable коллекциях, никаких гарантий на это не давали.
Как и нету гарантий что в следующем релизе этот код упадет еще на касте
list as MutableList<Int>
.На практике никаких проблем это не создает, т.к. пользовательском коде нету смысла кастатить листы к мутабальным листам. Работаем поверх интерфейсов и получаем ожидаемое поведение.
GerrAlt
Я не знаю нюансов Kotlin, но мне кажется что как раз об этом речь в статье: если я пишу метод, указываю в сигнатуре в параметрах List то у меня нет никакой возможности быть уверенным что мне не дадут read-only List. По правилам SOLID я в такую ситуацию не должен попадать.
UbuRus
Вам могут передать любой объект, который удовлетворяет интерфейсу
List
, т.к. MutableList: List, то в метод под видом List могут передать мутабальную реализацию. В методе естественно вам будут недоступны мутирущие методы. Принцип подстановки Лисков не нарушен. А вот когда происходит каст из List в MutableList — это уже нарушение принципа, т.к. разработчик полагается не на стабильный интерфейс, а на деталь реализации.kirekov Автор
Тут проблема не в
listOf
, он просто для примера. Дело в самой организации интерфейсовСуть в том, что если метод принимает на вход
List<Int>
ожидается, что список неизменяемый. Но по факту получается так, что мы можем передать мутабельную сущность и, как в данном примере, изменить ее, хотя по сути список вроде как был read-only.Я согласен с тем, что чаще всего вышеописанного не произойдет, но чаще всего != всегда. При полном же разделении интерфейсов такого даже теоретически случиться не может. Конечно, при условии, что мы не будем изменять объект с помощью рефлексии, но это уже другая история.
А вообще, в этой статье человек лучше меня объяснил этот феномен)
UbuRus
Ну пример прям плохой тогда выбран.
Вообще нет, нигде в Kotlin этого не обещают. Он read-only, не immutable. Это значит что ожидается что метод принимающий
List<Int>
не будет изменять лист, а только читать.Ничто не мешает реализации правильно-разделенного интерфейса мутировать данные скрытые за интерфейсом:
Так что теоретически этот метод не спасает, да и спасать не нужно, все и так работает.
CyberSoft
Где-то на просторах openjdk читал, что иметь у каждого объекта заголовок с синхронизацией расточительно и было бы неплохо переиспользовать его для других целей, в т.ч. и для флага заморозки. Правда не понятно что делать с существующим фреймворком коллекций, озвученных проблем с API это не решит. Но заголовок той статьи слишком резок, что ли..