Тема 9. Наследование и static - BelyiZ/JavaCourses GitHub Wiki

Содержание:

  1. Объявление и синтаксис наследования
  2. Абстрактные классы
    1. Абстрактные методы
  3. Обобщение
  4. Перегрузка и переопределение методов
    1. Переопределение методов
    2. Аннотация @override
    3. Запрет переопределения методов
    4. Перегрузка методов
    5. Вызов методов суперкласса
  5. Композиция и агрегирование
    1. IS A отношения
    2. HAS A отношения
  6. Приведение типов
  7. Upcasting и Downcasting
  8. Пример инструкции instanceof
  9. Наследование полей и методов
    1. Поля
    2. Конструкторы
    3. Вложенные классы
    4. Запрет наследования
  10. Статичные поля и методы
  11. Статичный блок инициализации
  12. Статичный метод
  13. Особенности использования статических методов, полей и классов
  14. Список литературы/курсов

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

Наследование допускает создание иерархии классов. Можно создать общий класс, в котором определяются основные характеристики (поля и методы), присущие набору разных классов. Затем классы из этого набора можно наследовать от базового, а каждый отдельный дочерний класс дополнить свойствами присущими только ему.

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

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

В Java существует ряд правил при наследовании классов:

  • Для каждого создаваемого подкласса можно указать только один суперкласс. Множественное наследование в Java не поддерживается.
  • У каждого родительского класса может быть любое количество потомков.
  • Количество уровней иерархии не регламентируется.
  • Класс не может наследовать сам себя.

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

Объявление и синтаксис наследования

В Java для наследования классов используется ключевое слово extends. Оно указывается в дочернем классе сразу после названия. Далее должно следовать название суперкласса, свойства которого планируется наследовать.Например:

class Car {

}

class Truck extends Car {

}

Попробуем создать объектную модель для работы с различными типами транспортных средств (ТС). Для начала создадим класс Vehicle и добавим в него свойства (поля) присущие всем ТС.

public class Vehicle {

    private String mark; // марка
    private String model; // модель
    protected int maxSpeed;  // максимальная скорость
    private int yearOfManufacture; // год выпуска
    protected String licensePlat; // номерной знак

    //  далее геттеры и сеттеры для всех полей
}

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


public class Vehicle {

    private String mark; // марка
    private String model; // модель
    protected int maxSpeed;  // максимальная скорость
    private int yearOfManufacture; // год выпуска
    protected String licensePlat; // номерной знак

    public Vehicle(String mark, int model, int yearOfManufacture) {
        this.mark = mark;
        this.model = model;
        this.yearOfManufacture = yearOfManufacture;
    }

    //  далее геттеры и сеттеры для всех полей
}

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


public class Vehicle {

    private String mark; // марка
    private String model; // модель
    protected int maxSpeed;  // максимальная скорость
    private int yearOfManufacture; // год выпуска
    protected String licensePlat; // номерной знак

    public Vehicle(String mark, int model, int yearOfManufacture) {
        this.mark = mark;
        this.model = model;
        this.yearOfManufacture = yearOfManufacture;
    }

    /**
     * Движение транспортного средства с указанной скоростью
     * @param speed Скорость, с которой движется ТС в км/ч. Отрицательное значение означает движение назад. 
     */
    public void move(int speed) {
        // Очень грамотная и правильная реализация метода
    }

    /**
     * Остановка ТС. Т.е. изменение скорости движения до нуля. Если необходимо уменьшить скорость, воспользуйтесь методом 
     * {@link #move(int)}} с указанием нужной скорости
     */
    public void stop() {
        // Очень грамотная и правильная реализация метода
    }

    //  далее геттеры и сеттеры для всех полей
}

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

Создадим класс Car, который будет являться транспортным средством и иметь дополнительные свойства: тип покрышек (зимняя/летняя), количество колес и пробег (км). Наследник обязан вызвать один из конструкторов класса-родителя. Так как в базовом классе есть обязательные поля, мы обязаны запросить их в конструкторе или передать родителю значения по умолчанию. В нашей модели авто без колес не может быть, значит при создании объекта запрашиваем еще один параметр. Пробег может быть указан при создании объекта типа Car, а может быть использовано значение по умолчанию - "0".


public class Car extends Vehicle {

    private boolean summerTires;
    private int mileage;
    private int wheelsNumber;

    public Car(String mark, int model, int yearOfManufacture, int wheelsNumber) {
        super(mark, model, yearOfManufacture);
        this.wheelsNumber = wheelsNumber;
        this.mileage = 0;
    }

    public Car(String mark, int model, int yearOfManufacture, int wheelsNumber, int mileage) {
        super(mark, model, yearOfManufacture);
        this.wheelsNumber = wheelsNumber;
        this.mileage = mileage;
    }
}

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

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

Абстрактный класс - это описание некоторой сущности, которая не существует сама по себе и является лишь обобщением нескольких типов. В нашем примере можно создать объект типа Vehicle и работать с ним. Но ведь в реальности не существует "какого-то транспорта" - мы имеем дело с конкретными реализациями, например, легковой автомобиль марки ХХХ, 2021-го года выпуска. Т.е. класс Vehicle обобщает свойств всех транспортных средств, но не является полноценной работоспособной сущностью. В Java такое поведение можно отразить используя абстрактные классы. Для этого перед ключевым словом class нужно добавить другое - abstract:

public abstract class Vehicle {
    // ...
}

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

Абстрактные методы

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

В нашем примере метод move() должен быть во всех ТС, но для каждого типа он реализуется по своему. Значит в базовом классе можно объявить его абстрактным и тем самым переложить описание функционала на дочерний класс Car.

public abstract class Vehicle {

    // ...

    /**
     * Движение транспортного средства с указанной скоростью
     * @param speed Скорость, с которой движется ТС в км/ч. Отрицательное значение означает движение назад. 
     */
    public abstract void move(int speed);

    // ...
}

public class Car extends Vehicle {

    // ...

    @Override
    public void move(int speed) {
        // Очень грамотная и правильная реализация метода
    }

    // ...
}

Каждый неабстрактный наследник абстрактного класса должен реализовать все абстрактные методы.

Обобщение

Добавим в нашу модель описание грузовых автомобилей. Основными характеристиками для них будут: пробег и грузоподъемность.


public class Truck extends Vehicle {

    private int mileage; // пробег
    private int liftingCapacity; // грузоподъемность

    public Truck(String mark, int model, int yearOfManufacture, int liftingCapacity) {
        super(mark, model, yearOfManufacture);
        this.liftingCapacity = liftingCapacity;
        this.mileage = 0;
    }

    public Truck(String mark, int model, int yearOfManufacture, int liftingCapacity, int mileage) {
        super(mark, model, yearOfManufacture);
        this.liftingCapacity = liftingCapacity;
        this.mileage = mileage;
    }
}

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


public class Automobile extends Vehicle {

    private boolean summerTires; // летняя резина
    private int wheelsNumber; // количество колес
    private int mileage; // пробег
    private int liftingCapacity; // грузоподъемность
    private double remainingFuel; // остаток топлива

    public Automobile(String mark, int model, int yearOfManufacture) {
        super(mark, model, yearOfManufacture);
    }

    @Override
    public void move(int speed) {
        // Увеличиваем пробег.
        // Уменьшаем остаток топлива.
    }

    /**
     * Заправка автомобиля
     * @param volume объем залитого топлива
     */
    public void refueling(double volume) {
        // Очень грамотная и правильная реализация метода.
        System.out.println("Топливный бак заправлен на [" + volume + "] литров");
    }

    // геттеры и сеттеры для всех полей
}

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


public class Car extends Automobile {

    public static final int WHEELS_NUMBER_DEFAULT = 4;

    public Car(String mark, int model, int yearOfManufacture) {
        super(mark, model, yearOfManufacture);
        setWheelsNumber(WHEELS_NUMBER_DEFAULT);
    }
}

public class Truck extends Automobile {

    public Truck(String mark, int model, int yearOfManufacture) {
        super(mark, model, yearOfManufacture);
    }
}

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

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

Перегрузка и переопределение методов

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

В некоторых случаях наследники повторяют свойства и функционал родителя за исключением отдельных элементов. Так, например, процесс заправки автомобиля с ДВС будет отличаться "заправки" электрокара. А в остальных аспектах относительно нашей модели различий не будет. Чтобы добавить в нашу объектную модель легковые авто работающие на электричестве, создадим новый класс и перепишем метод refueling.

public class ElectricCar extends Car {

    public ElectricCar(String mark, int model, int yearOfManufacture) {
        super(mark, model, yearOfManufacture);
    }

    /**
     * Заправка автомобиля
     * @param volume уровень, до которого производится зарядка в процентах
     */
    @Override
    public void refueling(double volume) {
        // Реализуем функционал зарядки аккумуляторов. 
        System.out.println("Аккумулятор заряжен на " + volume + "%");
    }
}

Теперь, когда мы будем работать с методами типа ElectricCar, будет вызываться метод написанный в этом классе. Для всех остальных наследников, не переопределивших этот метод, будет происходить вызов метода в классе Automobile.

Automobile autmobile;

autmobile = new Car();
autmobile.refueling(33);
> Топливный бак заправлен на [33] литров
        
autmobile = new ElectricCar();
autmobile.refueling(33);
> Аккумулятор заряжен на 33%

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

Аннотация @override

Бывают ситуации, когда в родительском классе изменяют метод, а переопределяющий его в дочернем классе - нет. Компилятор расценит это так, что метод в классе-потомке не переопределяет метод суперкласса и сборка может пройти успешно. Но на правильную работу приложения это точно повлияет. Чтобы избежать подобных ситуаций, существует аннотация @Override.

public class Automobile extends Vehicle {

    // ...

    @Override
    public void move(int speed) {
        // Очень грамотная и правильная реализация метода
    }

    // ...
}

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

Запрет переопределения методов

В Java есть возможность запрета переопределения методов потомками. Для этого достаточно в сигнатуру метода добавить модификатор final.

public abstract class Vehicle {
    // ...

    private String mark; // Марка

    // ...

    public final String getMark() {
        return mark;
    }

    // ...
}

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

Финализированные методы можно создавать в любых классах, как обычных, так и абстрактных.

Перегрузка методов

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


public class Automobile extends Vehicle {

    // ...

    /**
     * Заправка автомобиля
     * @param volume объем залитого топлива
     */
    public void refueling(double volume) {
        // Делаем проверки на наличие свободного места в баке и другие валидации.
        // Заливаем горючее.
        System.out.println("Топливный бак заправлен на [" + volume + "] литров");
    }

    /**
     * Заправка автомобиля до указанного процента объема бака, с ограничением на максимальное количество литров.
     * @param percentage Уровень, до которого производится заправка бака в процентах.
     * @param maxVolume Максимальное количество литров, которые могут быть залиты в бак. -1 - если ограничений нет.
     * @return количество залитых литров топлива
     */
    public double refueling(int percentage, double maxVolume) {
        // Делаем необходимые проверки.
        // Вычисляем на какой процент бак уже заполнен.
        double volume; // Вычисляем необходимое кол-во литров с учетом ограничений.
        refueling(volume); // Вызываем основной метод заполнения бака.
        return volume;
    }

    /**
     * Заправка автомобиля до указанного процента объема бака
     * @param percentage Уровень, до которого производится заправка бака в процентах.
     */
    public void refueling(int percentage) {
        refueling(percentage, -1);
    }

    // ...
}

Теперь у нас есть три метода отвечающих за заправку.

Первый (refueling(double)) позволяет просто залить в бак определенное количество литров. Эта процедура требует проведения определенного ряда проверок, например, на достаточный объем свободного места в баке или допустимость переданного значения (должно быть положительное).

С помощью второго метода (refueling(int, double)) можно заполнить бак до определенного процента, но количество литров ограничено, то есть мы заправляем бак либо до определенного процента, либо до определенного количества литров. В отличие от предыдущего метода, этот в результате выполнения возвращает значение - фактическое количество залитых литров. Но для перед проведением заправки необходимо выполнить те же проверки, что и в предыдущем методе. Для того чтобы избежать дублирования кода, который обязан быть идентичным всегда, мы просто вызовем метод refueling(double), а в текущем refueling(int, double) оставим только уникальные для него проверки.

Третий метод (refueling(int)) не выполняет никаких особенных функций, он нужен только для упрощения работы с классом Automobile и уменьшения количества кода. Так для случаев, когда ограничений по количеству литров нет, можно вызвать этот метод - он требует всего один параметр и ничего не возвращает. В отличие от refueling(int, double) он не занимается подготовкой данных, а просто вызывает соседний метод подставляя константные значения.

При вызове перегруженных методов выбирается тот, который наиболее подходит по передаваемым в метод параметрам и ожидаемому возвращаемому типу. Например, если мы осуществим вызов refueling(100), он удовлетворяет и методу refueling(double) и методу refueling(int), но так как в обычных случаях произошло бы про привидение типов и перед вызовом метода refueling(double) значение "100" было бы преобразовано в тип double, то в данной ситуации будет выбран метод не требующий такого преобразования. А вызов метода, заправляющего бак на определенное количество литров, должен выглядеть так: refueling(100.0) или refueling((double) 100.0). Аналогично происходит определение метода и по требуемому возвращаемому типу. Если невозможно однозначно определить какой из перегруженных методов необходимо вызвать, будет выброшено исключение во время компиляции.

Вызов методов суперкласса

Если вы переопределяете метод в подклассе, но по-прежнему должны вызывать метод, определенный в суперклассе, используйте ключевое слово super. Например, при движении грузовых автомобилей, в отличие от всех остальных, необходимо издавать звуковой сигнал для безопасности, но вся остальная логика должна повторяться. Это можно решить, добавил в метод move() для класса Truck вызов аналогичного метода в классе-родителе.

public class Truck extends Automobile {

    // ...

    @Override
    public void move(int speed) {
        if (speed < 0) {
            turnSignal(true);
        }
        super.move(speed); // Выполняем всю логику движения назад
        turnSignal(false);

    }

    /**
     * Включает или выключает предупреждающий сигнал в зависимости от переданного значения параметра
     * @param on Включает сигнал если true, выключает есть false
     */
    private void turnSignal(boolean on) {
        // Очень грамотная и правильная реализация метода
    }

    // ...
}

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

Вы можете обращаться к функционалу суперкласса из любого метода в подклассе, в том числе из методов, которые ничего не переопределяют. Например, если при выполнении какого-то действия, вы хотите выполнить движение назад без звукового сигнала можно вызвать не свой метод, а родительский: super.move(-5);.

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

Композиция и агрегирование

IS A отношения

Классы и объекты могут быть связаны друг с другом. Наследование описывает связь "является" (или по-английски "IS A"). То есть это способ показать: этот объект является типом этого объекта.

Пример:

public class Car {

}

public class Truck extends Car {

}

public class Sedan extends Car {

}

public class Vesta extends Sedan {

}

Теперь, на основе приведенного выше примера, в объектно-ориентированных терминах верно следующее:

Машина — суперкласс класса грузовиков. Машина — суперкласс класса седан. Грузовики и Седан являются подклассами класса машины.

Теперь, если мы рассмотрим отношения "IS-A", мы можем сказать: Грузовик IS-A Машина, Седан IS-A Машина, Vesta IS-A Седан Отсюда: Vesta тоже Машина

С использованием ключевого слова extends подклассы смогут наследовать все свойства суперкласса, за исключением частных свойств суперкласса.

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

public class Car {

}

public class Truck extends Car {

}

public class Sedan extends Car {

}

public class Vesta extends Sedan {

}

public class Main {

    public static void main(String[] args) {
        Car a = new Car();
        Sedan m = new Sedan();
        Vesta d = new Vesta();
        System.out.println(m instanceof Car);
        System.out.println(d instanceof Sedan);
        System.out.println(d instanceof Car);
    }
}

И результат:

true
true
true

Использование ключевого слова Implements для получения отношения "IS-A".

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

public interface Car {

}

public class Sedan implements Car {

}

public class Vesta extends Sedan {

}

HAS A отношения

Однако не все связи отношения в мире описываются таким образом. Пример: клавиатура определенно как-то связана с компьютером, но она не является компьютером. Руки как-то связаны с человеком, но они не являются человеком.

В этих случаях в его основе лежит другой тип отношения: не "является", а "является частью" ("HAS A"). Рука не является человеком, но является частью человека. Клавиатура не является компьютером, но является частью компьютера.

Отношения "HAS A" можно описать в коде, используя механизмы композиции и агрегирования. Разница между ними заключается в "строгости" этих связей.

Пример:

Из вышеописанного примера существует Car — машина. У каждой машины есть двигатель (поле Engine). Кроме того, у каждой машины есть пассажиры (поле passengers) внутри.

Принципиальная разница между полями Engine engine и Passenger [] passengers: Если у машины внутри сидит пассажир А, это не значит, что в ней не могут находиться пассажиры B и C.

Одна машина может соответствовать нескольким пассажирам. Кроме того, если всех пассажиров высадить из машины, она продолжит спокойно функционировать.

Связь между классом Car и массивом пассажиров Passenger [] passengers менее строгая. Она называется Агрегацией.

Иной пример: Допустим, у нас есть класс Student, обозначающий студента, и класс StudentsGroup (группа студентов). Студент может входить и в клуб любителей физики, и в команду КВН.

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

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

Можно ссылаться на подкласс как на экземпляр одного из его суперклассов. Например, используя определения класса из примера выше, можно ссылаться на экземпляр класса Truck как на экземпляр класса Car. Так как Truck расширяет (наследует) Car, он также называется Car.

Пример:

Truck truck = new Truck();
Car car = truck;

Сначала создается экземпляр грузовика. Экземпляр Truck присваивается переменной типа Car. Теперь переменная Car (ссылка) указывает на экземпляр Truck. Это возможно, потому что Truck наследуется от Car. То есть можно использовать экземпляр некоторого подкласса, как если бы он был экземпляром его суперкласса. Таким образом, вам не нужно точно знать, к какому подклассу относится объект. Например, вы можете рассматривать экземпляры Грузовика и Автомобиля как экземпляры Транспортного средства.

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

Upcasting и Downcasting

Вы всегда можете привести объект подкласса к одному из его суперклассов, либо из типа суперкласса к типу подкласса, но только если объект действительно является экземпляром этого подкласса (или экземпляром подкласса этого подкласса). Таким образом, этот пример downcasting действителен:

Truck truck = new Truck();

// Приведение к суперклассу
Car car = truck;

// Уточнение класса
Truck truck2 = (Truck) car;

Однако следующий приведенный ниже пример недопустим. Компилятор примет его, но во время выполнения, выдаст исключение ClassCastException.

Sedan sedan = new Sedan();

// Приведение к суперклассу
Car car = sedan;
        
// Уточнение класса
Truck truck = (Truck) car; // Исключение ClassCastException

Объект Sedan может быть передан объекту Car, но позже он не может быть передан объекту Truck. Это приведет к исключению ClassCastException.

Пример инструкции instanceof

Java содержит инструкцию с именем instanceof. Она может определить, является ли данный объект экземпляром некоторого класса:

Truck truck = new Truck();
boolean isTruck = truck instanceof Truck;

После выполнения этого кода переменная "isTruck" будет содержать значение true.

Инструкция instanceof может использоваться для определения того, является ли объект экземпляром суперкласса своего класса. Вот пример, который проверяет, является ли объект Car экземпляром Vehicle:

Truck truck = new Truck();
boolean isCar = truck instanceof Car;

Предполагая, что класс Truck расширяет (наследует от) класс Car, переменная isCar будет содержать значение true после выполнения этого кода. Объект Truck является объектом Car, поскольку Truck является подклассом Car.

Как видите, инструкция instanceof может использоваться для изучения иерархии наследования. Тип переменной, используемый с ней, не влияет на ее результат.

Truck truck = new Truck();
Car car = truck;
boolean isTruck = car instanceof Truck;

Несмотря на то, что переменная транспортного средства имеет тип Car, объект, на который она в конечном итоге указывает в этом примере, является объектом Truck. Поэтому экземпляр транспортного средства автомобиля будет оценен как истинный.

Вот тот же пример, но с использованием объекта Sedan вместо объекта Truck:

Sedan sedan = new Sedan();
Car car = sedan;
boolean isTruck = car instanceof Truck;

После выполнения этого кода isTruck будет содержать значение false. Объект Sedan не является объектом Truck.

Наследование полей и методов

Поля

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

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

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

Пример, который иллюстрирует, как поля в подклассах скрывают поля в суперклассах:

public class Car {

    String licensePlate = null;

    public void setLicensePlate(String licensePlate) {
        this.licensePlate = licensePlate;
    }

    public String getLicensePlate() {
        return licensePlate;
    }
}

public abstract class Truck extends Car {

    protected String licensePlate = null;

    @Override
    public void setLicensePlate(String license) {
        super.setLicensePlate(license);
    }

    @Override
    public String getLicensePlate() {
        return super.getLicensePlate();
    }

    public void updateLicensePlate(String license) {
        this.licensePlate = license;
    }
}

Обратите внимание, как для обоих классов определено поле licensePlate.

И класс Car, и класс Truck имеют методы setLicensePlate() и getLicensePlate(). Методы в классе Truck вызывают соответствующие методы в классе Car. В результате оба набора методов получают доступ к полю licensePlate в классе Car.

Однако метод updateLicensePlate() в классе Truck напрямую обращается к полю licensePlate. Таким образом, он получает доступ к полю licensePlate класса Truck. Следовательно, вы не получите тот же результат, если вызовете setLicensePlate(), как при вызове метода updateLicense().

Посмотрите на следующие строки кода:

Truck truck = new Truck();
truck.setLicensePlate("123");
truck.updateLicensePlate("abc");
System.out.println("license plate: " + truck.getLicensePlate());

Этот код распечатает текст 123.

Метод updateLicensePlate() устанавливает значение номерного знака в поле licensePlate в классе Truck. Однако метод getLicensePlate() возвращает значение поля licensePlate в классе Car. Следовательно, значение "123", которое устанавливается как значение для поля licensePlate в классе Car с помощью метода setLicensePlate(), является тем, что выводится на печать.

Конструкторы

Механизм наследования не включает конструкторы. Другими словами, конструкторы суперкласса не наследуются подклассами. Подклассы могут по-прежнему вызывать конструкторы в суперклассе, используя конструкцию super().

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

public class Car {

    public Car() {
    }
}

public class Truck extends Car {

    public Truck() {
        super();
        //здесь выступают другие инициализации
    }
}

Обратите внимание на вызов super() внутри конструктора Truck. Этот вызов super() выполняет конструктор в классе Car.

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

Если класс не имеет какого-либо явного конструктора, компилятор вставляет неявный без аргументов. Таким образом, класс всегда имеет конструктор.

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

public class Car {

}

Если конструктор явно не вызывает конструктор в суперклассе, компилятор вставляет неявный вызов конструктора no-arg в суперклассе. Это означает, что следующая версия класса Truck фактически эквивалентна версии, показанной ранее:

public class Truck extends Car {

    public Truck() {
    }
}

Фактически, поскольку конструктор теперь пуст, мы могли бы опустить его, и компилятор вставил бы его и неявный вызов конструктора no-arg в суперклассе. Вот как тогда будут выглядеть два класса:

public class Car {

}

public class Truck extends Car {

}

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

Если бы класс Car не имел конструктора без аргументов, но имел другой, который принимает параметры, компилятор жаловался бы. Класс Truck затем должен был бы объявить конструктор, а внутри него вызвать конструктор в классе Car.

Вложенные классы

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

Пример:


class MyClass {

    class MyNestedClass {

    }
}

public class MySubclass extends MyClass {

    public static void main(String[] args) {
        MySubclass subclass = new MySubclass();
        MyNestedClass nested = subclass.new MyNestedClass();
    }
}

Обратите внимание, как можно создать экземпляр вложенного класса MyNestedClass, который определен в суперклассе(MyClass) посредством ссылки на подкласс(MySubclass).

К полям и методам класса можно применять следующие модификаторы доступа:

  • private. В этом случае поля класса или метод класса доступен только из методов данного класса. Из методов подклассов и объектов (экземпляров) данного класса доступа нет;
  • protected (защищенный доступ). В этом случае есть доступ из данного класса, объекта этого класса и подкласса. Но нет доступа из объекта подкласса;
  • public. В этом случае есть доступ из данного класса, подкласса, объекта данного класса, а также объекта подкласса.

Особенности использования модификатора доступа private в случае унаследованных (производных) классов Если поля или методы класса объявлены с модификатором доступа private, тогда они считаются:

  • доступными из методов класса, в котором они объявлены;
  • недоступными для всех других методов любых классов (подклассов), экземпляров (объектов) любых классов.

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

  • доступными из методов класса, в котором они объявлены;
  • доступными из методов подкласса. Это касается и случая, когда унаследованный класс объявляется в другом пакете;
  • доступными из экземпляров (объектов) данного класса;
  • доступными из экземпляров (объектов) подкласса.

Особенности использования модификатора доступа public для унаследованных классов Модификатор доступа public применяется если нужно получить доступ к полю или методу класса из любой точки программы. Доступ к public полям и методам класса имеют:

  • методы данного класса;
  • методы подкласса;
  • объекты данного класса;
  • объекты подкласса;
  • объекты, которые объявлены в методах классов, находящихся в других пакетах.

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

Перед объявлением полей класса необязательно ставить модификатор доступа (private, protected, public). Возможен случай, когда модификатор доступа не ставится. В этом случае член данных класса или метод класса:

  • доступен из методов данного класса;
  • доступен из всех методов классов, которые реализованы в данном пакете (пакетный доступ). Это касается и подклассов, которые реализованы в данном пакете. В этом случае тип доступа считается как public, но в пределах пакета. За пределами пакета такой элемент класса недоступен;
  • недоступен для любых методов других классов, которые размещаются в других пакетах. Это касается и подклассов.

Приведем пример. Продемонстрировано объявление поле класса Car без модификатора доступа.


public class Car {

    double x; // объявление поля x без модификатора доступа
// ...
}

В данном случае поле x класса Car доступно в пределах пакета. Более подробно о пакетах в Java возможно прочитать в материалах к теме.

Неизменяемые классы

Иногда необходимо запретить наследование от класса. Для этого необходимо использовать ключевое слово final:


public final class MyClass {

}

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

Запрет наследования

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

Пример:

final class Car {

}

Тогда следующее определение класса недопустимо:

class Auto extends Car { // Ошибка! Создать подкласс от класса Car нельзя.

}

Как следует из комментариев к данному примеру, недопустимо, чтобы класс Auto наследовал от класса Car, так как последний определен как final.

Статичные поля и методы

Модификатор static в Java напрямую связан с классом. Если поле статично, значит оно принадлежит классу, если метод статичный — аналогично: он принадлежит классу. Исходя из этого, можно обращаться к статическому методу или полю, используя имя класса.

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

Static — модификатор, применяемый к полю, блоку, методу или внутреннему классу. Данный модификатор указывает на привязку субъекта к текущему классу. Статические поля при обозначении переменной уровня класса мы указываем на то, что это значение относится к классу. Если этого не делать, то значение переменной будет привязываться к объекту, созданному по этому классу.

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

Например, у нас есть класс Car с нестатической переменной:

public class Car {

    int km;
}

Тогда в main:

Car orangeCar = new Car();
orangeCar.km = 100;

Car blueCar = new Car();
blueCar.km = 85;

System.out.println("Orange car - " + orangeCar.km);
System.out.println("Blue car - " + blueCar.km);

Получим вывод:

Orange car - 100
Blue car - 85

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

Ну а если у нас переменная статическая, то это глобальное значение — одно для всех:

Теперь мы имеем Car со статической переменной:

public class Car {

    static int km;
}

Тогда тот же код в main будет выдавать в консоль:

Orange car - 85
Blue car - 85

Ведь переменная у нас одна на всех, и каждый раз мы меняем именно ее.

К статическим переменным, как правило, обращаются не по ссылке на объект — orangeCar.km, а по имени класса — Car.km

Статичный блок

Есть два блока инициализации — обычный и статический.

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

Пример класса со статическим блоком инициализации:

public class Car {

    static int km;

    static {
        km = 150;
    }
}

Статичный метод

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

Важным свойством статического метода является то, что он может обратиться только к статическим переменным/методам.

В качестве примера давайте рассмотрим класс, который у нас будет неким счётчиком, ведущим учет вызовов метода:

public class Counter {

    static int count;

    public static void invokeCounter() {
        count++;
        System.out.println("Текущее значение счётчика - " + count);
    }
}

Вызовем его в main:

Counter.invokeCounter();
Counter.invokeCounter();
Counter.invokeCounter();

И получим вывод в консоль:

Текущее значение счётчика - 1
Текущее значение счётчика - 2 
Текущее значение счётчика - 3

Статическим классом в Java может быть только внутренний класс.

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

По сути статический вложенный класс ничем не отличается от любого другого внутреннего класса за исключением того, что его объект не содержит ссылку на создавший его объект внешнего класса.

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

Пример вложенного статического класса:

public class Vehicle {

    public static class Car {

        public int km;
    }
}

Создание экземпляра данного класса и задание значения внутренней переменной:

Vehicle.Car car = new Vehicle.Car();
car.km = 90;

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

Конечно, следует учитывать модификаторы доступа. Например, поля private доступны только внутри класса, в котором они объявлены. Поля protected доступны всем классам внутри пакета (package) и всем классам-наследникам вне пакета.

Предположим, существует статический метод increment() в классе Counter, задачей которого является инкрементирование счётчика count. Для вызова данного метода можно использовать обращение вида Counter.increment(). Нет необходимости создавать экземпляр метода Counter для доступа к статическому полю или методу. Это фундаментальное отличие между статическими и НЕ статическими объектами (полями).

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

Особенности использования статических методов, полей и классов

  • Из статического контекста (блока, метода) нельзя получить доступ к нестатическим полям.

Результатом компиляции приведенного ниже кода будет ошибка:

public class Counter {

    private int count;

    public static void main(String[] args) {
        System.out.println(count); //compile time error
    }
}

Это одна из наиболее распространённых ошибок допускаемых программистами Java, особенно новичками. Так как метод main статичный, а переменная count - нет, в этом случае метод println, внутри метода main выбросит “Compile time error”.

В отличие от локальных переменных, статические поля и методы НЕ потокобезопасны (NOT Thread-safe) в Java. На практике это одна из наиболее частых причин возникновения проблем связанных с безопасностью мультипоточного программирования. Учитывая что каждый экземпляр класса имеет одну и ту же копию статической переменной, то такая переменная нуждается в защите — блокировке классом. Поэтому при использовании статических переменных, убедитесь, что они должным образом синхронизированы (synchronized), во избежание проблем, например таких, как "состояние гонки" (race condition).

Статические методы имеют преимущество в применении, так как. отсутствует необходимость каждый раз создавать новый объект для доступа к таким методам. Статический метод можно вызвать, используя тип класса, в котором эти методы описаны. Именно поэтому, подобные методы как нельзя лучше подходят в качестве методов-фабрик (factory), и методов-утилит (utility). Класс java.lang.Math — замечательный пример, в котором почти все методы статичны, по этой же причине классы-утилиты в Java финализированы (final).

  • Нельзя переопределять (Override) статические методы.

Если вы объявите такой же метод в классе-наследнике (subclass), то есть метод с таким же именем и сигнатурой, вы лишь спрячете метод суперкласса (superclass) вместо переопределения. Это явление известно как сокрытие методов (hiding methods). Это означает, что при обращении к статическому методу, который объявлен как в родительском, так и в дочернем классе, во время компиляции всегда будет вызван метод исходя из типа переменной. В отличие от переопределения, такие методы не будут выполнены во время работы программы. В случае если есть 2 класса: базовый и наследник - и в них определён метод с одинаковым именем и сигнатурой, то если мы создадим экземпляр класса-наследнмка, и присвоим его переменной с типом базового класса, то при обращении к данному методу от имени экземляра будет вызван метод из базового класса несмотря на то, что это экземпляр класса-наследника.

Пример:


class Car {

    public static void kmToMiles(int km) {
        System.out.println("Внутри родительского класса/статического метода");
    }
}

class Truck extends Car {

    public static void kmToMiles(int km) {
        System.out.println("Внутри дочернего класса/статического метода ");
    }
}

public class Demo {

    public static void main(String[] args) {
        Car v = new Truck();
        v.kmToMiles(10);
    }
}

Вывод в консоль:

Внутри родительского класса/статического метода

Код наглядно демонстрирует: несмотря на то, что объект имеет тип Truck, вызван статический метод из класса Car, так как произошло обращение к методу во время компиляции. Обратим внимание, что ошибки во время компиляции не возникло!

  • Объявить статическим можно и класс, за исключением классов верхнего уровня.

Такие классы известны как вложенные "статические классы" (nested static class). Они бывают полезными для представления улучшенных связей. Яркий пример вложенного статического класса — HashMap.Entry, который предоставляет структуру данных внутри HashMap. Стоит заметить, что как и любой другой внутренний класс, вложенные классы находятся в отдельном файле .class. Таким образом, если вы объявили пять вложенных классов в вашем главном классе, у вас будет 6 файлов с расширением .class. Ещё одним примером использования является объявление собственного компаратора (Comparator), например компаратор по возрасту (AgeComparator) в классе сотрудники (Employee).

Модификатор static может быть объявлен в статичном блоке, более известным как "Статический блок инициализации" Static initializer block, который будет выполнен во время загрузки класса. Если вы не объявите такой блок, то Java соберёт все статические поля в один список и выполнит его во время загрузки класса. Однако, статичный блок НЕ может пробросить перехваченные исключения, но может выбросить не перехваченные. В таком случае возникнет Exception Initializer Error. На практике, любое исключение возникшее во время выполнения и инициализации статических полей, будет завёрнуто Java в эту ошибку. Это самая частая причина ошибки No Class Def Found Error, так как класс не находился в памяти во время обращения к нему.

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

Важным свойством статического блока является инициализация. Статические поля или переменные инициализируются после загрузки класса в память. Порядок инициализации сверху вниз, в том же порядке, в каком они описаны в исходном файле Java класса. Поскольку статические поля инициализируются на потокобезопасный манер, это свойство используется и для реализации паттерна Singleton. Если вы не используете список Enum как Singleton, по тем или иным причинам, то для вас есть хорошая альтернатива. Но в таком случае необходимо учесть, что это не "ленивая" инициализация. Это означает, что статическое поле будет проинициализировано ещё ДО того как кто-нибудь об этом "попросит". Если объект ресурсоёмкий или редко используется, то инициализация его в статическом блоке сыграет не в вашу пользу.

Во время сериализации, аналогично как и transient переменные, статические поля не сериализуются. Действительно, если сохранить любые данные в статическом поле, то после десериализации новый объект будет содержать его первичное (по-умолчанию) значение, например, если статическим полем была переменная типа int, то её значение после десериализации будет равно нулю, если типа float – 0.0, если типа Object – null. Не храните наиболее важные данные об объекте в статическом поле!

И напоследок, поговорим о static import. Данный модификатор имеет много общего со стандартным оператором import, но в отличие от него позволяет импортировать один или все статические поля. При импортировании статических методов, к ним можно обращаться как будто они определены в этом же классе, аналогично при импортировании полей, мы можем получить доступ без указания имени класса. Данная возможность появилась в Java версии 1.5, и при должном использовании улучшает читабельность кода. Наиболее часто данная конструкция встречается в тестах JUnit, так как почти все разработчики тестов используют static import для assert методов, например assertEquals() и для их перегруженных дубликатов.

Список литературы/курсов

  1. https://www.bestprog.net/ru/2018/09/20/packages-using-packages-in-java-the-import-and-package-directives-compiled-modules-java-intermediate-class-files-project-structure-using-standard-java-libraries_ru/
  2. Книга «Effective Java»
  3. https://metanit.com/java/tutorial/3.5.php
  4. https://java-blog.ru/osnovy/nasledovanie-java-s-primerami
  5. https://javascopes.com/java-inheritance-d0d6946a/
  6. https://hr-vector.com/java/nasledovanie

Тема 8. Инкапсуляция | Оглавление | Тема 10. Полиморфизм