Вступление


Название поста действительно «холиварное», но на мой взгляд и у Java, и у популярной библиотеки Guava есть ряд архитектурных проблем, которые в перспективе приводят к двусмысленностям и разногласиям в команде.
image


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


image


Подробнее объяснение того, почему такой подход может привести к проблемам, приведено в этой статье, но здесь оставлю краткий пример.


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. Выглядеть это будет примерно так:


image


У изменяемых и неизменяемых коллекций не должно быть общих интерфейсов, это исключит возможность каста и связанных с этим неожиданных исключений или изменений тех объектов, которые объявлены как «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, который противоречит концепции неизменяемых сущностей. Есть несколько вариантов, решения этой проблемы.


  1. Добавляем новый интерфейс ImmutableIterable, который в свою очередь возвращает ImmutableIterator. Для того чтобы люди им пользовались, необходимо, чтобы Java позволяла использовать for-each как с Iterable, так и с ImmutableIterable.
  2. Пишем свой ImmutableIterator, которой имплементирует java.util.Iterator и объявляет метод remove, как final, который всегда выбрасывает исключение. В этом случае придется смириться с тем, что из интерфейса иммутабельных коллекций будет доступен один неприкаянный метод.
  3. Удаляем метод remove из Iterator, что поломает обратную совместимость и приведет к большому количество рефакторинга как со стороны тех, кто разрабатывает Java, так и тех, кто ее использует.

Заключение


Сложно сказать, как будет дальше развиваться Java и пойдет ли JСP на такое. Но я убежден в том, что вышеописанное решение — это правильный шаг, хоть и вызовет определенные дискуссии в Java-сообществе. Я понимаю, что затронутая мной тема спорная, поэтому готов выслушать комментарии и критику. Спасибо за внимание!


P.S.


В данный момент я как раз работаю над open-source библиотекой, которая предоставляют полностью иммутабельные Java-коллекции. Желающие могут ознакомиться по ссылке на Github.