На протяжении всей истории развития языка Java, он претерпевал изменения. Иногда изменения носили косметический характер, иногда это было просто исправление уязвимостей, а иногда переход на новую версию языка знаменовал поистине значительные, а в некоторых случаях и революционные изменения, одним из таких изменений стали обобщения (generics). Обобщения в Java были представлены в версии 5.0 это было результатом реализации самых первых требований к спецификации Java, которые были сформулированы еще в 1999 году. Они позволили создавать более безопасный и легче читаемый код, который не перегружен переменными типа Object и приведением классов. Вы вероятно уже сталкивались с обобщениями в своих программах сами того не осознавая:
1 |
ArrayList<String> list = new ArrayList<String>(); |
В качестве обобщенного типа в данном случае выступает тип String, который указан в треугольных скобках. Начиная с версии Java 7 SE, обобщенный тип можно опускать в объявлении конструктора класса:
1 |
ArrayList<String> list = new ArrayList<>(); |
Выше мы упоминали про причины появления обобщений в Java, давайте теперь рассмотрим их более подробно на примере:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package example; import java.util.ArrayList; import java.util.List; public class Example{ public static void main(String... args){ List list = new ArrayList(); list.add(new StringBuilder("Обобщения в Java")); showString(list); } public static void showString(List list){ for(int i = 0; i < list.size(); i++){ String str = (String) list.get(i); //ClassCastException System.out.println(str); } } } |
Мало того, что нам приходится использовать приведение типов, без него код просто не скомпилируется:
1 |
String str = (String) list.get(i); //ClassCastException |
Так еще нам это не помогло – во время выполнения программы было сгенерировано исключение ClassCastException (переменные с типом StringBuilder нельзя привести к переменным с типом String). Конечно, можно было бы заставить код работать так как нам нужно, добавить дополнительные проверки, комментарии для других программистов и т.д. и выводить в консоль элементы списка, но можно сделать все гораздо проще:
1 2 |
List<String> list = new ArrayList<>(); list.add(new StringBuilder("Обобщения в Java")); //ошибка компиляции |
Код не скомпилируется, но это не так уж и плохо, на этот раз мы четко видим в чем ошибка. Пришло время ее устранить и немного изменить код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package example; import java.util.ArrayList; import java.util.List; public class Example{ public static void main(String... args){ List<String> list = new ArrayList<>(); list.add("Обобщения в Java"); showString(list); } public static void showString(List<String> list){ for(int i = 0; i < list.size(); i++){ String str = list.get(i); System.out.println(str); } } } |
Теперь код компилируется без каких-либо проблем и выводит в консоль «Обобщения в Java». Если Вы внимательно посмотрите на код, то увидите, что метод showString() претерпел изменения в частности: в параметрах метода мы явно указали что будем использовать список с переменными строкового типа, что избавило нас от необходимости использовать приведение типов. Прежде чем мы углубимся в обобщенное программирование на Java и приступим к созданию обобщенных классов, я хотел бы вас предостеречь – обобщенное программирование намного сложнее, чем могло показаться из примеров выше и прежде чем использовать его в своем коде, надо иметь о нем очень хорошее и ясное представление. Как мы увидим далее опрометчивое использование обобщенных классов может привести к большому количеству не самых очевидных ошибок и «методом тыка» решить их не получится. Если ваш боевой настрой не упал и вы все еще рветесь постигнуть все нюансы этого невероятно мощного инструмента языка Java, тогда не будем задерживаться 🙂
Обобщенные классы.
Обобщенный класс – это класс с одной или более переменной типа. Для того, чтобы класс стал обобщенным достаточно указать тип обобщения в угловых скобках после названия класса:
1 2 3 |
class A <T> { } |
Переменные типа используются повсюду: в определении класса, для обозначения типов возвращаемых методами, а так же типов полей и локальных переменных. Предположим, что нам необходимо создать класс для mp3 плеера, который бы умел проигрывать разные форматы, для этого создадим два класса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Music{ } class Player <T> { private T song; public void addSong(T song){ this.song = song; } public T playSong(){ return song; } } |
Класс Music условно реализует музыку и в данный момент не представляет особого интереса, второй класс – Player, реализует наш mp3 плеер, как Вы можете заметить после названия класса в угловых скобках мы объявляем использование обобщенного типа
1 2 3 4 5 6 7 |
public class Example{ public static void main(String... args){ Music music = new Music(); Player<Music> mp3Player = new Player<>(); mp3Player.addSong(music); } } |
Как видите? музыка добавляется в плейлист нашего проигрывателя и все работает. Обратите внимание на создание объекта класса Player, где мы явно указали какой тип объектов в нем мы будем использовать:
1 |
Player<Music> mp3Player = new Player<>(); |
Любые другие объекты кроме класса Music использовать будет нельзя:
1 2 |
Object obj = new Object(); mp3Player.addSong(obj); //ошибка компиляции |
Обобщенные классы не ограничены в использовании одного обобщенного параметра:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class Player <T, U> { private T song; private U genre; public void addSong(T song, U genre){ this.song = song; this.genre = genre; } public T playSong(){ return song; } } public class Example{ public static void main(String... args){ Music music = new Music(); Player<Music, String> mp3Player = new Player<>(); mp3Player.addSong(music, "Rock"); } } |
Нашему классу плеера был добавлен второй обобщенный параметр, который отвечает за жанр музыки, а при создании экземпляра класса Player мы объявили, что вторым параметром будет переменная с типом String:
1 |
Player<Music, String> mp3Player = new Player<>(); |
Обобщенные интерфейсы.
Так же, как и классы интерфейсы могут использовать обобщенные параметры.
1 2 3 4 5 |
interface Playable <T>{ public void addSong(T t); public T playSong(); } |
Существует несколько вариантов того, как класс может реализовать этот интерфейс. Первый вариант – это конкретизировать обобщенный тип в классе реализующим интерфейс:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
interface Playable <T>{ public void addSong(T t); public T playSong(); } class Player implements Playable<Music>{ private Music song; @Override public void addSong(Music song){ this.song = song; } @Override public Music playSong(){ return song; } } |
Второй вариант – это продолжить использовать обобщения в классе реализующим интерфейс:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Player<T> implements Playable<T>{ private T song; @Override public void addSong(T song){ this.song = song; } @Override public T playSong(){ return song; } } |
И последний способ, он же не самый лучший – это написать код в «старом стиле», т.е. не использовать обобщения в принципе:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Player implements Playable{ private Object song; @Override public void addSong(Object song){ this.song = song; } @Override public Object playSong(){ return song; } } |
При компиляции этого кода вы скорее всего получите предупреждение, но код скомпилируется и будет работать.
Обобщенные методы.
Помимо классов и интерфейсов можно создавать и обобщенные методы. Обобщенные методы можно объявлять, как в обобщенных классах, так и в обычных, ограничений на это никаких не накладывается. Наиболее часто обобщения применяются в статичных методах, но вы можете использовать их и в обычных методах, никаких ограничений на это нет. Давайте создадим наш первый обобщенный метод:
1 2 3 4 5 |
public static <T> Player<T> listenMusic(T t){ Player<T> mp3Player = new Player<>(); mp3Player.addSong(t); return mp3Player; } |
В качестве параметра метод использует обобщенный тип T. Возвращает метод обобщенный класс Player
1 |
public static T method(T t){return t;} // не скоимпилируется |
В объявлении метода не был указан обобщенный тип в угловых скобках, что и привело к ошибке компиляции. Проделаем работу над ошибками:
1 |
public static <T> T method2(T t){return t;} |
На этот раз все компилируется прекрасно.
Ограничения на переменные типа.
До этого момента все было достаточно просто и очевидно, вот только из-за этой простоты обобщения могли вам показаться не очень полезными. Настало время постигнуть всю мощь обобщений и вместе с этим приобрести кучу проблем. Не редки ситуации, когда класс или метод нуждается в наложении ограничений на переменные типа.
Ограничивающий параметр (жесткие системы обобщенных типов) – это обобщенный тип, который определяет ограничения, накладываемые на обобщение.
1 2 3 4 5 |
interface A{} class Test{ public static <T extends A> void check(T t){}; } |
В метод check() мы можем передать только объекты классов реализующих интерфейс А. Обратите внимание, что в объявлении используется ключевое слово extends вместо implements! Как вы знаете в Java отсутствует множественное наследование в явном виде, но мы вольны классом реализовывать столько интерфейсов, сколько посчитаем нужным, так же и в обобщениях ограничивающим параметром можно указать реализацию двух интерфейсов разделив их знаком &:
1 2 3 4 5 6 |
interface A{} interface B{} class Test{ public static <T extends A & B> void check(T t){}; } |
Теперь метод check() принимает в качестве аргументов объект класса реализующего интерфейсы A и B. Ограничения реализуются не только интерфейсами:
1 2 3 4 5 6 |
class A{} interface B{} class Test{ public static <T extends A & B> void check(T t){}; } |
На этот раз класс должен реализовывать интерфейс B и расширять класс A. В последнем примере есть один нюанс – если в качестве ограничителя вы вводите какой-либо класс (в нашем примере класс A), то он должен стоять на первом месте:
1 2 3 4 5 6 |
class A{} interface B{} class Test{ public static <T extends B & A> void check(T t){}; // ошибка компиляции } |
Универсальный обобщенный тип или неограниченный подстановочный тип (кому как нравится, лично мне нравится больше первый вариант) – это неизвестный обобщенный тип, представленный знаком вопроса. Варианты его использования представлены в таблице:
Давайте теперь рассмотрим каждый из вариантов более подробно.
Универсальные без ограничений.
Название говорит само за себя, вы вольны использовать любой тип данных. Всякий раз, когда вы используете >, вы как бы говорите, что все в порядке, здесь можно использовать переменную любого типа и код будет работать.
1 2 |
List<?> ls = new ArrayList<String>(); List<?> ls1 = new ArrayList<Object>(); |
Как видите, мы можем создать список, содержащий любой обобщенный тип, ограничений никаких не накладывается. Настало время рассмотреть более практичный пример:
1 2 3 4 5 6 7 8 9 10 11 |
public class Example{ public static void main(String... args){ List<String> list = new ArrayList<>(); list.add("Обобщения в Java"); printList(list); // Ошибка компиляции } public static void printList(List<Object> list){ for(Object obj : list) System.out.println(obj); } } |
Несмотря на то, что класс String является подклассом Object, компилятор всё равно не дает запустить данный код. Это может показаться запутанным, так оно и есть. Давайте рассмотрим другой пример:
1 2 3 4 5 |
List<Integer> numbers = new ArrayList<>(); numbers.add(new Integer(1)); List<Object> obj = numbers; //Ошибка компиляции obj.add("one"); System.out.println(numbers.get(1)); |
Ситуация такая же, как и в первом примере, первой строчкой мы явно указали, что будем хранить в списке объекты типа Integer, если бы компилятор дал нам это скомпилировать, то мы бы нарушили ограничение, которое сами же и наложили, добавив в наш список строку. Вернемся к первому примеру, ситуация вроде бы безвыходная, но на помощь придут универсальные ограничители:
1 2 3 4 5 6 7 8 9 10 11 |
public class Example{ public static void main(String... args){ List<String> list = new ArrayList<>(); list.add("Обобщения в Java"); printList(list); } public static void printList(List<?> list){ for(Object obj : list) System.out.println(obj); } } |
На этот раз в методе printList() вместо списка с типом Object мы воспользовались списком с универсальным ограничителем ?, который подразумевает под собой любой тип и тип String полностью соответствует этому условию 🙂
Универсальные с восходящим ограничением (ограничение на подтип).
Предположим, нам надо создать список, который бы позволял хранить в себе переменные подклассов Number (иначе говоря любые числа), из предыдущей главы мы уже разобрались почему так сделать нельзя:
1 |
List<Number> list = new ArrayList<Integer>(); //Ошибка компиляции |
Как и в прошлый раз, на помощь нам придут универсальные ограничители:
1 |
List<? extends Number> list = new ArrayList<Integer>(); |
В данном случае универсальный ограничитель сообщает, что мы можем использовать в качестве формального параметра любой подкласс Number. Давайте напишем метод, который суммирует все числа в списке:
1 2 3 4 5 |
public static long sumList(List<? extends Number> list){ long sum = 0; for(Number num : list) sum += num.longValue(); return sum; } |
Есть одна интересная особенность первых двух рассмотренных ограничителей. При их использовании, список становится логически неизменяемым.
1 2 3 4 5 6 7 8 9 10 |
class A{} class B extends A{} public class Example{ public static void main(String... args){ List<? extends A> a = new ArrayList<A>(); a.add(new B()); //Ошибка компиляции a.add(new A()); //Ошибка компиляции } } |
Проблема кроется в том, что Java не знает какой тип кроется за <? extends A>, это может быть A или B или класс, который еще не написан. Иначе говоря, переменные класса A не могут быть добавлены в List<B>, а переменные класса B не могут быть добавлены в List<A>, с точки зрения Java оба сценария возможны, поэтому подобные операции пресекаются компилятором на стадии компиляции. Давайте посмотрим, как универсальные ограничители работают с интерфейсами:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
interface ABC{public void letter();} class A implements ABC{public void letter(){System.out.print("A");}} class B implements ABC{public void letter(){System.out.print("B");}} public class Example{ public static void main(String... args){ List<B> b = new ArrayList<B>(); b.add(new B()); showLetters(b); } public static void showLetters(List <? extends ABC> list){ for(ABC abc: list) abc.letter(); } } |
Самое важное, на что хотелось бы обратить ваше внимание, так это на метод showLetters(), при объявлении универсальных ограничителей всегда используется ключевое слово extends, не важно с чем вы работаете с классом или интерфейсом.
Универсальные с нисходящим ограничением (ограничение на супер тип).
Рассмотрим следующий пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import java.util.ArrayList; import java.util.List; public class Example{ public static void main(String... args){ List<String> listStr = new ArrayList<String>(); List<Object> listObj = new ArrayList<Object>(); addString(listStr); addString(listObj); } public static void addString(List<? super String> list){ list.add("Обобщения в Java"); } } |
В методе addString() используется ограничитель с нисходящим ограничением super String>, означающий, что мы можем использовать любые классы, которые являются родительскими классу String, так же включая класс String. Почему в методе addString() нельзя воспользоваться другими ограничителями? Рассмотрим этот вопрос подробнее:
- > при использовании универсального ограничителя, метод попросту не скомпилируется, потому что наш список автоматически становится неизменяемым (immutable)
- extends Object> метод так же не скомпилируется, по той же самой причине, что и выше, при использовании ограничителей с восходящим ограничением список становится неизменяемым
- c ограничением только на класс Object в наш метод мы не сможем передать список из строк
Обобщения и JVM.
JVM не работает с обобщениями, все объекты принадлежат обычным классам. Всегда при определении обобщенного типа, автоматически создается соответствующий ему базовый тип. Имя этого типа совпадает с именем обобщенного типа с удаленными параметрами типа. Переменные типа стираются и заменяются ограничивающими типами (или типом Object, если переменная не имеет ограничений). Рассмотрим пример как это работает:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Test <T>{ private T t1; private T t2; public Test(T t1, T t2){ this.t1 = t1; this.t2 = t2; } public T get(){ return t1; } } |
А вот как выглядит базовый тип:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Test{ private Object t1; private Object t2; public Test(Object t1, Object t2){ this.t1 = t1; this.t2 = t2; } public Object get(){ return t1; } } |
Если T – неограниченная переменная, то она попросту заменяется на Object и мы получаем совершенно обычный класс. С ограничивающими типами дела обстоят интереснее:
1 2 3 4 5 6 |
interface A{} interface B{} class Test <T extends A & B>{ private T t1; } |
Базовый тип:
1 2 3 4 5 6 |
interface A{} interface B{} class Test{ private A t1; } |
А если в обобщенном классе поменять местами A и B?
1 2 3 4 5 6 |
interface A{} interface B{} class Test <T extends B & A>{ private T t1; } |
Базовый тип:
1 2 3 4 5 6 |
interface A{} interface B{} class Test{ private B t1; } |
А переменная t1 где требуется будет приведена компилятором к типу A. По этой самой причине рекомендуется отмечающий интерфейсы (интерфейсы без методов) указывать в конце списка ограничений.
Давайте посмотрим, как JVM работает с обобщенными выражениями:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class A <T>{ private T t; public T get(){ return t; } } class B{} public class Example{ public static void main(String... args){ A <B> a = new A <B> (); B b = a.get(); } } |
Когда в программе вызывается обобщенный метод, компилятор вводит операции привидения типов при стирании возвращаемого типа. При вызове метода a.get() в результате стирания возвращается тип Object, далее компилятор автоматически приводит тип Object к типу класса B.
Ограничения при работе с обращениями.
Что нельзя делать при работе с обобщениями:
- Вызывать конструктор иными словами создавать экземпляры переменных типа. Вызов new T() недопустим, потому что во время выполнения программы этот вызов будет заменен на new Object().
- Создавать массив из параметризованного типа:
1Test<String> obj = new Test<String>[10]; //ошибка компиляции
Что интересно, несмотря на то, что инициализировать массив нельзя, объявить его можно без каких-либо проблем:
1Test <String>[] obj; - Вызвать метод instanceof, потому что все запросы типов во время выполнения дают только базовый тип:
1obj instanceof Test<String>; //ошибка компиляции
В примере метод instanceof проверяет является ли переменная obj экземпляром Test любого типа, что конечно же нелогично и недопустимо.
- Использовать примитивные типы в качестве обобщений. Иначе говоря, не существует объекта типа Test
. - Создавать переменные типа в статическом контексте обобщенных классов:
123class Test <T>{private static T obj; //ошибка компиляции}
- 6. Нельзя генерировать или перехватывать экземпляры обобщенного класса в виде исключений, так же обобщенный класс не может расширить класс Throwable:
1class Test <T> extends Throwable{} //ошибка компиляции
Кроме того, переменную типа нельзя использовать в блоке catch!