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

ОГЛАВЛЕНИЕ

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

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

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

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

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 перейдем к тому, что пока работает хорошо.