JavaRush /Курси /JAVA 25 SELF /Знайомство з дженериками

Знайомство з дженериками

JAVA 25 SELF
Рівень 16 , Лекція 4
Відкрита

1. Вступ

Уявіть, що у вас є універсальна коробка, куди можна покласти що завгодно: яблуко, книжку або навіть іграшку. У Java така «універсальна коробка» — це клас, який зберігає дані найзагальнішого типу, Object. Цей тип є батьківським для всіх інших класів у Java, тому в змінну типу Object можна покласти абсолютно будь-який об’єкт.

Тепер уявіть склад із такими коробками. Якщо на коробках немає етикеток, ви можете покласти туди що завгодно, але коли настане час щось дістати — доведеться відкривати коробку й гадати, що всередині. З дженериками ж ситуація схожа на склад із акуратними написами: «Лише яблука», «Лише книжки», «Лише інструменти». Тепер ви завжди знаєте, що лежить у кожній коробці, і не зможете випадково покласти книжку в коробку для яблук.

На перший погляд, зберігання в Object здається зручним: не потрібно створювати окремі класи під різні типи даних. Але на практиці така «універсальність» обертається проблемами:

  1. Легко припуститися помилки. Ви можете випадково покласти в коробку не той об’єкт, який очікували.
  2. Компілятор не помітить проблеми. Він просто дозволить вам покласти що завгодно, адже тип Object це допускає.
  3. Доводиться вручну «розпаковувати». Коли ви дістаєте об’єкт із такої коробки, він знову буде типу Object, і вам доведеться самостійно кастувати його до потрібного типу (це називається кастуванням, або cast). Якщо помилитеся з типом — програма аварійно завершиться з помилкою!

Подивімося на це на простому прикладі.

class Box {
    private Object value;

    public void set(Object value) {
        this.value = value;
    }

    public Object get() {
        return value;
    }
}

Тепер використаємо цю коробку:

Box box = new Box();
box.set("Привіт"); // Поклали рядок
String s = (String) box.get(); // Дістали рядок, усе добре

box.set(123); // Поклали число
// Компілятор не бачить проблеми...
String t = (String) box.get(); // Помилка під час виконання програми!

Як бачите, компілятор спокійно пропустив код, який зрештою призвів до помилки. Ми дізналися про проблему лише тоді, коли програму запустили, і вона «впала».

2. Рішення — дженерики

Дженерики (Generics) — це спосіб розв’язати цю проблему. Це спеціальний синтаксис, який дозволяє прив’язати клас або метод до конкретного типу даних ще на етапі компіляції. Простіше кажучи, це як наліпка на коробці, що каже: «У цій коробці можуть лежати лише рядки» або «У цій коробці можуть лежати лише числа».

Таким чином, ми отримуємо типобезпечність: компілятор не дасть покласти в «коробку» неправильний об’єкт. Він перевірить наш код і вкаже на помилку до того, як ми запустимо програму.

Той самий клас Box, але тепер із дженериками:

class Box<Type> {
    private Type value;

    public void set(Type value) {
        this.value = value;
    }

    public Type get() {
        return value;
    }
}

Тут Type — це параметр типу. Це умовне імʼя, яке ми обираємо самі (зазвичай використовують T, E, K, V). Воно означає: «Коли ми створюватимемо Box, ми вкажемо, з яким типом він працюватиме, і я використовуватиму цей тип усюди, де в коді написано Type».

3. Використання дженериків

Коли ми створюємо об’єкт класу з дженериками, у кутових дужках <...> вказуємо конкретний тип.

// Створили коробку, що працює лише з рядками
Box<String> stringBox = new Box<>();
stringBox.set("Привіт, світе!"); // ОК, поклали рядок
String s = stringBox.get(); // Дістали рядок без приведення типів

stringBox.set(123); // Помилка компіляції! Компілятор цього не дозволить.

Якщо ми створимо Box<Integer>, то компілятор стежитиме за тим, щоб у нього клали лише числа:

// Створили коробку, що працює лише з числами
Box<Integer> intBox = new Box<>();
intBox.set(42); // ОК, поклали число
Integer number = intBox.get(); // Дістали число без приведення типів

intBox.set("Привіт"); // Помилка компіляції!

Тепер компілятор точно знає, який тип даних має бути в кожній коробці, і надійно захищає нас від помилок.

4. Як це працює на прикладах

Дженерики можна використовувати не лише в класах, а й у методах. Це дозволяє писати дуже гнучкий і універсальний код.

Приклад 1 — клас із дженериками

Припустімо, нам потрібен клас Pair, який зберігає два об’єкти одного типу. З дженериками це виглядає так:

class Pair<T> {
    private T first;
    private T second;

    public Pair(T first, T second) {
        this.first = first;
        this.second = second;
    }

    public T getFirst() {
        return first;
    }

    public T getSecond() {
        return second;
    }
}

Використання:

Pair<String> greetings = new Pair<String>("Привіт", "Світ");
System.out.println(greetings.getFirst() + " " + greetings.getSecond());

Pair<Integer> numbers = new Pair<Integer>(10, 20);
System.out.println(numbers.getFirst() + numbers.getSecond());

У першому випадку компілятор упевнений, що в greetings лежать рядки, у другому — числа.

Приклад 2 — універсальний метод

Ми можемо написати метод, який працює з будь-яким типом даних.

class Utils {
    // <T> перед void означає, що метод працюватиме з параметром типу T
    public static <T> void printTwice(T value) {
        System.out.println(value);
        System.out.println(value);
    }
}

Тепер ми можемо викликати цей метод із будь-яким типом даних, і він працюватиме однаково:

Utils.printTwice("Java");
Utils.printTwice(123);
Utils.printTwice(3.14);

5. Переваги дженериків

Дженерики — це не просто «синтаксичний цукор», а потужний інструмент, що розв’язує реальні проблеми в розробці. Розгляньмо три ключові переваги.

Типобезпечність — головна перевага дженериків. Без них компілятор не може перевірити, чи правильно ви використовуєте типи даних у «універсальних» класах і методах. Такі помилки, як спроба покласти рядок до «коробки для чисел», виявляються лише під час виконання програми, коли вона «впаде» з винятком ClassCastException. З дженериками компілятор суворо стежить за типами: у Box<String> — лише рядки, у Box<Integer> — лише числа. Якщо ви спробуєте зробити щось неправильне, він одразу вкаже на помилку, тож ви виправите її ще до запуску програми. Це робить код значно надійнішим і передбачуванішим.

Чистота коду (без зайвого кастування). Згадайте, як виглядав код із нашою «універсальною» коробкою без дженериків: Box box = new Box(); box.set("Привіт"); String s = (String) box.get(); Кожного разу, коли ви діставали об’єкт із коробки, доводилося писати (String), (Integer) тощо. З дженериками ця потреба зникає. Компілятор уже знає, який тип лежить усередині, і ви отримуєте значення потрібного типу без додаткових дій: Box<String> stringBox = new Box<>(); stringBox.set("Привіт"); String s = stringBox.get(); Це не лише робить код коротшим, а й значно покращує його читабельність і зручність.

Гнучкість і повторне використання коду. Дженерики дозволяють створювати по-справжньому універсальні класи й методи, які працюють із різними типами даних, не втрачаючи при цьому типобезпечності. Наприклад, клас Box<T> можна використовувати для зберігання рядків, чисел, ваших власних класів (Student, Car) — чого завгодно! Не потрібно писати окремі класи StringBox, IntegerBox і StudentBox. Ви пишете один універсальний клас Box<T> і просто вказуєте потрібний тип під час створення об’єкта. Це дозволяє скоротити обсяг коду, уникнути дублювання та зробити вашу програму більш модульною та гнучкою.

6. Обмеження дженериків

Попри всі переваги, у дженериків є кілька важливих обмежень, про які варто знати.

Примітиви (primitives). Ви не можете використовувати примітивні типи (такі як int, double, boolean тощо) як параметр типу. Наприклад, код Box<int> intBox = new Box<>(); спричинить помилку компіляції. Натомість потрібно використовувати їхні «класи-обгортки» (Integer, Double, Boolean): Box<Integer> intBox = new Box<>();. Компілятор Java уміє автоматично перетворювати примітиви на класи-обгортки й навпаки (int у Integer і навпаки) — цей механізм називається автобоксингом/анбоксингом (autoboxing/unboxing).

Стирання типів (Type Erasure). Це ключова особливість того, як дженерики реалізовано в Java. Ідея в тому, що компілятор використовує інформацію про дженерики лише під час компіляції. Після того як він перевірив ваш код і переконався в його типобезпечності, всю інформацію про параметри типу стирають. Наприклад, Box<String> і Box<Integer> у скомпільованому коді (в байт-коді) виглядатимуть просто як Box. Тобто для віртуальної машини Java (JVM) це один і той самий тип.

Що це означає для вас? Ви не можете створити масив дженериків, наприклад new Box<String>[10], і не можете використовувати instanceof із дженериками (наприклад, instanceof Box<String>). Тема непроста, але її розуміння важливе для глибшого вивчення Java. На цьому етапі досить запам’ятати: дженерики — це, по суті, «наліпка» для компілятора, яка допомагає йому перевіряти код, але цю наліпку знімають, коли програма готова до запуску.

1
Опитування
Вкладені та внутрішні класи, рівень 16, лекція 4
Недоступний
Вкладені та внутрішні класи
Вкладені та внутрішні класи
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ