Сообщений 10    Оценка 411        Оценить  
Система Orphus

Generics в Java 1.5

Автор: Kobylansky Stanislav
Источник: RSDN Magazine #1-2005
Опубликовано: 22.05.2005
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Простые generic-и
Ограничения
Маски
Маски с ограничением extends
Маски с ограничением super
Маски без ограничений
Generic-и и исключения
Информация о типах и generic-и
Generic-и и унаследованный код
Как это работает
Что в generic-ах не так
Отсутствие runtime-информации о типе параметра 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-и

Начать работать с 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 – тип.

Чаще маски применяются при описании типов параметров методов, но их также можно использовать просто в объявлении переменных в теле метода или членов класса.

Маски с ограничением extends

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

        void drawAll(Collection<? extends Glyph> glyphs)
{
...
}

В данном примере метод принимает только коллекции, элементы которых – наследники класса Glyph. Вариант без использования маски (просто с Glyph) здесь не подойдет, т.к. при этом метод будет принимать только коллекции Glyph, но не его потомков.

Хочется также заметить, что если неизвестен точный тип параметра переданного класса, то использовать его можно с ограничениями. Например, компилятор не даст вызвать метод, который принимает generic-параметр в качестве аргумента.

Хотя использование масок с ограничением extends в параметрах методов является наиболее распространенным, в этом случае вполне можно обойтись без них. Следующий код будет полностью аналогичен коду, приведенному выше:

<T extends Glyph> void drawAll(Collection<T> glyphs)
{
  ...
}

Маски с ограничением super

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

Этот тип ограничения задается с помощью ключевого слова 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-и и исключения

Возможность использовать параметр 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.

Информация о типах и generic-и

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

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-ах не так

Из-за особенностей реализации generic-и в Java 1.5 обладают несколькими недостатками, которые портят общее впечатление. Далее я опишу те из них, с которыми мне пришлось столкнуться в процессе работы. Думаю, что этот список далеко не полон, но основные проблемы в нем точно будут затронуты.

Многие из недостатков generic-ов исправить очень не просто из-за особенностей концепций самой платформы. Например, возможность динамической подгрузки типов лишает возможности статической проверки наличия нужного конструктора при создании экземпляра параметра generic-а. С этой и другим проблемами, а также возможными путями их решения, можно ознакомиться в третьей части статьи “Java generics without the pain" [5].

Отсутствие runtime-информации о типе параметра generic-а

Для начала стоит напомнить, что никакой информации о типе, передаваемом в качестве параметра generic-а, нет. В какой-то степени этот недостаток сглаживается наличием ограничения extends, которое позволяет делать некоторые выводы о типе параметра.

Одна реализация на все случаи

Самая большая проблема вытекает из того, что в Java 1.5 компилятор не порождает отдельные специализации generic-класса для каждого применения, как это сделано в C++. С одной стороны, это позволило избежать проблем с совместимостью, т.к. не требует внесения изменений в формат байт-кода, но с другой стороны, повлекло за собой множество неприятных ограничений.

В generic-классе или интерфейсе невозможно объявить статическую переменную, тип которой являлся бы параметром типа. Кроме того, статические методы не могут получить доступ к параметрам generic-класса, в котором они находятся, хотя сами могут быть параметризованы и могут использовать свои параметры.

По той же причине нет специализаций generic-ов для конкретных типов параметров. Жаль, конечно, но все же Java, в отличие от С++, позволяет получить на этапе выполнения информацию о типе, что позволяет отчасти компенсировать этот недостаток.

Ни generic-классы, ни generic-интерфейсы не могут быть унаследованными от типа, переданного в качестве параметра. Это невозможно из-за того, что существует только одна реализация generic-типа на все случаи его применения. Кроме того, это привело бы к неоднозначности: неизвестно, что передается в качестве параметра – класс или интерфейс. Это ограничение приводит к одному из самых досадных недостатков generic-ов в Java – невозможно использовать mixin-типы, так популярные в С++.

ПРИМЕЧАНИЕ

Этот недостаток отчасти компенсируется runtime-природой Java, так, фреймворки вроде Spring с успехом предоставляют подобные возможности во время исполнения – прим.ред.

Все остальное

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

Заключение

Источники

  1. Статья “Generics in the Java Programming Language”. Gilad Bracha. http://Java.sun.com/j2se/1.5/pdf/generics-tutorial.pdf.
  2. Статья “Using and Programming Generics in J2SE 5.0”. Qusay H. Mahmoud. http://Java.sun.com/developer/technicalArticles/J2SE/generics/.
  3. “Java Generics FAQs”. http://www.langer.camelot.de/GenericsFAQ/JavaGenericsFAQ.html.
  4. Статья “Adding Generics to the Java Programming Language: Public Draft Specification, Version 2.0”. Gilad Bracha, Norman Cohen, Christian Kemper, Martin Odersky, David Stotamire, Kresten Thorup, Philip Walder. http://www.cs.purdue.edu/homes/hosking/352/generics.pdf.
  5. Статья “Java generics without the pain”. Eric E. Allen. http://www-106.ibm.com/developerworks/Java/library/j-djc02113.html.
  6. Статья “Adding Wildcards to the Java Programming Language”. Mads Trogersen, Christian Plesner Hansen, Erik Erns, Peter von Der Ahe, Gliad Bracha and Neal Gafter. http://bracha.org/wildcards.pdf.


Эта статья опубликована в журнале RSDN Magazine #1-2005. Информацию о журнале можно найти здесь
    Сообщений 10    Оценка 411        Оценить