Привет! Продолжаем изучать тему дженериков. Ты уже обладаешь солидным багажом знаний о них из предыдущих лекций (об использовании varargs при работе с дженериками и о стирании типов), но одну важную тему мы пока не рассматривали: wildcards. Это очень важная фишка дженериков. Настолько, что мы выделили для нее отдельную лекцию! Впрочем, ничего сложного в wildcards нет, в этом ты сейчас убедишься :) Если совсем коротко: wildcard это знак вопроса в угловых скобках, который говорит компилятору, что сюда подойдет не один конкретный тип, а целое семейство типов. <? extends Animal> принимает коллекцию Animal и любого его наследника, <? super Cat> принимает коллекцию Cat и любого его предка. Нужны они потому, что List<Cat> в Java не считается наследником List<Animal>, и без wildcard пришлось бы писать отдельный метод под каждый тип.

Кратко

  • Дженерики в Java инвариантны: если Cat наследник Animal, то List<Cat> наследником List<Animal> все равно не является.
  • Сделано это ради типобезопасности: иначе через ссылку List<Animal> в список котов можно было бы положить собаку, и ошибка вылезла бы только во время выполнения.
  • Wildcard, то есть ? в угловых скобках, снимает это ограничение для параметров метода. Видов у него три: <? extends T>, <? super T> и просто <?>.
  • <? extends Animal> (Upper Bounded) принимает коллекцию Animal и любого его наследника: Pet, Cat, Dog. Читать из такой коллекции можно, а добавлять в нее нельзя.
  • <? super Cat> (Lower Bounded) принимает коллекцию Cat и любого его предка: Pet, Animal, Object. Добавлять Cat в нее можно, зато чтение вернет Object.
  • Благодаря wildcard один метод обслуживает всю иерархию, вместо отдельного метода на каждый тип животных.
Пластиковые фигурки животных разных цветов расставлены вокруг красного и бирюзового кубиков: собаки, кошки и другие звери

Почему List<String> не наследник List<Object>

Давай рассмотрим пример:

public class Main {

   public static void main(String[] args) {
      
       String str = new String("Test!");
       // никаких проблем
       Object obj = str;
      
       List<String> strings = new ArrayList<String>();
       // ошибка компиляции!
       List<Object> objects = strings;
   }
}
Что же тут происходит? Мы видим две очень похожие ситуации. В первой из них мы пытаемся привести объект String к типу Object. Никаких проблем с этим не возникает, все работает как надо. Но вот во второй ситуации компилятор выдает ошибку. Хотя, казалось бы, мы делаем то же самое. Просто теперь мы используем коллекцию из нескольких объектов. Но почему возникает ошибка? Какая, по сути, разница, приводим мы один объект String к типу Object или 20 объектов? Между объектом и коллекцией объектов есть важное различие. Если класс B является наследником класса A, то Collection<B> при этом не наследник Collection<A>. Именно по этой причине мы не смогли привести наш List<String> к List<Object>. String является наследником Object, но List<String> не является наследником List<Object>. Интуитивно это выглядит не очень логично. Почему именно таким принципом руководствовались создатели языка? Давай представим, что здесь компилятор не выдавал бы нам ошибку:

List<String> strings = new ArrayList<String>();
List<Object> objects = strings;
В этом случае, мы бы могли, например, сделать следующее:

objects.add(new Object());
String s = strings.get(0);
Поскольку компилятор не выдал нам ошибок и позволил создать ссылку List<Object> object на коллекцию строк strings, можем добавить в strings не строку, а просто любой объект Object! Таким образом, мы лишились гарантии того, что в нашей коллекции находятся только указанные в дженерике объекты String. То есть, мы потеряли главное преимущество дженериков, типобезопасность. И раз компилятор позволил нам все это сделать, значит, мы получим ошибку только во время исполнения программы, что всегда намного хуже, чем ошибка компиляции. Чтобы предотвратить такие ситуации, компилятор выдает нам ошибку:

// ошибка компиляции
List<Object> objects = strings;
...и напоминает, что List<String> не наследник List<Object>. Это железное правило работы дженериков, и его нужно обязательно помнить при их использовании. Поехали дальше.

Задача: один метод для всех животных

Допустим, у нас есть небольшая иерархия классов:

public class Animal {

   public void feed() {

       System.out.println("Animal.feed()");
   }
}

public class Pet extends Animal {

   public void call() {

       System.out.println("Pet.call()");
   }
}

public class Cat extends Pet {

   public void meow() {

       System.out.println("Cat.meow()");
   }
}

public class Dog extends Pet {

   public void bark() {

       System.out.println("Dog.bark()");
   }
}
Во главе иерархии стоят просто Животные: от них наследуются Домашние Животные. Домашние Животные делятся на 2 типа: Собаки и Кошки. Если механика наследования пока не до конца ясна, загляни в разбор основ наследования. А теперь представь, что нам нужно создать простой метод iterateAnimals(). Метод должен принимать коллекцию любых животных (Animal, Pet, Cat, Dog), перебирать все элементы, и каждый раз выводить что-нибудь в консоль. Давай попробуем написать такой метод:

public static void iterateAnimals(Collection<Animal> animals) {

   for(Animal animal: animals) {

       System.out.println("Еще один шаг в цикле пройден!");
   }
}
Казалось бы, задача решена! Однако, как мы недавно выяснили, List<Cat>, List<Dog> или List<Pet> не являются наследниками List<Animal>! Поэтому при попытке вызвать метод iterateAnimals() со списком котиков мы получим ошибку компилятора:

import java.util.*;

public class Main3 {


   public static void iterateAnimals(Collection<Animal> animals) {

       for(Animal animal: animals) {

           System.out.println("Еще один шаг в цикле пройден!");
       }
   }

   public static void main(String[] args) {


       List<Cat> cats = new ArrayList<>();
       cats.add(new Cat());
       cats.add(new Cat());
       cats.add(new Cat());
       cats.add(new Cat());

       //ошибка компилятора!
       iterateAnimals(cats);
   }
}
Ситуация выглядит не очень хорошо для нас! Получается, нам придется писать отдельные методы для перебора всех видов животных? На самом деле нет, не придется :) И в этом нам как раз помогут wildcards!

Wildcard extends: верхняя граница

Мы решим задачу в рамках одного простого метода, используя вот такую конструкцию:

public static void iterateAnimals(Collection<? extends Animal> animals) {

   for(Animal animal: animals) {

       System.out.println("Еще один шаг в цикле пройден!");
   }
}
Это и есть wildcard. Точнее, это первый из нескольких типов wildcard, “extends” (другое название: Upper Bounded Wildcards, именно так он назван в руководстве Oracle по дженерикам). О чем нам говорит эта конструкция? Это значит, что метод принимает на вход коллекцию объектов класса Animal либо объектов любого класса-наследника Animal (? extends Animal). Иными словами, метод может принять на вход коллекцию Animal, Pet, Dog или Cat, без разницы. Давай убедимся что это работает:

public static void main(String[] args) {

   List<Animal> animals = new ArrayList<>();
   animals.add(new Animal());
   animals.add(new Animal());

   List<Pet> pets = new ArrayList<>();
   pets.add(new Pet());
   pets.add(new Pet());

   List<Cat> cats = new ArrayList<>();
   cats.add(new Cat());
   cats.add(new Cat());

   List<Dog> dogs = new ArrayList<>();
   dogs.add(new Dog());
   dogs.add(new Dog());

   iterateAnimals(animals);
   iterateAnimals(pets);
   iterateAnimals(cats);
   iterateAnimals(dogs);
}
Вывод в консоль:

Еще один шаг в цикле пройден!
Еще один шаг в цикле пройден!
Еще один шаг в цикле пройден!
Еще один шаг в цикле пройден!
Еще один шаг в цикле пройден!
Еще один шаг в цикле пройден!
Еще один шаг в цикле пройден!
Еще один шаг в цикле пройден!
Мы создали в общей сложности 4 коллекции и 8 объектов, и в консоли ровно 8 записей. Все отлично работает! :) Wildcard позволил нам легко уместить нужную логику с привязкой к конкретным типам в один метод. Мы избавились от необходимости писать отдельный метод для каждого вида животных. Представь, сколько методов у нас было бы, если бы наше приложение использовалось в зоопарке или ветеринарной клинике :)

Wildcard super: нижняя граница

А теперь давай рассмотрим другую ситуацию. Наша иерархия наследования останется неизменной: класс верхнего уровня Animal, чуть ниже класс домашних животных Pet, а на следующем уровне Cat и Dog. Теперь тебе нужно переписать метод iterateAnimals() таким образом, чтобы он мог работать с любым типом животных, кроме собак. То есть он должен принимать на вход Collection<Animal>, Collection<Pet> или Collection<Cat>, но не должен работать с Collection<Dog>. Как мы можем этого добиться? Кажется, перед нами опять замаячила перспектива писать отдельный метод для каждого типа :/ Как иначе объяснить компилятору нашу логику? А сделать это можно очень просто! Здесь нам снова придут на помощь wildcards. Но на этот раз мы воспользуемся другим типом, “super” (другое название: Lower Bounded Wildcards).

public static void iterateAnimals(Collection<? super Cat> animals) {

   for(int i = 0; i < animals.size(); i++) {

       System.out.println("Еще один шаг в цикле пройден!");
   }
}
Здесь принцип похож. Конструкция <? super Cat> говорит компилятору, что метод iterateAnimals() может принимать на вход коллекцию объектов класса Cat либо любого другого класса-предка Cat. Под это описание в нашем случае подходят сам класс Cat, его предок Pet, и предок предка Animal. Класс Dog не вписывается в это ограничение, и поэтому попытка использовать метод со списком List<Dog> приведет к ошибке компиляции:

public static void main(String[] args) {

   List<Animal> animals = new ArrayList<>();
   animals.add(new Animal());
   animals.add(new Animal());

   List<Pet> pets = new ArrayList<>();
   pets.add(new Pet());
   pets.add(new Pet());

   List<Cat> cats = new ArrayList<>();
   cats.add(new Cat());
   cats.add(new Cat());

   List<Dog> dogs = new ArrayList<>();
   dogs.add(new Dog());
   dogs.add(new Dog());

   iterateAnimals(animals);
   iterateAnimals(pets);
   iterateAnimals(cats);
  
   //ошибка компиляции!
   iterateAnimals(dogs);
}
Наша задача решена, и снова wildcards оказались крайне полезными :)

Неограниченный wildcard: просто вопросительный знак

Есть и третий вид wildcard, самый простой: одиночный знак вопроса вообще без границ.

public static void printSize(Collection<?> collection) {

   System.out.println("В коллекции " + collection.size() + " элементов");
}
Такой метод примет вообще любую коллекцию: List<Cat>, List<String>, Set<Integer>, что угодно. Называется эта конструкция неограниченным wildcard (Unbounded Wildcards), и применяют ее тогда, когда телу метода тип элементов попросту не важен и хватает того, что умеет Object. По смыслу <?> это то же самое, что <? extends Object>.

Что можно читать и что можно записывать

У wildcard есть особенность, о которой легко забыть: он ограничивает не только то, какие коллекции метод принимает, но и то, что внутри метода с этой коллекцией можно делать. Возьмем метод с ? extends Animal и попробуем что-нибудь в коллекцию добавить:

public static void iterateAnimals(Collection<? extends Animal> animals) {

   //ошибка компиляции!
   animals.add(new Cat());
}
Почему так? Компилятор знает лишь то, что пришла коллекция каких-то наследников Animal. Каких именно, он не знает: это вполне может оказаться List<Dog>, и тогда добавленный туда кот сломал бы ровно ту типобезопасность, ради которой в начале лекции и запретили присваивать List<Cat> переменной List<Animal>. Поэтому в такую коллекцию нельзя положить ничего, кроме null. Зато читать из нее можно, и элементы будут иметь тип Animal. С ? super Cat картина зеркальная:

public static void iterateAnimals(Collection<? super Cat> animals) {

   //все в порядке
   animals.add(new Cat());

   //ошибка компиляции: элемент имеет тип Object, а не Cat
   for (Cat cat : animals) {
   }
}
Здесь компилятор знает, что пришла коллекция Cat или кого-то из его предков, значит кот в нее поместится в любом случае. А вот при чтении гарантировать он может только Object: коллекция ведь может оказаться и List<Object>. Кстати, обрати внимание: в примере с ? super Cat выше цикл написан через индекс, а не через for-each. Иначе и не вышло бы: for (Cat cat : animals) под этот параметр не скомпилируется. Для всего этого есть удобная мнемоника, PECS: Producer Extends, Consumer Super. Если коллекция для метода поставщик данных и он только читает, бери ? extends. Если коллекция потребитель и метод в нее пишет, бери ? super.

Три вида wildcard: что принимают и что позволяют

Сведем все три вида wildcard в одну таблицу:
Параметр метода Какие коллекции примет Какие не примет Что вернет чтение Что можно добавить
Collection<Animal> только Collection<Animal> Collection<Pet>, Collection<Cat>, Collection<Dog> Animal Animal и его наследников
Collection<? extends Animal> Collection<Animal>, Collection<Pet>, Collection<Cat>, Collection<Dog> коллекции типов вне иерархии Animal Animal ничего, кроме null
Collection<? super Cat> Collection<Cat>, Collection<Pet>, Collection<Animal>, Collection<Object> Collection<Dog> Object Cat и его наследников
Collection<?> любую коллекцию нет таких Object ничего, кроме null
Обрати внимание на направление: extends расширяет список допустимых типов вниз по иерархии, к наследникам, а super вверх, к предкам. А две последние колонки это и есть PECS: читаешь из коллекции, бери extends; пишешь в коллекцию, бери super. На этом лекция подошла к концу. Теперь ты видишь, насколько важная тема дженериков при изучении джава: у нас ушло на нее целых 4 лекции! Зато теперь ты неплохо ориентируешься в теме и сможешь проявить себя на собеседовании :) А сейчас самое время вернуться к задачам! Успехов в обучении! :)

Вопросы и ответы

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

Это знак вопроса в угловых скобках, который ставится вместо конкретного типа: List<?>, Collection<? extends Animal>, Collection<? super Cat>. Он говорит компилятору, что сюда подойдет не один единственный тип, а целое семейство типов. Чаще всего wildcard ставят в параметрах методов, чтобы один метод принимал коллекции разных, но родственных типов.

Почему список котов нельзя присвоить переменной типа список животных?

Потому что дженерики в Java инвариантны: наследование между Cat и Animal не переносится на List<Cat> и List<Animal>. Сделано это ради типобезопасности. Если бы такое присваивание разрешили, через ссылку типа List<Animal> в список котов можно было бы добавить собаку, и ошибка вылезла бы уже во время выполнения, а не при компиляции.

Чем extends отличается от super в wildcard?

? extends Animal задает верхнюю границу: подойдет коллекция Animal и любого его наследника, то есть Pet, Cat, Dog. ? super Cat задает нижнюю границу: подойдет коллекция Cat и любого его предка, то есть Pet, Animal, Object. Первая конструкция расширяет набор допустимых типов вниз по иерархии, вторая вверх.

Что означает одиночный знак вопроса без extends и super?

Это неограниченный wildcard, unbounded wildcard. List<?> означает список элементов какого угодно типа. Его используют, когда телу метода тип элементов вообще не важен: например чтобы напечатать размер коллекции или обойти ее, пользуясь только возможностями класса Object. По сути <?> это то же самое, что <? extends Object>.

Можно ли добавить элемент в коллекцию с wildcard extends?

Нет, кроме null. Компилятор знает лишь то, что это коллекция каких-то наследников Animal, но не знает каких именно: может прийти List<Cat>, и добавление туда собаки сломало бы типобезопасность. Поэтому из такой коллекции можно только читать, причем элементы будут иметь тип Animal. А вот в Collection<? super Cat> добавлять Cat можно, зато чтение вернет Object.

Где wildcards встречаются в стандартной библиотеке Java?

Практически везде, где метод принимает чужую коллекцию. Например Collection.addAll(Collection<? extends E> c) принимает коллекцию элементов типа E или его наследников, а Collections.sort(List<T> list, Comparator<? super T> c) принимает компаратор, умеющий сравнивать T или его предков. Это тот же принцип, что и в примерах выше.

Читайте также