JavaRush /Java блог /Java Developer /Стирание типов
Автор
Горковенко Андрей
Фронтенд-разработчик в NFON AG

Стирание типов

Статья из группы Java Developer
Привет! Мы продолжаем серию лекций о дженериках. Ранее мы разобрались в общих чертах, что это такое, и зачем нужно. Сегодня поговорим о некоторых особенностях дженериков и рассмотрим некоторые подводные камни при работе с ними. Поехали! Стирание типов - 1В прошлой лекции мы говорили о разнице между 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). Также его стоит учитывать при выборе структуры данных для твоей программы. Дженерики подвержены стиранию типов. Информация о типе-параметре недоступна во время выполнения программы. В отличие от них, массивы знают и могут использовать информацию о своем типе данных во время выполнения программы. Попытка поместить в массив значение неверного типа приведет к исключению:

public class Main2 {

   public static void main(String[] args) {

       Object x[] = new String[3];
       x[0] = new Integer(222);
   }
}
Вывод в консоль:

Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
Из-за того, что между массивами и дженериками есть такая большая разница, у них могут возникнуть проблемы с совместимостью. Прежде всего, ты не можешь создать массив объектов-дженериков или даже просто типизированный массив. Звучит немного непонятно? Давай рассмотрим наглядно. К примеру, ты не сможешь сделать в 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, и объекты всех дочерних классов Х. Соответственно, в массив Objects можно поместить вообще все что угодно. В строке 4 мы подменяем единственный элемент массива objects (List<String>) на список List<Integer>. В результате мы поместили List<Integer> в наш массив, который предназначался только для хранения List<String>! С ошибкой же мы столкнемся только когда код дойдет до строки 5. Во время выполнения программы будет выброшено исключение ClassCastException. Поэтому запрет на создание таких массивов и был введен в язык Java — это позволяет нам избегать подобных ситуаций.

Как можно обойти стирание типов?

Что ж, стирание типов мы изучили. Давай попробуем обмануть систему! :) Задача: У нас есть класс-дженерик TestClass<T>. Нам нужно создать в нем метод createNewT(), который будет создавать и возвращать новый объект типа Т. Но ведь это невозможно сделать, так? Вся информация о типе Т будет стерта во время компиляции, и в процессе работы программы мы не сможем узнать, объект какого именно типа нам нужно создать. На самом деле, есть один хитрый способ. Ты наверняка помнишь, что в 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 — это дженерик! Стирание типов - 3В документации написано: “Т — это тип класса, моделируемого этим объектом 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 IllegalAccessException, InstantiationException {
       T t = typeParameterClass.newInstance();
       return t;
   }

   public static void main(String[] args) throws InstantiationException, IllegalAccessException {

       TestClass<MySecretClass> testString = new TestClass<>(MySecretClass.class);
       MySecretClass secret = testString.createNewT();

   }
}
Вывод в консоль:

Объект секретного класса успешно создан!
Мы просто передали нужный класс-параметр в конструктор нашего класса-дженерика:

TestClass<MySecretClass> testString = new TestClass<>(MySecretClass.class);
Благодаря этому мы сохранили информацию о типе-параметре и уберегли ее от стирания. В итоге мы смогли создать объект T! :) На этом сегодняшняя лекция подходит к концу. О стирании типов всегда необходимо помнить при работе с дженериками. Выглядит это дело не очень удобно, но нужно понимать — дженерики не были частью языка Java при его создании. Это позже прикрученная возможность, которая помогает нам создавать типизированные коллекции и отлавливать ошибки на этапе компиляции. В некоторых других языках, где дженерики появлялись с первой версии, стирание типов отсутствует (например, в C#). Впрочем, мы не закончили изучение дженериков! На следующей лекции ты познакомишься с еще несколькими особенностями работы с ними. А пока было бы неплохо решить пару задач! :)
Комментарии (44)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Алексей Уровень 41 Expert
24 марта 2023
Так и не могу понять про эту запись: public static <T> TestClass<T> createAndAdd2Values(Object o1, Object o2) Почему два раза пишем <T>? Нигде нет информации...
Gregory Parfer Уровень 82 Expert
3 января 2023
Отличное видео с разъяснениями по этой теме: https://www.youtube.com/watch?v=pgzHglCsTcw
Ant Уровень 29
9 декабря 2022
Как можно посмотреть стирание типов в коде? написал свой типизированный ArrayList, собрал в .class, и глядя внутрь вижу тот же типизированный класс. Не могу понять, что не так?)
hidden #3155849 Уровень 2
5 ноября 2022
Откуда вообще это ваше <T> TestClass<T> Зачем и что такое <T>?
velmik Уровень 38
5 сентября 2022
Все больше прихожу к пониманию того, что надо-бы как-то всем сообществам договориться о какой-то одной версии вектора алгоритма - безопасности в определленных сферах используемого языка, также касаемо и обучения программирования. А все остальное отдать(факультативно) уже на откуп для любителей поиска не стандартных решений! в других сферах жизни.
1 сентября 2022
Даже когда был сделан упор на объяснение понятным языком, все равно ничего не понял))). Нигде понятнее не нашел, чем в видео Заура Трегулова про Generics'ы
Руслан Уровень 43
31 мая 2022
В этом примере: List<String> strings = new ArrayList<>(); List<Cat> cats = new ArrayList<>(); System.out.println(strings.getClass() == cats.getClass()); == true. Но strings.getClass и cats.getClass будут равны ArrayList. Т.е. мы проверяем, равен ли ArrayList ему же, а не String и Cat. Или я чего-то не понял?
Серега Батенин Уровень 34
3 апреля 2022
Как говорит одна моя знакомая "Очень интересно, но нифига не понятно". Вот и мне пока мало что понятно(
LuneFox Уровень 41 Expert
26 января 2022
Мне кажется, или почти все лекции от профессора заканчиваются фразой "А пока было бы неплохо решить пару задач!", даже если следующей лекцией идёт не решение задач?)
Igor Уровень 41
12 июня 2021
Я бы пояснение дал в лекции по последнему примеру. Что там всё происходит из-за рефлексии.