Обобщения в Java: часть 1

ОГЛАВЛЕНИЕ

Аннотация

Java 5 (JDK 1.5) ввел принцип обобщений или параметризованных типов. Данная статья знакомит с принципами обобщений и показывает примеры их использования. В части II рассмотрено, как обобщения на самом деле реализованы в Java, и несколько проблем с применением обобщений.

Проблема безопасности типов

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

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

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

Однако, к сожалению, при появлении Java, это не осуществлялось полностью в библиотеке Коллекции. Так, например, можно написать:

Vector vec = new Vector();
vec.add("hello");
vec.add(new Dog());

Не контролируется то, какой тип объекта помещается в Vector. Рассмотрим следующий пример:

package com.agiledeveloper;

import java.util.ArrayList;
import java.util.Iterator;

public class Test
{
    public static void main(String[] args)
    {
        ArrayList list = new ArrayList();
        populateNumbers(list);

        int total = 0;
        Iterator iter = list.iterator();
        while(iter.hasNext())
        {
            total += ((Integer) (iter.next())).intValue();
        }

        System.out.println(total);
    }

    private static void populateNumbers(ArrayList list)
    {
        list.add(new Integer(1));
        list.add(new Integer(2));
    }
}

В программе выше создается ArrayList, заполняется некоторыми целыми значениями Integer, а затем значения суммируются путем извлечения Integer из ArrayList.

Вывод из вышеприведенной программы - значение 3, как ожидалось.

Что если изменить метод populateNumbers() следующим образом:

private static void populateNumbers(ArrayList list)
{
    list.add(new Integer(1));
    list.add(new Integer(2));
    list.add("hello");
}

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

Exception in thread "main" java.lang.ClassCastException: 
  java.lang.String at com.agiledeveloper.Test.main(Test.java:17)…

До Java 5 в коллекциях не было безопасности типов.

Что такое обобщения?

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

Наконец, в Java 5 было решено ввести обобщения. Хотя обобщения – возможность писать универсальный или обобщенный код, независимый от конкретного типа – в принципе похожи на шаблоны в C++, есть ряд отличий. Например, в отличие от C++, где генерируются разные классы для каждого параметризованного типа, в Java есть только один класс для каждого обобщенного типа, независимо от того, экземпляры скольких разных типов создаются посредством него. Конечно, в обобщениях Java есть определенные проблемы, но они будут рассмотрены в части II. В части I рассматриваются преимущества.

Развитие обобщений в Java началось с проекта под названием GJ1 (обобщенный Java), начатого как расширение языка. Затем эту идею принял Процесс сообщества Java (JCP) в качестве Запроса спецификации Java (JSR) 142.


Обобщенная безопасность типов

Начнем с рассмотренного необобщенного примера, чтобы увидеть, как можно выиграть от обобщений. Переделаем вышеприведенный код, чтобы использовать обобщения. Измененный код показан ниже:

package com.agiledeveloper;

import java.util.ArrayList;
import java.util.Iterator;

public class Test
{
    public static void main(String[] args)
    {
        ArrayList<Integer> list = new ArrayList<Integer>();
        populateNumbers(list);

        int total = 0;
        for(Integer val : list)
        {
           total = total + val;
        }

        System.out.println(total);
    }

    private static void populateNumbers(ArrayList<Integer> list)
    {
        list.add(new Integer(1));
        list.add(new Integer(2));
        list.add("hello");
    }
}

Используется ArrayList<Integer> вместо ArrayList. Сейчас при компиляции кода выдается ошибка компиляции:

Test.java:26: cannot find symbol
symbol  : method add(java.lang.String)
location: class java.util.ArrayList<java.lang.Integer>
        list.add("hello");
            ^
1 error

Параметризованный тип ArrayList обеспечивает безопасность типов.

Соглашения об именовании

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

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

public class PriorityQueue<E> {…}

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

Написание обобщенных классов

Синтаксис для написания обобщенного класса очень простой. Пример обобщенного класса:

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 как один тип? Это будет рассмотрено позже в разделе «Подстановочный знак».

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

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. Каждый обобщенный класс также имеет необобщенную форму, поэтому к нему можно обращаться из необобщенного кода. Это обеспечивает обратную совместимость с существующим кодом или кодом, не перенесенным для использования обобщений. В то время как такая совместимость имеет определенное преимущество, она может вызвать путаницу и проблемы с безопасностью типов.

Обобщенные методы

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

public static <T> void filter(Collection<T> in, Collection<T> out)
{
    boolean flag = true;
    for(T obj : in)
    {
        if(flag)
        {
            out.add(obj);
        }
        flag = !flag;
    }
}

Метод filter() копирует чередующиеся элементы из коллекции in в коллекцию out. <T> перед void показывает, что метод является обобщенным методом с <T>, означающим параметризованный тип. Рассмотрим применение данного обобщенного метода:

ArrayList<Integer> lst1 = new ArrayList<Integer>();
lst1.add(1);
lst1.add(2);
lst1.add(3);

ArrayList<Integer> lst2 = new ArrayList<Integer>();
filter(lst1, lst2);
System.out.println(lst2.size());

ArrayList lst1 заполняется тремя значениями, а затем его содержимое фильтруется (копируется) в другой ArrayList lst2. Размер lst2 после вызова метода filter() равен 2. Теперь посмотрим на немного другой вызов:

ArrayList<Double> dblLst = new ArrayList<Double>();
filter(lst1, dblLst);

Здесь получаем ошибку компиляции:

Error:  
line (34) <T>filter(java.util.Collection<T>,java.util.Collection<T>)
in com.agiledeveloper.Test cannot be applied to
(java.util.ArrayList<java.lang.Integer>,
java.util.ArrayList<java.lang.Double>)

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

ArrayList<Integer> lst3 = new ArrayList<Integer>();
ArrayList lst = new ArrayList();
lst.add("hello");
filter(lst, lst3);
System.out.println(lst3.size());

Этот код компилируется без ошибок, и вызов lst3.size() возвращает 1. Почему он скомпилировался, и что здесь происходит? Компилятор лезет из кожи вон, чтобы обеспечить вызовы обобщенных методов, если это возможно. В данном случае, рассматривая lst3 как простой ArrayList, то есть без параметризованного типа (смотрите последний абзац в разделе “Обобщения и заменяемость” выше), он в состоянии вызвать метод filter.

Это может привести к ряду проблем. Добавим еще один оператор к примеру выше. При начале набора с клавиатуры интегрированная среда разработки (IDE) (используется IntelliJ IDEA) подсказывает код, как показано ниже:

Она говорит, что вызов метода get() принимает индекс и возвращает Integer. Ниже приведен готовый код:

ArrayList<Integer> lst3 = new ArrayList<Integer>();
ArrayList lst = new ArrayList();
lst.add("hello");
filter(lst, lst3);
System.out.println(lst3.size());
System.out.println(lst3.get(0));

Как думаете, что произойдет при выполнении этого кода? Может, исключение времени выполнения? Сюрприз! Этот кусок кода даст следующий вывод:

1
hello

Почему? Ответ заключается в том, что фактически компилируется (подробней это обсуждается в части II данной статьи). Пока краткий ответ заключается в том, что хотя завершение кода предположило, что возвращается Integer, в реальности возвращаемый тип - Object. Поэтому строка "hello" сумела пройти без ошибки.

Что произойдет, если добавить следующий код:

for(Integer val: lst3)
{
    System.out.println(val);
}

Здесь запрашивается Integer из коллекции. Этот код сгенерирует ClassCastException. В то время как предполагается, что обобщения делают код безопасным по отношению к типам, данный пример показывает, как можно легко, умышленно или случайно, обойти это и, в лучшем случае, получить исключение времени выполнения или, в худшем случае, получить код, потихоньку работающий неправильно. Хватит пока проблем. Некоторые из них будут детально рассмотрены во второй части II. В части I перейдем к тому, что пока работает хорошо.


Верхние пределы

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

public static <T> T max(T obj1, T obj2)

Использовали бы его, как показано ниже:

System.out.println(max(new Integer(1), new Integer(2)));

Спрашивается, как доделать реализацию метода max()? Попытаемся сделать это:

public static <T> T max(T obj1, T obj2)
{
    if (obj1 > obj2) // ошибка
    {
        return obj1;
    }
    return obj2;
}

Это не сработает. Оператор > не определен в ссылках. Как же тогда сравнить два объекта? Вспоминается интерфейс Comparable(сравнимый). Почему бы не использовать интерфейс comparable для выполнения этой задачи:

public static <T> T max(T obj1, T obj2)
{
    // Не изящный код
    Comparable c1 = (Comparable) obj1;
    Comparable c2 = (Comparable) obj2;

    if (c1.compareTo(c2) > 0)
    {
        return obj1;
    }
    return obj2;
}

Несмотря на то, что этот код работает, есть две проблемы. Во-первых, он некрасив. Во-вторых, приходится учитывать случай, когда приведение к Comparable не удается. Поскольку так сильна зависимость от типа, реализующего этот интерфейс, почему бы не попросить компилятор навязать его? Именно это делают верхние пределы. Ниже приведен код:

public static <T extends Comparable> T max(T obj1, T obj2)
{
    if (obj1.compareTo(obj2) > 0)
    {
        return obj1;
    }
    return obj2;
}

Компилятор проверит, чтобы убедиться, что параметризованный тип, заданный при вызове этого метода, реализует интерфейс Comparable. Если попытаться вызвать max() с экземплярами некоторого типа, не реализующего интерфейс Comparable, выдастся строгая ошибка компиляции.

Подстановочный знак

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

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<?>, но нельзя добавлять элементы в такую коллекцию – компилятор не знает, с каким типом имеет дело.


Нижние пределы

Рассмотрим последний пример. Допустим, надо скопировать элементы из одной коллекции в другую. Ниже приведен код первой попытки сделать это:

public static <T> void copy(Collection<T> from, Collection<T> to) {…}

Попытаемся использовать данный метод:

ArrayList<Dog> dogList1 = new ArrayList<Dog>();
ArrayList<Dog> dogList2 = new ArrayList<Dog>();
//…
copy(dogList1, dogList2);

В этом коде копируются Dog из одного Dog ArrayList в другой. Поскольку Dog является Animal, Dog может находиться в Dog ArrayList и в Animal ArrayList, не так ли? Следующий код копирует из Dog ArrayList в Animal ArrayList.

ArrayList<Animal> animalList = new ArrayList<Animal>();
copy(dogList1,  animalList);

Однако при компиляции этого кода выдается ошибка:

Error:  
line (36) <T>copy(java.util.Collection<T>,java.util.Collection<T>)
in com.agiledeveloper.Test cannot be applied
to (java.util.ArrayList<com.agiledeveloper.Dog>,
java.util.ArrayList<com.agiledeveloper.Animal>)

Как заставить его работать? Здесь помогают нижние пределы. Второй аргумент Copy должен иметь тип T или любой тип, являющийся базовым типом T. Код выглядит так:

public static <T> void copy(Collection<T> from, Collection<? super T> to)

Здесь сказано, что принимаемый второй коллекцией тип является типом T или его супертипом.

Заключение

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

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