Что не так с коллекциями в Java и почему Guava не поможет +11





Вступление


Название поста действительно «холиварное», но на мой взгляд и у 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.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (36):

  1. UbuRus
    /#21426486

    В очередной раз в интернете не разобрались в Kotlin коллекциях:


    val list: List<Int> = listOf(1, 2, 3)
    val mutableList: MutableList<Int> = list as MutableList<Int>

    Дока на listOf:


    Returns a new read-only list of given elements.

    read-only это не immutable, а интерфейс который запрещает изменять, реализация же может быть основана на mutable коллекциях, никаких гарантий на это не давали.
    Как и нету гарантий что в следующем релизе этот код упадет еще на касте list as MutableList<Int>.


    На практике никаких проблем это не создает, т.к. пользовательском коде нету смысла кастатить листы к мутабальным листам. Работаем поверх интерфейсов и получаем ожидаемое поведение.

    • GerrAlt
      /#21426578

      Я не знаю нюансов Kotlin, но мне кажется что как раз об этом речь в статье: если я пишу метод, указываю в сигнатуре в параметрах List то у меня нет никакой возможности быть уверенным что мне не дадут read-only List. По правилам SOLID я в такую ситуацию не должен попадать.

      • UbuRus
        /#21426606

        Вам могут передать любой объект, который удовлетворяет интерфейсу List, т.к. MutableList: List, то в метод под видом List могут передать мутабальную реализацию. В методе естественно вам будут недоступны мутирущие методы. Принцип подстановки Лисков не нарушен. А вот когда происходит каст из List в MutableList — это уже нарушение принципа, т.к. разработчик полагается не на стабильный интерфейс, а на деталь реализации.

        • kirekov
          /#21427124

          Тут проблема не в listOf, он просто для примера. Дело в самой организации интерфейсов


          val mutableList: MutableList<Int> = ...
          // какие-то действия с изменяемым списком
          ...
          startJobInSeparateThread(mutableList)    // принимает List<Int>
          mutableList.clear()                      // непредвиденное изменение списка

          Суть в том, что если метод принимает на вход List<Int> ожидается, что список неизменяемый. Но по факту получается так, что мы можем передать мутабельную сущность и, как в данном примере, изменить ее, хотя по сути список вроде как был read-only.
          Я согласен с тем, что чаще всего вышеописанного не произойдет, но чаще всего != всегда. При полном же разделении интерфейсов такого даже теоретически случиться не может. Конечно, при условии, что мы не будем изменять объект с помощью рефлексии, но это уже другая история.


          А вообще, в этой статье человек лучше меня объяснил этот феномен)

          • UbuRus
            /#21427166

            Тут проблема не в listOf, он просто для примера. Дело в самой организации интерфейсов

            Ну пример прям плохой тогда выбран.


            Суть в том, что если метод принимает на вход List<Int> ожидается, что список неизменяемый.

            Вообще нет, нигде в Kotlin этого не обещают. Он read-only, не immutable. Это значит что ожидается что метод принимающий List<Int> не будет изменять лист, а только читать.


            Я согласен с тем, что чаще всего вышеописанного не произойдет, но чаще всего != всегда. При полном же разделении интерфейсов такого даже теоретически случиться не может.

            Ничто не мешает реализации правильно-разделенного интерфейса мутировать данные скрытые за интерфейсом:


            interface ImmutableList<T> {
                fun get(index: Int): T
            }
            
            class HabrImmutableList<T> : ImmutableList<T> {
                private val list = mutableListOf<T>()
                override fun get(index: Int) = list[index]
                // Only for testing! :)
                fun add(element: T) = list.add(element)
            }

            Так что теоретически этот метод не спасает, да и спасать не нужно, все и так работает.

          • CyberSoft
            /#21427646

            А вообще, в этой статье человек лучше меня объяснил этот феномен)
            Неизменяемых коллекций в Java не будет – ни сейчас, ни когда-либо

            Где-то на просторах openjdk читал, что иметь у каждого объекта заголовок с синхронизацией расточительно и было бы неплохо переиспользовать его для других целей, в т.ч. и для флага заморозки. Правда не понятно что делать с существующим фреймворком коллекций, озвученных проблем с API это не решит. Но заголовок той статьи слишком резок, что ли..

  2. risik
    /#21426580 / +1

    Если уж на то пошло, то мутабельные коллекции должны наследоваться от иммутабельных.

    • fshp
      /#21435388

      Тогда можно в метод подсунуть мутабельную коллекцию под видом иммутабельной. И получить разное содержимое одной и той же коллекции в разных строчках метода, потому что другой поток туда что-то пишет.

  3. vba
    /#21426796

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

    А зачем все это когда есть Vavr?

    • kirekov
      /#21427148

      Если честно, я не знал про эту библиотеку, ну и начал я писать свою, соответственно, до того. К тому же, много проектов, похожих друг на друга, но это ведь не повод, чтобы не написать что-то свое)

      • witbier
        /#21427342

        А про какие библиотеки вы знали, кроме java collections и guava, прежде чем начать свою писать? Ну так, чтобы предварительно рынок исследовать, так сказать.

    • witbier
      /#21427350

      vavr хорош, но вдруг человек ещё лучше напишет?

  4. mrbaranovskyi
    /#21427128 / -1

    Я судя по всему, чего-то не понимаю, но почему не зделать так как .net?
    ICollection: IEnumerableIReadOnlyCollection: IEnumerable

  5. VGoudkov
    /#21427328

    Вы смотрели Eclipse collections — www.eclipse.org/collections? Там много интересных методов и решений.

    • witbier
      /#21427366

      Нет, он не смотрел. 146%

      • kirekov
        /#21427384

        Eclipse Collections не смотрел. В работе использую преимущественно Apache Commons и Guava. Я не сомневаюсь, что есть множество библиотек, которые так или иначе решают проблему иммутабельных коллекций в Java. Но сама проблема из Java от этого никуда не уходит.

        • webmascon
          /#21428154

          Eclipse Collections это библиотека написанная в Goldman Sachs ипотом прееданная в open source. Там много чего вкусного есть

  6. witbier
    /#21427404

    Претензии к мутирующим методам интерфейса j.u.Collection вытекают из нежелания читать джавадоки, в которых такие методы являются optional operations и могут выкинуть UnsupportedOperationException.

    • witbier
      /#21427428

      SOLIDный L также не нарушается. Опять же, надо джавадоки читать на j.u.Collection и его наследников, чтобы это понять.

    • kirekov
      /#21427436

      Тогда выходит, что любой вызов add в любой имплементации List надо оборачивать в try-catch, а то вдруг unsupported operation выскочет) То, что метод описан в джавадоке не значит, что он должен быть в интерфейсе. Думаю, именно поэтому в kotlin изменили иерархию интерфейсов коллекций, а не просто взяли то, что уже есть в java

      • witbier
        /#21427454

        Оборачивать не надо, UnsupportedOperationException unchecked же.

        > То, что метод описан в джавадоке не значит, что он должен быть в интерфейсе

        Вот тут не распарсил, можете развернуть?

        • kirekov
          /#21427466

          Я имею в виду, что раз джавадок для метода есть, это не значит, что методу место в интерфейсе. На самом деле, ему было бы место, если в java не было неизменяемых коллекций типа Collections.emptyList(). Собственно, именно это и вызывает проблемы. Мы находимся в состоянии неопределённости — этот список можно менять, или нет? Будет исключение, или нет? А то, что оно unchecked, не значит, что оно не может навредить. Потом, если это все не обернуто в try-catch уровнем выше, придётся копаться в логах и искать, в чем же причина ошибки.

          • witbier
            /#21427498

            > Collections.emptyList()… этот список можно менять, или нет?

            В который раз, читаем джавадок

            Returns an empty list (immutable).

          • witbier
            /#21427504

            > А то, что оно unchecked, не значит, что оно не может навредить

            Потенциальные NPE тоже в try-catch оборачиваете?

          • witbier
            /#21427508

            > раз джавадок для метода есть, это не значит, что методу место в интерфейсе

            Это как это?
            Метод есть, документация есть, а места нет.

            Или изначально не должно было быть ни метода, ни документации на него, ни даже места для такого метода?

  7. witbier
    /#21427494

    del

  8. maxzh83
    /#21427720

    А если уж взяли Kotlin, то почему не взяли Scala? Там свои, в том числе и иммутабельные коллекции.

    • orthanner
      /#21428534

      Хотел спросить о том же самом. Больше того: в скалке коллекции неизменяемы по умолчанию (type scala.collections.List = scala.collections.immubale.List и т. д.). Ну, в случае с Java всё понятно: надо обеспечивать обратную совместимость. Но Guava и Kotlin просто «радуют».

      • maxzh83
        /#21428544

        Guava и Kotlin вынуждены использовать коллекции java по ряду причин (из-за совместимости главным образом)

  9. Beholder
    /#21428168

    Правильный ответ: Immutable persistent collections for Kotlin

  10. tinhol
    /#21428400

    Принцип Лисков тут не при чем, нарушается принцип инкапсуляции. Детали мутабельной абстракции протекают в иммутабельную. В Котлине это нормально сделано, проблема в том, что Котлин без Java пока еще не может, нужен interop.

  11. mayorovp
    /#21428924

    Нужно не две независимых иерархии, а три: mutable, immutable и read-only.

    • Antharas
      /#21430572

      А так к слову, чем read-only от immutable чем отличаются?

      • mayorovp
        /#21430582

        Read-only означает, что объект не может быть изменен через этот интерфейс, но может быть изменен другим способом.


        Immutable означает, что объект неизменен в принципе.

  12. Shamanische
    /#21435624

    Вот как в OpenJDK 10 (и мне кажется, что в последующих версиях ничего менялось) реализовано и описано, ничего про Mutable или Immutable, просто fixed-size. Пусть не смущает ArrayList — это вложенный класс, реализующий простую обертку над массивом. Суть этого метода — предоставить доступ к элементам массива через интерфейс List, что и сказано в документации «This method acts as bridge between array-based and collection-based APIs».

        /**
         * Returns a fixed-size list backed by the specified array.  (Changes to
         * the returned list "write through" to the array.)  This method acts
         * as bridge between array-based and collection-based APIs, in
         * combination with {@link Collection#toArray}.  The returned list is
         * serializable and implements {@link RandomAccess}.
         *
         * <p>This method also provides a convenient way to create a fixed-size
         * list initialized to contain several elements:
         * <pre>
         *     List<String> stooges = Arrays.asList("Larry", "Moe", "Curly");
         * </pre>
         *
         * @param <T> the class of the objects in the array
         * @param a the array by which the list will be backed
         * @return a list view of the specified array
         */
        @SafeVarargs
        @SuppressWarnings("varargs")
        public static <T> List<T> asList(T... a) {
            return new ArrayList<>(a);
        }
    

    Следовательно, меняя элементы в полученном списке они будут заменены и в исходном массиве:
            String[] strings = new String[]{ "foo", "boo" };
    
            List<String> stringsList = Arrays.asList(strings);
    
            stringsList.set(0, "Hello");
            stringsList.set(1, "Java!");
    
            System.out.println(Arrays.toString(strings)); // выведет [Hello , Java!]
    

    А вот что касается принципов SOLID, и буквы «L», и что имплементации интерфейсов должны быть заменяемыми без нарушения корректности программы, тут хотелось бы обратить внимание, что бросаемые исключения не являются нарушением корректности программы, просто, надо делать как надо, а как не надо делать не надо)

  13. commenter
    /#21438912

    Проблема не в Java. Совсем не в ней, а в разработчиках, которые нарушают базовые принципы логичности.

    Вот код из статьи:

    List<Integer> list = Arrays.asList(21, 22, 23);

    Здесь некий статический метод возвращает известный интерфейс. Далее в статье приводятся аналогичные примеры, когда другие методы точно так же возвращают тот же самый интерфейс, то есть проблема общая. А проблемность этой общности в том, что везде авторы реализации игнорируют ряд методов из возвращаемого типа. Небольшая аналогия — на двери написано «туалет», заходишь, а там только душ. А кто вам обещал унитаз? Вот примерно так и оправдываются авторы кода из приведённых в статье примеров.

    Вторая проблема вообще до безумия глупая. Вот котлиновский код:
    val mutableList: MutableList<Int> = list as MutableList<Int>

    И оказывается, что при приведении типа к его наследнику виртуальная машина не проверяет допустимость приведения несовместимых типов! Это просто адский угар и абсолютный трэш. Разработчики котлина, оказывается, не знают, что нужно проверять совместимость типов! В аналогии с туалетом мы бы увидели такую картину — нажимаешь на смыв и всё помещение заливается нечистотами. А что, нужно было читать документацию на входе в туалет! Так что сам дурак.

    В общем вижу банальное отсутствие здравого смысла у разработчиков приведённого в примерах кода и виртуальной машины. Либо наплевательское отношение к качеству, отсутствие тестирования и т.д.