Привет!
Мы продолжаем серию лекций о дженериках.
Ранее мы разобрались в общих чертах, что это такое, и зачем нужно. Сегодня поговорим о некоторых особенностях дженериков и рассмотрим некоторые подводные камни при работе с ними. Поехали!
Если коротко: стирание типов (type erasure) это то, что компилятор Java делает с дженериками перед тем, как превратить код в байт-код. Тип-параметр проверяется на этапе компиляции, а потом просто выбрасывается: в готовом байт-коде
List<String> и
List<Cat> это один и тот же класс, и во время работы программы узнать, чем именно параметризован список, уже нельзя. Сделано это ради совместимости: дженерики появились только в Java 5, а весь код, написанный до нее, должен был продолжать работать.
Кратко
- Стирание типов это удаление информации о типе-параметре при компиляции. Проверки происходят до нее, в байт-коде типа-параметра уже нет.
- Причина историческая: дженерики добавили только в Java 5, и байт-код с новой информацией о типах сломал бы весь код, написанный раньше.
- Следствие первое:
List<String> и List<Integer> в runtime это один и тот же класс, поэтому getClass() у них совпадает.
- Следствие второе: приведение к типу-параметру внутри дженерик-класса ничего не проверяет, и
String спокойно попадет туда, где ожидался Integer.
- Массивы устроены наоборот: они помнят свой тип в runtime и бросают
ArrayStoreException. Именно поэтому массив дженериков создать нельзя.
- Обойти стирание можно, передав в объект
Class<T>: сам класс дженерик, так что информация о типе сохраняется в поле и переживает компиляцию.
![Пять желтых карандашей с розовыми ластиками лежат в ряд на светлой поверхности]()
В
прошлой лекции мы говорили о разнице между
Generic Types и
Raw Types. Если ты забыл, Raw Type это класс-дженерик, из которого удалили его тип.
List list = new ArrayList();
Вот пример. Здесь мы не указываем, какого именно типа объекты будут помещаться в наш
List.
Если попытаться создать такой
List и добавить в него какие-то объекты мы увидим в IDEA предупреждение:
“Unchecked call to add(E) as a member of raw type of java.util.List”.
Откуда взялось стирание типов
Но также мы говорили о том, что дженерики появились только в версии языка Java 5. К моменту ее выхода программисты успели написать кучу кода с использованием Raw Types, и чтобы он не перестал работать, возможность создания и работы Raw Types в Java сохранилась.
Однако эта проблема оказалась гораздо обширнее.
Java-код, как ты знаешь, преобразуется в специальный байт-код, который потом выполняется виртуальной машиной Java.
И если бы в процессе перевода мы помещали в байт-код информацию о типах-параметрах, это сломало бы весь ранее написанный код, ведь до Java 5 никаких типов-параметров не существовало!
Что такое стирание типов
При работе с дженериками есть одна очень важная особенность, о которой необходимо помнить. Она называется “стирание типов” (type erasure).
Ее суть заключается в том, что внутри класса не хранится никакой информации о его типе-параметре.
Эта информация доступна только на этапе компиляции и стирается (становится недоступной) в runtime.
Если ты попытаешься положить объект не того типа в свой
List<String>, компилятор выдаст ошибку. Этого как раз и добивались создатели языка, создавая дженерики: проверок на этапе компиляции.
Но когда весь написанный тобой Java-код превратится в байт-код, в нем не будет информации о типах-параметрах.
Внутри байт-кода твой список
List<Cat> cats не будет отличаться от
List<String> strings. В байт-коде ничто не будет говорить о том, что
cats это список объектов
Cat. Информация об этом сотрется во время компиляции, и в байт-код попадет только информация о том, что у тебя в программе есть некий список
List<Object> cats.
Как стирание выглядит в коде
Давай посмотрим, как это работает:
public class TestClass<T> {
private T value1;
private T value2;
public void printValues() {
System.out.println(value1);
System.out.println(value2);
}
public static <T> TestClass<T> createAndAdd2Values(Object o1, Object o2) {
TestClass<T> result = new TestClass<>();
result.value1 = (T) o1;
result.value2 = (T) o2;
return result;
}
public static void main(String[] args) {
Double d = 22.111;
String s = "Test String";
TestClass<Integer> test = createAndAdd2Values(d, s);
test.printValues();
}
}
Мы создали собственный дженерик-класс
TestClass.
Он довольно прост: по сути это небольшая “коллекция” на 2 объекта, которые помещаются туда сразу же при создании объекта. В качестве полей у него 2 объекта
T.
При выполнении метода
createAndAdd2Values() должно произойти приведение двух переданных объектов
Object a и
Object b к нашему типу
T, после чего они будут добавлены в объект
TestClass.
В методе
main() мы создаем
TestClass<Integer>, то есть в качестве
T у нас будет
Integer.
Но при этом в метод
createAndAdd2Values() мы передаем число
Double и объект
String.
Как ты думаешь, сработает ли наша программа? Ведь в качестве типа-параметра мы указали
Integer, а
String точно нельзя привести к
Integer!
Давай запустим метод
main() и проверим.
Вывод в консоль:
22.111
Test String
Неожиданный результат! Почему такое произошло?
Именно из-за стирания типов.
Во время компиляции кода информация о типе-параметре
Integer нашего объекта
TestClass<Integer> test стерлась.
Он превратился в
TestClass<Object> test.
Наши параметры
Double и
String без проблем преобразовались в
Object (а не в
Integer, как мы того ожидали!) и спокойно добавились в
TestClass.
Вот еще один простой, но очень показательный пример стирания типов:
import java.util.ArrayList;
import java.util.List;
public class Main {
private class Cat {
}
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();
List<Cat> cats = new ArrayList<>();
System.out.println(strings.getClass() == numbers.getClass());
System.out.println(numbers.getClass() == cats.getClass());
}
}
Вывод в консоль:
true
true
Казалось бы, мы создали коллекции с тремя разными типами-параметрами:
String,
Integer и созданный нами класс
Cat.
Но во время преобразования в байт-код все три списка превратились в
List<Object>, поэтому при выполнении программа говорит нам, что во всех трех случаях у нас используется один и тот же класс.
Небольшое уточнение к формулировке. «Превратились в
List<Object>» это удобное упрощение, но на собеседовании лучше сказать точнее, потому что
спецификация языка описывает тут два разных правила. Параметризованный тип теряет свой параметр целиком: от
List<String> остается «сырой»
List, а не
List<Object>. А переменная типа, то есть
T в объявлении класса, заменяется на свою левую границу, и если граница не указана, ею оказывается
Object. Так что
Object всплывает не у самого списка, а в тех местах, где в коде стояло
T: в полях, в параметрах методов и в возвращаемых значениях.
Стирание типов при работе с массивами и дженериками
Есть один очень важный момент, который необходимо четко понимать при работе с массивами и дженериками (например,
List). Также его стоит учитывать при выборе структуры данных для твоей программы.
Дженерики подвержены стиранию типов. Информация о типе-параметре недоступна во время выполнения программы.
В отличие от них, массивы знают и могут использовать информацию о своем типе данных во время выполнения программы.
Попытка поместить в массив значение неверного типа приведет к исключению:
public class Main2 {
public static void main(String[] args) {
Object x[] = new String[3];
x[0] = Integer.valueOf(222);
}
}
Вывод в консоль:
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
Разницу удобно держать перед глазами целиком:
|
Массивы |
Дженерики |
| Информация о типе в runtime |
Сохраняется, тип известен виртуальной машине |
Стирается, тип известен только компилятору |
| Когда ловится ошибка типа |
Во время выполнения: ArrayStoreException |
Во время компиляции |
| Проверка при записи элемента |
Каждая запись проверяется виртуальной машиной |
В runtime проверок нет вообще |
| Ковариантность |
Object[] x = new String[3] разрешено |
List<Object> x = new ArrayList<String>() запрещено |
| Можно ли создать |
new String[3] можно |
new List<String>[1] нельзя, ошибка generic array creation |
Из-за того, что между массивами и дженериками есть такая большая разница, у них могут возникнуть проблемы с совместимостью.
Прежде всего, ты не можешь создать массив объектов-дженериков или даже просто типизированный массив.
Звучит немного непонятно?
Давай рассмотрим наглядно.
К примеру, ты не сможешь сделать в Java ничего из этого:
new List<T>[]
new List<String>[]
new T[]
Если мы попытаемся создать массив списков
List<String>, получим ошибку компиляции generic array creation:
import java.util.List;
public class Main2 {
public static void main(String[] args) {
//ошибка компиляции! Generic array creation
List<String>[] stringLists = new List<String>[1];
}
}
Но для чего это сделано? Почему создание таких массивов запрещено? Это все ради обеспечения типобезопасности.
Если бы компилятор позволял нам создавать такие массивы из объектов-дженериков, мы могли бы заработать кучу проблем.
Вот простой пример из книги Джошуа Блоха “Effective Java”:
public static void main(String[] args) {
List<String>[] stringLists = new List<String>[1]; // (1)
List<Integer> intList = Arrays.asList(42, 65, 44); // (2)
Object[] objects = stringLists; // (3)
objects[0] = intList; // (4)
String s = stringLists[0].get(0); // (5)
}
Давай представим, что создание массива
List<String>[] stringLists было бы разрешено, и компилятор бы не ругался.
Вот каких дел мы могли бы наворотить в этом случае:
В строке 1 мы создаем массив листов
List<String>[] stringLists.
Наш массив вмещает в себя один
List<String>.
В строке 2 мы создаем список чисел
List<Integer>.
В строке 3 мы присваиваем наш массив
List<String>[] в переменную
Object[] objects. Язык Java позволяет это делать: в массив объектов
X можно помещать и объекты
X, и объекты всех дочерних классов
X. Соответственно, в массив
Objects можно поместить вообще все что угодно.
В строке 4 мы подменяем единственный элемент массива
objects (List<String>) на список
List<Integer>.
В результате мы поместили
List<Integer> в наш массив, который предназначался только для хранения
List<String>!
С ошибкой же мы столкнемся только когда код дойдет до строки 5. Во время выполнения программы будет выброшено исключение
ClassCastException.
Поэтому запрет на создание таких массивов и был введен в язык Java: это позволяет нам избегать подобных ситуаций.
Как можно обойти стирание типов?
Что ж, стирание типов мы изучили. Давай попробуем обмануть систему! :)
Задача:
У нас есть класс-дженерик
TestClass<T>. Нам нужно создать в нем метод
createNewT(), который будет создавать и возвращать новый объект типа
T.
Но ведь это невозможно сделать, так? Вся информация о типе
T будет стерта во время компиляции, и в процессе работы программы мы не сможем узнать, объект какого именно типа нам нужно создать.
На самом деле, есть один хитрый способ.
Ты наверняка помнишь, что в Java есть класс
Class, с которого начинается вся
работа с рефлексией. Используя его, мы можем получить класс любого нашего объекта:
public class Main2 {
public static void main(String[] args) {
Class classInt = Integer.class;
Class classString = String.class;
System.out.println(classInt);
System.out.println(classString);
}
}
Вывод в консоль:
class java.lang.Integer
class java.lang.String
Но вот одна особенность, о которой мы не говорили. В документации Oracle ты увидишь, что класс Class это дженерик!
![Скриншот документации Oracle: заголовок java.lang.Class<T> и раздел Type Parameters, где сказано, что T это тип класса, моделируемого данным объектом Class]()
В документации написано: “T это тип класса, моделируемого этим объектом Class”.
Если перевести это с языка документации на человеческий, это означает, что классом для объекта
Integer.class является не просто
Class, а
Class<Integer>. Типом объекта
String.class является не просто
Class,
Class<String>, и т.д.
Если все еще непонятно, попробуй добавить тип-параметр к предыдущему примеру:
public class Main2 {
public static void main(String[] args) {
Class<Integer> classInt = Integer.class;
//ошибка компиляции!
Class<String> classInt2 = Integer.class;
Class<String> classString = String.class;
//ошибка компиляции!
Class<Double> classString2 = String.class;
}
}
И вот теперь, используя это знание, мы можем обойти стирание типов и решить нашу задачу!
Попробуем получить информацию о типе-параметре. Его роль будет играть класс
MySecretClass:
public class MySecretClass {
public MySecretClass() {
System.out.println("Объект секретного класса успешно создан!");
}
}
А вот как мы используем на практике наше решение:
public class TestClass<T> {
Class<T> typeParameterClass;
public TestClass(Class<T> typeParameterClass) {
this.typeParameterClass = typeParameterClass;
}
public T createNewT() throws ReflectiveOperationException {
T t = typeParameterClass.getDeclaredConstructor().newInstance();
return t;
}
public static void main(String[] args) throws ReflectiveOperationException {
TestClass<MySecretClass> testString = new TestClass<>(MySecretClass.class);
MySecretClass secret = testString.createNewT();
}
}
Вывод в консоль:
Объект секретного класса успешно создан!
Пара слов про
getDeclaredConstructor().newInstance(). Раньше в этом месте писали короче, просто
typeParameterClass.newInstance(), но с Java 9 этот метод помечен устаревшим: он умел пробрасывать наружу любое проверяемое исключение конструктора в обход компилятора. Замену предлагает
сама документация: сначала получаем конструктор без аргументов, потом вызываем его, и исключение конструктора аккуратно заворачивается в
InvocationTargetException. А
ReflectiveOperationException в
throws это общий родитель всех четырех исключений, которые тут могут вылететь.
Мы просто передали нужный класс-параметр в конструктор нашего класса-дженерика:
TestClass<MySecretClass> testString = new TestClass<>(MySecretClass.class);
Благодаря этому мы сохранили информацию о типе-параметре и уберегли ее от стирания. В итоге мы смогли создать объект
T! :)
На этом сегодняшняя лекция подходит к концу.
О стирании типов всегда необходимо помнить при работе с дженериками. Выглядит это дело не очень удобно, но нужно понимать: дженерики не были частью языка Java при его создании. Это позже прикрученная возможность, которая помогает нам создавать типизированные коллекции и отлавливать ошибки на этапе компиляции.
В некоторых других языках, где дженерики появлялись с первой версии, стирание типов отсутствует (например, в C#).
Впрочем, мы не закончили изучение дженериков! На следующей лекции ты познакомишься с
подстановочными типами (wildcards) и еще несколькими особенностями работы с дженериками. А пока было бы неплохо решить пару задач! :)
Вопросы и ответы
Что такое стирание типов в Java?
Это удаление информации о типах-параметрах на этапе компиляции. Компилятор проверяет, что вы кладете в
List<String> именно строки, и после этого сам тип-параметр из кода убирает. В байт-код попадает список без всякого указания на
String, поэтому во время работы программы выяснить, чем список был параметризован, штатными средствами невозможно.
Почему в Java сделали стирание типов, а не настоящие дженерики?
Ради обратной совместимости. Дженерики появились только в Java 5, а к тому моменту было написано огромное количество кода без них. Если бы в байт-код начали класть информацию о типах-параметрах, старые библиотеки и новый код перестали бы стыковаться. Стирание оказалось компромиссом: проверки типов работают при компиляции, а байт-код остается таким же, как раньше. В языках, где дженерики были с первой версии, например в C#, стирания нет.
Почему у List<String> и List<Integer> одинаковый getClass()?
Потому что после компиляции это буквально один и тот же класс. Тип-параметр исчезает, и обоих списков в runtime существует только
java.util.ArrayList без всяких уточнений. Поэтому сравнение
strings.getClass() == numbers.getClass() дает
true, хотя объявляли вы разные типы.
Почему в Java нельзя создать массив дженериков?
Потому что массив помнит свой тип, а дженерик нет, и вместе они дают дыру в типобезопасности. Если бы
new List<String>[1] было разрешено, такой массив можно было бы присвоить переменной
Object[] и положить в него
List<Integer>. Массив этого не заметит, ведь для него все элементы просто
List, а упадет программа позже и в другом месте, с
ClassCastException при чтении. Поэтому компилятор запрещает такие массивы сразу, с ошибкой generic array creation.
Чем массивы отличаются от дженериков в плане типобезопасности?
Моментом, когда ловится ошибка. Массивы проверяют тип во время выполнения: попытка положить
Integer в
String[] закончится
ArrayStoreException. Дженерики проверяют тип во время компиляции, а в runtime не проверяют ничего. Отсюда и разница в поведении: с массивом вы узнаете о проблеме в тот же момент, когда ее создали, а с дженериком, если обойти компилятор, ошибка вылезет далеко от места, где ее допустили.
Как узнать тип-параметр во время выполнения?
Передать его в объект явно, в виде
Class<T>. Класс
Class сам является дженериком, поэтому у
Integer.class тип не просто
Class, а
Class<Integer>. Если положить такой объект в поле класса-дженерика через конструктор, информация о типе никуда не денется и переживет компиляцию. Дальше по этому объекту можно создавать новые экземпляры нужного типа.
Читайте также
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ