JavaRush /Java блог /Java Developer /Что такое дженерики в Java
Автор
Владимир Портянко
Java-разработчик в Playtika

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

Статья из группы Java Developer
Привет! Сегодня мы поговорим о дженериках. Надо сказать, что ты выучишь много нового! Дженерикам будет посвящена не только эта, но еще и несколько следующих лекций. Что такое дженерики в Java - 1 Поэтому, если эта тема тебе интересна — тебе повезло: сегодня ты узнаешь многое об особенностях дженериков. Ну а если нет — смирись и расслабься! :) Это очень важная тема, и знать ее нужно. Давай начнем с простого: «что» и «зачем». Что такое дженерики? Дженерики — это типы с параметром. При создании дженерика ты указываешь не только его тип, но и тип данных, с которыми он должен работать. Думаю, самый очевидный пример уже пришел тебе в голову — это ArrayList! Вот как мы обычно создаем его в программе:

import java.util.ArrayList;
import java.util.List;

public class Main {

   public static void main(String[] args) {

       List<String> myList1 = new ArrayList<>();
       myList1.add("Test String 1");
       myList1.add("Test String 2");
   }
}
Как нетрудно догадаться, особенность списка заключается в том, что в него нельзя будет «запихивать» все подряд: он работает исключительно с объектами String. Теперь давай сделаем небольшой экскурс в историю Java и попробуем ответить на вопрос: «зачем?». Для этого мы сами напишем упрощенную версию класса ArrayList. Наш список умеет только добавлять данные во внутренний массив и получать эти данные:

public class MyListClass {

   private Object[] data;
   private int count;

   public MyListClass() {
       this.data = new Object[10];
       this.count = 0;
   }

   public void add(Object o) {
       this.data[count] = o;
       count++;
   }

   public Object[] getData() {
       return data;
   }
}
Допустим, мы хотим, чтобы наш список хранил только числа Integer. Дженериков у нас нет. Мы не можем явно указать проверку o instance of Integer в методе add(). Тогда весь наш класс будет пригоден только для Integer, и нам придется писать такой же класс для всех существующих в мире типов данных! Мы решаем положиться на наших программистов, и просто оставим в коде комментарий, чтобы они не добавляли туда ничего лишнего:

//use it ONLY with Integer data type
public void add(Object o) {
   this.data[count] = o;
   count++;
}
Один из программистов прозевал этот комментарий и попытался по невнимательности положить в список числа вперемешку со строками, а потом посчитать их сумму:

public class Main {

   public static void main(String[] args) {

       MyListClass list = new MyListClass();
       list.add(100);
       list.add(200);
       list.add("Lolkek");
       list.add("Shalala");

       Integer sum1 = (Integer) list.getData()[0] + (Integer) list.getData()[1];
       System.out.println(sum1);

       Integer sum2 = (Integer) list.getData()[2] + (Integer) list.getData()[3];
       System.out.println(sum2);
   }
}
Вывод в консоль: 300 Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer at Main.main(Main.java:14) Что худшее в этой ситуации? Далеко не невнимательность программиста. Худшее то, что неправильный код попал в важное место нашей программы и успешно скомпилировался. Теперь мы увидим ошибку не на этапе написания кода, а только на этапе тестирования (и это в лучшем случае!). Исправление ошибок на более поздних этапах разработки стоит намного больше — и денег, и времени. Именно в этом заключается преимущество дженериков: класс-дженерик позволит незадачливому программисту обнаружить ошибку сразу же. Код просто не скомпилируется!

import java.util.ArrayList;
import java.util.List;

public class Main {

   public static void main(String[] args) {

       List<Integer> myList1 = new ArrayList<>();
      
       myList1.add(100);
       myList1.add(100);
       myList1.add("Lolkek");//ошибка!
       myList1.add("Shalala");//ошибка!
   }
}
Программист сразу «очухается» и моментально исправится. Кстати, нам не обязательно было создавать свой собственный класс-List, чтобы увидеть ошибку такого рода. Достаточно просто убрать скобки с указанием типа (<Integer>) из обычного ArrayList!

import java.util.ArrayList;
import java.util.List;

public class Main {

   public static void main(String[] args) {

      List list = new ArrayList();

      list.add(100);
      list.add(200);
      list.add("Lolkek");
      list.add("Shalala");

       System.out.println((Integer) list.get(0) + (Integer) list.get(1));
       System.out.println((Integer) list.get(2) + (Integer) list.get(3));
   }
}
Вывод в консоль: 300 Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer at Main.main(Main.java:16) То есть даже используя «родные» средства Java, можно допустить такую ошибку и создать небезопасную коллекцию. Однако, если вставить этот код в IDEa, мы увидим предупреждение: “Unchecked call to add(E) as a member of raw type of java.util.List” Нам подсказывают, что при добавлении элемента в коллекцию без дженериков что-то может пойти не так. Но что значит фраза «raw type»? Дословный перевод будет вполне точным — «сырой тип» или «грязный тип». Raw type — это класс-дженерик, из которого удалили его тип. Иными словами, List myList1 — это Raw type. Противоположностью raw type является generic type — класс-дженерик (также известный как parameterized type), созданный правильно, с указанием типа. Например, List<String> myList1. У тебя мог возникнуть вопрос: а почему в языке вообще позволено использовать raw types? Причина проста. Создатели Java оставили в языке поддержку raw types чтобы не создавать проблем с совместимостью. К моменту выхода Java 5.0 (в этой версии впервые появились дженерики) было написано уже очень много кода с использованием raw types. Поэтому такая возможность сохраняется и сейчас. Мы уже не раз упоминали классическую книгу Джошуа Блоха «Effective Java» в лекциях. Как один из создателей языка, он не обошел в книге и тему использования raw types и generic types. Что такое дженерики в Java - 2Глава 23 этой книги носит весьма красноречивое название: «Не используйте raw types в новом коде» Это то, что нужно запомнить. При использовании классов-дженериков ни в коем случае не превращай generic type в raw type.

Типизированные методы

Java позволяет тебе типизировать отдельные методы, создавая так называемые generic methods. Чем такие методы удобны? Прежде всего тем, что позволяют работать с разными типами параметров. Если к разным типам можно безопасно применять одну и ту же логику, дженерик-метод будет отличным решением. Рассмотрим пример. Допустим, у нас есть какой-то список myList1. Мы хотим удалить из него все значения, и заполнить все освободившиеся места новым значением. Вот так будет выглядеть наш класс с дженерик-методом:

public class TestClass {

   public static <T> void fill(List<T> list, T val) {
       for (int i = 0; i < list.size(); i++)
           list.set(i, val);
   }

   public static void main(String[] args) {

       List<String> strings = new ArrayList<>();
       strings.add("Старая строка 1");
       strings.add("Старая строка 2");
       strings.add("Старая строка 3");

       fill(strings, "Новая строка");

       System.out.println(strings);

       List<Integer> numbers = new ArrayList<>();
       numbers.add(1);
       numbers.add(2);
       numbers.add(3);

       fill(numbers, 888);
       System.out.println(numbers);
   }
}
Обрати внимание на синтаксис, он выглядит немного необычно:

public static <T> void fill(List<T> list, T val)
Перед типом возвращаемого значения написано <T>, что указывает на дженерик метод. В данном случае метод принимает на вход 2 параметра: список объектов T и еще один отдельный объект Т. За счет использования <T> и достигается типизация метода: мы не можем передать туда список строк и число. Список строк и строку, список чисел и число, список наших объектов Cat и еще один объект Cat — только так. В методе main() наглядно демонстрируется, что метод fill() легко работает с разными типами данных. Сначала он принимает на вход список строк и строку, а потом — список чисел и число. Вывод в консоль: [Новая строка, Новая строка, Новая строка] [888, 888, 888] Представь, если бы логика метода fill() нужна была бы нам для 30 разных классов, и у нас не было бы дженерик-методов. Мы вынуждены были бы писать один и тот же метод 30 раз, просто для разных типов данных! Но благодаря generic-методам мы можем использовать наш код повторно! :)

Типизированные классы

Ты можешь не только пользоваться представленными в Java дженерик-классами, но и создавать собственные! Вот простой пример:

public class Box<T> {

   private T t;

   public void set(T t) {
       this.t = t;
   }

   public T get() {
       return t;
   }

   public static void main(String[] args) {

       Box<String> stringBox = new Box<>();

       stringBox.set("Старая строка");
       System.out.println(stringBox.get());
       stringBox.set("Новая строка");

       System.out.println(stringBox.get());
      
       stringBox.set(12345);//ошибка компиляции!
   }
}
Наш класс Box<T> («коробка») является типизированным. Назначив для него при создании тип данных (<T>), мы уже не сможем помещать в него объекты других типов. Это видно в примере. При создании мы указали, что наш объект будет работать со строками:

Box<String> stringBox = new Box<>();
И когда в последней строке кода мы пытаемся положить внутрь коробки число 12345, получаем ошибку компиляции! Вот так просто мы создали свой собственный дженерик-класс! :) На этом наша сегодняшняя лекция подходит к концу. Но мы не прощаемся с дженериками! В следующий лекциях поговорим о более продвинутых возможностях, поэтому не прощаемся! ) Успехов в обучении! :)
Комментарии (41)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Александр Уровень 34
3 марта 2024
Чудесная для понимания статья. Спасибо!
Олег Уровень 111 Expert
6 апреля 2023
"Назначив для него при создании тип данных (<T>), мы уже не сможем помещать в него объекты других типов." Т.е. в этот класс не сможем поместить объекты других типов? Да сколько угодно(немного расширил код другими объектами): public class Box<T> { private T t; public void set(T t) { this.t = t; } public T get() { return t; } public static void main(String[] args) { Box<String> stringBox = new Box<>(); stringBox.set("Старая строка"); System.out.println(stringBox.get()); stringBox.set("Новая строка"); System.out.println(stringBox.get()); Box<Integer> intBox3 = new Box<>(); intBox3.set(258); System.out.println(intBox3.get()); intBox3.set(357); System.out.println(intBox3.get()); Box<Double> dubBox4 = new Box<>(); dubBox4.set(258.159); System.out.println(dubBox4.get()); dubBox4.set(357.267); System.out.println(dubBox4.get()); } }
Andrey Karelin Уровень 41
30 апреля 2022
Вот сколько читаю про дженерики, нигде не проговаривается очевидная вещь, которая помогает понять их суть, а именно то, что использование типа T, K, V и т.п. обозначает лишь условный тип класса. И главное тут не название, а то какие типы методов/переменных объекта будут возвращаться/работать, по отношению к определенному изначально типу. Например, указанный в примере метод public static <T> void fill(List<T> list, T val) говорит нам о том, что в метод мы можем передать list и val только одинакового между собой типа, и такого же типа, которым при создании был определен класс.
Глеб Уровень 29 Expert
21 апреля 2022
Полезная статья
Q1R27 Уровень 17
28 марта 2022
LuneFox Уровень 41 Expert
26 января 2022
Добавлю ещё, кстати, что писать именно T не обязательно. Как я понимаю, это конвенция от слова Type. Допускается написание любой лабуды в качестве маркера для дженерика, например:

    public static <KEK> KEK fill(List<KEK> list, KEK value) {
        for (int i = 0; i < list.size(); i++) {
            list.set(i, value);
        }
        return list.get(0);
    }
При этом повторный код с другим названием этого типа ловится IDE-шкой. Именно поэтому перед объявлением возвращаемого значения не написать <T>, то программа будет думать, что это не дженерик, а настоящий класс с названием T, и будет ругаться, что не знает такого класса или попросит, чтобы ты создал class T{ }. Конечно, то, что я написал -- наверняка очевидные вещи, но иногда полезно пощупать код руками и попытаться "сломать систему", чтобы в голове отложилась чёткая картинка, как делать можно, а как нельзя, и почему.
hamster🐹 Уровень 36
12 ноября 2021
Отличная статья! Спасибо)
MICRO_MVP_10011 Уровень 37
5 ноября 2021
Спасибо!
Игорь Уровень 41
27 июля 2021
Дженирики существуют только на этапе компиляции, в байт коде они уничтожаются (стирание). К примеру нельзя вызвать метод у дженирика, только через приведение типов, но это тот же Object получается, нельзя унаследоваться.