Обобщения классов (Generics)

В языке Java можно описывать классы без привязки к конкретному типу данных. Например, мы собираемся записать класс Point для представления точки на плоскости с координатами x, y. И здесь перед программистом сразу встает вопрос: какой тип данных определить у этих координат? Если в будущем предполагаются только целочисленные величины x, y, то наверное, подойдет тип int. Если же будут использоваться вещественные значения, то тип float или double. Конечно, можно взять тип double и хранить с его помощью и целочисленные и вещественные значения. Но это не лучший путь, так как вещественная арифметика выполняется заметно дольше целочисленной. И для целочисленных координат лучше использовать именно целочисленный тип. Для разрешения такой ситуации в Java и была введена возможность создавать классы с обобщенными типами.

В нашем примере с классом Point его обобщенное описание можно объявить следующим образом:

class Point<T> {
    public T x, y;
}

Смотрите, здесь после имени класса в угловых скобках стоит буква T – это имя обобщенного типа. На момент записи класса мы пока не знаем что будет подставлено вместо T. Здесь может быть и тип Integer и тип Double и тип String и любой другой тип языка Java (кроме примитивных: int, float, double и т.п). После этого поля x, y будут соответствовать этому типу данных. Но когда этот тип становится определенным? В момент создания экземпляра класса Point. То есть, в методе main() мы должны теперь указывать не только имя класса, но и в угловых скобках тип данных, например:

Point<Integer> pt = new Point<Integer>();

И, затем, использовать целочисленные координаты:

        pt.x = 10;
        pt.y = 20;
 
        System.out.println(pt.x + " " + pt.y);

Если потом потребуется создать объект класса Point с вещественными полями, то достаточно будет выполнить конструкцию:

Point<Double> ptD = new Point<Double>();

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

pt = ptD;   // ошибка: разные типы ссылок

В итоге объекты Point<Integer> и Point<Double> это, фактически, разные объекты с разными типами данных, но построенные по одному шаблону класса Point. В этом и заключается смысл обобщения.

У вас может возникнуть вопрос: а почему в качестве обобщенного типа используется буква T? Можно ли вместо нее записать другую букву или слово? Да, можно. Например, вот такое объявление класса Point будет аналогично предыдущему:

class Point<Tt> {
    public Tt x, y;
}

Но, в практике программирования принято записывать просто одну букву T.

Начиная с версии JDK7 после оператора new и имени класса можно записывать только угловые скобки без указания типа(ов), например, так:

Point<Integer> pt = new Point<>();
Point<Double> ptD = new Point<>();

Это несколько более удобная запись и ее часто можно встретить в текстах программ.

Давайте теперь добавим в класс Point конструктор. Он будет выглядеть так:

class Point<T> {
    public T x, y;
 
    Point(T x, T y) {
        this.x = x;
        this.y = y;
    }
}

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

public class Main {
    public static void main(String[] args) {
        Point<Integer> pt = new Point<Integer>(1, 2);
        Point<Double> ptD = new Point<Double>(10.5, 20.8);
 
        System.out.println(pt.x + " " + pt.y);
        System.out.println(ptD.x + " " + ptD.y);
    }
}

По аналогии можно добавить методы, которые возвращали бы координаты точки:

    T getCoordX() { return x; }
    T getCoordY() { return y; }

Но вернуть сразу массив координат не получится, следующие строчки приведут к ошибке:

    T[] getCoords() {
        return new T[] {x, y};
    }

Дело в том, что Java запрещает создавать массив на основе обобщенного типа. Программа просто не может быть скомпилирована в байт-код, так как нельзя реализовать универсально оператор new с неким обобщенным типом. Но как же тогда быть, если нам нужно реализовать подобную операцию? Очень просто. Вспомним, что все классы на вершине иерархии имеют класс Object. Именно его можно указать в качестве общего типа:

    Object[] getCoords() {
        return new Object[] {x, y};
    }

И, затем, в методе main вызвать этот метод, например, так:

        for(Object coord: pt.getCoords())
            System.out.println((Integer)coord);

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

Класс с двумя обобщенными типами

Я думаю, общая идея использования обобщений понятна. Давайте сделаем следующий шажок и посмотрим как можно объявлять класс с двумя обобщенными типами. Чтобы не усложнять нашу реализацию, в класс Point добавим еще одно поле с именем id и типом V:

class Point<T, V> {
    public V id;
    public T x, y;
 
    Point(T x, T y) {
        this.x = x;
        this.y = y;
    }
 
    V getId() { return id; }
 
    T getCoordX() { return x; }
    T getCoordY() { return y; }
}

Как видите, все достаточно очевидно. Мы через запятую в угловых скобках указали еще один тип V и используем его для определения поля id. Также у нас имеется метод getId(), который возвращает это поле. Теперь, при создании объектов этого класса, нам нужно указывать два типа:

public class Main {
    public static void main(String[] args) {
        Point<Integer, Integer> pt = new Point<>(1, 2);
        Point<Double, String> ptD = new Point<>(10.5, 20.8);
 
        pt.id = 1;
        ptD.id = "point_1";
    }
}

В первом случае мы используем целочисленный тип для id, а во втором – строковый. Соответственно, ниже полю id присваиваем 1, а для второго объекта – строку "point_1". По аналогии можно записывать множество разных обобщенных типов при определении класса.

Итак, на этом занятии мы сделали введение в обобщения (Generics) классов. На последующих продолжим эту тему и увидим, как делаются ограничения на обобщенные типы данных, создаются обобщенные интерфейсы и методы, как работают обобщения в механизме наследования.

Видео по теме