9. Абстрактные классы и интерфейсы

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

Абстрактные классы

Рассмотрим еще раз пример с фигурами из темы про полиморфизм.

class Shape {
        public String draw() {
            return null;
        }

        public String erase() {
            return null;
        }
    }
    
    class Circle extends Shape {
        @Override
        public String draw() {
            return "Рисуем круг";
        }

        @Override
        public String erase() {
            return "Стираем круг";
        }
    }

    class Triangle extends Shape {
        @Override
        public String draw() {
            return "Рисуем треугольник";
        }

        @Override
        public String erase() {
            return "Стираем треугольник";
        }
    }

Методы базового класса Shape всегда были "фиктивными". Попытка вызова метода из класса Shape привела бы к ошибке в программе. Это было связано с тем, что класс Shape был нужен лишь для того, чтобы определить общий интерфейс всех классов, производных от него, а уже производные классы переопределяли эти методы и реализовывали их по-своему.

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

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

В языке Java для решения подобных задач используют механизм абстрактных методов. Абстрактный метод является незавершенным; он состоит только из объявления и не имеет тела. Синтаксис объявления абстрактных методов выглядит следующим образом:

abstract Shape draw();

Класс, содержащий хотя бы один абстрактный метод, называется абстрактным классом. Такой класс также должен помечаться ключевым словом abstract (в противном случае, компилятор выдает сообщение об ошибке):

abstract class Shape {
    
    abstract Shape draw();
}

На уровне языка и компилятора создать экземпляр абстрактного класса невозможно

Если вы объявите класс, производный от абстрактного класса, но хотите иметь возможность создания новых объектов, то вы должны переопределить все абстрактные методы базового абстрактного класса. Если это не будет сделано, то производный класс тоже останется абстрактным, и компилятор заставит пометить новый класс ключевым словом abstract.

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

Абстрактный класс может иметь другие, не абстрактные методы, поля и даже конструкторы.

abstract class Shape {
    private Point center;
    private Dimension size;

    // Геттеры и сеттеры полей

    // Нарисовать фигуру
    public abstract void drawFigure(Graphics2D graphics2D);

    // Вернуть площадь фигуры
    public abstract double getArea();
}

class Circle extends Shape {
    private int radius;

    @Override
    public void drawFigure(Graphics2D graphics2D) {
        graphics2D.drawOval(getCenter().x, getCenter().y, radius * 2, radius * 2);
    }

    @Override
    public double getArea() {
        return Math.PI * radius * radius;
    }
}

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

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

Интерфейс

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

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

Начиная с Java 8, в язык были добавлены различные механизмы для интерфейса - методы по умолчанию, статические и приватные методы, а также константы.

Это, безусловно, облегчает повседневную жизнь разработчику, но затрудняет изучение Java. Поэтому мы будет рассматривать механизм интерфейсов "в чистом виде", без упоминания различных дополнительных возможностей.

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

Кроме этого, в отличие от абстрактного класса, интерфейс позволяет реализовать своего рода, множественное наследование.

Чтобы создать интерфейс, используйте ключевое слово interface вместо class. Как и в случае с классами, перед словом interface указывается модификатор доступа. Интерфейс также может содержать поля, они автоматически являются статическими (static) и неизменяемыми (final).

interface Shape {
    void draw();
    double getArea();
}

Обратите внимание, что мы не указываем для методов модификатор - все методы интерфейса являются public.

Для создания класса, реализующего определенный интерфейс (или группу интерфейсов), используется ключевое слово implements. Фактически это означает "интерфейс определяет форму, а данный класс определяет, как это будет реализовано".

class Circle implements Shape {
    private int radius;
    private Point center;
    private Dimension size;

    @Override
    public void draw(Graphics2D g) {
        // Отрисовка фигуры
    }

    @Override
    public double getArea() {
        return Math.PI * radius * radius;
    }
}

В классе, который реализует интерфейс, реализуемые методы должны быть объявлены как public.

Неважно, приводите ли вы преобразование к "обычному" классу с именем Shape, к абстрактному классу Shape или к интерфейсу Shape - действие будет одинаковым.

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

Применение интерфейсных ссылок

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

public static void main(String[] args) {

    Shape s1 = new Circle();
    s1.draw();
}

Реализация нескольких интерфейсов

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

interface Shape {
    void draw();
    double getArea();
}

interface Movable {
    void move(int x_offset, int y_offset);
}


class Circle implements Shape, Movable {
    private int radius;
    private Point center;
    private Dimension size;

    @Override
    public void draw() {
        // Отрисовка фигуры
    }

    @Override
    public double getArea() {
        return Math.PI * radius * radius;
    }

    @Override
    public void move(int x_offset, int y_offset) {
        // Перемещение фигуры
    }
}

Наследование интерфейсов

Многие языки программирования, в том числе Java, позволяют наследовать интерфейс один от другого. Синтаксис наследования интерфейсов аналогичен синтаксису наследования классов. Когда класс реализует интерфейс, он обязан реализовать все методы, определенные по цепочке наследования интерфейсов.

interface Attackable {
    void attack(Attackable attackable);
    void takeDamage(Attackable attackable);
}

interface Movable extends Attackable {
    void move(int x_offset, int y_offset);
}

interface Flyable extends Movable {
    void fly(Point destination);
}

class Gargoyle implements Flyable {

    @Override
    public void attack(Attackable attackable) {}

    @Override
    public void takeDamage(Attackable attackable) {}

    @Override
    public void move(int x_offset, int y_offset) {}

    @Override
    public void fly(Point destination) {}
}

Частичные реализации

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

interface Shape {
    void draw();
    void getArea();
}

abstract class Circle implements Shape {
    @Override
    public void getArea() {}
}

Реализация механизма обратного вызова с помощью интерфейса

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

Приведем небольшой пример. В Java нам доступен класс Timer, который используется для отсчета интервала времени.

Устанавливая таймер, мы задаем интервал времени и указываем, что должно произойти по его истечении. Как указать таймеру, что он должен делать по истечении времени?

public static void main(String[] args) {

    // Как нам во втором аргументе указать
    // что должен делать таймер?
    Timer timer = new Timer(5000, );
}

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

Разумеется, таймер должен знать, какой метод объекта он должен вызвать и должен иметь гарантию, что в классе объекта реализован этот метод. Для этого таймеру нужно указать объект класса, который реализует интерфейс ActionListener. Этот интерфейс выглядит следующим образом

public interface ActionListener extends EventListener {
    public void actionPerformed(ActionEvent e);
}

По истечении заданного интервала времени таймер обращается к объекту, вызывает метод actionPerformed() и передает ему объект класса Event (класс Event описывает событие в Java).

Как мы видим, конструктор класса Timer запрашивает задержку и объект, у которого будет вызван метод actionPerformed. Создадим класс, который будет реализовывать интерфейс ActionListener

class TimerAction implements ActionListener {
    @Override
    public void actionPerformed(ActionEvent e) {
        JOptionPane.showMessageDialog(null,
        "Время истекло!", "Таймер", JOptionPane.WARNING_MESSAGE);
    }
}

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

Слушатель - это объект, который как бы "слушает" события, которые происходят с другим объектом. Когда это "слушаемое" событие происходит, вызывается указанный в интерфейсе метод этого объекта.

Создадим объект слушателя TimerAction и передадим этот объект таймеру

public static void main(String[] args) {
    JFrame frame = new JFrame();
    frame.setVisible(true);

    // Объект слушателя
    TimerAction action = new TimerAction();

    Timer timer = new Timer(5000, action);
    timer.setRepeats(false);
    timer.start();
}

Запустим приложение и посмотрим на результат

Обратите внимание, что метод actionPerformed() принимает на вход объект класса ActionPerformed. При вызове метода actionPerformed(), таймер передает в метод объект класса ActionEvent, который содержит различную информацию о событии. Таким образом, мы можем запрограммировать те или иные действия в зависимости от параметров события. Рассмотрим еще один пример, на этот раз будем использовать кнопку.

Создадим объект окна, объект кнопки и добавим кнопку в окно

public static void main(String[] args) {
    JFrame frame = new JFrame();
    frame.setVisible(true);

    frame.setLayout(new FlowLayout());
    JButton button = new JButton("Нажми меня");
    frame.add(button);
}

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

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

В данном случае, нас интересует метод addActionListener(ActionListener l), который принимает на вход слушатель, который реализует интерфейс ActionListener.

class ButtonListener implements ActionListener {
    @Override
    public void actionPerformed(ActionEvent e) {
        JOptionPane.showMessageDialog(null,
                "На кнопку нажали", "Кнопка", JOptionPane.WARNING_MESSAGE);
    }
}

С помощью этого метода мы передаем кнопке объект класса ButtonListener. Когда произойдет какое-то событие, кнопка обратится к переданному объекту и вызовет метод actionPerformed() этого объекта.

Такая реализация в большинстве случаев нас устраивает. Но что, если нужно обработать какое-то специфическое событие и получить детальную информацию. Например, что если мы хотим, чтобы кнопка вела себя по разному при нажатии левой и правой кнопок мыши?

Для этого у кнопки необходимо вызвать метод addMouseListener(MouseListener l) и передать ему слушатель, который реализует интерфейс MouseListener. Таким образом, мы обрабатываем не просто некоторое событие, а событие мыши. Событие мыши - более специфическое событие и поэтому нам доступна большая информация о событии.

Создадим слушатель, который реализует интерфейс MouseListener

class MouseListener implements java.awt.event.MouseListener {

    @Override
    public void mouseClicked(MouseEvent e) {
        String message = "Нажата неизвестная кнопка мыши";
        if (e.getButton() == MouseEvent.BUTTON1) {
            message = "Нажата первая кнопка мыши";
        } else if (e.getButton() == MouseEvent.BUTTON2) {
            message = "Нажата вторая кнопка мыши";
        }

        JOptionPane.showMessageDialog(null,
                message, "Кнопка", JOptionPane.WARNING_MESSAGE);
    }

    @Override
    public void mousePressed(MouseEvent e) {}

    @Override
    public void mouseReleased(MouseEvent e) {}

    @Override
    public void mouseEntered(MouseEvent e) {}

    @Override
    public void mouseExited(MouseEvent e) {}
}

Обратим внимание на две особенности:

1) интерфейс MouseListener определяет уже несколько методов, а не один, как ActionListener. Как видите, мы можем запрограммировать реакцию на очень специфические события, например, если курсор мыши вошел в область, которую занимает кнопка. Так как нас интересует только события клика, все остальные методы у нас имеют пустую реализацию. То есть, например, курсор мыши войдет в область кнопки, то кнопка вызовет пустой метод mouseEntered() и ничего не произойдет (поищите информацию о классе MouseAdapter, который упрощает работу с событиями мыши);

2) в методы передается объект класс MouseEvent. Объект класса MouseEvent обладает информацией о событии мыши, что дает нам возможность узнать о событии много подробностей.

Мы видим, что мы можем, например, с помощью метод getButton() узнать - какой кнопкой было произведено нажатие, с помощью методов getX() и getY() узнать координаты нажатия и так далее. Создадим объект слушателя MouseListener и передадим этот объект кнопке

MouseListener mouseListener = new MouseListener();
button.addMouseListener(mouseListener);

Запустим приложение и нажмем кнопку левой кнопкой мыши.

Last updated