Что такое дженерики?

Версия 4

Автор: Савченко В.В. ИП-41

*Изменена структура лекции – сначала теория, потом наглядный пример.
*Добавлены новые вопросы.

Лекция: Generics

ВВЕДЕНИЕ

Введение дженериков(generics), так же называемых обобщениями или родовыми типами, в JDK 5 стало гигантским шагом вперед для языка Java. В этой лекции мы рассмотрим, зачем это было сделано и как они нам могут пригодиться.

Что такое дженерики?

С выпуском JDK 5 язык Java вдруг обрел странный и захватывающий новый синтаксис. Некоторые знакомые классы JDK были заменены на эквивалентные обобщения.

Обобщения - это механизм компилятора, посредством которого можно некоторым стандартным образом создавать и использовать типы (классов, интерфейсов и т.п.), получая единый код и параметризуя (или стандартизуя) все остальное. Звучит не очень понятно, не правда ли?

Давайте рассмотрим, что же подтолкнуло разработчиков на введение обобщений.

ПРОБЛЕМА БЕЗОПАСНОСТИ ТИПОВ

Java - строго типизированный язык. При программировании на Java во время компиляции надо знать, передается ли верный тип параметра методу. Например, если определить:

Dog aDog = aBookReference; // ошибка

где aBookReference – ссылка типа Book, не связанная с Dog, вы получите ошибку компиляции. И вправду, если заявить, что собака – это книга, звучать это будет, как минимум, не совсем логично.

Рассмотрим данную проблему более детально:

Чтобы увидеть, что дают родовые типы, возьмем пример класса, который присутствовал в JDK в течение длительного времени: java.util.ArrayList, который представляет собой список объектов, поддерживаемых массивом.

Создадим экземпляр нашего java.util.ArrayList.


ArrayList arrayList = new ArrayList();

arrayList.add("A String");

arrayList.add(new Integer(10));

arrayList.add("Another String");

// Пока все хорошо

Как видите, ArrayList неоднороден: он содержит два типа String и один тип Integer. До JDK 5 в языке Java не было ничего, что ограничивало бы такое поведение, и это приводило к многочисленным ошибкам программирования. Например, в нашем коде пока все выглядит хорошо. Но как насчет доступа к элементам ArrayList? Рассмотрим следующий фрагмент кода:

(на экране можно просто добавить до предыдущего фрагмента для наглядности)

*processArrayList(arrayList);

*// метод в некоторой более поздней части кода...

private void processArrayList(ArrayList theList) {

for (int index = 0; index < theList.size(); index++) {

// В какой-то момент это не удастся...

String s = (String)theList.get(index); //(*)

}

}

Не зная, элементы каких типов находятся в ArrayList, мы либо должны проверить элемент, к которому обращаемся, чтобы понять, можно ли управлять его типом, либо столкнемся с возможностью исключения ClassCastException, что не очень приятно.

И появляется такой вопрос: "Как с этим бороться? " В частности: "Как же зарезервировать ArrayList для определенного типа данных?"

Как раз такую проблему решают Generics.

Вопрос: Подумайте, почему ошибка компиляции «болем приятна» программисту, чем ошибка времени выполнения?
(Пауза…Ответ: потому что первую гораздо просто локализировать и устранить, нежели вторую.)

С помощью дженериков можно указать тип элемента, который находится в ArrayList. Типы могут быть самыми разными за исключением примитивных – для них написали их непримитивные аналоги - классы, унаследованные от Object. Давайте взглянем на реализацию этого:

ArrayList <String> arrayList = new ArrayList <String>();

arrayList.add("A String");

arrayList.add(new Integer(10)); //тут уже имеем ошибку компиляции

arrayList.add("Another String");

// Пока все хорошо

*processArrayList(arrayList);

*// В некоторой более поздней части кода...

private void processArrayList(ArrayList <String> theList) {

for (int index = 0; index < theList.size(); index++) {

// заметьте - приведение не требуется...

String s = theList.get(index); //(*)

}

}

Как видите, (*) больше не нужно приводить к String.


Вопрос: Почему приведение больше не обязательно?
(Пауза…Ответ: так как метод get() возвращает ссылку на объект конкретного типа (в данном случае – String).)

И когда мы говорим, что arrayList объявлен как ArrayList<String>, это будет справедливо во всем коде и компилятор это гарантирует.

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

Вопрос: Можно ли написать следующий фрагмент кода?

ArrayList<int>

(Пауза… Ответ:
нет, так как int – примитивный тип, вместо него следует использовать Integer)

Эффект от Generics особенно проявляется в крупных проектах: он улучшает читаемость и надежность кода в целом.

НАПИСАНИЕ ОБОБЩЕННЫХ КЛАССОВ

В прошлом примере мы наблюдали реализацию методов с обобщениями, но давайте не забывать о том, что такими могут быть и классы. Синтаксис для написания обобщенного класса очень простой. Взглянем на пример обобщенного класса:

package com.agiledeveloper;

public class Pair<E>
{
private E obj1;
private E obj2;

public Pair(E element1, E element2)
{
obj1 = element1;
obj2 = element2;
}

public E getFirstObject() { return obj1; }
public E getSecondObject() { return obj2; }
}

Этот класс представляет собой пару значений некоторого обобщенного типа E. Рассмотрим несколько примеров использования данного класса:

// Правильное использование
Pair<Double> aPair = new Pair<Double>(new Double(1), new Double(2.2));

Рассмотрим следующий пример:

// Неправильное использование
Pair<Double> anotherPair = new Pair<Double>(new Integer(1), new Double(2.2));

Тут получим ошибку компиляции.

Вопрос: Почему?
(Пауза…Ответ:
она возникнет, если попытаться создать объект с несоответствующими типами.)

Здесь предпринимается попытка отправить экземпляр Integer и экземпляр Double экземпляру Pair. Однако это дает ошибку компиляции по известным причинам.

Как видите, можно указать два и более параметров типа через запятую. Работает все абсолютно так же, как и с одним параметром.

Вопрос: Будет ли корректно работать программа, в коде которой есть как обобщения, так и код, не адаптированный для использования обобщений.

(Пауза….Ответ: Да, и мы плавно переходим к следующему разделу.)

ОБОБЩЕНИЯ И ЗАМЕНЯЕМОСТЬ

Обобщения соблюдают принцип заменяемости. Каждый обобщенный класс также имеет необобщенную форму, поэтому к нему можно обращаться из необобщенного кода. Это обеспечивает обратную совместимость с существующим кодом или кодом, не перенесенным для использования обобщений.

Поясним на примере. Допустим, есть корзина фруктов. В нее можно добавить апельсины, бананы, виноград и т.д. Теперь создадим корзину бананов. В нее должно быть разрешено добавлять только бананы. Она должна запрещать добавление других типов фруктов. Банан является фруктом, т.е. банан наследуется от фрукта. Должна ли корзина бананов наследоваться от корзины фруктов, как показано на рисунке ниже?

Если бы корзина бананов наследовалась от корзины фруктов, то можно было бы заставить ссылку типа корзина фруктов ссылаться на экземпляр корзины бананов. Затем с помощью этой ссылки можно было бы добавить банан в корзину, но можно было бы добавить и апельсин. Тогда как добавление банана в корзину бананов правильно, добавление апельсина - нет. В лучшем случае это вызовет исключение времени выполнения. Однако код, использующий корзину фруктов, может не знать, как выйти из этой ситуации. Корзина бананов не заменяема там, где используется корзина фруктов.


Обобщения соблюдают этот принцип. Рассмотрим следующий пример:

Pair<Object> objectPair = new Pair<Integer>(new Integer(1), new Integer(2));

Этот код даст ошибку времени компиляции:

Error: line (9) incompatible types found:
com.agiledeveloper.Pair<java.lang.Integer> required:
com.agiledeveloper.Pair<java.lang.Object>

Прежде чем оставить данную тему, рассмотрим этот фрагмент более детально. Тогда как:

Pair<Object> objectPair = new Pair<Integer>(new Integer(1), new Integer(2));

запрещено, однако следующее – разрешено:

Pair objectPair = new Pair<Integer>(new Integer(1), new Integer(2));

Pair без параметризованного типа является необобщенной формой класса Pair. В то время как такая совместимость имеет определенное преимущество, она может вызвать путаницу и проблемы с безопасностью типов, что тоже не следует забывать.

ШАБЛОНЫ АРГУМЕНТОВ(подстановочные типы)

Обобщенные типы позволяют описывать ограничения, накладываемые на поведение класса или метода, в терминах неизвестных типов. Например, "Каких бы типов ни были параметры x и y этого метода, они должны быть одного типа", "необходимо передать параметр одного типа обоим этим методам" или "возвращаемое значение метода foo() имеет тот же тип, что и параметр методаbar()."

Подстановочные символы - знаки вопроса на том месте, где обычно указывается тип данных, - это способ выражения ограничений, накладываемых на тип, в терминах неизвестного типа.

одстановочные символы играют важную роль в системе типов; с их помощью вы можете описать тип, ограниченный некоторым семейством типов, описываемых обобщенным классом. Для обобщенного класса ArrayList, тип ArrayList<?> обозначает супертипArrayList<T> для любого типа T (также как простой тип ArrayList и корневой тип Object, которые, однако, гораздо менее полезны для определения типа).

Подстановочный тип списка List<?> отличается как от простого типа List, так и от конкретного типа List<Object>. Когда говорят, что переменная x имеет тип List<?>, это значит, что существует некоторый тип T, для которого x имеет тип List<T>, хотя и неизвестно, элементы какого именно типа она содержит. Это не значит, что содержимым может быть все что угодно, это значит, что мы не знаем, какие именно ограничения типа имеются у содержимого, — но мы знаем, что ограничения присутствуют.

Перейдем к более интересным принципам обобщений. Рассмотрим следующий пример:

public abstract class Animal
{
public void playWith(Collection<Animal> playGroup)
{

}
}

public class Dog extends Animal
{
public void playWith(Collection<Animal> playGroup)
{
}
}

Класс Animal(животное) имеет метод playWith(), принимающий коллекцию Animal. Dog(собака), расширяющая Animal, переопределяет этот метод. Попробуем использовать класс Dog в примере:

Collection<Dog> dogs = new ArrayList<Dog>();

Dog aDog = new Dog();
aDog.playWith(dogs); //ошибка

Здесь создается экземпляр Dog и отправляется коллекция Dog его методу playWith(). Выдается ошибка компиляции:

Error: line (29) cannot find symbol
method playWith(java.util.Collection<com.agiledeveloper.Dog>)

Причина состоит в том, что коллекцию Dog нельзя рассматривать как коллекцию Animal, которую ожидает метод playWith() …

Вопрос: Почему же?
(Пауза…Ответ:
Рассмотрим на примере животного мира – все собаки являются животными, не так ли? Но далеко не все животные - собаки. Подробнее мы рассматривали это в предыдущем разделе.)


Однако было бы логично иметь возможность отправить коллекцию Dog этому методу, не так ли? Как это сделать? Здесь вступает в дело подстановочный знак или неизвестный тип.

Оба метода playMethod()(в Animal и Dog) изменяются следующим образом:

public void playWith(Collection<?> playGroup)

Collection не имеет тип Animal. Вместо этого она имеет неизвестный тип (?). Неизвестный тип – не Object, он просто неизвестный или неопределенный.
Теперь код:

aDog.playWith(dogs);

компилируется без ошибок. Однако есть проблема. Также можно написать:

ArrayList<Integer> numbers = new ArrayList<Integer>();
aDog.playWith(numbers);

Изменение, сделанное, чтобы позволить отправить коллекцию Dog методу playWith(), теперь позволяет отправить и коллекцию Integer. Если разрешить это, получится странная собака. Как сказать, что компилятор должен разрешать коллекции Animal или коллекции любого типа, расширяющего Animal, но не любую коллекцию других типов? Это позволяет осуществить применение ограниченных неизвестных параметров, как показано ниже:

public void playWith(Collection<? extends Animal> playGroup)

Ограничение применения шаблонов аргументов состоит в том, что разрешено извлекать элементы из Collection<?>, но нельзя добавлять элементы в такую коллекцию – компилятор не знает, с каким типом имеет дело.


ОГРАНИЧЕННЫЙ НЕИЗВЕСТНЫЙ ПАРАМЕТР.
ВЕРХНИЕ И НИЖНИЕ ГРАНИЦЫ.

Ограниченный неизвестный параметр указывает, что вместо него можно подставить не любой тип, а только тип, наследуемый от определённого класса. Пример:

Vector <? extends aClass>


здесь на место неизвестного параметра? можно поставить aClass или его наследников, пусть даже непрямых. aClass в этом случае называется верхней границей неизвестного параметра. Другой пример:

Vector <? super aClass>


Параметр вида <? super aClass> называется нижняя граница. Фактический параметр должен быть родительским по отношению к aClass, или должен быть самим aClass.

Рассмотрим формальный пример. Создадим три пустых класса: ClassA

public class ClassA {}


его наследника ClassB

public class ClassB extends ClassA {}


ClassC, как наследника от ClassB:

public class ClassC extends ClassB {}


Почему классы пустые? В этом примере важно увидеть отношения наследования между классами, а не содержимое этих классов.

Теперь создадим класс MainClass, а в нём метод

static void addObject(Vector <? extends ClassA> vect) {}


В методе addObject имеем ограниченный неизвестный параметр <? extends ClassA>, который говорит нам, что аргументом может быть любой вектор, содержащий объекты класса ClassA или его наследников. А таковыми и являются наши три пустых класса: ClassA, ClassB, ClassC.

В коде метода main создадим объект класса ClassA

ClassA ca = new ClassA();


вектор, содержащий объекты класса ClassA

Vector < ClassA> vectA = new Vector < ClassA>();


добавим ca к vectA

vectA.add(ca);


Такой вектор соответсвует определению аргумента метода addObject, к которому мы и обращаемся:

addObject(vectA);


Для классов ClassB, ClassC действия аналогичные. Вот полный код класса MainClass:

import java.util.Vector;

public class MainClass

{

static void addObject(Vector<? extends ClassA> vect)

{

}

public static void main(String[] args)

{

new MainClass();

ClassA ca = new ClassA();

Vector<ClassA> vectA = new Vector<ClassA>();

vectA.add(ca);

addObject(vectA);

ClassB cb = new ClassB();

Vector<ClassB> vectB = new Vector<ClassB>();

vectB.add(cb);

addObject(vectB);

ClassC cc = new ClassC();

Vector<ClassC> vectC = new Vector<ClassC>();

vectC.add(cc);

addObject(vectC);

}

}

Если метод addObject объявить так:

static void addObject(Vector < ClassA> vect) {}

т.е. без ограниченного неизвестного параметра, то получим сообщения об ошибках в строках addObject(vectB); и addObject(vectC);(несоответствие типов аргументов).

Вопрос:Почему?

(Пауза…Ответ:

Vector < ClassB> и Vector < ClassC> есть подтипы не Vector < ClassA>, а Vector <? extends ClassA>.)

СОГЛАШЕНИЯ ОБ ИМЕНОВАНИИ

Ну и к завершению нашей лекции не будем забывать о Java Code Conventions.
Во избежание путаницы между обобщенными параметрами и настоящими типами в коде надо придерживаться продуманного соглашения об именовании. Если вы придерживаетесь продуманных соглашений Java и практик программирования, то наверняка не станете называть ваши классы одной буквой. Вы будете использовать смешанный регистр для имен классов, начиная с верхнего регистра. Ниже дано несколько соглашений, применяемых для обобщений:

• Использовать букву E для элементов коллекции, как в определении:

public class PriorityQueue<E> {…}

• Использовать буквы T, U, S и т.д. для универсальных типов.

Готово! Теперь мы можем писать чистый код J

ЗАКЛЮЧЕНИЕ

В этой лекции были рассмотрены принципы обобщений в Java и их применение. Обобщения реализованы так, чтобы обеспечить обратную совместимость с необобщенным кодом.

В целом, generic-и в Java получились довольно простыми, но обладают довольно внушительными возможностями. Тем не менее, они внесли несколько интересных концепций, таких как маски и ограничения, которые, добавили удобство при работе и помогли решить многие проблемы.

Использование обобщений автоматически гарантирует безопасность типов во всех операциях, где они задействованы. Это очень мощный механизм, широко используемый в Java.


Но, как и любое усложнение языка, эти нововведения затрудняют его понимание и изучение - такие вещи, как super -ограничения могут легко ввести разработчиков, только начинающих изучать язык, в ступор, но не стоит расстраиваться – еще будет масса возможностей попрактиковаться.

Появление generic-ов сделало язык Java более выразительным и строгим. На мой взгляд, такие изменения только на пользу, и надеюсь, что Sun не остановится на достигнутом и устранит те ограничения, которые присущи языку в этой области на данный момент.
(можно еще рассказать о некоторых недостатках дженериков, но я решила пока не включать эту информацию в лекцию)

ВОПРОСЫ И ЗАДАНИЯ ПО ЛЕКЦИИ

1. Скомпилируется ли нижеописанный класс? Если нет, то почему?

public final class Algorithm {
public static <T> T max(T x, T y) {

return x > y? x: y;

}

}

Ответ: нет, так как применение оператора «>» возможно только для примитивных числовых типов.

2. Скомпилируется ли нижеописанный класс? Если нет, то почему?

public class Singleton<T> {

public static T getInstance() {

if (instance == null)

instance = new Singleton<T>();

return instance;

}

private static T instance = null;

}

Ответ: нет, так как нельзя создавать static поле типа параметра Т.

3. Рассмотрим нижеописанные классы:

class Shape { /*... */ }

class Circle extends Shape { /*... */ }

class Rectangle extends Shape { /*... */ }

class Node<T> { /*... */ }

Скомпилируется ли следующий фрагмент кода? Если нет, то почему?

Node<Circle> nc = new Node<>();

Node<Shape> ns = nc;

Ответ: нет, так как Node<Circle> не является подтипом Node<Shape>.

4. Как будет выглядеть нижеописанный метод после приведения типов компилятором?

public class Pair<K, V> {

public Pair(K key, V value) {

this.key = key;

this.value = value;

}

public K getKey(); { return key; }

public V getValue(); { return value; }

public void setKey(K key) { this.key = key; }

public void setValue(V value) { this.value = value; }

private K key;

private V value;

}

Ответ:

public class Pair {

public Pair(Object key, Object value) {

this.key = key;

this.value = value;

}

public Object getKey() { return key; }

public Object getValue() { return value; }

public void setKey(Object key) { this.key = key; }

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

private Object key;

private Object value;

}

5. Как будет выглядеть нижеописанный метод после приведения типов компилятором?

public static <T extends Comparable<T>>

int findFirstGreaterThan(T[] at, T elem) {

//...

}

Ответ:

public static int findFirstGreaterThan(Comparable[] at, Comparable elem) {

//...

}

6. Напишите дженерик-метод, который бы менял местами два разных элемента массива.

Ответ:


public final class Algorithm {
public static <T> void swap(T[] a, int i, int j) {

T temp = a[i];

a[i] = a[j];

a[j] = temp;

}

}

7. Скомпилируется ли этот фрагмент кода? Если нет, то почему?

public static void print(List<? extends Number> list) {

for (Number n: list)

System.out.print(n + " ");

System.out.println();

}

Ответ: да

Тест по лекции:

1) Как правильно определить LinkedList для элементов целочисленного типа?

a) LinkedList<double> lList = new LinkedList<double>();

b) LinkedList<Integer> lList = new LinkedList<int>();

c) LinkedList<Integer> lList = new LinkedList<Integer>();

d) LinkedList<Double> lList = new LinkedList<Double>();

2) Какой вид будет иметь метод для работы со списком, в котором все элементы символьного типа?

a) private void listMethod(ArrayList<> aList) {…}

b) private void listMethod(char ArrayList aList) {…}

c) private void listMethod(ArrayList<Character> aList) {…}

d) private void listMethod(Character ArrayList aList) {…}

3) Обязательно ли приведение вида String s = (String) theList.get(index);, если theList уже определен как ArrayList<String>?

a) Да, потому что мы точно не знаем, данные какого типа находятся в списке.

b) Нет, потому что наш список уже «зарезервирован» под элементы только строчного типа.

c) Да, потому что компилятор не знает, ссылка на какой обьект возвращается в данном фрагменте кода.

d) Нет, потому что нельзя реализовать приведение типов в данном случае.

4) К ошибкам какого вида может привести реализация дженериков, если программист допустил неточность в использовании типов данных?

a) Ошибки компиляции.

b) Ошибки времени выполнения.

c) Ошибки вышеописанных видов.

d) Использование дженериков не приводит к ошибкам, связанных с типами данных.

5) Почему следующий фрагмент кода не сработает?

Result<double> anotherNum = new Result<double>(new Integer(1.2), new Double(2.2));

a) Потому что при реализации обобщений нельзя использовать примитивные типы – их следует заменить классами-аналогами, унаследованными от Object.

b) Потому что создается обьект с типами данных, которые не соответствуют заданому обобщению.

c) Все вышеперечисленные варианты.

d) Потому что 1.2 не является целым числом.

6) Какая из нижеприведенных записей обеспечит обратную совместимость с существующим кодом или кодом, не перенесенным для использования обобщений?

a) Num objectNum = new Num<Integer>(new Integer(1), new Integer(2));

b) Num<Object> objectNum = new Num<Integer>(new Integer(1), new Integer(2));

c) Num objectNum = new Num<Object>(new Integer(1), new Integer(2));

d) Num <Object>objectNum = new Num<Object>(new Integer(1), new Integer(2));

7) Можно ли реализовать обобщение с неопределенным параметром? Есть ли возможность ограничивать этот параметр?

a) Использование неопределенного параметра допускается, его ограничение – нет.

b) Родовые типы должны быть строго определены.

c) Допускается ограничение параметра, но он должен быть обязательно определен.

d) Разрешается использовать неопределенный параметр и на него можно наложить ограничения, если есть такая потребность.

8) Как правильно использовать подстановочный тип методе?

a) public void doSmth(<?>Collection Data) {…}

b) public void <?>doSmth(Collection Data) {…}

c) public void doSmth(Collection<?> Data) {…}

d) public void doSmth(Collection<> Data) {…}

9) Как указать компилятору на то, что он должен разрешать коллекции Number или коллекции любого типа, расширяющего Number, но не любую коллекцию других типов?

a) public void countSum(Collection<? extends Number> numData) {…}

b) public void countSum extends Number(Collection<?> numData) {…}

c) public void countSum((Number)Collection<?> numData) {…}

d) public void countSum(Collection<? extends Collection Number> numData) {…}

10) На что указывает super в записи Vector <? super aClass>?

a) На то, что здесь на место неизвестного параметра? можно поставить aClass или его наследников, пусть даже непрямых.

b) aClass в этом случае называется верхней границей неизвестного параметра.

c) Все вышеперечисленные варианты.

d) На то, что фактический параметр должен быть родительским по отношению к aClass, или должен быть самим aClass.


Понравилась статья? Добавь ее в закладку (CTRL+D) и не забудь поделиться с друзьями:  



double arrow
Сейчас читают про: