Привіт!
Ми продовжуємо серію лекцій про дженеріки. Раніше ми розібралися в загальних рисах, що це таке і навіщо потрібно. Сьогодні поговоримо про деякі особливості дженеріків і розглянемо деякі підводні камені при роботі з ними. Поїхали!
У минулій лекції ми говорили про різницю між Generic Types і Raw Types. Якщо ти забув, Raw Type — це клас-дженерік, з якого видалили його тип.
У документації написано: “Т — це тип класу, що моделюється цим об'єктом Class”.
Якщо перекласти це з мови документації на людську, це означає, що класом для об'єкта
У минулій лекції ми говорили про різницю між 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 — це дженерік!
У документації написано: “Т — це тип класу, що моделюється цим об'єктом 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#).
Втім, ми не закінчили вивчення дженеріків! На наступній лекції ти познайомишся з ще кількома особливостями роботи з ними. А поки було б непогано вирішити кілька задач! :)
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ