Наследование и композиция

Повторное использование кода

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

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

  • наследование (inheritance) - новый класс создается как специализация уже существующего класса. Взяв существующий класс за основу, вы добавляете к нему свой код без изменения существующего класса.

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

class FileManager {
    public void saveToFile(String text, String path) {
        // тело метода
    }
}

class Document {
    
    // класс Document содержит ссылку на объект
    // класса FileManager
    private FileManager manager;
    private StringBuilder contents;
    private String path;

    public Document(FileManager manager, String path) {
        this.manager = manager;
        this.contents = new StringBuilder();
        this.path = path;
    }

    public void saveDocument() {
        manager.saveToFile(contents.toString(), path);
    }
}

Базовые понятия механизма наследования

Наследование - отношение между классами, в котором один класс повторяет структуру и поведение другого класса (или нескольких других классов).

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

Создание подкласса в Java

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

// Суперкласс
class Person {
    String firstName;
    String lastName;
}

// Подкласс
class Student extends Person {
    String group;
    long id;
}

public class Main {
    public static void main(String[] args) {
        Student student = new Student();
        student.firstName = "Иван";
        student.lastName = "Иванов";
        student.id = 10000L;
    }
}

В Java, в отличие от C++, отсутствует множественное наследование, то есть подкласс может создаваться на основе только одного суперкласса.

// Суперкласс
class Person {
    String firstName;
    String lastName;
}

class UniversityMember{}

// МНОЖЕСТВЕННОЕ НАСЛЕДОВАНИЕ ЗАПРЕЩЕНО!
// ЭТОТ КОД ВЫЗОВЕТ ОШИБКУ КОМПИЛЯТОРА
class Student extends Person, UniversityMember {
    String group;
    long id;
}

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

class Vehicle {

    public void moveTo(Point destination) {
        // тело метода
    }
}

class Truck extends Vehicle {

    public void carryWeight(double weight) {
        // тело метода
    }
}

class DumpTruck extends Truck {

    public void dumpWeight() {
        // тело метода
    }
}

Наследование членов суперкласса

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

class Shape2D {
    private double width;
    private double height;
}

class Rectangle extends Shape2D {

    // ОШИБКА НА ЭТАПЕ КОМПИЛЯЦИИ
    public double getArea() {
        return width * height;
    }
}

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

class Shape2D {
    protected double width;
    protected double height;
}

class Rectangle extends Shape2D {

    // Данный код корректен
    public double getArea() {
        return width * height;
    }
}

Данный пример демонстрирует доступ к полям с помощью геттеров

class Shape2D {
    private double width;
    private double height;

    public double getWidth() {
        return width;
    }

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

    public double getHeight() {
        return height;
    }

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

class Rectangle extends Shape2D {

    // Данный код корректен
    public double getArea() {
        return getWidth() * getHeight();
    }
}

Инициализация базового класса

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

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

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

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

class Animal {
    public Animal() {
        System.out.println("Конструктор класса Animal");
    }
}

class Mammal extends Animal {
    public Mammal() {
        System.out.println("Конструктор класса Mammal");
    }
}

class Cat extends Mammal {
    public Cat() {
        System.out.println("Конструктор класса Cat");
    }
}

public class Main {
    public static void main(String[] args) {
        Cat cat = new Cat();
    }
}

Результат работы такого приложения будет следующим

Конструктор класса Animal
Конструктор класса Mammal
Конструктор класса Cat

Как видно из данного примера, цепочка вызовов конструкторов начинается с самого базового класса. Таким образом, подобъект базового класса инициализируется еще до того, как он станет доступным для конструктора производного класса. Даже если конструктор класса Cat не будет определен, Java сгенерирует конструктор по умолчанию, в котором также будет вызван конструктор базового класса.

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

public class Main {
    public static void main(String[] args) {
        Box3D box = new Box3D(100);
    }
}

class Box {
    public double width;
    public double height;

    public Box(double width, double height) {
        this.width = width;
        this.height = height;
    }
}

class Box3D extends Box {
    public double depth;

    // НЕТ ЯВНОГО ВЫЗОВА КОНСТРУКТОРА СУПЕРКЛАССА !
    public Box3D(double depth) {
        this.depth = depth;
    }
}

Для явного вызова конструктора суперкласса используется ключевое слово super. Более подробно мы рассмотрим его ниже, а сейчас приведем пример корректного вызова конструктора суперкласса

public class Main {
    public static void main(String[] args) {
        Box3D box = new Box3D(100, 200, 300);
    }
}

class Box {
    public double width;
    public double height;

    public Box(double width, double height) {
        this.width = width;
        this.height = height;
    }
}

class Box3D extends Box {
    public double depth;
    
    public Box3D(double width, double height, double depth) {
        super(width, height); // <-- ВЫЗОВ КОНСТРУКТОРА СУПЕРКЛАССА
        this.depth = depth;
    }
}

Ключевое слово super

Как было сказано ранее, наследование в Java реализуется следующим образом - в объект производного класса добавляется скрытый объект базового класса, который и обеспечивает вызов методов суперкласса.

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

class Box {
    private double width;
    private double height;

    public Box(double width, double height) {
        this.width = width;
        this.height = height;
    }

    // Площадь прямоугольника
    public double getArea() {
        return width * height;
    }
}

class Box3D extends Box {
    private double depth;

    public Box3D(double width, double height, double depth) {
        super(width, height); // <-- ВЫЗОВ КОНСТРУКТОРА СУПЕРКЛАССА
        this.depth = depth;
    }
    
    // Мы используем метод суперкласса, чтобы
    // посчитать площадь трехмерной коробки
    public double get3DArea() {
        double area2D = super.getArea(); // <---- Вызов метода суперкласса
        return area2D * depth;
    }
}

Как видно из примера, ключевое слово super имеет что-то общее с ключевым словом this.

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

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

public class Main {
    public static void main(String[] args) {
        Box3D box = new Box3D(100, 200, 300);
        System.out.println(box.getInfo());
    }
}

class Box {
    private double width;
    private double height;

    public Box(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public String getInfo() {
        return "Объект Box {" +
                "ширина = " + width +
                ", высота = " + height +
                '}';
    }
}

class Box3D extends Box {
    private double depth;

    public Box3D(double width, double height, double depth) {
        super(width, height);
        this.depth = depth;
    }
}

Результат работы такого приложения будет следующим

Объект Box {ширина = 100.0, высота = 200.0}

Как вы уже понимаете, в данной части кода

Box3D box = new Box3D(100, 200, 300);
System.out.println(box.getInfo());

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

Первый вариант - создать в подклассе Box3D свой метод для вывода информации

class Box3D extends Box {
    private double depth;

    public Box3D(double width, double height, double depth) {
        super(width, height);
        this.depth = depth;
    }
    
    public String get3DInfo() {
        return "Объект Box3D {" +
                "ширина = " + super.getWidth() +
                ", высота = " + super.getHeight() +
                ", глубина = " + depth +
                '}';
    }
}

Тогда мы будем вызывать метод get3DInfo()

public class Main {
    public static void main(String[] args) {
        Box3D box = new Box3D(100, 200, 300);
        System.out.println(box.get3DInfo());
    }
}

что даст нам корректный результат

Объект Box3D {ширина = 100.0, высота = 200.0, глубина = 300.0}

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

Самым правильным вариантом будет как бы "переписать" метод getInfo(), предоставить версию метода getInfo() для класса Box3D. Таким образом и класс Box и класс Box3D будет иметь метод getInfo(), просто в классе Box3D он будет иначе реализован, с учетом появления третьего параметра. Кроме того, объект класса Box3D теперь будет содержать только один метод для получения информации об объекте и этот метод будет корректно работать.

Для реализации своеобразного "переписывания" метода в подклассе, в Java существует механизм переопределения метода (method overriding).

Воспользуемся механизмом переопределения метода, чтобы корректно решить проблему с классами Box и Box3D (в примере для наглядности опущены некоторые члены классов)

public class Main {
    public static void main(String[] args) {
        
        Box box = new Box(600,600);
        System.out.println(box.getInfo());
        
        Box3D box3D = new Box3D(100, 200, 300);
        System.out.println(box3D.getInfo());
    }
}

class Box {
    
    // Поля, конструктор и геттеры\сеттеры

    public Box(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public String getInfo() {
        return "Объект Box {" +
                "ширина = " + width +
                ", высота = " + height +
                '}';
    }
}

class Box3D extends Box {

    // Поля, конструктор и геттеры\сеттеры
    
    @Override
    public String getInfo() {
        return "Объект Box3D {" +
                "ширина = " + super.getWidth() +
                ", высота = " + super.getHeight() +
                ", глубина = " + depth +
                '}';
    }
}

Обратите внимание что сигнатуры двух методов getInfo() полностью совпадают - это обязательное условие для срабатывания механизма переопределения метода. Метод в суперклассе называется переопределенным.

Также обратите внимание на строку 33, где записано @Override. Такая запись называется аннотацией.

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

Результат работы нашего примера будет следующим

Объект Box {ширина = 600.0, высота = 600.0}
Объект Box3D {ширина = 100.0, высота = 200.0, глубина = 300.0}

Обратите внимание, что и для класса Box и для класса Box3D мы вызываем метод с одним и тем же названием и с одной и той же сигнатурой

Box box = new Box(600, 600);
System.out.println(box.getInfo());

Box3D box3D = new Box3D(100, 200, 300);
System.out.println(box3D.getInfo());

Но в зависимости от того, для какого класса мы вызываем этот метод, в первом случае отрабатывает метод в классе Box, а во втором случае - метод в классе Box3D.

Следует отличать механизм перегрузки метода от механизма переопределения метода.

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

Запрет наследования с помощью ключевого слова final

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

final class A {}

class B extends A {
    // ВЫЗОВЕТ ОШИБКУ КОМПИЛЯЦИИ !
}

class C {
    final public void foo() {}
}

class D extends C {
    @Override
    public void foo() {} // <-- Ошибка компиляции !
}

Класс Object

В Java определен специальный класс Object, который является суперклассом для всех классов Java. Иными словами, все классы в языке Java являются подклассами, производными от класса Object.

В классе Object определены перечисленные ниже методы, которые доступны в любом объекте

Метод

Описание

Object clone()

Создает новый объект, аналогичный клонируемому объекту

boolean equals(Object объект)

Определяет равнозначность объектов

void finalize()

Вызывается перед тем, как неиспользуемый объект будет удален "сборщиком мусора"

Class<?> getClass()

Определяет класс объекта во время выполнения

int hashCode()

Возвращает хеш-код, связанный с вызывающим объектом

void notify()

Возобновляет работу потока, ожидающего уведомления от вызывающего объекта

void notifyAll()

Возобновляет работу всех потоков, ожидающих уведомления от вызывающего объекта

String toString()

Возвращает символьную строку, описывающую объект

void wait()

void wait(long мсек)

void wait(long мсек, int наносек)

Ожидает исполнения другого потока

С некоторыми методами класса Object мы встретимся позже, а сейчас нам интересен только метод toString().

Метод toString()

Метод toString() призван возвращать строковое представление объекта (список значений полей). Рассмотрим пример

public class Main {
    public static void main(String[] args) {
        
        Box box = new Box();
        System.out.println(box.toString());
    }
}

class Box{}

Обратите внимание, что класс Box не содержит никаких членов, однако, так как Box наследуется от класса Object, ему доступны методы суперкласса и метод toString() в частности.

Результатом работы данного примера является следующая строка

com.company.Box@1540e19d

Метод toString() в классе Object выводит полное название класса и 16-ричное представление хеш-кода объекта.

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

Метод getInfo() в примере выше был использован только в демонстрационных целях!

Чтобы метод toString() выводил нужную информацию, его необходимо пепреопределить как в следующем примере

public class Main {
    public static void main(String[] args) {

        Box box = new Box(100, 200);
        System.out.println(box.toString());
    }
}

class Box {
    private double width;
    private double height;

    public Box(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public String toString() {
        return "Box{" +
                "width=" + width +
                ", height=" + height +
                '}';
    }
}

Результат будет следующим

Box{width=100.0, height=200.0}

Также метод toString() имеет одну примечательную особенность - его можно явно не вызывать, а просто указывать ссылочную переменную, Java сама вызовет метод toString(). Например, следующий код

public class Main {
    public static void main(String[] args) {

        Box box = new Box(100, 200);
        System.out.println(box.toString());
        System.out.println(box);
    }
}

выдаст следующий результат

Box{width=100.0, height=200.0}
Box{width=100.0, height=200.0}

Как вы видите, результат работы строк 5 и 6 является идентичным.

Last updated