12. Обобщенные типы. Автоупаковка и автораспаковка

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

Обобщенный код будет автоматически работать с типом данных, переданным ему в качестве параметра. Многие алгоритмы выполняются одинаково, независимо от того, к данным какого типа они будут применяться. Например, сортировка не зависит от типа данных, будь то String, Student или любой другой пользовательский класс, объекты которого можно сравнить между собой. Используя обобщения, можно реализовать алгоритм один раз, а затем применять его без дополнительных усилий к любому типу данных.

Рассмотрим небольшой пример. Рассмотрим класс Box, который может содержать в себе один объект.

class Box {
    
    private String item;

    public Box(String item) {
        put(item);
    }

    public void put(String item) {
        this.item = item;
    }

    public String get() {
        return item;
    }
}

В данном случае наша коробка хранит объекты класса String. Но что, если нам в нашей программе необходима коробка, которая хранила бы не только объекты класса String, но и любого другого класса, как стандартного из библиотек Java, так и нашего созданного? Как мы можем реализовать это с помощью уже известных механизмов ООП?

Мы можем использовать механизм полиморфизма. Например, мы можем использовать класс Object, который, как известно, является суперклассом для всех классов Java.

class Box {

    private Object item;

    public Box(Object item) {
        put(item);
    }

    public void put(Object item) {
        this.item = item;
    }

    public Object get() {
        return item;
    }
}

Второй вариант - объявить интерфейс BoxItem и использовать его в качеcтве типа ссылочной переменной. Тогда в коробку можно будет "положить" объект класса, который реализует этот интерфейс.

interface BoxItem {}

class Box {

    private BoxItem item;

    public Box(BoxItem item) {
        put(item);
    }

    public void put(BoxItem item) {
        this.item = item;
    }

    public BoxItem get() {
        return item;
    }
}

Метод стал чуть более общим и может использоваться в большем количестве мест. Однако, огромный недостаток такого подхода - необходимость нисходящего преобразования извлеченного из коробки объекта. Если же мы не знаем заранее, объект какого класса будет помещен в коробку, то и нисходящее преобразование реализовать будет достаточно затруднительно.

Box box = new Box(new Item());
Object obj = box.get();

if (obj instanceof Item) {
    Item item = (Item)obj;
}

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

Обобщенные классы

Реализуем класс Box с помощью механизма обобщенных типов

class Box<T> {

    private T item;

    public Box(T item) {
        put(item);
    }

    public void put(T item) {
        this.item = item;
    }

    public T get() {
        return item;
    }
}

Такой класс называется обобщенным классом. Под термином обобщение следует понимать "применимость к большой группе классов". Создадим объект класса Box

Box<Item> box = new Box<>(new Item());

После создания объекта обобщенного класса, вместо T будет подставлен тип Item

Box<Item> box = new Box<>(new Item());

box.put(new Item());
Item i = box.get();

То есть, нам нет необходимости заниматься нисходящим преобразованием и приведением типов - объект класса Box будет работать с классом Item.

Механизм обобщения работает только с объектами

Когда объявляется экземпляр обобщенного типа, аргумент передаваемый параметру типа должен быть ссылочным типом. Использовать для этого примитивные типы, например, int или char, нельзя.

Box<int> box = new Box<>(20); // ошибка компиляции

Для преодоления этой проблемы в Java предусмотрен механизм автоупаковки (autoboxing) и автораспаковки (unboxing), который позволяет использовать классы-оболочки типов данных (их еще называют "обертки", wrapper).

Механизм автоупаковки и автораспаковки

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

Как мы знаем, в Java предусмотрены 8 примитивных типов данных. Примитивные типы данных имеют преимущества по сравнению с объектами, но механизм обобщенных типов и многие структуры данных в Java предполагают работу с объектами и поэтому в них нельзя хранить данные простых типов.

Для решения этой проблемы в Java предусмотрены классы-обертки (wrappers). Классы-обертки реализуются в классах Double, Float, Long, Integer, Short, Byte, Character и Boolean. Все обертки числовых типов данных являются производными от абстрактного класса Number.

Процесс преобразования значения примитивного типа в объект соответствующего класса-обертки называется упаковкой (boxing).

Процесс извлечения значения примитивного типа из объекта-обертки называется распаковкой (unboxing).

Автоупаковка - процесс автоматической упаковки (инкапсуляции) простого типа данных в объектную обертку без необходимости явного создания объекта.

Integer i = 5; // автоупаковка
foo(5); // автоупаковка при передачи функции аргумента
Double result = bar(); // автоупаковка при получении результата

void foo(Integer i) {
    ...
}

double bar() {
    return 0.0;
}

Автораспаковка - это обратный процесс автоматической распаковки (извлечения) значения, упакованного в объектную оболочку.

Integer i = 5;
int a1 = i; // автораспаковка

int a2 = new Integer(10); // старый вариант
int a3 = Integer.valueOf(10); // новый вариант

Integer a4 = 10;
foo(a4); // автораспаковка при передачи функции аргумента

double result = bar(); // автораспаковка при получении результата

static void foo(int i) {
    ...
}

static Double bar() {
    return 0.0;
}

Передача нескольких параметризированных типов

Количество типов, которые можно передать в обобщенный класс, не ограничено. Чтобы передать несколько типов, нужно перечислить из через запятую. Синтаксис объявления ссылки и создания такого объекта аналогичен созданию объекта с одним параметров.

DualBox<String, Integer> box = new DualBox<>("Вася", 2);

class DualBox<T, V> {
    private T firstItem;
    private V secondItem;

    public DualBox(T firstItem, V secondItem) {
        this.firstItem = firstItem;
        this.secondItem = secondItem;
    }
}

Ограничения типов

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

Для этого необходимо после placeholder написать ключевое слово extends и указать имя класса (в этом случае будут доступны объекты этого типа или типов-наследников) или интерфейса (объекты типов, которые реализуют интерфейс).

class NumericValue<T extends Number> {
    private T value;

    public NumericValue(T value) {
        this.value = value;
    }
}

NumericValue<Double> num1 = new NumericValue<>(100.0);
NumericValue<Integer> num2 = new NumericValue<>(100);
NumericValue<Long> num3 = new NumericValue<>(100L);

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

class Pair<T, V extends T> {}

Шаблон аргумента (wildcard)

Для того, чтобы иметь возможность использовать обобщенный класс в качестве аргумента метода, необходимо использовать так называемый "шаблон аргумента" (wildcard).

Предположим, нужно реализовать метод, который сравнивает два объекта класса NumericValue и возвращает true, если оба объекта равны.

Необходимо сообщить компилятору, что входным аргументом является объект обобщенного класса. Написать NumericValue<T> не получится, так как это не объявление класса, а записать какое-то конкретное значение мы не можем, так как мы хотим подать объект обобщенного типа с любым допустимым типом данных. Для такого случая используется специальный символ ?, который и является шаблоном аргумента.

boolean isEquals(NumericValue<?> obj1, NumericValue<?> obj2) {
    double d1 = Math.abs(obj1.value.doubleValue());
    double d2 = Math.abs(obj2.value.doubleValue());

    return d1 == d2;
}

class NumericValue<T extends Number> {
    T value;

    public NumericValue(T value) {
        this.value = value;
    }
}

Такая сигнатура метода означает, что метод isEquals() принимает на вход аргументы обобщенного типа NumericValue, где параметр типа может быть любым допустимым для типа NumericValue.

Ограничение снизу при использовании wildcard

Кроме "ограничения сверху", мы можем устанавливать "ограничение снизу", то есть установить в качестве корректного типа этот тип и суперклассы выше по цепочке наследования. Это реализуется с помощью ключевого слова super.

void foo(GenericClass<? super C> obj1, GenericClass<? super B> obj2) {
    // типы obj1 могут быть C, B, A, Object
    // типы obj2 могут быть B, A, Object
}

class GenericClass<T> {}

class A {}
class B extends A {}
class C extends B {}
class D extends C {}

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

Методы в обобщенных классах могут использовать параметр типа своего класса, а следовательно, автоматически становятся обобщенными относительно параметра класса. Однако можно объявить обобщенный метод, который сам по себе использует параметр типа. Более того, такой метод может быть объявлен в обычном, а не обобщенном классе.

К примеру, реализуем методы, который будет сравнивать два массива обобщенных типов. Сначала объявим два класса

class Student {
    private String name;
    private int avgMark;

    public Student(String name, int avgMark) {
        this.name = name;
        this.avgMark = avgMark;
    }
}

class PostGradStudent extends Student {
    private String phDTopic;

    public PostGradStudent(String name, int avgMark, String phDTopic) {
        super(name, avgMark);
        this.phDTopic = phDTopic;
    }
}

Далее объявим метод compareArrays()

public <T extends Comparable<T>, V extends T> boolean compareArrays(T[] arg0, V[] arg1) {
    if (arg0.length != arg1.length)
        return false;
    
    for (int i = 0; i < arg0.length; i++) {
        if (!arg0[i].equals(arg1[i]))
            return false;
    }

    return true;
}

Выражение T extends Comparable<T> означает, что тип T должен реализовывать обобщенный интерфейс Comparable<T>, то есть чтобы мы могли сравнить объект типа T с другим объектом типа T. Выражение V extends T означает, что тип V должен быть или типом T или производным от T типом.

Создадим два массива и попытаемся вызвать метод compareArrays().

Student[] students1 = new Student[10];
PostGradStudent[] students2 = new PostGradStudent[10];

compareArrays(students1, students2);

Такой код вызовет ошибку компиляции, так как тип Student не реализует интерфейс Comparable<Student>.

java: method compareArrays in class com.company.Main cannot be applied to given types;
  required: T[],V[]
  found:    com.company.Student[],com.company.PostGradStudent[]
  reason: inference variable T has incompatible bounds
    lower bounds: java.lang.Comparable<T>
    lower bounds: com.company.Student

Исправим данную ошибку

class Student implements Comparable<Student> {
    private String name;
    private int avgMark;

    public Student(String name, int avgMark) {
        this.name = name;
        this.avgMark = avgMark;
    }

    @Override
    public int compareTo(Student o) {
        return this.avgMark - o.avgMark;
    }
}

Если мы перепишем класс PostGradStudent так, чтобы он не является наследником типа Student, то при исполнении кода получим следующую ошибку

java: method compareArrays in class com.company.Main cannot be applied to given types;
  required: T[],V[]
  found:    com.company.Student[],com.company.PostGradStudent[]
  reason: inference variable V has incompatible bounds
    lower bounds: java.lang.Comparable<T>,T
    lower bounds: com.company.PostGradStudent

Обобщенный конструктор

Так как конструктор является методом, то мы также можем объявлять обобщенные конструкторы. Обобщенный конструктор можно объявить даже тогда, когда сам класс не является обобщенным.

class Accumulator {

    public <T extends Number> Accumulator(T number1, T number2) {
        // ...
    }
}

Accumulator accumulator = new Accumulator(5, 10);
Accumulator accumulator2 = new Accumulator(5.5d, 10L);
Accumulator accumulator3 = new Accumulator(5.5d, "four"); // вызовет ошибку

Обобщенный интерфейс

Обобщенный интерфейс объявляется также как и обобщенный класс

interface MyInterface<T> {
    void foo(T value);
}

Если класс реализует обобщенный интерфейс, то он также должен быть обобщенным. Не обобщенным он может быть только в том случае, если вы явно указываете тип при объявлении класса.

interface MyInterface<T> {
    void foo(T value);
}

// Ошибка, класс должен быть обобщенным
class MyClass1 implements MyInterface<T> {
    @Override
    public void foo(T value) {}
}

// Правильное объявление. Класс реализующий
// обобщенный интерфейс должен быть обобщенным
class MyClass2<T> implements MyInterface<T> {
    @Override
    public void foo(T value) {}
}

// Мы явно указали параметр типа интерфейса
// и класс может быть не обобщенным
class MyClass3 implements MyInterface<Double> {
    @Override
    public void foo(Double value) {}
}

В интерфейсе можно указать ограничение с помощью ключевого слова extends. Класс, который реализует обобщенный интерфейс с ограничением, также должен соблюдать эти ограничения. При объявлении класса ограничение следует прописать после названия класса, но после ключевого слова implements его дублировать не надо.

interface Iface<T extends Number> {}

// Выдаст ошибку
class MyClass4<T> implements Iface<T>{}

// Всё правильно
class MyClass5<T extends Number> implements Iface<T> {}

// Выдаст ошибку
class MyClass6<T extends Number> implements Iface<T extends Number>{}

Соглашение по правилам названия параметров типа

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

  • E (означает Element, часто используется в коллекциях);

  • K (означает Key);

  • N (означает Number);

  • T (означает Type);

  • V (означает Value);

  • S, U - обычно вторые, третьи, четвертые параметры типа.

Last updated