Сообщений 10 Оценка 411 Оценить |
Введение Простые generic-и Ограничения Маски Generic-и и исключения Информация о типах и generic-и Generic-и и унаследованный код Как это работает Что в generic-ах не так Заключение Источники |
Не так давно вышла очередная версия Java – Java 1.5. Эта версия включает в себя большое количество нововведений, которые, несомненно, будут оценены программистами. Среди новых возможностей языка, появившихся в этой версии, на одном из первых мест стоит поддержка обобщенного программирования, названная в Java generics. Эта возможность позволяет создавать более статически типизированный код. Соответственно, программы теперь должны стать надежнее и проще в отладке.
Самым широко распространенным языком, поддерживающим обобщенное программирование, до недавнего времени был С++. Не странно, что многие ожидали от дженерик-ов Java чего-то похожего на шаблоны C++. На деле оказалось, что различия между generic-ами Java и шаблонами С++ довольно велики. В основном generic-и в Java получились проще, чем их C++-аналог, однако они не являются упрощенной версией шаблонов C++ и имеют ряд значительных отличий. Так, в языке появилось несколько новых концепций, касающихся generic-ов – это маски и ограничения.
Признаться, от Java generic-ов я ожидал чего-то простого и интуитивного, но там встретились довольно трудные моменты. Я постарался структурировать статью по принципу «от простого к сложному». Надеюсь, она поможет читателю разобраться с generic-ами сравнительно малой кровью.
Статья не претендует на детальное рассмотрение generic-ов в Java или изучение тонкостей работы с ними. Ее цель – донести до читателя общие принципы и ввести в курс дела. За более подробной информацией на эту тему можно обраться к источниками, перечисленным в конце статьи.
В процессе написания статьи использовалась версия языка 1.5.0_01, соответственно, все, что здесь описано, касается только этой версии.
Начать работать с generic-ами в Java очень просто. Легче всего это показать на примере коллекций. Для начала сравним код без применения generic-ов и код с generic-ами. Вот пример кода без применения generic-ов:
List strList = new ArrayList(); strList.add("some text"); // ОК, хотя коллекция предназначалась для хранения строк! strList.add(new Integer(0));String str = (String)strList.get(0); // Ошибка приведения типов во время выполнения (ClassCastException) Integer i = (Integer)strList.get(0); |
Недостатки этого кода очевидны, и обидно, что язык позволял такое писать. В runtime подобный код приведет к генерации исключения ClassCastException.
А вот код с generic-ами:
List<String> strList = new ArrayList<String>(); strList.add("some text"); strList.add(new Integer()); // сообщение об ошибке компилятора String str = strList.get(0); Integer i = strList.get(0); // сообщение об ошибке компилятора |
Разница вполне очевидна, нет надобности в приведении типов, проводится проверка типов на этапе компиляции, и код стал более надежным – получить ClassCastException уже сложнее. У экземпляра ArrayList, параметризованного String, метод get() будет возвращать String, и методу put() в качестве аргумента ничего, кроме String, передать не удастся.
Объявить generic-класс совсем несложно. Вот пример такого объявления:
class GenericList<E> { E getFirst() { ... } void add(E obj) { ... } } |
Это пример generic-класса с одним параметром E, определяющим тип элементов списка, который реализует данный класс. Таких параметров может быть сколь угодно много.
Доступ к generic-параметру возможен в любом не статическом контексте параметризованного класса.
Хочется обратить внимание на такой момент:
List<String> strList = new ArrayList<String>(); // 1 List<Object> objList = strList; // 2 |
Строка 2 выдаст ошибку времени компиляции. Это может показаться неочевидным. String является наследником Object, и при приведении коллекций, казалось бы, все должно работать нормально, но на самом деле это не так. Если разрешить такое приведение, то после него можно будет добавить в List любой объект, унаследованный от Object. Такая ситуация легко может стать причиной проблем. Предположим, что у нас есть класс Car и два его наследника – SportCar и Truck. Если можно было бы преобразовать список со SportCar к списку Car, то мы бы смогли добавить туда экземпляр Truck, т.к. он является наследником Car. Получилось так, что в коллекцию с элементами одного типа мы добавили элемент другого типа, которой не является наследником первого. Это сводит на нет все преимущества generic-ов, ни о какой типобезопасности и речи быть не может (подобное поведение называется ковариантностью, ковариантность совершенно логична для неизменяемых коллекций, но, как показано выше, для изменяемых коллекций она не подходит – прим.ред.).
Если generic-классу вообще не передаются параметры, считается, что в качестве параметров переданы Object, т.е. строки 1 и 2 в следующем примере эквивалентны:
List objList = new ArrayList(); // 1 List<Object> objList1 = new ArrayList<Object>(); // 2 |
Существуют не только generic-классы, но и generic-методы. Объявление generic-метода может выглядеть так:
public static <T> T getFirst(Collection<T> col) {...} |
Параметр типа generic-метода определяется до возвращаемого значения (выделено красным). Generic-методы могут быть как статическими, так и не статическими.
Синтаксис вызова generic-метода c явным указанием параметров может показаться несколько необычным тем, кто пользовался шаблонами С++. Generic-параметр в Java ставится перед именем функции, а не после:
<Integer>swap(ints, 1, 3); strings.<Integer>zip(ints); |
Так сделано из-за того, что запись f(a<b, c>(d)) неоднозначна и может встретиться в коде не только как вызов generic-метода a, результат которого передается в функцию f.
Конструкторы также могут быть параметризованы. Синтаксис вызова и объявления такой же, как и у функций.
Параметрами типов могут быть только ссылочные типы, соответственно, примитивные типы не могут передаваться в качестве параметра.
В отличие от С++, generic-и в Java позволяют задать класс, от которого должен быть унаследован параметр generic-а, или интерфейс, который он должен реализовать. Это делается с помощью ключевого слова extends.
Предположим, у нас есть функция, которая находит ближайший к точке Glyph из заданной коллекции. Glyph – это базовый тип, и может иметься неограниченное количество потомков этого типа. Также может иметься неограниченное количество коллекций, хранящих элементы, тип которых соответствует одному из этих потомков. Хотелось бы, чтобы функция могла работать со всеми подобными коллекциями, и возвращала элемент, тип которого совпадал бы с типом элемента коллекции, а не приводился к Glyph. Возникает желание написать примерно такую функцию:
<T>T findNearest(Collection<T> glyphs, int x, int y) { ... } |
Функция выглядит неплохо, но, тем не менее, не лишена недостатков. Получается так, что функции можно передать коллекцию любого типа. Это усложняет реализацию функции, порождая необходимость проверки типа элемента. Будет гораздо лучше написать так:
<T extends Glyph>T findNearest(Collection<T> glyphs, int x, int y) {...} |
Теперь все встает на свои места – в функцию можно передать только коллекцию, элементы которой реализуют интерфейс Glyph. Generic-и сделали свое дело, код получился более типобезопасным.
Extends можно применять и для параметров generic-классов:
class <T extends Glyph> GlyphsContainter { ... publicvoid addGlyph(T glyph){...} } |
Как в методах, так и в классах можно задать более одного базового интерфейса, который должен реализовывать generic-параметр. Это делается при помощи следующего синтаксиса:
class <T extends Glyph & MoveableGlyph> MoveableGlyphsContainter { ... publicvoid addGlyph(T glyph){...} } |
Теперь generic-параметр должен реализовывать не только интерфейс Glyph, но и MoveableGlyph. Ограничений на количество интерфейсов, которые должен реализовывать переданный тип, нет. Но класс можно передать только один, т.к. в Java нет множественного наследования. Типы в этом списке могут быть generic-типами, но ни один конкретный интерфейс не может появляться в списке более одного раза, даже с разными параметрами:
interface Bar<T> {...} interface Bar1 {...} publicclass Foo<T extends Bar<T> & Bar1> {...} // okpublicclass Foo<T extends Bar<T> & Bar<Object> & Bar1> {...} // ошибка |
Маски являются еще одним отличием Java generic-ов от шаблонов С++. Они используются тогда, когда нужно абстрагироваться от конкретных аргументов типа, и позволяют использовать его там, где не требуется знать конкретные типы параметров. Например, это может быть метод, который очищает список, или метод, которому не интересен тип ключа карты (Map), т.к. он оперирует только со значениями. Приведенные ниже примеры помогут более подробно разобраться, что такое маски и для чего они нужны.
Синтаксически маска – это выражение, состоящее из символа ‘?’, и, возможно, ограничений, например, ‘? extends T’ или ‘? super T’, где T – тип.
Чаще маски применяются при описании типов параметров методов, но их также можно использовать просто в объявлении переменных в теле метода или членов класса.
Этот тип ограничения перекликается с соответствующим ограничением на параметр generic-класса или метода, т.е. тип, используемый как параметр, должен быть наследником соответствующего типа:
void drawAll(Collection<? extends Glyph> glyphs) { ... } |
В данном примере метод принимает только коллекции, элементы которых – наследники класса Glyph. Вариант без использования маски (просто с Glyph) здесь не подойдет, т.к. при этом метод будет принимать только коллекции Glyph, но не его потомков.
Хочется также заметить, что если неизвестен точный тип параметра переданного класса, то использовать его можно с ограничениями. Например, компилятор не даст вызвать метод, который принимает generic-параметр в качестве аргумента.
Хотя использование масок с ограничением extends в параметрах методов является наиболее распространенным, в этом случае вполне можно обойтись без них. Следующий код будет полностью аналогичен коду, приведенному выше:
<T extends Glyph> void drawAll(Collection<T> glyphs) { ... } |
Это условие ограничивает набор типов, которые могут быть аргументами типа, конкретным типом и его родителями. Этот тип масок наиболее сложен для понимания. Впрочем, он и используется редко.
Этот тип ограничения задается с помощью ключевого слова super. Проще всего показать применение этого типа на примере коллекций и интерфейса Comparator:
public class TreeMap<K,V> extends AbstractMap<K,V> implements SortedMap<K,V>, Cloneable, Java.io.Serializable { public TreeMap(Comparator<? super K> c) {...} ... } |
В качестве аргумента конструктора данный класс принимает Comparator, который может сравнивать всех предков K (типа ключа карты). Без введения этого типа маски невозможно бы было воспользоваться Comparator-ом. Это также позволяет получить дополнительную поддержку унаследованного кода, где Comparator-ы сравнивают объекты, приведенные к типу Object.
Кроме того, маски данного типа меньше ограничивают использование generic-класса. С ними можно вызывать функции, которые принимают в качестве параметра generic-аргумент, передавая туда тип, указанный в качестве параметра super, а также его наследников. Вот пример такого вызова, это безопасно – тип параметра точно подойдет, т.к. является предком параметра:
public static void addElement(List<? super Glyph> list) { Glyph val = new CircleGlyph(); list.add(val); } |
В качестве маски допустимо указать просто “?”. Это означает, что generic-параметр может быть любого типа и коду, который использует generic с такой маской, тип параметра не важен:
Collection<?> objs = new ArrayList<String>(); for (Object obj: objs ) System.out.println(obj.toString()); objs.add("text"); // ошибка времени компиляции objs.add(new Object()); // ошибка времени компиляции |
В этом случае, при получении ссылки на объект, тип которого задан как ”?”, его можно интерпретировать только как Object. Это безопасно, т.к. все ссылочные типы являются потомками Object. Вызывать методы, принимающие ссылки на тип, определенный такой маской, нельзя.
Наиболее ярким примером использования маски без ограничений может служить метод, который работает со значениями карты и совсем не интересуется типом ключа:
static void doSomeWork(Map<?, ? extends Glyph> map) {...} |
Тип “?” может быть также использован как аргумент generic-а и при вызове функций, что позволяет писать следующий код:
class Collections { ... publicstatic <T> List<T> unmodifiableList(List<T> list) { ... } } List<?> unknownList = new ArrayList<String>(); List<?> lt = Collections.unmodifiableList(unknownList); |
Этот код безопасен и компилируется без ошибок, но вот такое, например, уже откомпилировано не будет:
public static <T> void addElement(List<? extends T> list, T val) {...} List<?> unknownList = new ArrayList<String>(); addElement(unknownList, "String"); // ошибка времени компиляции |
Причина в том, что тип аргумента неизвестен, поэтому любая попытка как-то его конкретизировать приводит к ошибке.
Часть того, что умеют маски, можно решить и без них, пользуясь только generic-параметрами. Но существуют ситуации, когда без масок не обойтись. К таким ситуациями относится случай, где требуется ограничение super, а также случай, когда коду не требуется знать тип параметра, или он просто недоступен. Например, классу требуется переменная-член generic-класса или интерфейса, и ему нужно абстрагироваться от параметра типа. Иллюстрацией такого случая может служить класс, реализующий интерфейс LoginModule, который является частью JAAS (Java Authentication and Authorization Service). В этом интерфейсе определен метод initialize, который получает карту параметров модуля. Типы этих параметров заранее не определены, так что они могут быть какими угодно, их тип определяется во время исполнения и может быть разным для каждого параметра. После вызова initialize класс, реализующий интерфейс, должен сохранить эти параметры и использовать при вызове метода login:
public class DBLoginModule implements LoginModule { private Map<String, ?> options; ... publicvoid initialize(Subject subject, CallbackHandler callbackHandler, Map<String, ?> sharedState, Map<String, ?> options) { this.options = options; ... } publicboolean login() throws LoginException { final Object dataSourceJndiObj = options.get("datasource.jndi"); if (!(dataSourceJndiObj instanceof String)) { thrownew LoginException("Invalid login module parameters."); } dataSourceJndi = (String)dataSourceJndiObj; ... } ... } |
Возможность использовать параметр generic-класса или метода в throws позволяет при описании абстрактного метода не ограничивать разработчика, использующего класс или интерфейс, конкретным типом исключения. Но использовать тип, заданный в качестве параметра, в catch-выражениях нельзя.
Кроме того, можно сгенерировать исключение, тип которого задается generic-параметром, но экземпляр должен быть создан извне. Это ограничение порождается одним из ограничений Java generic-ов - нельзя создать объект, используя оператор new, тип которого является параметром generic-а.
abstract class Processor <T extends Throwable> { abstractvoid process() throws T; // okvoid doWork() { try { process(); } catch (T e) { // ошибка времени компиляции } } void doThrow(T except) throws T { throw except; // ok } } |
Хочется только добавить, что тип, переданный в качестве параметра, должен обязательно быть наследником Throwable.
Несмотря на то, что в документе [4] описаны примеры работы параметров generic-ов с оператором instanceof, в текущей версии это работать не будет. Instanceof не принимает в качестве rvalue ни generic-тип, ни тип параметра. Бессмысленность передачи этому оператору типа, переданного в качестве аргумента типа, очевидна - в рантайме generic-классы и generic-интерфейсы не различаются по параметрам.
public abstract class Seq<E> implements List<E> { static <T> boolean isSeq(List<T> x) { return x instanceof Seq<T>; // 1 сообщение об ошибке во время компиляции } staticboolean isElement(Object x) { return x instanceof E; // 2 сообщение об ошибке во время компиляции } staticboolean isSeq(Object x) { return x instanceof Seq; // 3 ok } staticboolean isSeqArray(Object x) { return x instanceof Seq[]; // 4 ok } } |
Ссылка на generic-тип или параметр generic-а может быть использована как lvalue для instanceof, что ничему не противоречит.
Нет доступа к информации о типе параметра (кроме той, что задается через ограничение extends) и на этапе компиляции, что делает невозможным использование этого типа вместе с оператором new.
Reflection позволяет получить информацию о generic-параметрах, ограничениях и масках, но эта все та же информация, которая была доступна на этапе компиляции. Это означает, что будет доступно число параметров, типы, от которых параметры должны быть унаследованы и т.д., но информацию о том, каким типом параметризован конкретный экземпляр, получить невозможно.
Java 1.5 хорошо совместима с предыдущими версиями JDK. Исходный код, написанный для предыдущих версий Java, может быть без проблем откомпилирован на новой версии Java. Благодаря тому, что generic-и можно использовать без параметров, старый код, использующий классы, методы и интерфейсы, ставшие параметризованными в новой версии, компилируется, правда, не без предупреждений.
На уровне байт-кода совместимость сохранилась благодаря тому, что добавление generic-ов в язык не внесло изменений в структуру байт-кода. Так что классы, откомпилированные предыдущими версиями компилятора, будут работать на Java 1.5.
Присваивание или приведение ссылок на generic-типы к их не-generic аналогам (или наоборот), будет встречено предупреждениями со стороны компилятора, но код будет откомпилирован.
Поддержка generic-ов реализована средствами компилятора. Виртуальная машина не предоставляет никакой поддержки generic-ов, кроме возможности получения информации о типах. В общих чертах это работает так – во время компиляции generic-параметры убираются, и, там, где это требуется, вместо них вставляется приведение типов.
Некоторые трудности возникают только при реализации или перегрузке методов, содержащих в своей сигнатуре параметры, тип которых является параметром типа. Например, может существовать такой класс:
class Foo implements Comparable<Foo> { publicint compareTo(Foo foo) { ... } } |
Чтобы в рантайме код работал корректно, в процессе его компиляции будет создан специальный метод, реализующий метод из интерфейса Comparable:
class Foo implements Comparable<Foo> { publicint compareTo(Object foo) { return compareTo((Foo)foo); } publicint compareTo(Foo foo) { ... } } |
Несмотря на то, что такой код компилироваться не будет, в байт-коде он вполне корректен.
Из-за особенностей реализации generic-и в Java 1.5 обладают несколькими недостатками, которые портят общее впечатление. Далее я опишу те из них, с которыми мне пришлось столкнуться в процессе работы. Думаю, что этот список далеко не полон, но основные проблемы в нем точно будут затронуты.
Многие из недостатков generic-ов исправить очень не просто из-за особенностей концепций самой платформы. Например, возможность динамической подгрузки типов лишает возможности статической проверки наличия нужного конструктора при создании экземпляра параметра generic-а. С этой и другим проблемами, а также возможными путями их решения, можно ознакомиться в третьей части статьи “Java generics without the pain" [5].
Для начала стоит напомнить, что никакой информации о типе, передаваемом в качестве параметра generic-а, нет. В какой-то степени этот недостаток сглаживается наличием ограничения extends, которое позволяет делать некоторые выводы о типе параметра.
Самая большая проблема вытекает из того, что в Java 1.5 компилятор не порождает отдельные специализации generic-класса для каждого применения, как это сделано в C++. С одной стороны, это позволило избежать проблем с совместимостью, т.к. не требует внесения изменений в формат байт-кода, но с другой стороны, повлекло за собой множество неприятных ограничений.
В generic-классе или интерфейсе невозможно объявить статическую переменную, тип которой являлся бы параметром типа. Кроме того, статические методы не могут получить доступ к параметрам generic-класса, в котором они находятся, хотя сами могут быть параметризованы и могут использовать свои параметры.
По той же причине нет специализаций generic-ов для конкретных типов параметров. Жаль, конечно, но все же Java, в отличие от С++, позволяет получить на этапе выполнения информацию о типе, что позволяет отчасти компенсировать этот недостаток.
Ни generic-классы, ни generic-интерфейсы не могут быть унаследованными от типа, переданного в качестве параметра. Это невозможно из-за того, что существует только одна реализация generic-типа на все случаи его применения. Кроме того, это привело бы к неоднозначности: неизвестно, что передается в качестве параметра – класс или интерфейс. Это ограничение приводит к одному из самых досадных недостатков generic-ов в Java – невозможно использовать mixin-типы, так популярные в С++.
ПРИМЕЧАНИЕ Этот недостаток отчасти компенсируется runtime-природой Java, так, фреймворки вроде Spring с успехом предоставляют подобные возможности во время исполнения – прим.ред. |
Остальные ограничения менее фундаментальны и, большей частью, вытекают из уже перечисленных:
Сообщений 10 Оценка 411 Оценить |