Перейти к содержанию

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

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

Проще говоря, в Java класс может наследовать другой класс и несколько интерфейсов, а интерфейс может наследовать другие интерфейсы.

В этой статье начнем с необходимости наследования и перейдем к тому, как наследование работает с классами и интерфейсами.

Затем рассмотрим, как имена переменных/методов и модификаторы доступа влияют на унаследованные члены.

И в конце увидим, что значит наследовать тип.

Потребность в наследовании

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

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

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

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

  • базовый тип также называется super или родительским типом;
  • производный тип называется расширенным, подтипом или дочерним типом.

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

Расширение класса

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

Начнем с определения базового класса Car:

public class Car {
    int wheels;
    String model;
    void start() {
        // Проверка основных частей
    }
}

Класс ArmoredCar может наследовать членов класса Car, используя в своем объявлении ключевое слово extends:

public class ArmoredCar extends Car {
    int bulletProofWindows;
    void remoteStartCar() {
	// этот автомобиль можно запустить с помощью пульта дистанционного управления
    }
}

Теперь можно сказать, что класс ArmoredCar является подклассом Car, а последний является суперклассом ArmoredCar.

Классы в Java поддерживают одиночное наследование; класс ArmoredCar не может расширять несколько классов.

Обратите внимание, что при отсутствии ключевого слова extends класс неявно наследует класс java.lang.Object.

Подкласс наследует нестатические protected и public члены от суперкласса. Кроме того, члены с доступом по умолчанию (package-private) наследуются, если два класса находятся в одном пакете.

С другой стороны, private и static члены класса не наследуются.

Доступ к родительским элементам из дочернего класса

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

public class ArmoredCar extends Car {
    public String registerModel() {
        return model;
    }
}

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

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

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

Хотя классы могут наследовать только один класс, они могут реализовывать несколько интерфейсов.

Представьте, что ArmoredCar, который определили в предыдущем разделе, требуется для супершпиона. Поэтому компания-производитель автомобилей подумала о добавлении функций полета и плавания:

public interface Floatable {
    void floatOnWater();
}
public interface Flyable {
    void fly();
}
public class ArmoredCar extends Car implements Floatable, Flyable{
    public void floatOnWater() {
        System.out.println("Я могу плавать!");
    }
 
    public void fly() {
        System.out.println("Я могу летать!");
    }
}

В приведенном выше примере мы заметили использование ключевого слова implements для наследования от интерфейса.

Проблемы с множественным наследованием

Java допускает множественное наследование с использованием интерфейсов.

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

Посмотрим, как это простое уравнение изменилось с введением в интерфейсы методов по умолчанию в Java 8.

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

Java запрещает наследование нескольких реализаций одних и тех же методов, определенных в отдельных интерфейсах.

Рассмотрим пример:

public interface Floatable {
    default void repair() {
    	System.out.println("Ремонт плавающего объекта");	
    }
}
public interface Flyable {
    default void repair() {
    	System.out.println("Ремонт летающего объекта");	
    }
}
public class ArmoredCar extends Car implements Floatable, Flyable {
    // это не скомпилируется
}

Если действительно хотим реализовать оба интерфейса, нам придется переопределить метод repair(). Если интерфейсы в предыдущих примерах определяют переменные с одинаковыми именами, скажем, duration, мы не можем получить к ним доступ, если перед именем переменной не указано имя интерфейса:

public interface Floatable {
    int duration = 10;
}
public interface Flyable {
    int duration = 20;
}
public class ArmoredCar extends Car implements Floatable, Flyable {
 
    public void aMethod() {
    	System.out.println(duration); // не будет компилироваться
    	System.out.println(Floatable.duration); // выведет 10
    	System.out.println(Flyable.duration); // выведет 20
    }
}

Интерфейсы, расширяющие другие интерфейсы

Интерфейс может расширять несколько интерфейсов. Ниже пример:

public interface Floatable {
    void floatOnWater();
}
interface interface Flyable {
    void fly();
}
public interface SpaceTraveller extends Floatable, Flyable {
    void remoteControl();
}

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

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

Если класс наследует другой класс или интерфейсы, помимо наследования их членов, он также наследует их тип. Это также относится к интерфейсу, который наследует другие интерфейсы.

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

Например, представьте условие, при котором организация ведет список автомобилей, принадлежащих ее сотрудникам. У сотрудников могут быть разные модели автомобилей. Как можно ссылаться на разные экземпляры автомобиля? Вот решение:

public class Employee {
    private String name;
    private Car car;
    
    // стандартный конструктор
}

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

Employee e1 = new Employee("Иван", new ArmoredCar());
Employee e2 = new Employee("Петр", new SpaceCar());
Employee e3 = new Employee("Елена", new BMW());

Скрытые члены класса

Скрытые члены экземпляра

Что произойдет, если и в суперклассе, и в подклассе будет определена переменная или метод с одним и тем же именем? Не волнуйтесь; мы все еще можем получить доступ к ним обоим. Тем не менее, необходимо сделать наши намерения понятными для Java, добавив префикс к переменной или методу с ключевыми словами this или super. Ключевое слово this относится к экземпляру, в котором оно используется. Ключевое слово super относится к экземпляру родительского класса:

public class ArmoredCar extends Car {
    private String model;
    public String getAValue() {
    	return super.model;   // возвращает значение модели, определенной в базовом классе Car
    	// return this.model;   // вернет значение модели, определенное в ArmoredCar
    	// return model;   // вернет значение модели, определенное в ArmoredCar
    }
}

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

Скрытые статические члены

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

public class Car {
    public static String msg() {
        return "Car";
    }
}
public class ArmoredCar extends Car {
    public static String msg() {
        return super.msg(); // это не скомпилируется.
    }
}

Нет, мы не можем. Статические члены принадлежат классу, а не экземплярам. Поэтому мы не можем использовать нестатическое ключевое слово super в msg().

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

return Car.msg();

Рассмотрим следующий пример, в котором и базовый класс, и производный класс определяют статический метод msg() с одной и той же сигнатурой:

public class Car {
    public static String msg() {
        return "Car";
    }
}
public class ArmoredCar extends Car {
    public static String msg() {
        return "ArmoredCar";
    }
}

Вот как можно их вызвать:

Car first = new ArmoredCar();
ArmoredCar second = new ArmoredCar();

Для предыдущего кода first.msg() выведет «Car», а second.msg() выведет «ArmoredCar». Статическое сообщение, которое вызывается, зависит от типа переменной, используемой для ссылки на экземпляр ArmoredCar.

Заключение

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

Полный исходный код примеров доступен на GitHub.

Оригинал