Массивы или коллекции в Java – что выбрать для типизированных объектов?

В объектно-ориентированном программировании регулярно приходится сталкиваться с необходимостью применения однотипных классов, методов, структур хранения данных и т.п. для разных типов объектов. В ранних версиях Java эта проблема решалась через написание кода для объекта типа Object или заведомо старшего класса с последующим принудительным приведением к нужному типу. Это требовало большой аккуратности, так как при таком подходе, несмотря на отсутствие проблем на этапе компиляции, во время исполнения сложного кода с множеством обрабатываемых типов легко было получить ошибку приведения типов (ClassCastException).

Для облегчения работы программистам в решении этих проблем в Java 5 было введено понятие типизированных, или обобщённых классов — то есть, Generics. Это дало возможность писать намного более безопасный универсальный код, прописывая в каждом конкретном месте необходимые ограничения на используемые типы и интерфейсы. А среда разработки уже на стадии компиляции теперь указывает разработчику на потенциально проблемные места.

Тем не менее, из-за требований к совместимости с предыдущими версиями JDK, вся типизация, указанная в коде разработчиком, используется компилятором при создании байт-кода лишь для корректного приведения типов (а также, при необходимости, генерации дополнительных необобщенных методов, bridge-methods). А сам полученный байт-код уже выглядит практически так же, как выглядел бы для аналогичной программы, написанной «по-старинке», без применения дженериков (за редким исключением). Происходит так называемое стирание типов (types erasure) в runtime.

Одно из наиболее часто употребимых применений дженериков – типизированные коллекции List<T>. В чём их отличие от обычных массивов, кроме способности динамически расширяться?

В версиях Java младше 5-й коллекции существовали только в нетипизированном, т.е. сыром (raw) виде:

List elements = new ArrayList();

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

Записывать в такую коллекцию можно любые типы вперемешку, что, конечно, может приводить к проблемам с соответствием типов при попытке извлечения оттуда элементов для дальнейшей обработки:

Тогда, как извлечение Integer пройдёт нормально, попытка в следующей строке получить String приведёт к падению программы. В runtime будет выброшено исключение: “…java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String…”. То есть, при таком подходе, извлечение каждого элемента с приведением должно предваряться проверкой на соответствие ожидаемому типу:

Современные версии JDK продолжают поддерживать сырые типы, но предупреждают о небезопасности такого кода, выделяя проблемные участки:

Безопасность применения типизированных коллекций обеспечивается их инвариантностью. Например, хотя класс Integer и является наследником класса Number, коллекции List<Number> и List<Integer> соответствующими признаками наследования не связаны (они наследуются только от интерфейсов коллекций и класса Object). Поэтому присвоение, вроде:

компилятор делать не позволяет.

Добавить элемент-наследник в коллекцию элементов-предков можно, но извлечь без приведения оттуда можно только элемент указанного для коллекции типа, т.е. экземпляр предка:

Массивы же, в отличие от типизированных коллекций, ковариантны (covariant). Например, поскольку Integer является подтипом Number, то и массив Integer[] является подтипом массива Number[]. Среда позволяет присвоить в переменную, ссылающуюся на массив родителей, массив наследников. Т.е., на один массив Integer станут одновременно ссылаться переменные как будто бы разных типов:

Опасность кроется в том, что попытка в приведённом примере переписать значение члена Number[] несовпадающим подтипом Number приведёт к ArrayStoreException в runtime. Программа упадёт:

Здесь проявляется второе отличие массивов от коллекций: они доступны при выполнении (reified). В runtime машина всегда знает тип данных массива, и при нарушении соответствия выбрасывает ArrayStoreException, вызывая сбой программы.

Напротив, для обобщённых типов выбран другой подход: обеспечить максимальную безопасность на стадии написания кода, а всю «грязную работу» по корректному стиранию типов с расстановкой обратных приведений, и пр., отдать компилятору при генерации байт-кода. В итоге, во время выполнения программы (в runtime) вероятность получить ошибку приведения типов существенно снижается.

Вопрос: можно ли повысить безопасность использования массивов, применяя их для работы с типизированными классами?

Ниже показан сравнительный пример использования массивов и списков как параметров в обобщённых методах:

Несмотря на то, что метод paramArray обобщённый, из-за несовпадения концепций массивов и обобщённого программирования компилятор пропустил потенциально небезопасную операцию присвоения. Это произошло из-за того, что после процедуры стирания тип T преобразовался в Object. А поскольку и String, и Integer являются потомками Object, помещение их в один массив не противоречит принципу ковариантности.

Здесь стоит отметить разницу между массивами и varargs (аргументов переменной длины) в роли параметров:

При попытке передать vararg неизвестного типа компилятор предупреждает о возможном heap pollution, “загрязнении” передаваемого набора значений посторонними типами. Это связано с тем, что, хотя внутри метода поступившие значения представляются в форме массива, на момент его формирования уже произошло стирание типов. И этот массив по сути перестаёт быть reifiable, т.е. утрачивает возможность контроля типов в runtime.

Исходя из небезопасности сочетания массивов и дженериков, Java запрещает прямое создание массивов обобщённых типов:

И хотя существует способ обойти такой запрет через создание и инициализацию массива «некоего» типа <?> с приведением к нужному, практика использования таких конструкций чревата теми же проблемами с падением программы из-за ошибки приведения типов:

Пользуясь ковариантностью массивов, в примере удалось беспрепятственно получить ссылку на один из членов массива из переменной типа Object[] (так как List[] ковариантен Object[]), и потом подменить его на ArrayList<String>. (На самом деле, вместо списка мог быть какой угодно объект!).

В окне отладчика хорошо виден факт стирания типов, все ArrayList присутствуют в сыром виде.

Если вместо массива списков в примере выше использовать список списков, попытка сходным путём получить доступ к одному из списков с целью его дальнейшей подмены с трудом удаётся только через сырой тип List. С трудом, так как компилятор и в этом случае выдаст предупреждение о непроверяемой операции (unchecked call):

Попытки же обойти ограничения, используя даже типы <?> или <Object>, сразу пресекаются компилятором.

Java допускает ковариантность и для обобщённых типов коллекций, но только с явного разрешения разработчика. Для этого предусмотрен специальный синтаксис — wildcard с указанием ограничений:

List<? extends someType> –  тип объектов ограничен типом someType и его наследниками

List<? super someType> – тип объектов ограничен типом someType и его предками

Например, если Type2 наследуется от Type1, то список List<? extends Type1> ковариантен c List<Type2>:

Чаще всего такие списки выступают параметрами обобщённых методов. Из-за того, что типы конкретных элементов в подобных списках заранее неизвестны, работа с ними имеет свою специфику.

Можно инициализировать такой список при создании, наполнив соответственно переменными-наследниками и переменными-предками разного типа:

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

Обычно списки такого рода используются как параметры методов, поля классов. Это повышает гибкость программы, позволяя одним и тем же кодом обрабатывать коллекцию не одного, а нескольких типов, обладающих общими признаками наследственности:

Списки вида List<? extends someType> имеют не совсем очевидные особенности. Оказывается, что извлечь из такого списка можно только объект с типом someType или его предка, а добавить нельзя вообще ничего, кроме null.

Это объясняется тем, что список с таким синтаксисом компилятор рассматривает как список элементов заранее неизвестного типа-наследника Number. Тогда, без риска промахнуться, присвоить значение из списка можно только переменной типа Number или его предка (в данном случае, единственный предок это Object).

Добавить же в такой список нельзя ничего, кроме null, так как предугадать, каким из потомков Number он реально типизирован, компилятор заранее не может. А добавлять предка (в данном случае, Object в список чисел) также неуместно. Фактически, список List<? extends someType> представляет собой коллекцию только для чтения.

Теперь рассмотрим ситуацию со списком типа List<? super someType> :

Компилятор видит, что это список Number или какого-то из его предков. То есть, гарантированно безопасно в этот список можно добавить только объект класса Number или его потомка (Integer, Double и т.п.), но никак не предка Number, который не сможет быть приведён к Number.

Извлечь же безопасно можно только Number, воспользовавшись приведением. Как видно в примере, попытка получить Integer (наследника Number) привела к ClassCastException, так как по запрошенному индексу находился элемент, определённый компилятором как Double.

Таким образом, список типа List<? super someType> выступает в роли потребителя объектов типа someType или его наследников. Такой список будет удобно использовать в универсальном методе, оперирующем внутри себя коллекцией someType или какого-то из его наследников, и/или возвращающем такую коллекцию.

Стоит отметить, что, хотя рассмотренные виды списков и позволяют помещать в них объекты разных типов одновременно, желательно всячески избегать таких «коктейлей» в практической работе. Универсальность структур должна повышать лаконичность кода, расширяя концепцию полиморфизма, но не снижать при этом его безопасность.

Принимая во внимание вышеописанные свойства обобщённых типов с синтаксисом «wildcard с ограничениями», был сформулирован принцип PECS (Producer Extends Consumer Super): структура, функционирующая как «поставщик», использует в синтаксисе extends, а структура-«потребитель» — super . Например, метод copy в классе java.util.Collections выглядит так:

    public static <T> void copy(List<? super T> dest, List<? extends T> src) { … }

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

Чтобы гарантировать безопасную работу с обобщёнными коллекциями Java, строго рекомендуется использовать их только с указанием типа. Даже в том случае, если о будущем типе заранее ничего не известно, лучше указывать тип <Object> или <?> (wildcard) чтобы компиляция в байт-код проходила по безопасному сценарию. А продолжающуюся поддержку сырых типов надо воспринимать исключительно как дань совместимости с младшими версиями JDK, так как написанное на них программное обеспечение используется до сих пор.

Антон Максимов, после окончания базового курса, июнь 2023