JavaRush /Java блог /Random UA /Стирання типів
Professor Hans Noodles
41 рівень

Стирання типів

Стаття з групи Random UA
Вітання! Ми продовжуємо серію лекцій про дженериків. Раніше ми розібралися загалом, що це таке, і навіщо потрібно. Сьогодні поговоримо про деякі особливості дженериків і розглянемо деякі підводні камені під час роботи з ними. Поїхали! Стирання типів - 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#). Втім, ми не закінчабо вивчення дженериків! На наступній лекції ти познайомишся з декількома особливостями роботи з ними. А поки що було б непогано вирішити пару завдань! :)
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ