Код
Java
#База знаний

Дженерики в Java для самых маленьких: синтаксис, границы и дикие карты

Разбираемся, зачем нужны дженерики и как добавить их в свой код.

Оля Ежак для Skillbox Media

У нас в парадной подъезде рядом с почтовыми ящиками стоит коробка. Предполагалось, что туда будут выбрасывать бумажный спам, который какие-то вредители упорно кладут в эти самые ящики. Но в коробке вместе с бумажками лежат пустые бутылки и банки, подозрительного вида пакеты, а в нынешних реалиях — ещё и использованные медицинские маски. Почему люди так делают? Потому что так можно.

Теперь представьте, что содержимое коробки вы отвозите на переработку, а перед этим каждый раз приходится отделять бумагу от прочего мусора. Не хотели бы вы заполучить такую коробку, которая не даст положить в себя что-то, кроме бумаги? Если ваш ответ «да» — вам понравятся дженерики (generics).

Содержание

Знакомимся с дженериками

До появления дженериков программисты могли неявно предполагать, что какой-то класс, интерфейс или метод работает с элементами определённого типа.

Посмотрите на этот фрагмент кода:

public static void printSomething(List list){

   for (int i = 0; i < list.size(); i++){
       String item = (String)list.get(i);
       System.out.println(String.format("Длина строки \"%1$s\" = %2$d", item, item.length()));
   }
}

Здесь предполагается, что метод printSomething работает со списком строк. Мы можем догадаться об этом, потому что в цикле все элементы приводятся (преобразуются) к классу String, а потом ещё и метод length этого класса вызывается.

Но смотрите, что сделали программисты Саша и Маша, — они поленились заглянуть внутрь метода и положили в список: один — число, а вторая — экземпляр StringBuilder.

Вот только тестировщик назначил баг не кому-то из них, а Паше, который написал метод printSomething, — потому что ошибка произошла именно во время его выполнения.

Как всё работало без дженериков. Схема: Екатерина Степанова / Skillbox

Паша быстро нашёл истинных виновников и попросил их исправить заполнение списка. Но на будущее решил подстраховаться от подобных ситуаций и переписал метод с использованием дженериков. Вот так:

public static void printSomething(List<String> list) {

   for (int i = 0; i < list.size(); i++) {
       String item = list.get(i);
       System.out.println(String.format("Длина строки \"%1$s\" = %2$d", item, item.length()));
   }
}

Теперь, если кто-то захочет положить в массив нестроковый элемент, ошибка станет заметной сразу — ещё на этапе компиляции.

Как всё работает с дженериками. Схема: Екатерина Степанова / Skillbox

Обратите внимание, что во второй версии Пашиного метода item не приводится насильно к типу String. Мы просто получаем в цикле очередной элемент списка, и компилятор соглашается, что это, очевидно, будет строка. Код стал менее громоздким, читать его стало проще.

Объявляем дженерик-классы и создаём их экземпляры

Давайте запрограммируем ту самую коробку, о которой шла речь в начале статьи: создадим класс Box, который умеет работать только с элементами определённого типа. Пусть для простоты в этой коробке пока будет только один элемент:

class Box<T> { // обозначение типа - T
   // переменная с типом T
   private T item;
 
   public void putItem(T item) { //параметр метода типа T
      this.item = item;
   }

   public T getItem() { // возвращает объект типа T
     return item;
   }
}

В классе два метода:

  • первый добавляет элемент в коробку;
  • второй достаёт его обратно и возвращает пользователю.

Во всех случаях, кроме заголовка класса, символ T пишется без угловых скобок, он обозначает один и тот же параметр типа.

Параметром типа для дженерика может быть только ссылочный тип, интерфейс или перечисление (enum). Примитивные типы и массивы с дженериками не используются, то есть нельзя создать Box<int> или Box<int[]>, но можно — Box<Integer> или Box<List<Integer>>.

Теперь создадим коробку для бумаги. Пусть за бумагу отвечает класс Paper, а значит, экземпляр правильной коробки создаётся вот так:

class Paper {}
Box<Paper> boxForPaper = new Box<Paper>();

Это полный вариант записи, но можно и короче:

Box<Paper> boxForPaper = new Box<>();

Так как слева мы уже показали компилятору, что нужна коробка именно для бумаги, справа можно опустить повторное упоминание Paper — компилятор «догадается» о нём сам.

Это «угадывание» называется type inference — выведение типа, а оператор «<>» — это diamond operator. Его так назвали из-за внешнего сходства с бриллиантом.

Для обозначения дженерик-типа в классе Box мы использовали латинскую букву T. Это необязательно, то есть можно было бы использовать любую другую букву или даже слово — Box<MyType>. Тем не менее есть набор рекомендаций от Oracle о том, когда какие обозначения лучше использовать в дженериках. Вот они:

E — element, для элементов параметризованных коллекций;

K — key, для ключей map-структур;

V — value, для значений map-структур;

N — number, для чисел;

T — type, для обозначения типа параметра в произвольных классах;

S, U, V и так далее — применяются, когда в дженерик-классе несколько параметров.

Дженерик-классы хороши своей универсальностью: с классом Box теперь можно создать не только коробку для бумаги, но и, например, коробки для сбора пластика или стекла:

class Plastic {}
class Glass {}
Box<Plastic> boxForPlastic = new Box<>();
Box<Glass> boxForGlass = new Box<>();

А можно пойти ещё дальше и создать дженерик-класс с двумя параметрами для коробки с двумя отсеками. Вот так:

class TwoCellsBox<T, S> {
   private T firstCellItem;
   private S secondCellItem;
   //...
}

Теперь легко запрограммировать коробку, в одном отсеке которой будет собираться пластик, а во втором — стекло:

TwoCellsBox<Plastic, Glass> plasticGlassBox = new TwoCellsBox<>();

Обратите внимание, что type inference и diamond operator позволяют нам опустить оба параметра в правой части.

Объявляем и реализуем дженерик-интерфейсы

Объявление дженерик- интерфейсов похоже на объявление дженерик-классов. Продолжим тему переработки и создадим интерфейс пункта переработки GarbageHandler сразу с двумя параметрами: тип мусора и способ переработки:

interface GarbageHandler<T, S> {
   void handle(T what, S how);
}

Реализовать (имплементить) этот интерфейс можно в обычном, не дженерик- классе:

class MyPaperHandleMethod {
}

class MyNonGenericPaperHandler implements GarbageHandler<Paper, MyPaperHandleMethod> {
   @Override
   public void handle(Paper what, MyPaperHandleMethod how) {
       // здесь что-то делается с бумажным мусором способом MyPaperHandleMethod

   }
}

Но можно пойти другим путём и сначала объявить дженерик-класс с двумя параметрами:

class GarbageHandlerImpl<T, S> implements GarbageHandler<T, S> {
   @Override
   public void handle(T what, S how) {
       // здесь что-то делается с мусором типа T способом S
   }
}

Или скомбинировать эти два способа и написать дженерик-класс только с одним параметром:

class PaperHandler<T> implements GarbageHandler<Paper, T> {
   @Override
   public void handle(Paper what, T how) {
       // здесь что-то делается с бумагой способом T   
   }
}

Дженерик-классы и дженерик-интерфейсы вместе называются дженерик-типами.

Можно создавать экземпляры дженерик-типов «без расшифровки», то есть никто не запретит вам объявить переменную типа Box — просто Box:

Box box = new Box<>();

Для такого случая даже есть термин — raw type, то есть «сырой тип». Эту возможность оставили в языке для совместимости со старым кодом, который был написан до появления дженериков.

В новых программах так писать не рекомендуется. Да и зачем? Ведь при таком способе теряются все преимущества использования дженериков.

Пишем дженерик-методы

В примерах выше мы уже видели параметризованные методы в дженерик-классах и интерфейсах. Типизированными могут быть как параметры метода, так и возвращаемый тип.

До этого мы использовали в методах только те обозначения типов, которые объявлены в заголовке дженерик-класса или интерфейса, но это не обязательно. Предположим, у нашего пункта переработки есть ещё опция: сбор опасных отходов, которые сотрудники вывозят на утилизацию в другое место. Напишем метод для этого:

interface GarbageHandler<T, S> {
   void handle(T what, S how);

   <E> void transfer(E dangerousWaste);
}

У метода transfer есть свой личный параметр для типа, который не обязан совпадать ни с типом T, ни с типом S. При первом упоминании новый параметр, как и в случае с заголовком класса или интерфейса, пишется в угловых скобках.

Дженерик-методы можно объявлять и в обычных (не дженерик) классах и интерфейсах. Наш класс для переработки мог быть выглядеть так:

class GarbageHandlerImpl {

   public <T, S> void handle(T what, S how) {
       // здесь что-то делается с мусором типа T способом S
   }
}

Здесь дженерики используются только в методе.

Обратите внимание на синтаксис: параметры типов объявляются после модификатора доступа (public), но перед возвращаемым типом (void). Они перечисляются через запятую в общих угловых скобках.

Ограничиваем дженерики сверху и снизу

Давайте немного расширим наше представление о мусоре и введём для него дополнительное свойство — массу «типичного представителя», то есть массу одной пластиковой бутылки или листка бумаги, например.

abstract class Garbage{
   public abstract double getWeight();
}

class Paper extends Garbage{
   @Override
   public double getWeight() {
       return 0.01;
   }
}

class Plastic extends Garbage{
   @Override
   public double getWeight() {
       return  0.3;
   }
}

Теперь попробуем использовать эту массу в методе уже знакомого класса Box:

class Box<T> {

   private T item;

   public double getItemWeight() {
       // не скомпилируется
       return item == null ? 0 : item.getWeight();
   }
//... остальные методы
}

И получим ошибку при компиляции: мы не рассказали компилятору, что T — это какой-то вид мусора. Исправим это с помощью так называемого upper bounding — ограничения сверху:

class Box<T extends Garbage> {
// методы класса
}

Теперь метод getItemWeight успешно скомпилируется.

Здесь T extends Garbage означает, что в качестве T можно подставить Garbage или любой класс-наследник Garbage. Из уже известных нам классов это могут быть, например, Paper или Plastic. Так как и у Garbage, и у всех его наследников есть метод getWeight, его можно вызывать в новой версии дженерик-класса Box.

Для одного класса или интерфейса можно добавить сразу несколько ограничений. Вспомним про интерфейс для пункта приёма мусора и введём класс для метода переработки — HandleMethod. Тогда GarbageHandler можно переписать так:

class HandleMethod {
}

interface GarbageHandler<T extends Garbage, S extends HandleMethod> {
       void handle(T what, S how);
   }

В качестве границы может выступать класс, интерфейс или перечисление (enum), но не примитивный тип и не массив. При этом для интерфейсов тоже используется слово extends, а не implements: <T extends SomeInterface> вместо <T implements SomeInterface>.

Wildcards

До этого мы использовали для параметров типов буквенные имена, но в Java есть и специальный символ для обозначения неизвестного типа — «?». Его принято называть wildcard, дословно — «дикая карта».

Термин wildcard пришёл в программирование из карточной игры. В покере, например, так называют карту, которая может сыграть вместо любой другой. Джокер — известный пример такой «дикой карты».

Wildcard нельзя подставлять везде, где до этого мы писали буквенные обозначения. Не получится, например, объявить класс Box<?> или дженерик-метод, который принимает такой тип:

class Box<?>{ // не скомпилируется       
   ? variable;  // не скомпилируется
   public <?> void someMethod(? param){ // не скомпилируется
    //...   
   }
}

Wildcards удобно использовать для объявления переменных и параметров методов совместно с классами из Java Collection Framework — здесь собраны инструменты Java для работы с коллекциями. Если вы не очень хорошо знакомы с ними, освежите знания, прочитав эту статью.

В примере ниже мы можем подставить вместо «?» любой тип, в том числе Paper, поэтому строка успешно скомпилируется:

List<?> example1 = new ArrayList<Paper>();

Wildcards можно применять для ограничений типов:

List<? extends Garbage> example2 = new ArrayList<Paper>();

Это уже знакомое нам ограничение сверху, upper bounding, — вместо «?» допустим Garbage или любой его класс-наследник, то есть Paper подходит.

Но можно ограничить тип и снизу. Это называется lower bounding и выглядит так:

List<? super Garbage> example3 = new ArrayList<Garbage>();

Здесь <? super Garbage> означает, что вместо «?» можно подставить Garbage или любой класс-предок Garbage. Все ссылочные классы неявно наследуют класс Object, так что в правой части ещё может быть ArrayList<Object>.

Множества допустимых классов при использовании ограниченных wildcards. Схема: Екатерина Степанова / Skillbox

Собираем понятия, связанные с дженериками

Мы не успели разобраться с более сложными вещами — например, с заменами аргументов типов в классах-наследниках, с переопределением дженерик-методов, не узнали об особенностях коллекций с wildcards.

Обо всём этом и не только — в следующих статьях. А пока соберём небольшой словарик из терминов, которые связаны с использованием дженериков, — они пригодятся при чтении специальной литературы:

ТерминРасшифровка
Дженерик-типы (generic types)Дженерик-класс или дженерик-интерфейс с одним или несколькими параметрами в заголовке
Параметризованный тип (parameterized types)Вызов дженерик-типа. Для дженерик-типа List<E> параметризованным типом будет, например, List<Box>
Параметр типа (type parameter)Используются при объявлении дженерик-типов. Для Box<T> T — это параметр типа
Аргумент типа (type argument)Тип объекта, который может использоваться вместо параметра типа. Например, для Box<Paper> Paper — это аргумент типа
WildcardОбозначается символом «?» — неизвестный тип
Ограниченный wildcard (bounded wildcard)Wildcard, который ограничен сверху — <? extends Garbage> или снизу — <? super Garbage>
Сырой тип (raw type)Имя дженерик-типа без аргументов типа. Для List<E> сырой тип — это List

Ещё больше о дженериках, коллекциях и других элементах языка Java узнайте на нашем курсе «Профессия Java-разработчик». Научим программировать на самом востребованном языке и поможем устроиться на работу.



Проверьте свой английский. Бесплатно ➞
Нескучные задания: small talk, поиск выдуманных слов — и не только. Подробный фидбэк от преподавателя + персональный план по повышению уровня.
Пройти тест
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована