Подстановочные знаки в Java Generics
Ограниченные и неограниченные wildcards в Java generics, правило PECS и практические примеры использования.
Wildcard — это токен ?, который появляется в обобщённых типах вместо конкретного аргумента типа: List<?>, List<? extends Number>, List<? super Integer>. Это решение проблемы, с которой сталкиваешься почти сразу при написании обобщённого кода: List<Integer> не является подтипом List<Number>, хотя Integer является подтипом Number. Wildcards позволяют описать «список некоторых Number», не привязываясь к конкретному типу элементов — и они, пожалуй, самая запутанная часть системы типов Java.
Неочевидная отправная точка
Вот факт, который делает wildcards необходимыми:
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = ints; // ❌ does not compileНесмотря на то что Integer extends Number, List<Integer> не расширяет List<Number>. Обобщённые типы инвариантны — List<Sub> и List<Super> не связаны между собой независимо от того, какими являются Sub и Super.
Причина обоснована, пусть и удивительна. Если бы List<Integer> являлся List<Number>, можно было бы написать:
List<Number> nums = ints; // pretend this is legal
nums.add(3.14); // legal — 3.14 is a Number
int x = ints.get(3); // KABOOM at runtime — it's a DoubleПриведение типа в последней строке взорвалось бы. Чтобы этого не случилось, компилятор отвергает самый первый шаг: List<Integer> — не List<Number>. И точка.
Wildcards позволяют вернуть гибкость безопасным способом.
Неограниченный wildcard: List<?>
Самый простой wildcard — одиночный ? — «список некоторого неизвестного типа»:
public static void printAll(List<?> list) {
for (Object o : list) System.out.println(o);
}
printAll(List.of(1, 2, 3)); // List<Integer> — OK
printAll(List.of("a", "b")); // List<String> — OK
printAll(new ArrayList<>()); // List<Object> — OKВнутри тела единственное, что можно делать с элементами List<?> — читать их как Object, потому что компилятор понятия не имеет, что такое ?. Добавить что-либо в List<?> нельзя (единственное исключение — null):
public static void corrupt(List<?> list) {
list.add("hello"); // ❌ does not compile — ? is unknown
list.add(null); // ✓ — null is a value of every reference type
}List<?> используется, когда нужно выразить: «принимаю любой список и буду только читать из него как из Object».
Ограниченный сверху wildcard: ? extends T
Когда нужно читать элементы как конкретный тип — например, обрабатывать их все как Number — используется ограниченный сверху wildcard:
public static double sum(List<? extends Number> list) {
double total = 0;
for (Number n : list) total += n.doubleValue(); // legal — every element IS-A Number
return total;
}
sum(List.of(1, 2, 3)); // List<Integer> — OK, Integer extends Number
sum(List.of(1.5, 2.5)); // List<Double> — OK
sum(List.of(1L, 2L, 3L)); // List<Long> — OKList<? extends Number> читается как «список некоторого конкретного типа, который является Number или подтипом Number». Читать из него как из Number можно. Добавлять в него ничего нельзя — снова с исключением для null — потому что компилятор не знает, какой именно подтип Number хранит список. Добавить Integer в List<? extends Number>, который на самом деле является List<Double>, означало бы испортить его; вместо того чтобы выяснять, какой это подтип, компилятор просто отклоняет любой add.
Ограниченный снизу wildcard: ? super T
Зеркальный вариант. ? super T означает «тип элементов списка — это T или некоторый супертип T»:
public static void addOneTwoThree(List<? super Integer> list) {
list.add(1);
list.add(2);
list.add(3);
}
List<Integer> ints = new ArrayList<>(); addOneTwoThree(ints); // ✓
List<Number> nums = new ArrayList<>(); addOneTwoThree(nums); // ✓ — Number is a supertype of Integer
List<Object> objs = new ArrayList<>(); addOneTwoThree(objs); // ✓ — Object is tooЗдесь можно безопасно добавлять любой Integer (или его подтип) — тип элементов списка гарантированно является Integer или каким-то его предком, так что Integer туда подходит. Что нельзя — так это вычитать элемент конкретного типа: всё, что можно сказать об элементе, — это то, что он Object, потому что реальный список может оказаться List<Object>.
Правило PECS
Существует один мнемонический приём, который рано или поздно запоминает каждый Java-разработчик:
PECS — Producer Extends, Consumer Super.
Это эмпирическое правило выбора wildcard:
- Если параметр производит значения (вы читаете из него): используйте
? extends T. - Если параметр потребляет значения (вы пишете в него): используйте
? super T.
Каноническая сигнатура, которую оно порождает, — Collections.copy:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i));
}
}src читается (он производит T) — ? extends T. В dest записывается (он потребляет T) — ? super T. Именно в этом причина асимметрии: одна и та же сигнатура работает независимо от того, является ли src List<Integer>, а dest — List<Number>, или наоборот, лишь бы T был точкой пересечения между ними.
Если вы ничего больше не запомните из этой главы, запомните PECS.
Когда не нужен wildcard
Если параметр и читается, и записывается в одном и том же методе, ни ? extends T, ни ? super T не подойдут — ни один не даст делать и то, и другое. В этом случае просто используйте обычный параметр типа:
public static <T> void swap(List<T> list, int i, int j) {
T tmp = list.get(i); // read
list.set(i, list.get(j)); // write
list.set(j, tmp); // write
}Wildcard — правильный инструмент, когда одна сторона отношений «только читает» или «только пишет». Параметр типа — правильный инструмент, когда нужно говорить о конкретном типе элементов с обеих сторон.
Wildcards vs. ограниченные параметры типа
Сравним:
public static <T extends Number> double sumNamed(List<T> list) { ... }
public static double sumWildcard(List<? extends Number> list) { ... }Функционально они принимают одно и то же множество аргументов. Различие — в том, что может сказать тело:
- Именованная форма (
<T extends Number>) даёт имяT— полезно, если нужно вернутьT, принять ещё одинList<T>в качестве второго параметра или написатьT tmp = list.get(0), чтобы сохранить точный тип элемента. - Wildcard-форма (
? extends Number) не даёт имени — ссылаться на элементы можно только как наNumber. Она компактнее в API (имя не просачивается в сигнатуру), но менее выразительна в теле.
Эмпирическое правило: если элементы нужны только как Number, wildcard — более лаконичный и чистый выбор. Если телу нужно говорить о конкретном T, дайте ему имя.
Развёрнутый пример: PECS на практике
Программа копирует элементы из одного списка в другой и вычисляет накопленную сумму — обе операции параметризованы по правилу PECS. Обратите внимание на вызовы: copyOf(intList, numberList) смешивает типы элементов, потому что wildcards позволяют списку с Number принимать значения Integer.
sum принимает как List<Integer>, так и List<Double>, потому что wildcard говорит «некоторый подтип Number». fillWithSquares добавляет значения Integer в List<Number>, потому что wildcard говорит «любой список, который может хранить Integer или одного из его предков». copyTo использует оба варианта — источник является производителем, назначение — потребителем, а T — общий тип элемента, который компилятор выводит из согласования двух сторон.
Что дальше
Вы познакомились с четырьмя способами, которыми generics проявляются в исходном коде: классы, методы, интерфейсы и wildcards. Теперь заглянем на уровень ниже и посмотрим, как JVM реализует всё это на самом деле. Ответ — стирание типов — объясняет некоторые удивительные ограничения (нет new T(), нет instanceof T, нет обобщённых массивов) и является единственным озарением, которое делает историю generics в Java понятной. Продолжите с Java Type Erasure.