JavaRush /Java блог /Random UA /Дженерики на котиках
Viacheslav
3 рівень

Дженерики на котиках

Стаття з групи Random UA
Дженерики на котиках.

Вступ

Сьогодні чудовий день, щоб згадати, що ж ми знаємо про Java. Згідно з найголовнішим документом, тобто. специфікації мови Java (JLS - Java Language Specifiaction), Java є строго типізованим мовою, що сказано у розділі " Chapter 4. Types, Values, and Variables " . Що це означає? Припустимо, у нас є головний метод:
public static void main(String[] args) {
String text = "Hello world!";
System.out.println(text);
}
Сувора типізація гарантує, що з компіляції даного коду компілятор перевірить, що ми вказали тип змінної text як String, ми ніде намагаємося використовувати її як змінну іншого типу (наприклад як число Integer). Наприклад, якщо ми замість тексту спробуємо зберегти значення 2L(тобто long замість String), то на момент компіляції отримаємо помилку:

Main.java:3: error: incompatible types: long cannot be converted to String
String text = 2L;
Тобто. Сувора типізація дозволяє забезпечити виконання операцій з об'єктами лише тоді, коли ці операції є правомірними для цих об'єктів. Це ще називається типобезпекою або "Type safety". Як сказано в JLS, у Java існує дві категорії типів: примітивні типи (primitive types) і типи посилань (reference types). Про примітивні типи можна згадати за оглядовою статтею: " Примітивні типи в Java: Не такі вони і примітивні ". Типи посилань можуть бути представлені класом, інтерфейсом або масивом. І сьогодні нас цікавитимуть саме типи посилань. І почнемо з масивів:
class Main {
  public static void main(String[] args) {
    String[] text = new String[5];
    text[0] = "Hello";
  }
}
Такий код виконується без помилок. Як ми знаємо (наприклад, за " Oracle Java Tutorial: Arrays "), масив - це контейнер, який зберігає дані лише одного типу. У даному випадку – лише рядки. Спробуємо додати до масиву long замість String:
text[1] = 4L;
Виконаємо цей код (наприклад, Repl.it Online Java Compiler ) і отримаємо помилку:
error: incompatible types: long cannot be converted to String
Масив та типобезпека мови не дали нам зберегти в масив те, що не підходить за типом. Ось він прояв type safety. Нам сказали: "Виправи помилку, а доти я не скомпілюю код". І найголовніше — це відбувається в момент компіляції, а не запуску програми. Тобто помилки ми бачимо відразу, а не колись. А якщо ми згадали про масиви, то згадаємо і про Java Collections Framework . Там ми мали різні структури. Наприклад, списки. Давайте перепишемо приклад:
import java.util.*;
class Main {
  public static void main(String[] args) {
    List text = new ArrayList(5);
    text.add("Hello");
    text.add(4L);
    String test = text.get(0);
  }
}
При компіляції його ми отримаємо на рядку ініціалізації змінної testпомилки:
incompatible types: Object cannot be converted to String
У нашому випадку, List може зберігати будь-який об'єкт (тобто об'єкт типу Object). Тому компілятор каже, що він на себе такий тягар відповідальності брати не може. Тому нам треба явно вказати тип, який ми отримаємо зі списку:
String test = (String) text.get(0);
Така вказівка ​​називається наведенням типів (type conversion чи type casting). І все буде працювати тепер чудово, поки ми не спробуємо дістати елемент індексу 1, т.к. він має тип Long. І ми отримаємо справедливу помилку, але вже під час роботи програми (в Runtime):

type conversion, typecasting
Exception in thread "main" java.lang.ClassCastException: java.lang.Long cannot be cast to java.lang.String
Як бачимо, тут відразу кілька важливих мінусів. По-перше, ми змушені самі "кастити" значення, отримане зі списку, до класу String. Погодьтеся, це негарно. По-друге, у разі помилки ми її побачимо тільки тоді, коли програма буде виконуватися. Якби наш код був складнішим, ми могли б виявити таку помилку далеко не відразу. І стали розробники думати, як зробити роботу в таких ситуаціях простішим, а код наочнішим. І народабося вони — Дженерики (Generics).
Дженерики на котиках.

Generics

Отже, дженерики. Що це таке? Дженерік - це особливий спосіб опис типів, який використовує у своїй роботі компілятор коду для забезпечення типобезпеки. Виглядає це приблизно так:
Дженерики на котиках.
А ось коротенький приклад і пояснення його:
import java.util.*;
class Main {
  public static void main(String[] args) {
    List<String> text = new ArrayList<String>(5);
    text.add("Hello");
    text.add(4L);
    String test = text.get(1);
  }
}
У цьому прикладі ми говоримо, що ми не просто List, а List, що працює ТІЛЬКИ з об'єктами типу String. І жодними іншими. Ось щойно зазначено в дужках, то й можемо зберігати. Такі "дужки" називаються "angle brackets", тобто. кутові (angle) дужки (brackets). Компілятор люб'язно за нас перевірить, чи припустабося ми помилок при роботі зі списком рядків (список має ім'я text). Компілятор побачить, що ми намагаємося до списку String покласти зухвалим чином Long. І на момент компіляції видасть помилку:
error: no suitable method found for add(long)
Ви згадали про те, що String - це спадкоємець CharSequence. І вирішіть зробити щось на зразок:
public static void main(String[] args) {
	ArrayList<CharSequence> text = new ArrayList<String>(5);
	text.add("Hello");
	String test = text.get(0);
}
Але не можна і ми отримаємо помилку: error: incompatible types: ArrayList<String> cannot be converted to ArrayList<CharSequence> Здається дивним, т.к. рядок CharSequence sec = "test";не містить помилок. Давайте розумітися. Про таку поведінку кажуть: "Дженерики є інваріантними". Що таке "інваріант"? Мені подобається, як про це сказано на вікіпедії у статті " Covariance and contravariance ":
Дженерики на котиках.
Таким чином, інваріантність - це відсутність успадкування між похідними типами. Якщо Кішка - це підтип Тварини, то Множина<Кішки> не є підтипом Множина<Тварини> і Множина<Тварини> не є підтипом Множина<Кішки>. До речі, варто сказати, що починаючи з Java SE 7 з'явився так званий " Diamond Operator ". Тому що дві кутові дужки <> схожі на діамант. Це дозволяє нам використовувати дженерики таким чином:
public static void main(String[] args) {
  List<String> lines = new ArrayList<>();
  lines.add("Hello world!");
  System.out.println(lines);
}
Компілятор за цим кодом розуміє, що якщо ми вказали в лівій частині, що Listміститиме об'єкти типу String, то в правій частині ми маємо на увазі, що хочемо в змінну linesзберегти новий ArrayList, який зберігатиме також об'єкт того типу, який вказаний у лівій частині. Таким чином, компілятор із лівої частини розуміє або виводить тип для правої частини. Саме тому така поведінка називається виведенням типу або "Type Inference" англійською. Варто відзначити ще таку цікаву річ, як RAW Types або "сирі типи". Т.к. дженерики були не завжди, а Java намагається по можливості підтримувати зворотну сумісність, то дженерики змушені якось працювати з кодом, де не вказано жодного дженерика. Подивимося приклад:
List<CharSequence> lines = new ArrayList<String>();
Як ми пам'ятаємо, такий рядок не скомпілюється через інваріантність дженериків.
List<Object> lines = new ArrayList<String>();
І така теж не скомпілюється, з тієї ж причини.
List lines = new ArrayList<String>();
List<String> lines2 = new ArrayList();
Такі рядки скомпілюється і працюватимуть. Саме використовується Raw Types, тобто. не вказані типи. Ще раз варто вказати, що Raw Types у сучасному коді НЕ ПОВИННІ бути використані.
Дженерики на котиках.

Типізовані класи

Отже, типізовані класи. Побачимо, як ми можемо написати свій типізований клас. Наприклад, у нас є ієрархія класів:
public static abstract class Animal {
  public abstract void voice();
}

public static class Cat extends Animal {
  public void voice(){
    System.out.println("Meow meow");
  }
}

public static class Dog extends Animal {
  public void voice(){
    System.out.println("Woof woof");
  }
}
Ми хочемо створити клас, який реалізує контейнер для тварин. Можна було б написати клас, який міститиме будь-яких Animal. Це просто, зрозуміло, АЛЕ... заважати собак і кішок погано, вони не дружать один з одним. Крім того, якщо хтось отримає такий контейнер, він помилково може запустити з контейнера кішок у зграю собак... і до добра це не доведе. І тут нам допоможуть дженерики. Наприклад, напишемо реалізацію так:
public static class Box<T> {
  List<T> slots = new ArrayList<>();
  public List<T> getSlots() {
    return slots;
  }
}
Наш клас буде працювати з об'єктами типу, вказані дженериком з ім'ям T. Це свого роду псевдонім. Т.к. дженерик вказаний у імені класу, то й отримуватимемо його при оголошенні класу:
public static void main(String[] args) {
  Box<Cat> catBox = new Box<>();
  Cat murzik = new Cat();
  catBox.getSlots().add(murzik);
}
Як ми бачимо, ми вказали, що у нас Box, який працює тільки з Cat. Компілятор зрозумів, що замість catBoxдженерика Tпотрібно підставити тип Catскрізь, де вказано ім'я дженерика T:
Дженерики на котиках.
Тобто. саме завдяки Box<Cat>компілятор розуміє, що slotsнасправді має бути List<Cat>. Для Box<Dog>всередині буде slots, що містить List<Dog>. Дженеріков в оголошенні типу може бути кілька, наприклад:
public static class Box<T, V> {
Назва дженерика може бути будь-якою, хоча рекомендується дотримуватися деяких негласних правил - "Type Parameter Naming Conventions": Тип елемента - E, тип ключа - K, тип числа - N, T - для позначення типу, V - для типу значення. До речі, пам'ятаєте ми з вами говорабо, що дженерики інваріантні, тобто. не зберігають ієрархію спадкування. Насправді ми можемо на це вплинути. Тобто ми маємо можливість зробити дженерики КОваріантними, тобто. що зберігають успадкування у тому порядку. Таке поведінка називається " Bounded Type " , тобто. обмежені типи. Наприклад, наш клас Boxміг би утримувати всіх тварин, тоді ми оголосабо б дженерик таким чином:
public static class Box<T extends Animal> {
Тобто ми поставабо обмежабо верхню межу класом Animal. Також ми можемо вказати кілька типів після ключового слова extends. Це означатиме, що тип, з яким ми будемо працювати, має бути спадкоємцем деякого класу і при цьому реалізовувати певний інтерфейс. Наприклад:
public static class Box<T extends Animal & Comparable> {
У цьому випадку, якщо ми спробуємо покласти в таке Boxщось, що не є спадкоємцем Animalі не реалізує Comparable, то під час компіляції ми отримаємо помилку:
error: type argument Cat is not within bounds of type-variable T
Дженерики на котиках - 7

Типізація методів

Дженерики застосовуються у типах, а й у окремих методах. Застосування у методах можна побачити в офіційному tutorial: " Generics Methods ".

Передісторія:

Дженерики на котиках.
Подивимося на цю картинку. Як видно, компілятор дивиться на сигнатуру методу і бачить, що ми приймаємо на вхід якийсь невизначений клас. Не сигнатурою він визначає, що ми повертаємо якийсь об'єкт, тобто. Object. Отже, якщо ми хочемо створити, скажімо, ArrayList, то треба робити так:
ArrayList<String> object = (ArrayList<String>) createObject(ArrayList.class);
Доводиться самим явно писати, що на виході буде ArrayList, що негарно і додає шансу зробити помилку. Наприклад, ми можемо написати таку марення і це скомпілюється:
ArrayList object = (ArrayList) createObject(LinkedList.class);
Чи можемо допомогти компілятору? Так, дженерики нам це дозволяють. Розглянемо той самий приклад:
Дженерики на котиках.
Тоді ми можемо створити об'єкт просто ось так:
ArrayList<String> object = createObject(ArrayList.class);
Дженерики на котиках - 10

WildCard

Відповідно до Tutorial від Oracle по дженерикам, саме розділу " Wildcards " , ми можемо описати "невідомий тип" символом питання, так званого question mark. Wildcard - зручний інструмент, щоб пом'якшити деякі обмеження дженериків. Наприклад, як ми раніше розбирали, дженерики інваріантні. Це означає, що хоча всі класи є спадкоємцями (підтипами, subtypes) типу Object, але List<любой тип>не є підтипом List<Object>. АЛЕ List<любой тип>є підтипом List<?>. Таким чином, ми можемо написати наступний код:
public static void printList(List<?> list) {
  for (Object elem: list) {
    System.out.print(elem + " ");
  }
  System.out.println();
}
Як і звичайні дженерики (тобто без використання wildcard'ів), дженерики з wildcard можуть бути обмежені. Обмеження верхньої межі (Upper bounded wildcard) виглядає звично:
public static void printCatList(List<? extends Cat> list) {
  for (Cat cat: list) {
    System.out.print(cat + " ");
  }
  System.out.println();
}
Але можна обмежити і по нижньому кордоні (Lower bound wildcard):
public static void printCatList(List<? super Cat> list) {
Таким чином, метод почне приймати всіх котів, а так само по ієрархії вище (аж до Object).
Дженерики на котиках - 11

Стирання типів або Type Erasure

Говорячи про дженерики варто знати про "Стирання типів". Насправді стирання типів це про те, що дженерики це інформація для компілятора. Під час виконання програми інформації про дженериків більше немає, це і називається "прання". Це стирання має ефект, що тип дженерика замінюється конкретним типом. Якщо дженерик у відсутності кордону, буде підставлено тип Object. Якщо ж межа була вказана (наприклад <T extends Comparable>), вона і буде підставлена. Ось приклад із Tutorial від Oracle: " Erasure of Generic Types ":
Дженерики на котиках - 12
Як і було сказано, у цьому прикладі дженерик Tстертий до свого кордону, тобто. до Comparable.
Дженерики на котиках - 13

Висновок

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