Введение
Сегодня отличный день, чтобы вспомнить, что же мы знаем про Java. Согласно самому главному документу, т.е. спецификации языка Java (JLS — Java Language Specifiaction), Java является строго типизированным языком, о чём сказано в главе "Chapter 4. Types, Values, and Variables". Что же это означает? Предположим, у нас есть главный метод:
public static void main(String[] args) {
String text = "Hello world!";
System.out.println(text);
}
Строгая типизация гарантирует, что при компиляции данного кода компилятор проверит, что если мы указали тип переменной text как String, то мы нигде не пытаемся использовать её как переменную другого типа (например как число Integer).
Например, если мы вместо текста попытаемся сохранить значение 2L
(т.е. long вместо String), то в момент компиляции получим ошибку:
Main.java:3: error: incompatible types: long cannot be converted to String
String text = 2L;
Т.е. строгая типизация позволяет обеспечить выполнение операций с объектами только тогда, когда эти операции правомерны для этих объектов. Это ещё называется типобезопасностью или "Type safety".
Как сказано в JLS, в Java существует две категории типов: примитивные типы (primitive types) и ссылочные типы (reference types). Про примитивные типы можно вспомнить по обзорной статье: "Примитивные типы в Java: Не такие уж они и примитивные".
Ссылочные типы могут быть представлены классом, интерфейсом или массивом. И сегодня нас будут интересовать именно ссылочные типы. И начнём с массивов:
class Main {
public static void main(String[] args) {
String[] text = new String[5];
text[0] = "Hello";
}
}
Такой код выполняется без ошибки. Как мы знаем (например, по "Oracle Java Tutorial: Arrays"), массив — это контейнер, который хранить данные только одного типа. В данном случае — только строки.
Попробуем добавить в массив long вместо String:
text[1] = 4L;
Выполним этот код (например, в Repl.it Online Java Compiler) и получим ошибку:
error: incompatible types: long cannot be converted to String
Массив и типобезопасность языка не дали нам сохранить в массив то, что не подходит по типу. Вот оно проявление type safety. Нам сказали: "Исправь ошибку, а до тех пор я не скомпилирую код". И самое главное в этом — это происходит в момент компиляции, а не запуска программы. То есть ошибки мы видим сразу, а не "когда-нибудь".
А раз уж мы вспомнили про массивы, то вспомним и про Java Collections Framework. Там у нас были разные структуры. Например, списки. Давайте перепишем пример:
import java.util.*;
class Main {
public static void main(String[] args) {
List text = new ArrayList(5);
text.add("Hello");
text.add(4L);
String test = text.get(0);
}
}
При компиляции его мы получим на строчке инициализации переменной test
ошибку:
incompatible types: Object cannot be converted to String
В нашем случае, List может хранить любой объект (т.е. объект типа Object). Поэтому, компилятор говорит, что он на себя такой груз ответственности брать не может. Поэтому, нам надо явно указать тип, который мы получим из списка:
String test = (String) text.get(0);
Такое указание называется приведением типов (type conversion или type casting). И всё будет работать теперь замечательно, пока мы не попробуем достать элемент по индексу 1, т.к. он имеет тип Long. И мы получим справедливую ошибку, но уже во время работы программы (в Runtime):
type conversion, typecasting
Exception in thread "main" java.lang.ClassCastException: java.lang.Long cannot be cast to java.lang.String
Как мы видим, здесь сразу несколько важных минусов. Во-первых, мы вынуждены сами "кастить" значение, полученное из списка, к классу String. Согласитесь, это некрасиво. Во-вторых, в случае ошибки, мы её увидим только тогда, когда программа будет выполнятся. Будь наш код сложнее, мы могли бы обнаружить такую ошибку далеко не сразу.
И стали разработчики думать, как сделать работу в таких ситуациях проще, а код нагляднее. И на свет родились они — Дженерики (Generics).
Generics
Итак, дженерики. Что же это такое? Дженерик — это особый способ описание используемых типов, который сможет использовать в своей работе компилятор кода для обеспечения типобезопасности. Выглядит это примерно так:А вот коротенький пример и объяснение его:
import java.util.*;
class Main {
public static void main(String[] args) {
List<String> text = new ArrayList<String>(5);
text.add("Hello");
text.add(4L);
String test = text.get(1);
}
}
В данном примере мы говорим, что у нас есть не просто List
, а List
, который работает ТОЛЬКО с объектами типа String. И никакими другими. Вот только что указано в скобках, то и можем хранить. Такие "скобки" называются "angle brackets", т.е. угловые (angle) скобки (brackets). Компилятор любезно за нас проверит, не допустили ли мы ошибок при работе со списком строк (список имеет имя text). Компилятор увидит, что мы пытаемся в список String положить наглым образом Long. И в момент компиляции выдаст ошибку:
error: no suitable method found for add(long)
Возможно, Вы вспомнили про то, что String — это наследник CharSequence. И решите сделать что-нибудь вроде:
public static void main(String[] args) {
ArrayList<CharSequence> text = new ArrayList<String>(5);
text.add("Hello");
String test = text.get(0);
}
Но так нельзя и мы получим ошибку:
error: incompatible types: ArrayList<String> cannot be converted to ArrayList<CharSequence>
Кажется странным, т.к. строка CharSequence sec = "test";
не содержит ошибок. Давайте разбираться.
Про такое поведение говорят: "Дженерики являются инвариантными". Что такое "инвариант"?
Мне нравится, как про это сказано на википедии в статье "Covariance and contravariance":
Таким образом, Инвариантность — это отсутствие наследования между производными типами.
Если Кошка — это подтип Животные, то Множество<Кошки> не является подтипом Множество<Животные> и Множество<Животные> не является подтипом Множество<Кошки>.
Кстати, стоит сказать, что начиная с Java SE 7 появился так называемый "Diamond Operator". Потому что две угловые скобки <> похожи на бриллиант. Это позволяет нам использовать дженерики следующим образом:
public static void main(String[] args) {
List<String> lines = new ArrayList<>();
lines.add("Hello world!");
System.out.println(lines);
}
Компилятор по данному коду понимает, что если мы указали в левой части, что List
будет содержать объекты типа String, то в правой части мы подразумеваем, что хотим в переменную lines
сохранить новый ArrayList, который будет хранить тоже объект того типа, который указан в левой части. Таким образом, компилятор из левой части понимает или выводит тип для правой части. Именно поэтому такое поведение называется выведением типа или "Type Inference" на английском.
Стоит отметить ещё такую интересную вещь, как RAW Types или же "сырые типы". Т.к. дженерики были не всегда, а Java старается по возможности поддерживать обратную совместимость, то дженерики вынуждены как-то работать с кодом, где не указан никакой дженерик.
Посмотрим пример:
List<CharSequence> lines = new ArrayList<String>();
Как мы помним, такая строчка не скомпилируется из-за инвариантности дженериков.
List<Object> lines = new ArrayList<String>();
И такая тоже не скомпилируется, по той же самой причине.
List lines = new ArrayList<String>();
List<String> lines2 = new ArrayList();
Такие строчки скомпилируется и будут работать. Именно в них используется Raw Types, т.е. не указанные типы. Ещё раз стоит указать, что Raw Types в современном коде НЕ ДОЛЖНЫ быть использованы.
Типизированные классы
Итак, типизированные классы. Давайте увидим, как мы можем написать свой типизированный класс. Например, у нас есть иерархия классов:
public static abstract class Animal {
public abstract void voice();
}
public static class Cat extends Animal {
public void voice(){
System.out.println("Meow meow");
}
}
public static class Dog extends Animal {
public void voice(){
System.out.println("Woof woof");
}
}
Мы хотим создать класс, реализующий контейнер для животных. Можно было бы написать класс, который будет содержать любых Animal
. Это просто, понятно, НО... мешать собак и кошек плохо, они не дружат друг с другом. Кроме того, если кто-то получит такой контейнер, то он по ошибке может запустить из контейнера кошек в стаю собак... и до добра это не доведёт. И тут нам помогут дженерики.
Например, напишем реализацию так:
public static class Box<T> {
List<T> slots = new ArrayList<>();
public List<T> getSlots() {
return slots;
}
}
Наш класс будет работать с объектами типа, указанные дженериком с именем T. Это своего рода псевдоним. Т.к. дженерик указан у имени класса, то и получать его будем при объявлении класса:
public static void main(String[] args) {
Box<Cat> catBox = new Box<>();
Cat murzik = new Cat();
catBox.getSlots().add(murzik);
}
Как мы видим, мы указали что у нас Box
, который работает только с Cat
. Компилятор понял, что для catBox
вместо дженерика T
нужно подставить тип Cat
везде, где указано имя дженерика T
:
Т.е. именно благодаря
Box<Cat>
компилятор понимает, что slots
на самом деле должен быть List<Cat>
.
Для Box<Dog>
внутри будет slots
, содержащий List<Dog>
.
Дженериков в объявлении типа может быть несколько, например:
public static class Box<T, V> {
Название дженерика может быть любым, хотя рекомендуется придерживаться некоторым негласным правилам — "Type Parameter Naming Conventions": Тип элемента — E, тип ключа — K, тип числа — N, T — для обозначения типа, V — для типа значения.
Кстати, помните мы с вами говорили, что дженерики инвариантны, т.е. не сохраняют иерархию наследования. На самом деле, мы можем на это повлиять. То есть у нас есть возможность сделать дженерики КОвариантными, т.е. сохраняющими наследования в том же порядке. Такое поведение называется "Bounded Type", т.е. ограниченные типы.
Например, наш класс Box
мог бы содержать всех животных, тогда бы мы объявили дженерик следующим образом:
public static class Box<T extends Animal> {
То есть мы задали ограничили верхнюю границу классом Animal
.
Так же мы можем указать несколько типов после ключевого слова extends
. Это будет означать, что тип, с которым мы будем работать, должен быть наследником некоторого класса и при этом реализовывать некоторый интерфейс. Например:
public static class Box<T extends Animal & Comparable> {
В этом случае, если мы попробуем положить в такой Box
что-то, что не является наследником Animal
и не реализует Comparable
, то во время компиляции мы получим ошибку:
error: type argument Cat is not within bounds of type-variable T
Типизация методов
Дженерики применяются не только в типах, но и в отдельных методах. Применение в методах можно увидеть в официальном tutorial: "Generics Methods".Предыстория:
Посмотрим на эту картинку. Как видно, компилятор смотрит на сигнатуру метода и видит, что на вход мы принимаем какой-то неопределённый класс. Не по сигнатуре он определяет, что мы возвращаем какой-то объект, т.е. Object.
Следовательно, если мы хотим создать, скажем, ArrayList, то нам надо делать так:
ArrayList<String> object = (ArrayList<String>) createObject(ArrayList.class);
Приходится самим явно писать, что на выходе будет ArrayList, что некрасиво и добавляет шанс сделать ошибку. Например, мы можем написать такой бред и это скомпилируется:
ArrayList object = (ArrayList) createObject(LinkedList.class);
Можем ли мы помочь компилятору? Да, дженерики нам это позволяют. Рассмотрим тот же пример:
Тогда, мы можем создать объект просто вот так:
ArrayList<String> object = createObject(ArrayList.class);
WildCard
Согласно Tutorial от Oracle по дженерикам, а именно разделу "Wildcards", мы можем описать "неизвестный тип" символом вопроса, так называемого question mark. Wildcard — удобный инструмент, чтобы смягчить некоторый ограничения дженериков. Например, как мы ранее разбирали, дженерики инвариантны. Это значит что хотя все классы являются наследниками (подтипами, subtypes) типа Object, ноList<любой тип>
не является подтипом List<Object>
.
НО, List<любой тип>
является подтипом List<?>
. Таким образом, мы можем написать следующий код:
public static void printList(List<?> list) {
for (Object elem: list) {
System.out.print(elem + " ");
}
System.out.println();
}
Как и обычные дженерики (т.е. без использования wildcard'ов), дженерики с wildcard могут быть ограничены. Ограничение по верхней границе (Upper bounded wildcard) выглядит привычно:
public static void printCatList(List<? extends Cat> list) {
for (Cat cat: list) {
System.out.print(cat + " ");
}
System.out.println();
}
Но можно ограничить и по нижней границе (Lower bound wildcard):
public static void printCatList(List<? super Cat> list) {
Таким образом метод начнёт принимать всех котов, а так же по иерархии выше (вплоть до Object).
Стирание типов или Type Erasure
Говоря про дженерики стоит знать про "Стирание типов". На самом деле стирание типов это про то, что дженерики — это информация для компилятора. Во время выполнения программы информации о дженериках больше нет, это и называется "стирание". Это стирание имеет тот эффект, что тип дженерика заменяется конкретным типом. Если дженерик не имел границы, то будет подставлен тип Object. Если же граница была указана (например<T extends Comparable>
), то она и будет подставлена.
Вот пример из Tutorial от Oracle: "Erasure of Generic Types":
Как выше и было сказано, в данном примере дженерик
T
стёрт до своей границы, т.е. до Comparable
.
Заключение
Дженерики — очень интересная тема. Надеюсь, данная тема Вас заинтересовала. Подводя итоги, можно сказать, что дженерики — прекрасное средство, которые получили разработчики, чтобы подсказывать компилятору дополнительную информацию для обеспечения типобезопасности с одной стороны и гибкости с другой. И если заинтересовала, то предлагаю к ознакомлению ресурсы, которые понравились самому:- coursehunters : "java generics"
- Oracle Tutorial: "Lesson: Generics (Updated)"
- JavaRush: "Теория дженериков в Java или как на практике ставить скобки"
- Юрий Ткач: "Advanced Java - Generics"
- Александр Маторин — "Неочевидные Дженерики"
- IBM Developer: "Unit 20: Generics"
- Хабр: "Пришел, увидел, обобщил: погружаемся в Java Generics"
- Книга "Java Generics and Collections"
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ