Лекция 6

Тема: Принцип абстракции. Дополнительные принципы ООП. Абстрактный класс. Интерфейс. Использование абстрактного класса и интерфейса. Реализация механизма обратного вызова с помощью интерфейса.

Принцип абстракции

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

Принцип минимальных обязательств - интерфейс объекта должен описать только существенные аспекты его поведения;

Принцип наименьшего удивления - абстракция должна описывать только поведение объекта, ни больше, ни меньше.

Виды абстракций:

  • абстракция сущности - объект представляет собой полезную модель некоторой сущности в предметной области ("Студент", "Преподаватель", "Аудитория");

  • абстракция поведения - объект состоит из обобщенного множества операций ("Менеджер соединения с базой данных");

  • абстракция виртуальной машины - объект группирует операции, которые вместе используются более высоким уровнем управления;

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

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

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

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

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

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

Как это сделать? Можно воспользоваться следующей методикой: опишите процесс словами и проанализируйте получившиеся фразы. "Завод выпускает автомобили" - здесь два объекта: завод и автомобиль. Производственно-технические характеристики завода составят набор полей объекта "Завод", а процесс выпуска автомобиля будет описан в виде набора методов объекта "Завод".

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

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

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

Дополнительные принципы ООП

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

Модульность

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

Иерархия

Иерархия - ранжированная или упорядоченная система абстракций. Принцип иерархичности предполагает использование иерархий при разработке программных систем. В ООП используется два вида иерархии:

  • иерархия "целое/часть" - показывает, что некоторые абстракции являются частями других абстракций. Например, лампа состоит из цоколя, нити накаливания и колбы;

  • иерархия "общее/частное" - показывает, что некоторая абстракция является частным случаем другой абстракции. Например, "обеденный стол" - конкретный вид стола, а "стол" - конкретный вид мебели. Такая иерархия используется при разработке структуры классов, когда сложные классы строятся на базе более простых классов путем добавления к ним новых характеристик и, возможно, уточнения имеющихся. Реализуется с помощью иерархии наследования.

Типизация

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

Устойчивость

Устойчивость - свойство абстракции существовать во времени (независимо от процесса, породившего данный программный объект) и в пространстве (перемещаясь из адресного пространства, в котором он был создан). Различают:

  • временные объекты - хранят промежуточные результаты некоторых действий, например, вычислений;

  • локальные объекты - существуют внутри методов, объект уничтожается после окончания работы метода;

  • глобальные объекты - существуют, пока программа загружена в память;

  • сохраняемые объекты - хранятся в файлах внешней памяти между сеансами работы программы.

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

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

Рассмотрим пример с базовым классом Shape и его подклассами.

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;
    }
}

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

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

С помощью абстрактного класса объявляется контракт (требования к функциональности) для его подклассов. Примером может служить уже рассмотренный выше абстрактный класс Shape и его подклассы Circle, Triangle и другие. Класс Shape объявляет контракт на реализацию ряда методов для рисования и определения площади. Реализация этих методов будет различной для каждого из подклассов.

Интерфейс

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

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

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

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

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

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

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

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

Обратите внимание, что мы не указываем для методов модификатор. Все объявленные в интерфейсе абстрактные методы автоматически трактуются как public abstract, а все поля - как public static final, даже если они так не объявлены.

Для создания класса, реализующего определенный интерфейс (или группу интерфейсов), используется ключевое слово 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 существуют три вида интерфейсов: интерфейсы, определяющие функциональность для классов посредством описания методов, но не их реализации; функциональные интерфейсы, определяющие в одном абстрактном методе свое применение и интерфейсы, реализация которых автоматически придает классу определенные свойства (к последним относятся, например, интерфейсы Cloneable, Serializable, Comparable и так далее).

В языке Java существуют

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

В 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