JavaRush /Java блог /Random UA /Теорія дженериків у Java або як на практиці ставити дужки...
Viacheslav
3 рівень

Теорія дженериків у Java або як на практиці ставити дужки

Стаття з групи Random UA

Вступ

Починаючи з JSE 5.0 до арсеналу мови Java були додані дженерики.
Теорія дженериків у Java або як на практиці ставити дужки.

Що таке дженерики в Java?

Дженерики (узагальнення) - це особливі засоби мови Java для реалізації узагальненого програмування: особливого підходу до опису даних та алгоритмів, що дозволяє працювати з різними типами даних без зміни їх опису. На сайті Oracle дженерикам присвячений окремий tutorial: " Lesson: Generics ".

По-перше, щоб зрозуміти дженерики, потрібно розібратися, навіщо вони взагалі потрібні і що дають. У tutorial у розділі " Why Use Generics ?" сказано, що одне з призначень - сильніша перевірка типів під час компіляції та усунення необхідності явного приведення.
Теорія дженериків у Java або як на практиці ставити дужки.
Приготуємо для дослідів улюблений tutorialspoint online java compiler . Уявімо такий код:
import java.util.*;
public class HelloWorld{
	public static void main(String []args){
		List list = new ArrayList();
		list.add("Hello");
		String text = list.get(0) + ", world!";
		System.out.print(text);
	}
}
Цей код виконається добре. Але якщо до нас прийшли і сказали, що фраза "Hello, world!" побита і можна повернути лише Hello? Видалимо з коду конкатенацію з рядком ", world!". Здавалося б, що може бути невинніше? Але насправді ми отримаємо помилку ПРИ КОМПІЛЯЦІЇ : error: incompatible types: Object cannot be converted to String Справа в тому, що в нашому випадку List зберігає список об'єктів типу Object. Так як String - спадкоємець для Object (бо всі класи неявно успадковуються Java від Object), то вимагає явного приведення, чого ми не зробабо. А при конкатенації об'єкта буде викликаний статичний метод String.valueOf(obj), який у результаті викличе метод toString для Object. Тобто List містить Object. Виходить, там, де нам потрібен конкретний тип, а не Object, нам доведеться самим робити приведення типів:
import java.util.*;
public class HelloWorld{
	public static void main(String []args){
		List list = new ArrayList();
		list.add("Hello!");
		list.add(123);
		for (Object str : list) {
		    System.out.println((String)str);
		}
	}
}
Проте, у разі, т.к. List приймає список об'єктів, він зберігає як String, а й Integer. Але найгірше, у цьому випадку компілятор не побачить нічого поганого. І тут ми отримаємо помилку вже ПІД ЧАС ВИКОНАННЯ (ще кажуть, що помилка отримана "в Runtime"). Помилка буде: java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String Погодьтеся, не найприємніше. І все це тому, що компілятор - не штучний інтелект і він не може вгадати все, що передбачає програміст. Щоб розповісти компілятору докладніше про свої наміри, які типи ми збираємося використовувати, Java SE 5 ввели дженерики . Виправимо наш варіант, підказавши компілятору, що ми хочемо:
import java.util.*;
public class HelloWorld {
	public static void main(String []args){
		List<String> list = new ArrayList<>();
		list.add("Hello!");
		list.add(123);
		for (Object str : list) {
		    System.out.println(str);
		}
	}
}
Як ми бачимо, нам більше не потрібне приведення до String. Крім того, у нас з'явабося кутові дужки (angle brackets), які обрамляють дженерики. Тепер компілятор не дасть скомпілювати клас, доки ми не видалимо додавання 123 до списку, т.к. це Integer. Він нам так і скаже. Багато хто називає дженерики "синтаксичним цукром". І вони мають рацію, тому що дженерики дійсно при компіляції стануть тими самими кастами. Подивимося на байткод скомпілованих класів: з кастом вручну та з використанням дженериків:
Теорія дженериків у Java або як на практиці ставити дужки.
Після компіляції будь-яка інформація про дженериків стирається. Це називається "Стирання типів" або " Type Erasure ". Стирання типів та дженерики зроблено так, щоб забезпечити зворотну сумісність зі старими версіями JDK, але при цьому дати можливість допомагати компілятору з визначенням типу у нових версіях Java.
Теорія дженериків у Java або як на практиці ставити дужки.

Raw Types або сирі типи

Говорячи про дженериків ми завжди маємо дві категорії: типізовані типи (Generic Types) та "сирі" типи (Raw Types). Сирі типи - це типи без вказівки "уточнення" у фігурних дужках (angle brackets):
Теорія дженериків у Java або як на практиці ставити дужки.
Типізовані типи - навпаки, із зазначенням "уточнення":
Теорія дженериків у Java або як на практиці ставити дужки.
Як бачимо, ми використовували незвичайну конструкцію, позначену стрілкою на скріншоті. Це особливий синтаксис, який додали Java SE 7, і називається він " the diamond " , що в перекладі означає алмаз. Чому? Можна провести аналогію форми алмазу та форми фігурних дужок: <> Також Diamond синтаксис пов'язаний з поняттям " Type Inference ", або виведення типів. Адже компілятор, бачачи праворуч дивиться на ліву частину, де розташоване оголошення типу змінної, в яку присвоюється значення. І з цієї частини розуміє, яким типом типізується значення праворуч. Насправді, якщо в лівій частині вказано дженерик, а справа не вказано, компілятор зможе вивести тип:
import java.util.*;
public class HelloWorld{
	public static void main(String []args) {
		List<String> list = new ArrayList();
		list.add("Hello World");
		String data = list.get(0);
		System.out.println(data);
	}
}
Однак це буде змішування нового стилю з дженериками і старого стилю без них. І це вкрай небажано. При компіляції коду ми отримаємо повідомлення: Note: HelloWorld.java uses unchecked or unsafe operations. Насправді здається незрозумілим, навіщо взагалі треба тут додавати. Але приклад:
import java.util.*;
public class HelloWorld{
	public static void main(String []args) {
		List<String> list = Arrays.asList("Hello", "World");
		List<Integer> data = new ArrayList(list);
		Integer intNumber = data.get(0);
		System.out.println(data);
	}
}
Як ми пам'ятаємо, ArrayList має і другий конструктор, який приймає на вхід колекцію. І ось тут і криється підступність. Без diamond синтаксису компілятор не розуміє, що його обманюють, а ось із diamond – розуміє. Тому, правило #1 : завжди використовувати синтексис алмаз, якщо ми використовуємо типізовані типи. А якщо ні, то ми ризикуємо пропустити, де у нас використовується raw type. Щоб уникнути попереджень у лозі про те, що "uses unchecked or unsafe operations" можна над методом або класом вказати особливу анотацію: @SuppressWarnings("unchecked") Suppress перекладається як придушувати, тобто дослівно - придушити попередження. Але подумайте, чому ви вирішабо її вказати? Згадайте правило номер один і, можливо, вам потрібно додати типізацію.
Теорія дженериків у Java або як на практиці ставити дужки.

Типізовані методи (Generic Methods)

Дженеріки дозволяють типизувати методи. Даній можливості в tutorial від Oracle присвячений окремий розділ: " Generic Methods ". З цього tutorial важливо запам'ятати про синтаксис:
  • включає список типизованих параметрів усередині кутових дужок;
  • список типизованих параметрів йде до методу, що повертається.
Подивимося на приклад:
import java.util.*;
public class HelloWorld{

    public static class Util {
        public static <T> T getValue(Object obj, Class<T> clazz) {
            return (T) obj;
        }
        public static <T> T getValue(Object obj) {
            return (T) obj;
        }
    }

    public static void main(String []args) {
		List list = Arrays.asList("Author", "Book");
		for (Object element : list) {
		    String data = Util.getValue(element, String.class);
		    System.out.println(data);
		    System.out.println(Util.<String>getValue(element));
		}
    }
}
Якщо подивитися на клас Util, бачимо в ньому два типизовані методи. Завдяки можливості виведення типів ми можемо надати визначення типу безпосередньо компілятор, а можемо самі це вказати. Обидва варіанти представлені у прикладі. До речі, синтаксис дуже логічний, якщо подумати. При типізації методу ми вказуємо дженерик до методу, тому що якщо ми будемо використовувати дженерик після методу, Java не зможе зрозуміти, який тип використовувати. Тому спочатку оголошуємо, що використовуватимемо дженерик T, а потім уже говоримо, що цей дженерик ми збираємось повертати. Звичайно, Util.<Integer>getValue(element, String.class)впаде з помилкою incompatible types: Class<String> cannot be converted to Class<Integer>. При використанні типізованих методів варто завжди пам'ятати про стирання типів. Подивимося на приклад:
import java.util.*;
public class HelloWorld {

    public static class Util {
        public static <T> T getValue(Object obj) {
            return (T) obj;
        }
    }

    public static void main(String []args) {
		List list = Arrays.asList(2, 3);
		for (Object element : list) {
		    System.out.println(Util.<Integer>getValue(element) + 1);
		}
    }
}
Він чудово працюватиме. Але тільки доти, поки компілятор буде розуміти, що у методу, що викликається, тип Integer. Замінимо виведення на консоль на наступний рядок: System.out.println(Util.getValue(element) + 1); І ми отримаємо помилку: bad operand types for binary operator '+', перший тип: Object, second type: int Тобто відбулося стирання типів. Компілятор бачить, що тип ніхто не вказав, тип вказується як Object та виконання коду падає з помилкою.
Теорія дженериків у Java або як на практиці ставити дужки.

Типізовані класи (Generic Types)

Типізувати можна як методи, а й самі класи. У Oracle в їх гайді цьому присвячено розділ " Generic Types ". Розглянемо приклад:
public static class SomeType<T> {
	public <E> void test(Collection<E> collection) {
		for (E element : collection) {
			System.out.println(element);
		}
	}
	public void test(List<Integer> collection) {
		for (Integer element : collection) {
			System.out.println(element);
		}
	}
}
Тут все просто. Якщо ми використовуємо клас, дженерик вказується після імені класу. Давайте тепер у методі main створимо екземпляр цього класу:
public static void main(String []args) {
	SomeType<String> st = new SomeType<>();
	List<String> list = Arrays.asList("test");
	st.test(list);
}
Він добре відпрацює. Компілятор бачить, що є List із чисел і Collection типу String. Але якщо ми зітремо дженерики і зробимо так:
SomeType st = new SomeType();
List<String> list = Arrays.asList("test");
st.test(list);
Ми отримаємо помилку: java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer Знову стирання типів. Оскільки у класу більше немає дженерика, компілятор вирішує: якщо ми передали List, метод із List<Integer> більш підходящий. І ми падаємо з помилкою. Тому, правило #2: Якщо клас типизований, завжди вказувати тип у дженериці .

Обмеження

До типів, що вказуються в дженериках ми можемо застосувати обмеження. Наприклад, ми хочемо, щоб контейнер приймав на вхід лише Number. Ця можливість описана в Oracle Tutorial у розділі Bounded Type Parameters . Подивимося на приклад:
import java.util.*;
public class HelloWorld{

    public static class NumberContainer<T extends Number> {
        private T number;

        public NumberContainer(T number)  { this.number = number; }

        public void print() {
            System.out.println(number);
        }
    }

    public static void main(String []args) {
		NumberContainer number1 = new NumberContainer(2L);
		NumberContainer number2 = new NumberContainer(1);
		NumberContainer number3 = new NumberContainer("f");
    }
}
Як бачимо, ми обмежабо тип дженерика як клас/інтерфейс Number та спадкоємці. Цікаво, що можна вказати не лише клас, а й інтерфейси. Наприклад: public static class NumberContainer<T extends Number & Comparable> { Ще дженерики мають поняття Wildcard https://docs.oracle.com/javase/tutorial/java/generics/wildcards.html Вони в свою чергу діляться на три типи: З Wildcard діє так званий Get Put principle . Можна їх висловити у такому вигляді:
Теорія дженериків у Java або як на практиці ставити дужки.
Цей принцип ще називають принципом PECS (Producer Extends Consumer Super). Докладніше можна прочитати на хабре в статті " Використання generic wildcards для підвищення зручності Java API ", а також у відмінному обговоренні на stackoverflow: " Використання wildcard в Generics Java ". Ось невеликий приклад із вихідних джерел Java — метод Collections.copy:
Теорія дженериків у Java або як на практиці ставити дужки - 10
Ну і невеликий приклад того, як НЕ працюватиме:
public static class TestClass {
	public static void print(List<? extends String> list) {
		list.add("Hello World!");
		System.out.println(list.get(0));
	}
}

public static void main(String []args) {
	List<String> list = new ArrayList<>();
	TestClass.print(list);
}
Але якщо замінити extends на super, все стане гаразд. Так як ми наповнюємо список list значенням перед виведенням, він для нас є споживачем, тобто consumer'ом. Отже, використовуємо super.

успадкування

Є ще одна незвичайна особливість дженериків – це їхнє успадкування. Спадкування дженериків описано в tutorial від Oracle у розділі " Generics, Inheritance, and Subtypes ". Головне це запам'ятати та усвідомити таке. Ми не можемо зробити так:
List<CharSequence> list1 = new ArrayList<String>();
Тому що успадкування працює з дженериками по-іншому:
Теорія дженериків у Java або як на практиці ставити дужки.
І ось ще добрий приклад, який впаде з помилкою:
List<String> list1 = new ArrayList<>();
List<Object> list2 = list1;
Тут також все просто. List<String> не є спадкоємцем List<Object>, хоча String є спадкоємцем Object.

Final

Ось ми й освіжабо у пам'яті дженерики. Якщо їх рідко використовувати у всій їх мощі, якісь деталі випадають із пам'яті. Сподіваюся, цей невеликий огляд допоможе освіжити у пам'яті. А для більшого результату рекомендую ознайомитися з наступними матеріалами: #Viacheslav
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ