Привіт! Ми продовжуємо серію лекцій про дженеріки. Раніше ми розібралися в загальних рисах, що це таке і навіщо потрібно. Сьогодні поговоримо про деякі особливості дженеріків і розглянемо деякі підводні камені при роботі з ними. Поїхали! Стирання типів - 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#). Втім, ми не закінчили вивчення дженеріків! На наступній лекції ти познайомишся з ще кількома особливостями роботи з ними. А поки було б непогано вирішити кілька задач! :)