1. Введение
В этой статье мы рассмотрим несколько примеров вопросов и ответов на собеседовании по дженерикам Java.
Обобщения — это основная концепция Java, впервые представленная в Java 5. Из-за этого почти все кодовые базы Java будут использовать их, почти гарантируя, что разработчик в какой-то момент столкнется с ними. Вот почему важно правильно их понимать, и именно поэтому о них, скорее всего, будут спрашивать во время собеседования.
2. Вопросы
Q1. Что такое параметр универсального типа?
Тип
— это имя класса
или интерфейса
. Как следует из названия, параметр универсального типа — это когда тип
может использоваться в качестве параметра в объявлении класса, метода или интерфейса.
Давайте начнем с простого примера без дженериков, чтобы продемонстрировать это:
public interface Consumer {
public void consume(String parameter)
}
В этом случае тип параметра метода Consumer()
— String.
Он не параметризуется и не настраивается.
Теперь давайте заменим наш тип String
универсальным типом, который мы назовем T.
По соглашению он назван так:
public interface Consumer<T> {
public void consume(T parameter)
}
Когда мы реализуем нашего потребителя, мы можем указать тип
, который мы хотим, чтобы он потреблял, в качестве аргумента. Это параметр универсального типа:
public class IntegerConsumer implements Consumer<Integer> {
public void consume(Integer parameter)
}
В этом случае теперь мы можем потреблять целые числа. Мы можем заменить этот тип
на все, что нам нужно.
Q2. Каковы некоторые преимущества использования универсальных типов?
Одним из преимуществ использования дженериков является избежание приведения типов и обеспечение безопасности типов. Это особенно полезно при работе с коллекциями. Давайте продемонстрируем это:
List list = new ArrayList();
list.add("foo");
Object o = list.get(0);
String foo = (String) o;
В нашем примере тип элемента в нашем списке неизвестен компилятору. Это означает, что единственное, что можно гарантировать, это то, что это объект. Поэтому, когда мы извлекаем наш элемент, мы возвращаем объект
. Как авторы кода, мы знаем, что это String,
но мы должны привести наш объект к единице, чтобы явно решить проблему. Это производит много шума и шаблонов.
Далее, если мы начнем думать о возможности ручной ошибки, проблема приведения усугубится. Что, если в нашем списке случайно окажется Integer ?
list.add(1)
Object o = list.get(0);
String foo = (String) o;
В этом случае мы получим исключение ClassCastException
во время выполнения, поскольку целое число
не может быть приведено к строке.
Теперь давайте попробуем повторить себя, на этот раз используя дженерики:
List<String> list = new ArrayList<>();
list.add("foo");
String o = list.get(0); // No cast
Integer foo = list.get(0); // Compilation error
Как мы видим, с помощью дженериков у нас есть проверка типа компиляции, которая предотвращает ClassCastExceptions
и устраняет необходимость приведения типов.
Другое преимущество состоит в том, чтобы избежать дублирования кода . Без дженериков нам приходится копировать и вставлять один и тот же код, но для разных типов. С дженериками нам не нужно этого делать. Мы даже можем реализовать алгоритмы, применимые к универсальным типам.
Q3. Что такое стирание шрифта?
Важно понимать, что информация об универсальном типе доступна только компилятору, а не JVM. Другими словами , стирание типа означает, что информация об универсальном типе недоступна для JVM во время выполнения, а только во время компиляции .
Причина выбора основной реализации проста — сохранение обратной совместимости со старыми версиями Java. Когда универсальный код скомпилирован в байт-код, это будет так, как если бы универсальный тип никогда не существовал. Это означает, что компиляция будет:
- Замените общие типы объектами
- Замените ограниченные типы (подробнее об этом в следующем вопросе) первым связанным классом
- Вставьте эквивалент приведения при извлечении универсальных объектов.
Важно понимать стирание типа. В противном случае разработчик может запутаться и подумать, что сможет получить тип во время выполнения:
public foo(Consumer<T> consumer) {
Type type = consumer.getGenericTypeParameter()
}
Приведенный выше пример представляет собой псевдокод, эквивалентный тому, как все могло бы выглядеть без стирания типов, но, к сожалению, это невозможно. Опять же, информация об универсальном типе недоступна во время выполнения.
Q4. Если при создании экземпляра объекта не указан общий тип, будет ли код компилироваться?
Поскольку дженериков не существовало до Java 5, их можно вообще не использовать. Например, дженерики были адаптированы к большинству стандартных классов Java, таких как коллекции. Если мы посмотрим на наш список с первого вопроса, то увидим, что у нас уже есть пример опускания универсального типа:
List list = new ArrayList();
Несмотря на возможность компиляции, вполне вероятно, что компилятор выдаст предупреждение. Это связано с тем, что мы теряем дополнительную проверку во время компиляции, которую получаем при использовании дженериков.
Следует помнить, что, хотя обратная совместимость и стирание типов позволяют опускать общие типы, это плохая практика.
Q5. Чем универсальный метод отличается от универсального типа?
Универсальный метод — это когда параметр типа вводится в метод, находящийся в рамках этого метода. Давайте попробуем это на примере:
public static <T> T returnType(T argument) {
return argument;
}
Мы использовали статический метод, но при желании могли бы использовать и нестатический. Используя вывод типа (описанный в следующем вопросе), мы можем вызывать его как любой обычный метод, не указывая при этом никаких аргументов типа.
Q6. Что такое вывод типа?
Вывод типа — это когда компилятор может посмотреть на тип аргумента метода, чтобы вывести универсальный тип. Например, если мы передали T
методу, который возвращает T,
то компилятор сможет определить возвращаемый тип. Давайте попробуем это, вызвав наш универсальный метод из предыдущего вопроса:
Integer inferredInteger = returnType(1);
String inferredString = returnType("String");
Как мы видим, нет необходимости в приведении и нет необходимости передавать какой-либо аргумент универсального типа. Тип аргумента определяет только тип возвращаемого значения.
Q7. Что такое параметр ограниченного типа?
До сих пор все наши вопросы касались аргументов универсальных типов, которые не ограничены. Это означает, что наши аргументы универсального типа могут быть любого типа, который мы хотим.
Когда мы используем ограниченные параметры, мы ограничиваем типы, которые можно использовать в качестве аргументов универсального типа.
В качестве примера предположим, что мы хотим, чтобы наш универсальный тип всегда был подклассом животного:
public abstract class Cage<T extends Animal> {
abstract void addAnimal(T animal)
}
Используя extends ,
мы заставляем T
быть подклассом animal .
Тогда у нас могла бы быть клетка с кошками:
Cage<Cat> catCage;
Но у нас не могло быть клетки объектов, так как объект не является подклассом животного:
Cage<Object> objectCage; // Compilation error
Одним из преимуществ этого является то, что компилятору доступны все методы животных. Мы знаем, что наш тип расширяет его, поэтому мы можем написать общий алгоритм, который работает с любым животным. Это означает, что нам не нужно воспроизводить наш метод для разных подклассов животных:
public void firstAnimalJump() {
T animal = animals.get(0);
animal.jump();
}
Q8. Можно ли объявить несколько параметров ограниченного типа?
Возможно объявление нескольких границ для наших универсальных типов. В нашем предыдущем примере мы указали одну границу, но при желании могли бы указать и больше:
public abstract class Cage<T extends Animal & Comparable>
В нашем примере животное — это класс, а сравнение — интерфейс. Теперь наш тип должен учитывать обе эти верхние границы. Если бы наш тип был подклассом животного, но не реализовывал бы сопоставимость, тогда код не скомпилировался бы. Также стоит помнить, что если одна из верхних границ является классом, она должна быть первым аргументом.
Q9. Что такое подстановочный знак?
Подстановочный знак представляет неизвестный тип
. Он взорвался со знаком вопроса следующим образом:
public static void consumeListOfWildcardType(List<?> list)
Здесь мы указываем список, который может быть любого типа
. Мы могли бы передать список чего угодно в этот метод.
Q10. Что такое подстановочный знак с верхней границей?
Подстановочный знак с верхней границей — это когда тип подстановочного знака наследуется от конкретного типа . Это особенно полезно при работе с коллекциями и наследованием.
Давайте попробуем продемонстрировать это с помощью класса фермы, в котором будут храниться животные, сначала без подстановочного знака:
public class Farm {
private List<Animal> animals;
public void addAnimals(Collection<Animal> newAnimals) {
animals.addAll(newAnimals);
}
}
Если бы у нас было несколько подклассов животных ,
таких как кошка и собака ,
мы могли бы сделать неверное предположение, что можем добавить их всех на нашу ферму:
farm.addAnimals(cats); // Compilation error
farm.addAnimals(dogs); // Compilation error
Это связано с тем, что компилятор ожидает коллекцию конкретного типа animal ,
а не подклассов.
Теперь давайте введем верхний ограниченный подстановочный знак в наш метод добавления животных:
public void addAnimals(Collection<? extends Animal> newAnimals)
Теперь, если мы попробуем еще раз, наш код скомпилируется. Это потому, что теперь мы сообщаем компилятору принять коллекцию любого подтипа животных.
Q11. Что такое неограниченный подстановочный знак?
Неограниченный подстановочный знак — это подстановочный знак без верхней или нижней границы, который может представлять любой тип.
Также важно знать, что подстановочный знак не является синонимом объекта. Это связано с тем, что подстановочный знак может быть любым типом, тогда как тип объекта является конкретно объектом (и не может быть подклассом объекта). Продемонстрируем это на примере:
List<?> wildcardList = new ArrayList<String>();
List<Object> objectList = new ArrayList<String>(); // Compilation error
Опять же, причина, по которой вторая строка не компилируется, заключается в том, что требуется список объектов, а не список строк. Первая строка компилируется, потому что допустим список любого неизвестного типа.
Q12. Что такое подстановочный знак с нижней границей?
Подстановочный знак с нижней границей — это когда вместо верхней границы мы предоставляем нижнюю границу с помощью ключевого слова super
. Другими словами, нижний ограниченный подстановочный знак означает, что мы заставляем тип быть суперклассом нашего ограниченного типа . Давайте попробуем это на примере:
public static void addDogs(List<? super Animal> list) {
list.add(new Dog("tom"))
}
Используя super,
мы могли бы вызвать addDogs для списка объектов:
ArrayList<Object> objects = new ArrayList<>();
addDogs(objects);
Это имеет смысл, поскольку объект является надклассом животного. Если бы мы не использовали подстановочный знак с нижней границей, код не скомпилировался бы, так как список объектов — это не список животных.
Если подумать, мы не сможем добавить собаку в список любого подкласса животных, например, кошек или даже собак. Только суперкласс животных. Например, это не будет компилироваться:
ArrayList<Cat> objects = new ArrayList<>();
addDogs(objects);
Q13. Когда бы вы предпочли использовать тип с нижней границей по сравнению с типом с верхней границей?
При работе с коллекциями общим правилом выбора между верхними и нижними подстановочными знаками является PECS. PECS расшифровывается как « производитель расширяется», «потребитель супер».
Это можно легко продемонстрировать с помощью некоторых стандартных интерфейсов и классов Java.
Расширение производителя
просто означает, что если вы создаете производителя универсального типа, используйте ключевое слово extends
. Давайте попробуем применить этот принцип к коллекции, чтобы понять, почему это имеет смысл:
public static void makeLotsOfNoise(List<? extends Animal> animals) {
animals.forEach(Animal::makeNoise);
}
Здесь мы хотим вызвать makeNoise()
для каждого животного в нашей коллекции. Это означает, что наша коллекция является производителем ,
так как все, что мы делаем с ней, — это заставляем ее возвращать животных для выполнения нашей операции. Если бы мы избавились от extends
, мы бы не смогли передавать списки кошек ,
собак или любых других подклассов животных. Применяя принцип расширения производителя, мы получаем максимально возможную гибкость.
Потребительский супер
означает противоположное расширению производителя.
Все это означает, что если мы имеем дело с чем-то, что потребляет элементы, то мы должны использовать ключевое слово super
. Мы можем продемонстрировать это, повторив наш предыдущий пример:
public static void addCats(List<? super Animal> animals) {
animals.add(new Cat());
}
Мы только добавляем в наш список животных, поэтому наш список животных является потребителем. Вот почему мы используем ключевое слово super
. Это означает, что мы можем передать в список любой суперкласс животных, но не подкласс. Например, если бы мы попытались передать список собак или кошек, код не скомпилировался бы.
Последнее, что нужно рассмотреть, это то, что делать, если коллекция является одновременно потребителем и производителем. Примером этого может быть коллекция, в которой элементы добавляются и удаляются. В этом случае следует использовать неограниченный подстановочный знак.
Q14. Существуют ли ситуации, когда информация об общем типе доступна во время выполнения?
Есть одна ситуация, когда универсальный тип доступен во время выполнения. Это когда общий тип является частью сигнатуры класса, например:
public class CatCage implements Cage<Cat>
Используя отражение, мы получаем этот параметр типа:
(Class<T>) ((ParameterizedType) getClass()
.getGenericSuperclass()).getActualTypeArguments()[0];
Этот код несколько хрупкий. Например, это зависит от параметра типа, определяемого в непосредственном суперклассе. Но это демонстрирует, что у JVM есть информация об этом типе.