Наследование
— это инструмент, позволяющий описать новый класс на основе уже существующего с частично или полностью заимствованной функциональностью.
Это мощный инструмент переиспользования кода и создания собственных иерархий классов.
Можно сказать, что мы на основе одного класса строим новый класс, путем добавления новых характеристик и методов.
Сперва давайте постараемся ответить на вопрос: Зачем вообще придумали Наследование
?
Давайте объявим некоторый класс.
Тут нам на помощь снова придет извечный бродяга примеров - класс Person.
class Person {
private int age;
private String name;
// some remaining code
}
Теперь нам понадобилось создать класс Employee
- работника.
Работник имеет те же черты, что и Person
, но вдобавок к этому он устроен на работу.
Т.е это тот же Person
, но с еще одним дополнительным полем - работа.
В мире, где механизм наследования отсутствует - мы бы написали такой класс примерно так:
class Employee {
private int age;
private String name;
private String work;
// some remaining code
}
По сути, все что мы сделали - это скопировали один класс и вставили его в другой, добавив, разумеется сверху еще щепотку нового функционала. Не мне вам объяснять, что подобный подход - копирования и вставки - это пагубная, опасная и гневающая богов привычка.
Вдобавок к этому есть и еще один неприятный момент!
Для нас очевидно, что эти классы связаны логически, т.е Employee
- является еще и Person
, но для Java
эти классы никак не связаны, с точки зрения Java
это просто два разных, никак не связанных и не имеющих ничего общего класса. Но мы-то знаем, что это не так!
Возникает резонное желание - а что если делегировать ответственность за это копирование кода и поддержание его в консистентном состоянии на Java
?
Под консистентном состоянии я имею в виду, что при добавлении нового поля в Person
это поле появится и у Employee
.
Тут нам и поможет механизм наследования
!
Для того, чтобы воспользоваться механизмом наследования, т.е унаследовать один класс от другого, в Java
существует ключевое слово extends
.
Класс, от которого производится наследование, называется базовым
, родительским
или суперклассом
.
Новый класс — потомком
, наследником
, дочерним
или производным
классом.
Перепишем теперь наш код с использованием наследования
, при этом не забудем изменить и модификаторы доступа.
class Person {
protected int age;
protected String name;
public Person(int age, String name) {
this.age = age;
this.name = name;
}
// some remaining code
}
class Employee extends Person {
private String address;
public Employee(int age, String name, String address) {
super(age, name);
this.address = address
}
}
Теперь, мало того, что код получился более компактным, мы убрали дублирование,
так еще и получилась иерархия классов, ровно та, которая нам нужна.
Person
и Employee
уже больше не являются двумя независимыми классами, как было в первом примере.
Для демонстрации этого мы просто создадим метод, который ждет на вход экземпляры класса Person
.
Допустим, у нас реализована некая телефонная книга:
class PhoneBook {
public static void find(Person p) {
System.out.println(p.name);
}
}
Запустите следующий код в обоих случаях, про которые мы говорили.
public static void main(String[] args) {
Person p = new Person(27, "Aleksandr");
Employee e = new Employee(27, "Aleksandr", "Sberbank");
PhoneBook.find(p);
PhoneBook.find(e);
}
В случае, когда классы не наследуют друг друга - код даже не скомпилируется, ведь телефонная книга ждет что-то, что является Person
.
А в первом случае Person
и Employee
это два абсолютно разных класса, которые никак не связаны друг с другом.
Из примера выше можно сделать вывод, что производный класс полностью удовлетворяет спецификации родительского, однако может иметь дополнительную функциональность. Именно поэтому мы безболезненно в телефонную книгу можем передать и Person, и Employee.
Однако, стоит понимать, что наследование - это не просто инструмент для избавления от дублирования кода. Помните, что наследование - это приобретение и состояния, и поведения класса-родителя.
Наследование - это приобретение и состояния, и поведения класса-родителя.
Внимательный читатель сразу обратил внимание на еще одно новое ключевое слово в теле конструктора класса Employee - super
.
А если вы присмотритесь еще внимательней, то вы увидите и ключевое слово this
.
Так вот, this
- это ссылка на объект, текущего класса, а super
- ссылка на объект его родительского класса.
Само слово super
пошло из теории множеств, где используется термин супермножество.
Раз у нас есть ссылка на объект текущего или родительского класса, то и доступ до полей класса может быть организован через эту ссылку. Примеры этого вы уже неоднократно встречали:
public Person(int age, String name) {
this.age = age;
this.name = name;
}
С помощью ссылки мы получили поле age
у текущего объекта и присвоили туда значение, переданное в конструктор.
Аналогичная ситуация с полем name
.
Ключевые слова this
и super
также могут быть использованы для вызова конструктора текущего или родительского класса соответственно:
class Person {
protected int age;
protected String name;
public Person(int age, String name) {
this.age = age;
this.name = name;
}
public Person(String name) {
this(20, name);
}
// some remaining code
}
Здесь мы создали два конструктора класса, один из которых принимает два аргумента, а другой - только один.
В теле второго конструктора мы вызываем первый, наиболее расширенный, передавая ему значение по умолчанию 20
.
Второй конструктор по сути равносилен следующей записи:
public Person(String name) {
this.age = 20;
this.name = name;
}
То же самое справедливо и для super()
, что мы видели в примере выше:
class Employee extends Person {
private String address;
public Employee(int age, String name, String address) {
super(age, name);
this.address = address
}
}
Из этого кода можно сделать вывод, что если у родительского класса есть не пустой конструктор, то при создании дочернего класса мы обязаны вызвать родительский конструктор, что и происходит.
Если задуматься, то это довольно логично, ведь вы строите свой дочерний класс, который базируется на родителськом. Нельзя построить дом, не заложив фундамент.
И this
, и super
— это нестатические переменные, соответственно их нельзя использовать в статическом контексте.
Переменной this
нельзя присвоить новое значение, потому что она объявлена как final
, что тоже очень даже логично.
Теперь стоит сказать о том, что в Java
все классы неявно наследники java.lang.Object
.
А значит при создании любого класса, происходит вызов родительского конструктора через super()
.
При этом, даже если вы не создали ни одного конструктора класса, то Java
все равно создаст конструктор пустой по умолчанию и вызовет там super()
.
Ведь мы создаем объект класса, родительский класс которого - java.lang.Object
.
Вообще, если вам потребовалось внутри класса вызвать конструктор, например, более расширенный, то делать это надо именно через this:
class Employee extends Person {
protected String address;
public Employee(int age, String name, String address) {
super(age, name);
this.address = address;
}
public Employee(int age, String name) {
this(age, name, "EMPTY");
}
}
Мы создали второй конструктор, внутри которого вызываем главный - с наибольшим количеством аргументов. Этот прием используется тогда, когда вам необходимо для каких-то полей сделать значения по умолчанию.
Для закрепления:
Ключевое слово | Описание |
---|---|
this | Ссылка на текущий объект |
super | Ссылка на родительский объект |
this() | Вызов конструктора без аргументов |
super() | Вызов конструктора без аргументов родительского класса |
Снова давайте начнем с вопроса: Может ли у класса быть более одного предка?
java.lang.Object
мы не берем в виду, так как он является родительским классом для всех.
В языках программирования типа C++
, Python
и т.д это возможно, такой механизм называется множественным наследованием.
В Java
так сделать нельзя.
С одной стороны, это довольно удобно когда вы можете отнаследоваться от нескольких предков. Однако, как показывает практика, множественное наследование — может оказываеться потенциальным источник ошибок. И дело даже не в том, что у двух классов-предков могуть оказаться методы и переменные с одинаковыми именами - хотя это тоже довольно неприятно.
На мой взгляд, множественное наследование слишком развязывает руки разработчикам, позволяя часто не задумываться о том, что такое наследование и насколько это опасный инструмент.
Допустим, мы пишем класс Tank, описывающий боевой танк. У нашего танка есть пушка, пулемет, гусеницы и броня. Пушка и пулемет - это тоже объекты. И при множественном наследовании никто не запрещает нам взять и отнаследоваться от этих классов, получив при этом их функциональность.
Вы представляете весь ужас такого действия? И дело не в том, что почти наверняка оба класса оружия имеют у себя метод fire
.
Дело в том, что наследование значит, что наш класс-наследник - это класс, который является - отношение is a
- тем же, что и родитель.
У него те же свойства и параметры, он только расширяет родителя, но никак не сужает его возможности.
И в итоге при таком подходе наш Танк - это и пулемет, и пушка. А это как раз таки в корне не верно.
Танк содержит и пулемет, и пушку, т.е здесь уже отношение - has a
.
На лицо - типичное нарушение абстракции. Вспомним пример из начала этой заметки, про телефонную книгу и ужаснемся - ведь теперь везде, где можно использовать пулемет - можно использовать и танк! А значит и рядовой боец может взять в руки танк, что даже в патриотичных странах невозможно.
Применительно к программированию - такие просчеты могут быть фатальными и разрушить всю архитектуру приложения.
Частично из-за возможности подобных ошибок в Java
просто решали не разрешать множественное наследование.
Если вам интересно мое мнение, то я думаю, что множественное наследование не предоставляет настолько незаменимые и необходимые возможности, а потенциальных проблем, имеющих разрушительные последствия можно наплодить более чем необходмио.
Забегая вперед я скажу, что наследование в чистом виде, как оно есть, применяется не так часто, вместо него чаще пользуются композицией
.
Об этом мы еще поговорим подробнее.
Выше мы разобрали пример не совсем разумного применения наследования и у вас должен возникнуть вопрос - когда можно использовать наследование, а когда нельзя?
И ответ на этот вопрос довольно прост. Если класс, который потенциальный потомок, удовлетворяет отношению is a
к потенциальному родителю - т.е можно сказать, что он является
тем же, что и родительский класс, то вы можете и это будет правильно использовать наследование.
Если на вопрос "является ли он тем же, что и родитель" ответ отрицательный - то использовать наследование не логично. Вместо этого правильно использовать композицию.
Для примера возьмем два класса - Figure
и Rectangle
.
Является ли прямоуголник некоей абстрактной фигурой? Скорее всего да.
Значит, логично сделать так, что Rectangle
является наследником Figure
.
Теперь поговорим о том, что делать в случае, если наше отношение - это has a
Существует несколько видов взаимодействия объектов, объединенных под общим понятием "Has-A Relationship" или "Part Of Relationship". Это отношение означает, что один объект является составной частью другого объекта.
Существует два подвида этого отношения: если один объект создает другой объект и время жизни составляющего объекта зависит от времени жизни целого, то это называется композиция
,
если же один объект получает ссылку на другой объект в процессе конструирования, то это уже агрегация
.
В обоих случаях мы буквально составляем наш класс по кирпичикам. Именно так, как какой-нибудь прибор состоит из деталей.
Мы не тащим за собой все из родительского объекта, а работаем только с составными частями. Т.е это расширение функционала класса за счет внедрения других объектов.
-
Композиция
Композиция - это такой случай, когда составные части класса не могут существовать без существования класса, в который они входят.
Это как сердце и человек, сердце - это часть человека, но отдельно от него оно не может существовать, не может или его существоание не имеет смысла.
-
Агрегация
Агрегация - это такой случай, когда составные части класса могут существовать без существования класса, в который они входят.
Это как колеса и машина, колеса - это часть машины, но они вполне могут существовать без конкретного текущего автомобиля, мы можем заменить их на зимние или вообще продать.
Композиция - это частный случай агрегации.
Давайте разберем минусы и плюсы подхода с наследованием и композциией/агрегацией.
Плюсы:
- Повторное использование уже существующих и протестированных участков кода
- Выстраиваемая иерархия наследников
Минусы:
-
Дочерний класс зависит от изменений в родительском классе, изменив что-то в родительском - мы автоматически получаем эти изменения в дочернем.
Пусть у нас есть своя какая-то
MyHashMap
и мы отнаследовали ее отHashMap
, переопределили методadd(...)
, если разработчикиHashMap
введутaddAll(..)
- у нас будет в этом месте дыра, ведь мы переопределили ужеadd
по-своему, но неaddAll
, который будет добавлять элементы 'по старому'. -
Ошибка в неверной иерархии наследования - и у нас огромные проблемы с расширением нашего кода в дальнейшем.
-
Нарушение инкапсуляции.
-
Тянем все проблемы и ошибки наследованного кода.
-
Тяжело выстраивать правильные абстракции.
Плюсы:
- Ситуации на подобие той, что описана выше с
MyHashMap
исключены. - Возможность скрыть проблемы класса-родителя, создав обертку, в которой скроем недостатки API класс-родителя.
- Легко применять и строить абстракции.
Минусы:
- Иногда действительно удобно работать с наследованием и иерархией классов.
- Если объектов-владельцев достаточно много, то создание и уничтожение вместо одного объекта двух или более может пагубно сказаться на производтельнсоти.
Наследование - это именно 'расширение' какого-то функционала, в то время как композиция - это включение(внедрение) функционала.
Существует даже правило: "Предпочитайте композицию наследованию".
Почему при композиции мы избегаем ситуации описанной в примере с MyHashMap
?
Ответ прост - при композиции мы просто создадим HashMap
полем класса и сами пропишем интерфейс нашего класса, вызывая лишь те методы, которые нам нужны.
При этом ситуаций, которые описаны выше, просто не возникнет - мы определим метод add
, как нам надо и даже если разработчики добавят в HashMap
какой-то
свой еще один addAll
метод - нас это никак не затронет, так как наш класс умеет только то, что мы прописали - и не более.
Т.е при композиции мы максимально контролируем и знаем поведение нашего класса - ведь мы сами его и написали. Никакие "новые" методы не могут попасть в интерфейс нашего класса - пока мы сами их явно не вызовем и не напишем на них свои обертки.
При наследовании
же, как было сказано, мы получаем любые изменения родительского класса в дочернем, более того, мы можем даже не заметить, что у нас изменился интерфейс - если он просто расширился и не сломал старый!
В какой-то мере при использовании наследования
мы не полностью контролируем интерфейс нашего класса, а это может привести к трудно уловимым и раздражающим ошибкам.
- Не злоупотребляйте наследованием.
Помните про солдат, берущих в руки танки.
- Всегда задавайтесь вопросом
is a
илиhas a
и только после этого делайте выбор. - Старайтесь избегать дублирования кода применяя или композицию/агрегацию, или наследование.
- Думайте над полученной иерархией классов.