Згідно з найголовнішим документом, тобто специфікацією мови Java (JLS — Java Language Specification), 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).
Дженеріки на котиках - 2

Generics

Отже, дженеріки. Що ж це таке? Дженерік — це особливий спосіб опису використовуваних типів, який може використовувати у своїй роботі компілятор коду для забезпечення типобезпеки. Виглядає це приблизно так:
Дженеріки на котиках - 3
А ось коротенький приклад і пояснення його:

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", тобто кутові скобки (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":
Дженеріки на котиках - 4
Таким чином, Інваріантність — це відсутність успадкування між похідними типами. Якщо Кішка — це підтип Тварини, то Множина<Кішки> не є підтипом Множина<Тварини> і Множина<Тварини> не є підтипом Множина<Кішки>. До речі, варто сказати, що починаючи з Java SE 7 з'явився так званий "Diamond Operator". Тому що дві кутові дужки <> схожі на діамант. Це дозволяє нам використовувати дженерики наступним чином:

public static void main(String[] args) {
  List<String> lines = new ArrayList<>();
  lines.add("Привіт, світе!");
  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 у сучасному коді НЕ ПОВИННІ використовуватися.
Дженерики на котиках - 5

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

Отже, типізовані класи. Давайте побачимо, як ми можемо написати свій типізований клас. Наприклад, у нас є ієрархія класів:

public static abstract class Animal {
  public abstract void voice();
}

public static class Cat extends Animal {
  public void voice(){
    System.out.println("Мяу-мяу");
  }
}

public static class Dog extends Animal {
  public void voice(){
    System.out.println("Гав-гав");
  }
}
Ми хочемо створити клас, який реалізовує контейнер для тварин. Можна було б написати клас, що буде містити будь-яких 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:
Дженерики на котиках - 6
Тобто саме завдяки 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".

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

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

Висновок

Дженеріки — дуже цікава тема. Сподіваюся, ця тема тебе зацікавила. Підбиваючи підсумки, можна сказати, що дженеріки — чудовий інструмент, який отримали розробники, щоб підказувати компілятору додаткову інформацію для забезпечення типобезпеки з одного боку і гнучкості з іншого. І якщо зацікавила, то пропоную ознайомитися з ресурсами, які сподобались самому: