Java Core
Дженерики в Java
Привет!
1. Работа с типами до появления дженериков (Java 1.4 и ранее)
Для понимания назначения дженериков важно рассмотреть, каким образом в Java работали с объектами и типами данных до версии Java 5.

На практике разработчикам часто требовалось писать классы и методы, которые могли бы работать не с одним конкретным типом, а с разными типами данных: например, контейнер для хранения значения, коллекция элементов, кэш, очередь или стек. Без такого подхода пришлось бы создавать отдельную реализацию для String, Integer, Date и каждого нового типа, что быстро приводило бы к дублированию кода.

Однако в ранних версиях Java язык не предоставлял специальных средств для описания таких «обобщённых» решений на уровне системы типов, и единственным доступным инструментом для этого был класс Object.
1.1. Универсальный контейнер на основе Object
До появления дженериков единственным способом создать контейнер (класс для хранения значения), способный работать с объектами различных типов, было использование класса Object — корневого класса иерархии Java.

Рассмотрим простой пример класса Box, предназначенного для хранения одного значения:
public class Box {
    private Object value;

    public void set(Object value) {
        this.value = value;
    }

    public Object get() {
        return value;
    }
}
Такой класс допускает хранение объекта любого ссылочного типа.
1.2. Использование контейнера
Пример корректного использования данного класса:
Box box = new Box();
box.set("Hello, world");

String text = (String) box.get();
System.out.println(text);
Поскольку метод get () возвращает значение типа Object, вызывающая сторона обязана выполнить явное приведение типа.
1.3. Отсутствие проверки типов на этапе компиляции
Рассмотрим следующий пример:
Box box = new Box();
box.set(42); // Integer

String text = (String) box.get();
С точки зрения компилятора данный код является корректным. Однако во время выполнения программа завершится с исключением:
java.lang.ClassCastException:
java.lang.Integer cannot be cast to java.lang.String
Таким образом, ошибка, связанная с несовместимостью типов, обнаруживается только на этапе выполнения, а не при компиляции.
1.4. Основные недостатки подхода
Использование Object в качестве универсального типа имеет ряд существенных недостатков:

  • Отсутствие статической типобезопасности
  • Компилятор не может гарантировать корректность приведения типов.
  • Необходимость явных приведений типов
  • Код становится более громоздким и менее читаемым.
  • Неявные контракты использования
  • Из объявления класса невозможно понять, объекты какого типа он предполагает хранить.
1.5. Проблема на уровне коллекций
Аналогичная ситуация существовала и в стандартных коллекциях, если использовать их без указания хранимого типа:
List list = new ArrayList();

list.add("Hello");
list.add(10);
list.add(new Date());
При последующем извлечении элементов:
String value = (String) list.get(1);
компилятор не обнаружит ошибку, однако во время выполнения возникнет ClassCastException.

С ростом объёма кода такие ошибки становились:
  • менее предсказуемыми;
  • сложнее воспроизводимыми;
  • труднее диагностируемыми.
1.6. Вывод
До появления дженериков Java позволяла создавать универсальные контейнеры, однако:

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

Именно эти ограничения стали одной из ключевых причин введения механизма дженериков в Java 5.
2. Использование дженериков
В Java 5 был введён механизм дженериков (обобщений), который позволил описывать обобщённые классы и методы с сохранением информации о типах на этапе компиляции. Рассмотрим, как при этом изменится ранее использовавшийся класс Box.
2.1. Класс Box с параметром типа
Теперь вместо использования Object класс можно объявить с параметром типа <T>:
public class Box<T> {
    private T value;

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

    public T get() {
        return value;
    }
}
Здесь:
  • T — это параметр типа, а не конкретный класс;
  • конкретный тип будет задан при использовании класса Box;
  • внутри класса T используется так же, как обычный тип.
2.2. Использование параметризованного Box
При создании объекта Box теперь указывается конкретный тип данных:
Box<String> box = new Box<>();

box.set("Hello, world");

String text = box.get();
System.out.println(text);
Обратите внимание:
  • приведение типов больше не требуется;
  • метод get () возвращает String, а не Object;
  • компилятор точно знает, какой тип хранится в контейнере.
2.3. Проверка типов на этапе компиляции
Рассмотрим ошибочную ситуацию:
Box<String> box = new Box<>();

box.set(42); // ошибка компиляции
Компилятор сообщит об ошибке, например:
Required type: String
Provided: int
Таким образом, ошибка обнаруживается на этапе компиляции, а не во время выполнения программы.
2.4. Связь входных и выходных типов
Важно отметить, что параметр типа T связывает:
  • тип значения, передаваемого в метод set;
  • тип значения, возвращаемого методом get.

Если Box объявлен как Box<String>, то компилятор гарантирует, что:
  • в контейнер можно поместить только String;
  • из контейнера всегда будет получен String.
3. Что такое T: дженерики, type erasure и эволюция Java
При изучении дженериков важно понимать, что их появление в Java было результатом эволюционного развития языка, а не его радикальной переработки. Java изначально проектировалась с приоритетом обратной совместимости, и введение дженериков не должно было ломать уже существующий код, написанный до Java 5.

Именно это требование во многом определило то, как дженерики работают внутри Java.
3.1. Принцип "evolution, not revolution"
Java развивается по принципу:
новые возможности не должны нарушать работу старого кода

К моменту выхода Java 5 существовали:
  • миллионы строк кода;
  • библиотеки и фреймворки, скомпилированные под Java 1.4 и ранее;
  • стандартная библиотека без дженериков (List, Map, Set и т. д.).

Введение дженериков не могло потребовать:
  • изменения JVM;
  • несовместимости байткода;
  • переписывания старых библиотек.
Поэтому дженерики были реализованы на уровне компилятора, а не виртуальной машины.
3.2. Type erasure: что происходит с T на самом деле
В Java дженерики реализованы с помощью механизма type erasure (стирание типов).

Это означает, что:
  • во время компиляции информация о параметрах типа (T, String, Integer) используется для проверки кода;
  • в скомпилированном байткоде информация о параметрах типа отсутствует.

Пример:
Box<String> box = new Box<>();
После компиляции это, в упрощённом виде, эквивалентно:
Box box = new Box();
А класс:
public class Box<T> {
    private T value;
}
На уровне байткода выглядит примерно так:
public class Box {
    private Object value;
}
Важно уточнить: стирание происходит до Object или до верхней границы (upper bound), если параметр типа ограничен через extends.
Пример 1: без границы (по умолчанию Object)
public class Box<T> {
    private T value;
}
  • T стирается в Object
Пример 2: с границей через extends
public class Box<T extends Number> {
    private T value;
}
Пример 3: несколько границ
public class Box<T extends Number & Comparable<T>> {
    private T value;
}
Стирание:

  • T стирается в первую границу (Number)

Причина: JVM должна заменить T на один конкретный тип, и в Java для стирания выбирается левый верхний bound. Если границ нет — используется Object.
Эта деталь важна, потому что она объясняет, почему ограничение через extends влияет не только на проверки компилятора, но и на то, какой тип будет виден в байткоде после стирания.
3.3. Raw types: следствие type erasure и обратной совместимости
Из механизма стирания типов напрямую следует существование raw types.

Raw type — это использование дженерик-класса без указания параметра типа:
List list = new ArrayList();
Вместо параметризованного варианта:
List<String> list = new ArrayList<>();
Raw types существуют не как отдельная языковая возможность, а как механизм совместимости:

  • старый код, написанный до Java 5, продолжает компилироваться без изменений;
  • старые библиотеки могут использоваться вместе с новым дженерик-кодом.

Используя raw type, программист осознанно отказывается от типобезопасности, предоставляемой дженериками. Компилятор при этом:

  • не выполняет полноценную проверку типов;
  • выдаёт предупреждения (например, unchecked warning), а не ошибки.

Фактически raw type означает:
работать с классом так,
как будто дженериков не существует.
3.4. Какую роль в этом играет T
С учётом type erasure параметр типа T следует рассматривать как:
  • инструмент компилятора;
  • средство проверки корректности использования типов;
  • механизм связывания входных и выходных типов на этапе компиляции.

T существует для компилятора,
но не существует как отдельный тип в рантайме.
3.5. Почему T нельзя использовать как обычный класс
Из-за стирания типов недопустимы следующие операции:
new T();       // нельзя
T.class;       // нельзя
instanceof T;  // нельзя
Причина одна и та же:

  • в рантайме T не существует;
  • JVM не знает, каким был параметр типа.
  • Это не ограничение дженериков как идеи, а следствие эволюционного подхода Java, чтобы сохранить совместимость с кодом, написанным до появления дженериков.
3.6. Что Java получила благодаря такому подходу
Несмотря на ограничения, подход с type erasure дал важные преимущества:
  • старый код продолжил работать без изменений;
  • новые дженерик-классы совместимы со старыми библиотеками;
  • стандартная библиотека могла быть «обобщена» без переписывания JVM.

Например:
List<String>
и
List
в рантайме — это один и тот же класс.
3.7. Главное, что нужно запомнить
  • T — это параметр типа, существующий только на этапе компиляции;
  • в рантайме все дженерики стираются до Object (или до ограничений);
  • raw types — механизм совместимости, а не полноценная альтернатива параметризации;
  • такое поведение — осознанный выбор в пользу обратной совместимости;
  • дженерики в Java — пример эволюции языка, а не революции.

Понимание этого принципа объясняет многие «странные» ограничения дженериков и помогает правильно с ними работать.
4. Инвариантность дженериков и ковариантность массивов
Прежде чем говорить об инвариантности дженериков, полезно рассмотреть массивы, поскольку массивы и дженерики в Java обладают важным общим свойством: они являются производными типами, то есть тип массива или контейнера зависит от типа элементов. При этом массивы в Java являются ковариантными, тогда как дженерики — инвариантными.
4.1. Массивы как производные и ковариантные типы
Если в Java существует иерархия типов:
String → Object
то для массивов автоматически существует иерархия:
String[] → Object[]
Это означает, что массивы ковариантны.

Пример допустимого кода:
String[] strings = new String[10];
Object[] objects = strings;
С точки зрения системы типов это разрешено, поскольку String является подтипом Object.
4.2. Чем ковариантность массивов удобна
Ковариантность массивов упрощает работу с API:
  • методы могут принимать Object[] и работать с массивами любых ссылочных типов;
  • код становится более гибким;
  • массивы легко вписываются в иерархию наследования.

Например:
public void printAll(Object[] array) {
    for (Object o : array) {
        System.out.println(o);
    }
}
Такой метод можно вызвать и с String[], и с Integer[].
4.3. В чём проблема ковариантности массивов
Основная проблема заключается в том, что массивы проверяют типы в рантайме, а не на этапе компиляции.

Рассмотрим пример:
String[] strings = new String[10];
Object[] objects = strings;

objects[0] = 42; // компилируется
Компилятор считает этот код корректным, поскольку objects имеет тип Object[].

Однако во время выполнения возникнет исключение:
ArrayStoreException
Причина:
  • реальный массив — String[];
  • попытка положить в него Integer нарушает его фактический тип.

Таким образом, ковариантность массивов:
  • удобна на этапе компиляции;
  • но небезопасна и приводит к ошибкам в рантайме.
4.4. Почему дженерики нельзя было сделать ковариантными
Если бы дженерики вели себя так же, как массивы, следующий код был бы допустим:
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // если бы дженерики были ковариантными
Тогда можно было бы написать:
objects.add(42);
Это означало бы, что в List<String> оказался Integer, что полностью разрушает типобезопасность.
В отличие от массивов, дженерики не проверяются в рантайме, поскольку они реализованы через type erasure. JVM не может выбросить аналог ArrayStoreException, потому что в рантайме информация о параметрах типа отсутствует.
4.5. Почему дженерики сделали инвариантными
Поэтому в Java было принято решение:

дженерики должны быть инвариантными

Это означает, что даже если:
String → Object
то
List<String> ↛ List<Object>
Пример:
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // ошибка компиляции
Компилятор запрещает такой код, предотвращая потенциально опасные ситуации заранее, до запуска программы.
4.6. Сравнение подходов
Массивы:
  • ковариантны;
  • проверяют типы в рантайме;
  • могут приводить к ArrayStoreException.

Дженерики:
  • инвариантны;
  • проверяют типы на этапе компиляции;
  • не допускают небезопасных операций в принципе.
5. Дженерики в методах
Дженерики в Java применяются не только к классам и интерфейсам, но и к отдельным методам. Это позволяет описывать обобщённое поведение локально, не параметризуя весь класс целиком.
5.1. Обобщённый метод без обобщённого класса
Рассмотрим пример статического метода, который возвращает переданное ему значение:
public static <T> T identity(T value) {
    return value;
}
Здесь важно следующее:

  • <T> объявляется перед возвращаемым типом метода;
  • T — параметр типа, относящийся только к этому методу;
  • сам класс при этом может не быть дженериком.

Использование метода:
String text = identity("Hello");
Integer number = identity(42);
Компилятор выводит конкретный тип T на основе аргументов метода.
5.2. Отличие дженерик-метода от дженерик-класса
Важно различать два случая:
  1. Дженерик-класс — параметр типа задаётся при создании объекта
  2. Дженерик-метод — параметр типа выводится при каждом вызове метода

Пример дженерик-класса:
public class Box<T> {
    public T get() {
        return value;
    }
}
Пример дженерик-метода:
public static <T> void print(T value) {
    System.out.println(value);
}
Даже если класс не параметризован, метод может быть обобщённым.
5.3. Зачем нужны дженерики в методах
Дженерики в методах используются, когда:

  • обобщение требуется только для одной операции;
  • нет смысла параметризовать весь класс;
  • метод должен работать с разными типами, сохраняя типобезопасность.

Типичный пример — вспомогательные и утилитарные методы.
5.4. Ограничения дженерик-методов и type erasure
Как и в случае с дженерик-классами, дженерик-методы подчиняются правилам type erasure.
Это означает, что внутри метода нельзя:
new T();
T.class;
value instanceof T;
Причина та же самая: параметр типа T существует только на этапе компиляции и отсутствует в рантайме.
5.5. Вывод
  • дженерики могут применяться не только к классам, но и к методам;
  • параметр типа метода объявляется перед возвращаемым типом;
  • дженерик-методы позволяют писать типобезопасный и переиспользуемый код без параметризации всего класса;
  • все ограничения, связанные с type erasure, полностью применимы и к дженерик-методам.
6. Wildcard-типы (? extends,? super) и управляемая ковариантность
В предыдущем разделе мы увидели, что ковариантность массивов удобна, но небезопасна, а инвариантность дженериковбезопасна, но менее гибка.
Wildcard-типы (?) в Java появились как компромиссное решение, позволяющее получить удобство ковариантности там, где это безопасно.

Ключевая идея wildcard-типов состоит в следующем:

ковариантность нужна в первую очередь для чтения,
а проблемы начинаются тогда, когда разрешена запись.
6.1. Почему ковариантность массивов оказалась проблемной
Массивы в Java ковариантны:
String[] strings = new String[10];
Object[] objects = strings;
Это удобно: String[] можно передать туда, где ожидается Object[].

Проблема возникает из-за того, что массивы допускают и чтение, и запись:
objects[0] = 42; // компилируется
Компилятор разрешает запись, потому что objects имеет тип Object[].
Однако в рантайме возникает ArrayStoreException, так как реальный массив — String[].

Таким образом, основная проблема ковариантности массивов заключается не в чтении, а в том, что в ковариантный контейнер разрешена запись.
6.2. Почему дженерики сделали инвариантными
Для дженериков ситуация принципиально иная:

  • из-за type erasure информация о параметрах типа отсутствует в рантайме;
  • JVM не может выполнить проверку, аналогичную ArrayStoreException.

Поэтому Java запрещает ковариантность дженериков полностью:
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // ошибка компиляции
Это решение гарантирует типобезопасность, но лишает нас удобства, которое иногда действительно нужно.
6.3. Ковариантность как операция чтения
Рассмотрим типичный сценарий использования коллекции только для чтения:
public static void printAll(List<String> list) {
    for (String s : list) {
        System.out.println(s);
    }
}
Этот метод не модифицирует список — он только читает данные.
Однако его нельзя вызвать с List<Object> или List<CharSequence>, даже если это логически допустимо.

Здесь и возникает вопрос:

можно ли разрешить ковариантность только для чтения,
запретив при этом небезопасную запись?
6.4. Wildcard? extends — ковариантность для чтения
Для таких случаев в Java используется wildcard с ограничением extends:
public static void printAll(List<? extends CharSequence> list) {
    for (CharSequence s : list) {
        System.out.println(s);
    }
}
Теперь метод можно вызвать с:
List<String>
List<StringBuilder>
List<CharSequence>
Почему это безопасно:

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

Попытка записи приведёт к ошибке компиляции:
list.add("text"); // ошибка компиляции
Таким образом,? extends реализует ковариантность только для чтения.
6.5. Ключевая идея: почему запись запрещена
При типе:
List<? extends CharSequence>
компилятор знает лишь то, что список содержит какой-то неизвестный подтип CharSequence.

Это может быть:
List<String>
List<StringBuilder>
Добавление String было бы корректно для первого случая, но некорректно для второго.
Поэтому Java запрещает любую запись, кроме null.

Это принципиальное отличие от массивов, где запись разрешена и приводит к ошибкам в рантайме.
6.6. Связь с проблемой ковариантности массивов
Сравним два подхода:

Массивы:
  • ковариантны;
  • разрешают чтение и запись;
  • обнаруживают ошибки в рантайме.

Wildcard-типы (? extends):
  • ковариантны;
  • разрешают только чтение;
  • запрещают небезопасную запись на этапе компиляции.

Wildcard-типы можно рассматривать как исправленную версию ковариантности, лишённую её главного недостатка.
6.7. Вывод
  • ковариантность полезна прежде всего для чтения данных;
  • основная проблема ковариантности массивов — разрешённая запись;
  • дженерики по умолчанию инвариантны для обеспечения типобезопасности;
  • wildcard? extends возвращает ковариантность в безопасной форме;
  • Java предпочитает запретить потенциально опасный код на этапе компиляции, а не обрабатывать ошибки в рантайме.

Понимание этой логики делает wildcard-типы естественным продолжением инвариантности дженериков, а не «магическим» исключением из правил.
7. Wildcard? super T: контрвариантность и безопасная запись
В предыдущем разделе мы рассмотрели? extends T как способ получить ковариантность для чтения: можно безопасно читать элементы как T, но нельзя безопасно записывать новые значения (кроме null).

Однако на практике встречается и обратная задача:
иногда контейнер нужен не для чтения, а для добавления (записи) элементов определённого типа.

Для таких случаев в Java существует wildcard с ограничением super:
  • ? extends T — удобно, когда мы получаем (read) значения типа T;
  • ? super T — удобно, когда мы передаём (write) значения типа T.
7.1. Что такое контрвариантность
Контрвариантность — это ситуация, когда «совместимость типов контейнеров» направлена в сторону предков, то есть противоположно наследованию элементов.

Если в иерархии типов:
ChildClass → ParentClass
то для контейнеров, используемых как приёмники значений, логика такая:
  • если контейнер способен хранить ParentClass, то он способен хранить и ChildClass, потому что ChildClass является частным случаем ParentClass.

Именно это и выражает ограничение? super T:
? super T означает: «контейнер элементов типа T или любого его предка».
Такой контейнер удобно рассматривать как «место, куда можно безопасно добавлять T».
7.1.1. Пример с присваиванием: List<? super ChildClass>
Рассмотрим иерархию:
class ParentClass { }
class ChildClass extends ParentClass { }
Объявим несколько списков и переменную с типом List<? super ChildClass>:
List<ParentClass> parents = new ArrayList<>();
List<ChildClass> children = new ArrayList<>();
List<Object> objects = new ArrayList<>();

List<? super ChildClass> sink;
Теперь посмотрим, какие присваивания допустимы:
sink = parents;  // корректно: ParentClass — предок ChildClass
sink = children; // корректно: ChildClass — это сам T
sink = objects;  // корректно: Object — предок всех ссылочных типов
Почему это работает:

  • тип List<? super ChildClass> читается как «список ChildClass или любого его предка»;
  • значит, под него подходит List<ChildClass>, List<ParentClass>, List<Object> и т. д.

Далее важно увидеть обратное направление (что запрещено), чтобы не перепутать смысл super:
List<? super ParentClass> badSink = children; // ошибка компиляции
Почему это ошибка:
  • List<? super ParentClass> означает «список ParentClass или его предков»;
  • List<ChildClass> не подходит, потому что он способен хранить только ChildClass, но не произвольный ParentClass.

Этот пример подчёркивает ключевую идею:? super T — это контрвариантность, при которой тип контейнера «расширяется вверх» по иерархии предков, чтобы контейнер можно было безопасно использовать как приёмник значений типа T.
7.2. Зачем нужна контрвариантность
Рассмотрим типичную задачу: у нас есть метод, который добавляет строки в список.

Без wildcard мы можем написать:
public static void addHello(List<String> list) {
    list.add("Hello");
}
Этот метод принимает только List<String>.
Но что если у нас список более общего типа, например List<Object>?

Логически мы можем добавить строку и туда:
  • строка — это объект;
  • значит, List<Object> вполне подходит как «приёмник строк».
Но компилятор не позволит передать List<Object> туда, где ожидается List<String>, из-за инвариантности дженериков.
7.3.? super String как «приёмник значений»
Используем? super String:
public static void addHello(List<? super String> list) {
    list.add("Hello");
}
Теперь метод можно вызвать так:
List<String> strings = new ArrayList<>();
addHello(strings);

List<Object> objects = new ArrayList<>();
addHello(objects);
Это и есть практическая польза контрвариантности:
  • метод принимает контейнеры разного уровня обобщённости;
  • при этом сохраняется типобезопасность: внутрь можно добавлять String.
7.4. Почему при? super T безопасна запись
При типе:
List<? super String>
компилятор знает следующее:
  • список хранит элементы типа String или типа более общего (например, Object).

Из этого следует важное правило:
  • добавлять String можно, потому что любой допустимый вариант списка способен хранить String;
  • добавлять любой «более общий» тип нельзя, потому что неизвестно, насколько общий реальный список.

Пример безопасной записи:
List<? super String> list = new ArrayList<Object>();
list.add("A");
list.add("B");
Также можно добавлять подтипы String (если бы они существовали как обычные классы), то есть в общем виде:

в List<? super T> можно добавлять T и его подтипы.
7.5. Почему чтение при? super T ограничено
Теперь рассмотрим чтение:
Object value = list.get(0); // корректно
String text = list.get(0);  // ошибка компиляции
Почему так происходит:
  • если фактический список — List<Object>, то get (0) вернёт Object, и это может быть что угодно;
  • компилятор не может гарантировать, что вернётся именно String.

Поэтому при? super T безопасно читать только как Object (или как общий верхний тип).

Это симметрично поведению? extends T, только наоборот:
  • ? extends T: чтение как T безопасно, запись небезопасна;
  • ? super T: запись T безопасна, чтение как T небезопасно.
7.6. Пример: метод, который копирует элементы в «приёмник»
Частая практическая ситуация — перенос элементов из одного контейнера в другой.

Пусть есть источник строк и приёмник объектов:
List<String> source = List.of("A", "B");
List<Object> target = new ArrayList<>();
Мы хотим написать метод, который добавит все элементы источника в приёмник.
Для приёмника нам важна возможность принимать значения, то есть запись.

Значит, он должен быть? super T:
public static <T> void addAll(List<? super T> target, List<T> source) {
    for (T item : source) {
        target.add(item);
    }
}
Использование:
List<String> source = List.of("A", "B");
List<Object> target = new ArrayList<>();

addAll(target, source);
Здесь T выводится как String, и приёмник допускается как List<? super String>, то есть List<Object> подходит.
7.7. Вывод: extends для чтения, super для записи
Wildcard-ограничения можно запомнить через простую логику:
  • ? extends T — когда контейнер используется как источник значений типа T (читаем);
  • ? super T — когда контейнер используется как приёмник значений типа T (пишем).

Контрвариантность (? super T) — это управляемая гибкость, позволяющая безопасно работать с записью в контейнеры разных уровней обобщённости, не разрушая типобезопасность.
8. PECS: итоговая модель работы с wildcard-типами
После рассмотрения? extends T и? super T полезно зафиксировать общее правило, которое объединяет оба подхода и позволяет быстро и правильно выбирать wildcard в практических задачах.

Это правило известно как PECS.
Идея PECS была сформулирована Джошуа Блохом (Joshua Bloch) — автором Effective Java и одним из ключевых архитекторов Java Collections Framework.
Профиль Джошуа Блоха в LinkedIn:
https://www.linkedin.com/in/joshua-bloch-7b66a51/
8.1. Что такое PECS
PECS — это аббревиатура от:
Producer Extends, Consumer Super

Идея формулируется так:
  • если контейнер производит (producer) значения типа T, используйте? extends T;
  • если контейнер потребляет (consumer) значения типа T, используйте? super T.

Под «производством» и «потреблением» здесь понимается роль контейнера в конкретном контексте, а не его фактическая реализация.
8.2. Producer:? extends T
Контейнер является producer, если мы:

  • извлекаем из него значения;
  • используем его как источник данных;
  • не модифицируем его содержимое.
  • public static void printAll (List<? extends CharSequence> list) { for (CharSequence s: list) { System.out.println (s); } }
В этом случае контейнер производит значения, поэтому используется? extends T.
8.3. Consumer:? super T
Контейнер является consumer, если мы:

  • добавляем в него значения;
  • используем его как приёмник данных;
  • не полагаемся на точный тип элементов при чтении.
  • public static void addHello (List<? super String> list) { list. add («Hello»); }
В этом случае контейнер потребляет значения, поэтому используется? super T.
8.4. PECS на примере копирования элементов
public static <T> void copy(List<? super T> target, List<? extends T> source) {
    for (T item : source) {
        target.add(item);
    }
}
  • source — producer → ? extends T;
  • target — consumer → ? super T.
8.5. Итог
  • PECS — это практическая формализация идей, лежащих в основе wildcard-типов;
  • правило было предложено Джошуа Блохом как инженерное обобщение реального опыта проектирования API;
  • ? extends T используется для чтения;
  • ? super T используется для записи;
  • понимание PECS завершает целостную картину работы с дженериками и wildcard-типами в Java.