8. Углубленное изучение ООП (Java. Базовый курс)

8. Углубленное изучение ООП (Java. Базовый курс)


Date of publication 15.03.2021



Углубленное изучение объектно-ориентированного программирования

Основные принципы ООП

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

1. Абстракция

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

2. Инкапсуляция

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

3. Наследование

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

4. Полиморфизм

Полиморфизм это обработка одних и тех же данных разными способами в зависимости от ситуации (переопределение методов), или же выполнение одних и тех же действий над разным набором данных (перегрузка методов). Про полиморфизм детально поговорим на сегодняшнем уроке.

Все базовые принципы ООП нужно обязательно запомнить, так как они пригодятся и на собеседовании и для дальнейшего понимания материала!

 

Наследование

Предположим что возникла необходимость создать несколько классов с очень похожим функционалом, который различается в каких-то незначительных деталях. Если решать данную задачу "в лоб", то в результате во всех написанных классах код будет практически одинаковым, что довольно плохо. Чем это может грозить? Любое новое изменение или исправление ошибки придётся делать сразу в нескольких классах, а не в одном месте. Это не только не удобно, но и потенциально создаёт возможности для новых проблем, в случае если разработчик забыл про какой-то класс в процессе внесения исправлений.

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

Чтобы отнаследоваться от существующего класса, необходимо использовать ключевое слово extends:

class <имя подкласса> extends <имя суперкласса> {
    ...
}

Чтобы лучше понять как работает наследование, разберём простейший пример - создадим несколько классов для графического редактора. И первый класс будет описывать некую фигуру, которую необходимо нарисовать. Фигуры бывают разные, но общее у них одно - координаты точки отрисовки, соответственно класс Figure будет содержать переменные x и y, а так же методы для работы с данными переменными:

package ru.akutepov;

public class Figure {

    private int x;
    private int y;

    public Figure() {
    }

    public Figure(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }
}

Предположим что наш графический редактор должен уметь рисовать окружности, соответственно нужен такой класс, который бы содержал координаты центра окружности и радиус. Если бы в Java не было наследования, то пришлось бы в новом классе заново прописывать переменные x и y, а так же все методы, которые есть в классе Figure. А с наследованием всё гораздо проще:

package ru.akutepov;

public class Circle extends Figure {

    private int radius;

    public int getRadius() {
        return radius;
    }

    public void setRadius(int radius) {
        this.radius = radius;
    }
}

Класс Circle наследуется от класса Figure, а значит имеет доступ ко всем переменным и методам класса Figure кроме приватных. Так как переменные x и y имеют модификатор private, то напрямую работать с ними из подкласса Circle нельзя, для этого есть методы getX(), setX(), getY() и setY(). Если необходим прямой доступ к переменной суперкласса, то можно использовать модификатор protected.

Сделаем ещё один подкласс для описания прямоугольника:

package ru.akutepov;

public class Rectangle extends Figure {

    private int width;
    private int height;

    public Rectangle(int x, int y, int width, int height) {
        super(x, y); // Вызов конструктора суперкласса
        this.width = width;
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}

Обратите внимание на вызов super(x, y). Ключевое слово super работает по аналогии с this, но ссылается на родительский класс. Через super() мы вызываем конструктор суперкласса и передаём ему координаты. Без этого поля x и y не были бы инициализированы.

Правило здесь простое: если суперкласс не имеет конструктора по умолчанию (без параметров), то в конструкторе подкласса нужно явно вызвать super(...) с нужными параметрами. И этот вызов, как и с this(), должен быть первой строкой.

 

Модификатор protected

В примере выше поля x и y объявлены как private, поэтому прямой доступ к ним из подклассов закрыт. Это правильный подход с точки зрения инкапсуляции, но иногда он создаёт неудобства. Представьте, что в классе Circle нужно часто обращаться к координатам. Писать каждый раз getX() и getY() не слишком удобно.

Для таких случаев существует модификатор protected. Он открывает доступ к полям и методам внутри самого класса, внутри пакета и всем подклассам, даже если они находятся в других пакетах:

public class Figure {
    protected int x; // Теперь наследники видят это поле напрямую
    protected int y;

    // конструкторы и методы...
}

Теперь в классе Circle можно писать просто x и y без геттеров. Но помните: protected слабее private с точки зрения инкапсуляции. Поле становится доступно не только наследникам, но и любым классам из того же пакета. Используйте с умом.

 

Переопределение методов

Наследование даёт не только новые поля, но и возможность переопределять поведение родителя. Предположим, мы хотим, чтобы каждая фигура умела считать свою площадь. В классе Figure такого метода нет, но мы можем добавить его в подклассы:

public class Circle extends Figure {

    private int radius;

    // геттеры и сеттеры...

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

public class Rectangle extends Figure {

    private int width;
    private int height;

    // геттеры и сеттеры...

    public double getArea() {
        return width * height;
    }
}

У обоих классов есть метод getArea(), но считает он площадь по-разному. Это и есть полиморфизм в действии: одинаковый вызов — разный результат в зависимости от того, с каким объектом мы работаем.

⚡ Вопрос с собеседования:
В чём разница между переопределением (override) и перегрузкой (overload)?

Переопределение — это когда метод в подклассе имеет точно такую же сигнатуру (имя и параметры), как в суперклассе. Перегрузка — когда в одном классе есть несколько методов с одинаковым именем, но разными параметрами. Переопределение работает на этапе выполнения (runtime), перегрузка — на этапе компиляции.

 

Класс Object — всему голова

В Java все классы неявно наследуются от класса Object. Даже если мы не пишем extends, компилятор подставляет это за нас. Поэтому у любого объекта есть методы, определённые в Object:

  • toString() — строковое представление объекта

  • equals(Object obj) — сравнение объектов

  • hashCode() — числовое представление объекта для хеш-таблиц

Мы уже разбирали toString(). Теперь посмотрим на equals(). По умолчанию он сравнивает ссылки, что почти никогда не нужно. Сравним два прямоугольника:

Rectangle r1 = new Rectangle(0, 0, 10, 20);
Rectangle r2 = new Rectangle(0, 0, 10, 20);

System.out.println(r1.equals(r2)); // false! Сравниваются ссылки, а не содержимое

Чтобы это исправить, equals() нужно переопределить:

@Override
public boolean equals(Object obj) {
    if (this == obj) return true;           // Ссылки равны — объекты точно равны
    if (obj == null) return false;          // Сравнение с null всегда false
    if (!(obj instanceof Rectangle)) return false; // Разные типы — точно не равны

    Rectangle other = (Rectangle) obj;      // Приводим к Rectangle
    return this.width == other.width 
        && this.height == other.height 
        && getX() == other.getX() 
        && getY() == other.getY();
}

Важное правило: если переопределяешь equals(), нужно переопределить и hashCode(). Контракт между ними звучит так: если два объекта равны по equals(), их hashCode() обязан быть одинаковым. Иначе коллекции типа HashSet и HashMap будут работать непредсказуемо.

 

Оператор instanceof

В примере выше мы использовали instanceof. Этот оператор проверяет, является ли объект экземпляром указанного класса или его наследника:

Figure f = new Circle();
System.out.println(f instanceof Figure);   // true
System.out.println(f instanceof Circle);   // true
System.out.println(f instanceof Rectangle); // false

Штука полезная, но злоупотреблять не стоит. Если код пестрит проверками instanceof, это часто говорит о том, что архитектура хромает и полиморфизм используется неправильно.

 

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

Вернёмся к нашему классу Figure. По смыслу он базовый — описывает любую фигуру. Но технически ничто не мешает написать new Figure(10, 20). Какая это фигура? Никакая. Бессмысленный объект, который не должен существовать.

Чтобы запретить создание таких объектов, используется ключевое слово abstract:

public abstract class Figure {
    protected int x;
    protected int y;

    public Figure(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public abstract double getArea(); // Без реализации — каждый наследник обязан написать свою
}

Теперь new Figure() написать нельзя — компилятор не даст. И каждый наследник обязан реализовать метод getArea(). Не реализует — сам станет абстрактным.

Абстрактный класс может содержать и обычные методы с реализацией. Например, метод move(int dx, int dy) логично поместить именно в Figure, потому что двигается любая фигура одинаково:

public void move(int dx, int dy) {
    this.x += dx;
    this.y += dy;
}

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

 

Интерфейсы

Абстрактный класс — это «общий предок». Интерфейс — это «контракт на поведение». Если класс говорит implements Drawable, он обязуется иметь метод draw().

Создадим интерфейс для всего, что можно нарисовать:

public interface Drawable {
    void draw();
}

Теперь наши фигуры могут его реализовать:

public class Circle extends Figure implements Drawable {
    private int radius;

    // конструкторы, геттеры, сеттеры...

    @Override
    public void draw() {
        System.out.println("Рисуем круг с центром (" + x + ", " + y + ") и радиусом " + radius);
    }

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

public class Rectangle extends Figure implements Drawable {
    private int width;
    private int height;

    // конструкторы, геттеры, сеттеры...

    @Override
    public void draw() {
        System.out.println("Рисуем прямоугольник " + width + "x" + height + " в точке (" + x + ", " + y + ")");
    }

    @Override
    public double getArea() {
        return width * height;
    }
}

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

public class Circle extends Figure implements Drawable, Serializable, Cloneable {
    // implements можно перечислять через запятую
}

⚡ Вопрос с собеседования:
Чем отличается абстрактный класс от интерфейса?

Абстрактный класс — это база для родственных объектов, он может содержать состояние (поля) и частичную реализацию методов. Интерфейс — это чистый контракт, он описывает только поведение без состояния (с оговоркой — с Java 8 в интерфейсах появились default-методы). Главное практическое различие: множественное наследование классов запрещено, интерфейсов — пожалуйста.

 

Композиция против наследования

Наследование — мощный инструмент, но им часто злоупотребляют. Классический пример — квадрат и прямоугольник. Формально квадрат — это прямоугольник, но на практике такое наследование создаёт проблемы:

public class Square extends Rectangle {

    public Square(int x, int y, int side) {
        super(x, y, side, side); // Передаём side и за ширину, и за высоту
    }

    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width); // При изменении ширины меняем и высоту
    }

    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height); // Аналогично
    }
}

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

public void stretch(Rectangle rect) {
    rect.setWidth(rect.getWidth() * 2);
    // Ожидаем, что высота осталась прежней. Но если передали Square — высота тоже удвоилась.
}

Проблема в том, что наследование нарушило принцип подстановки: мы не можем везде использовать Square вместо Rectangle, хотя по иерархии должны.

Альтернатива — композиция. Вместо «квадрат — это прямоугольник» делаем «квадрат содержит прямоугольник»:

public class Square {
    private Rectangle rectangle; // Не наследуемся, а используем внутри

    public Square(int x, int y, int side) {
        rectangle = new Rectangle(x, y, side, side);
    }

    public int getSide() {
        return rectangle.getWidth();
    }

    public void setSide(int side) {
        rectangle.setWidth(side);
        rectangle.setHeight(side);
    }
}

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

 

Ключевое слово final в наследовании

final мы уже разбирали в контексте переменных. В наследовании оно работает на двух уровнях:

final у метода - метод нельзя переопределить в подклассе. Используется, когда поведение должно быть строго фиксированным:

public class Figure {
    public final String getType() {
        return "Фигура";
    }
}

final у класса - от такого класса вообще нельзя наследоваться:

public final class StringUtils {
    // Вспомогательные методы, наследование от которых бессмысленно
}

Класс String в Java объявлен как final. Это сделано в том числе из соображений безопасности — чтобы никто не мог подменить его реализацию.

 

Практика: расширяем иерархию

Закрепим всё, что изучили. Создадим интерфейс Movable для объектов, которые можно двигать:

public interface Movable {
    void move(int dx, int dy);
}

Пусть Figure его реализует:

public abstract class Figure implements Movable {
    protected int x;
    protected int y;

    public Figure(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public void move(int dx, int dy) {
        this.x += dx;
        this.y += dy;
        System.out.println("Фигура перемещена в (" + x + ", " + y + ")");
    }

    public abstract double getArea();
}

Теперь любая фигура умеет двигаться. Метод move() один для всех, а getArea() каждый наследник реализует сам. Интерфейс Movable можно дать и другим классам, которые фигурами не являются — например, курсору мыши или игровому персонажу. В этом и сила интерфейсов: они связывают поведением совершенно разные объекты.

 

Домашнее задание

Задача 1. Иерархия транспорта

Создайте базовый класс Vehicle(транспортное средство) с полями speed и capacity. От него отнаследуйте Car и Bicycle. У автомобиля добавьте поле fuelType (тип топлива), у велосипеда — hasBell (наличие звонка). В каждом подклассе переопределите метод toString(), чтобы он возвращал осмысленное описание объекта.

Задача 2. Сравнение книг

Возьмите класс Book из прошлого урока и переопределите в нём метод equals(). Две книги считаются одинаковыми, если у них совпадают название и автор. Проверьте работу метода на нескольких примерах. Убедитесь, что две книги с одинаковыми названием и автором, но разным годом издания, всё равно считаются равными.

Задача 3. Интерфейс Drawable

Создайте интерфейс Drawable с методом draw(). Реализуйте его в классах Circle и Rectangle из сегодняшнего урока. Метод должен выводить в консоль параметры фигуры в читаемом виде. Создайте массив типа Drawable[], поместите в него несколько разных фигур и в цикле вызовите draw() у каждой.

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

< Предыдущий урок


🍪 Сайт использует cookie и Яндекс.Метрику для аналитики. Подробнее