Интерфейсы - приватные, статические и дефолтные методы, наследование интерфейсов

Продолжаем изучение темы «интерфейсы», которую начали на предыдущем занятии и вначале посмотрим как можно прописывать в интерфейсах не только методы, но и константы. Давайте в интерфейсе GeomInterface (из предыдущего занятия) пропишем две вот такие переменные:

interface GeomInterface {
    int MIN_COORD = 0;
    int MAX_COORD = 1000;
}

Почему эти переменные я называю константами? Дело в том, что в Java к этим определениям автоматически добавляются ключевые слова:

public static final

и любые переменные превращаются в общедоступные статические константы. То есть, в интерфейсах попросту нельзя объявлять переменные – только константы. Далее, мы можем использовать MIN_COORD и MAX_COORD в классах, где применен интерфейс GeomInterface. Например, в классе Line:

class Line extends Geom implements GeomInterface, MathGeom {
    int x1, y1, x2, y2;
 
    void draw() {
        System.out.println("Рисование линии");
    }
 
    private boolean isCheck(int x) {
        return (MIN_COORD <= x && x <= MAX_COORD);
    }
 
    void setCoord(int x1, int y1, int x2, int y2) {
        if(isCheck(x1) && isCheck(y1) && isCheck(x2) && isCheck(y2)) {
            this.x1 = x1;
            this.y1 = y1;
            this.x2 = x2;
            this.y2 = y2;
        }
    }
}

Смотрите, мы здесь объявили сеттер setCoord и в нем проверяем соответствие переданных координат диапазону [MIN_COORD; MAX_COORD] с помощью вспомогательного приватного метода isCheck. Наличие констант как раз и объясняется их объявлением в интерфейсе GeomInterface.

Статические методы в интерфейсах

Но если в интерфейсе можно объявлять статические константы, то можно ли задавать и статические методы? Да, это стало возможно, начиная с версии JDK 8, и делается очевидным образом:

interface GeomInterface {
    int MIN_COORD = 0;
    int MAX_COORD = 1000;
 
    static void showInterval() {
        System.out.println("[" + MIN_COORD + "; " + MAX_COORD + "]");
    }
}

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

Мы уже говорили с вами, что такое статические переменные и методы и как они себя ведут (https://www.youtube.com/watch?v=jEUXJRsHwmY). Я не стану здесь повторяться. Отмечу лишь, что это метод, располагающийся в строго определенной области памяти на всем протяжении работы программы. Следовательно, к нему можно обратиться и вызвать непосредственно из интерфейса, в котором он определен. Например, так:

GeomInterface.showInterval();

Точно также к нему следует обращаться и из экземпляров классов, например:

class Line extends Geom implements GeomInterface, MathGeom {
    int x1, y1, x2, y2;
 
    void draw() {
        GeomInterface.showInterval();
        System.out.println("Рисование линии");
    }}

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

Вложенные интерфейсы и их расширение

Далее, интерфейсы можно объявлять внутри классов. Делается это очевидным образом, и я здесь приведу лишь отвлеченный пример. Пусть имеется класс InterfaceGroup, в котором определены два интерфейса: Interface_1 и Interface_2:

class InterfaceGroup {
    interface Interface_1 {
        void method_1();
    }
 
    interface Interface_2 {
        void method_2();
    }
}

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

class ReleaseInterface implements InterfaceGroup.Interface_1 {
    public void method_1() { }
}

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

class InterfaceGroup {
    private interface Interface_1 {
        void method_1();
    }
...
}

то возникнет ошибка. Такой интерфейс можно использовать только внутри класса. Как? Например, для расширения других публичных интерфейсов. Расширение – это когда один интерфейс наследуется от другого. В частности, мы можем расширить Interface_2, следующим образом:

class InterfaceGroup {
    private interface Interface_1 {
        void method_1();
    }
 
    interface Interface_2 extends Interface_1 {
        void method_2();
    }
}

То есть, здесь используется тот же синтаксис, что и при наследовании классов, только применительно к интерфейсам. В результате такой операции, второй интерфейс унаследует все публичные элементы первого. И, затем, указывая его в классе ReleaseInterface, должны определить уже два метода:

class ReleaseInterface implements InterfaceGroup.Interface_2 {
    public void method_1() { }
    public void method_2() { }
}

Приватные методы интерфейса

Но что значит: наследуются все публичные методы интерфейса? Разве в интерфейсах методы и константы могут быть не публичными? Да, начиная с версии JDK 9, допускается в интерфейсах объявлять приватные методы. Конечно, они обязательно должны иметь реализацию и используются исключительно внутри интерфейса. Например, мы можем объявить такой приватный метод:

class InterfaceGroup {
    private interface Interface_1 {
        void method_1();
 
        private void privateMethod() {
            System.out.println("privateMethod");
        }
    }
...
}

Тогда при расширении второго интерфейса этот метод унаследован не будет.

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

Интерфейсы с абстрактными классами

Давайте теперь зададимся вопросом: а можно ли к абстрактному классу применять интерфейсы? Оказывается, да, можно и при этом реализация интерфейсного метода getSquare в нем может отсутствовать:

interface MathGeom {
    double getSquare();
}
 
abstract class Geom implements MathGeom {
    int width, color;
    abstract void draw();
}

В этом случае метод getSquare обязательно должен быть определен в дочернем классе. Если же этот метод прописать непосредственно в классе Geom:

abstract class Geom implements MathGeom {
    int width, color;
    abstract void draw();
 
    public double getSquare() {
        return 0;
    }
}

То дочерние классы могут его не переопределять. Тогда при обращении к getSquare() будет возвращаться значение 0. Благодаря такой гибкости, мы можем в программе реализовывать самую разную логику взаимодействия с интерфейсами.

Методы с реализацией по умолчанию

Фактически вот этот последний пример позволяет использовать абстрактный класс для определения метода getSquare с реализацией по умолчанию (то есть, его действие, когда он не переопределяется в дочерних классах). Так приходилось делать до версии JDK 8, чтобы не «заставлять» программистов определять методы интерфейса, если это не требовалось. Теперь (начиная с JDK 8 и выше) в интерфейсах можно определять методы с реализацией по умолчанию и такие методы можно не переопределять в классах. Для их объявления используется следующий синтаксис:

default <тип метода> <имя метода>([аргументы]) {
      [операторы]
}

Например, определим в интерфейсе MathGeom метод getSquare с реализацией по умолчанию:

interface MathGeom {
    default double getSquare() {
        return 0;
    }
}

И применим его ко всем классам графических примитивов:

interface GeomInterface {
    double[] getCoords();
}
 
class Line extends Geom implements GeomInterface, MathGeom {
    void draw() {
        System.out.println("Рисование линии");
    }
 
    public double[] getCoords() {
        return new double[] {1, 2, 3, 4};
    }
}
 
class Rectangle extends Geom implements GeomInterface, MathGeom {
    void draw() {
        System.out.println("Рисование прямоугольника");
    }
 
    public double getSquare() {
        return 5*10;
    }
 
    public double[] getCoords() {
        return new double[] {10, 20, 30, 40};
    }
}
 
class Triangle extends Geom implements GeomInterface, MathGeom {
    void draw() {
        System.out.println("Рисование треугольника");
    }
 
    public double getSquare() {
        return 0.5*4*10;
    }
 
    public double[] getCoords() {
        return new double[] {11, 12, 13, 14};
    }
}

Смотрите, в классе Line мы не переопределяли метод getSquare, а в классах Rectangle и Triangle он переопределен. Теперь, создавая экземпляры этих классов в функции main:

        final int N = 3;
        Geom g[] = new Geom[N];
        g[0] = new Line();
        g[1] = new Rectangle();
        g[2] = new Triangle();

мы можем совершенно свободно вызывать у них метод getSquare:

        MathGeom m = null;
        for(int i = 0;i < N; ++i) {
            m = (MathGeom) g[i];
            System.out.println(m.getSquare());
        }

Обратите внимание, нам здесь сначала нужно привести ссылку g[i] к типу MathGeom и только потом вызвать метод getSquare. В консоли увидим значения:

0.0
50.0
20.0

Здесь первый ноль был получен из реализации метода по умолчанию для класса Line. Остальные значения – из переопределенных методов. То есть, теперь, мы можем не прописывать реализацию метода getSquare в классах примитивов, если она нам не нужна. И это добавляет дополнительное удобство при программировании.

Но что будет, если в GeomInterface также определить метод getSquare с реализацией по умолчанию:

interface GeomInterface {
    double[] getCoords();
 
    default double getSquare() {
        return -1;
    }
}

Тогда для класса Line, который применяет оба интерфейса, какая реализация будет использована? В действительности, никакая. Виртуальная машина Java в этом случае выдаст ошибку и потребуется явное определение этого метода. И это можно сделать так:

class Line extends Geom implements GeomInterface, MathGeom {
…
    public double getSquare() {
        return GeomInterface.super.getSquare();
    }
}

Смотрите, мы здесь из переопределенного метода getSquare обращаемся к объекту GeomInterface через ключевое слово super и, затем, вызываем его метод по умолчанию getSquare. Если теперь запустить программу, то ошибок не будет и в консоли для линии увидим значение -1. А вот если этот метод прописать так:

    public double getSquare() {
        return MathGeom.super.getSquare();
    }

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

Заключение

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

  • что это такое и зачем они нужны;
  • чем интерфейсы отличаются от классов;
  • как реализовывать интерфейсы в классах;
  • как использовать ссылки на интерфейсы;
  • как определять статические константы и методы;
  • как объявлять интерфейсы в классах и делать их расширение;
  • понимать и уметь применять приватные методы;
  • применять интерфейсы в абстрактных классах;
  • реализовывать методы по умолчанию и понимать зачем это нужно.

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

Путь кодера

Великий подвиг. Объявить класс DataGraph для хранения данных для графика в виде массива вещественных чисел размерностью N элементов (число N задать как константу, например, N=10). Записать отдельные классы (НЕ дочерние): LineGraph (точки в графике соединяются линиями), BarGraph (график в виде столбцов), ChartGraph (график в виде круговой диаграммы). При создании экземпляров этих классов они должны хранить ссылку на объект класса DataGraph. При рисовании графиков, данные следует брать через публичный метод getData() (класса DataGraph), т.е. получать ссылку на массив из N вещественных чисел. Взаимодействие между объектами классов должно выглядеть так:

Далее, объявить интерфейс Observer с методом update() и применить его к классам LineGraph, BarGraph и ChartGraph. По методу update() должно происходить обновление данных и перерисовка графика. В классе DataGraph хранить массив graphs для экземпляров классов LineGraph, BarGraph и ChartGraph. Как только происходит изменение данных в массиве data, вызывать метод update через ссылки graphs. (Изменение данных делать искусственно, например, в программе поменять данные, а затем, вызвать некий метод в DataGraph для запуска вызовов update).

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

Для самых неистовых. В данной реализации класс DataGraph должен иметь только один экземпляр. Поэтому здесь целесообразно реализовать метод getInstance(), который бы возвращал ссылку на объект класса и контролировал бы единственность этого объекта. При этом нужно закрыть возможность создавать экземпляр класса напрямую через оператор new.

Видео по теме