Наследование – пожалуй, второй по простоте понимания принцип ООП (после абстракции).
Наследование – это когда классы-наследники перенимают поля и методы классов-родителей. Они могут их использовать так, как будто бы этот код был записан в них самих.
Что такое наследование и зачем
Наследование – это один из 4 китов ООП (помимо абстракции, инкапсуляции и полиморфизма).
Сразу разъясним понятия и их синонимы:
- Класс, который наследуется от другого класса – класс-потомок, класс-наследник, подкласс, производный класс.
- Класс, от которого наследуется другой класс – класс-родитель, родительский класс, суперкласс, базовый класс.
Механизм наследования позволяет одним классам ложиться в основу других классов. При этом класс-наследник имеет всё, что имеет класс-родитель, но может и добавлять новое. Ведь наследование (от английского – inheritance) также называют расширением или обобщением.
Соответственно:
- класс-наследник расширяет класс-родитель, как понятие “Свитер” расширяет и добавляет новое, уточняет понятие “Одежда”;
- класс-родитель обобщает класс-наследник, как понятие “Устройство” обобщает понятия “Телефон” и “Ноутбук”.
Но для чего весь сыр-бор? Вообще-то, по веским причинам.
И первая из них – это дублирование кода. Да, вам не нужно копировать весь код из класса-родителя в класс-наследник просто потому, что ему он тоже нужен. Вы просто указываете, что класс-наследник наследуется от класса-родителя и дело в шляпе.
Вторая причина – это иерархия. Классы могут быть организованы в иерархическую структуру, где более конкретные классы (“Телефон”, “Ноутбук”) наследуются от более общих (“Устройство”). Например, класс “Фрукт” может быть базовым классом, а “Яблоко” и “Апельсин” будут производными классами.
А третья причина – это способствование использованию полиморфизма. Да, наследование позволяет реализовывать полиморфизм в программах с парадигмой ООП. По секрету скажу, что это не единственный способ реализации полиморфизма, но, пожалуй, наиболее частый. Получается, объекты класса-наследника могут быть использованы там, где ожидается объект класса-родителя. Я лично была в шоке, когда узнала, что можно легально делать так:
1 |
Device device = new Phone(); //Phone наследуется от Device |
И дальше вы можете работать с этим объектом как будто это объект Device!
Наследование в Java
В Java используется ключевое слово extends для того, чтобы показать, что один класс наследуется от другого:
1 2 3 |
public class Phone extends Device { ... } |
Причём в Java не поддерживается множественное наследование от классов. Вы можете унаследоваться только от одного класса. Зато вы можете наследовать множество интерфейсов!
При этом, все классы в Java автоматически наследуются от класса Object. У него есть всякие методы вроде getClass(), toString(), equals(), hashCode().
Вот, откуда берутся все эти методы, которые подсказывает среда разработки. Вы не создавали этих методов, но они отображаются в подсказках. Так что вы можете спокойно их вызвать. Они определены в Object.
Кстати говоря, например, метод toString() возвращает строковое представление объекта. Давайте на его примере рассмотрим, как это – переопределять методы.
1 2 3 4 5 6 |
public class Example { @Override public String toString() { return "Объект Example"; } } |
Метод toString() уже есть в родительском классе Object. Но мы хотим изменить поведение объекта Example при вызове toString() на объекте Example. Поэтому можно просто написать свою реализацию этого метода внутри класса Example.
Желательно помечать переопределённые методы аннотацией @Override. Да, программа скомпилируется и без этого. Но так удобнее для разработчиков. Во-первых, видно, что это переопределённый метод, а не тот, что был определён изначально в классе Example. Во-вторых, аннотация @Override позволяет проверить, есть ли в родительском классе метод с таким же названием и набором параметров, пригодный для переопределения. И если нет – вы это увидите. Удобно? Удобно.
Итак, проверим, как будет работать метод toString() на объекте Example в двух ситуациях:
Без переопределения метода toString()
С переопределением на возврат строки “Объект Example”
Получается, в первом случае объект Example, вызывая метод toString(), вызвал первоначальную реализацию этого метода в классе Object. А во втором случае – свою собственную, так как было добавлено переопределение метода с аннотацией @Override.
Важно понимать, что private-переменные и private-методы класса-родителя не будут доступны в классах-наследниках. Нужно либо инкапсулировать логику работы с ними в protected- или public-методах, либо их самих сделать protected. Зависит от задачи.
Ещё один важный момент – это обращение к конструкторам и методам класса-родителя. Да, класс Example переопределяет метод toString() от Object, но это не мешает ему обратиться к нему при помощи ключевого слова super:
1 2 3 4 5 6 |
public class Example { @Override public String toString() { return "Объект Example (" + super.toString() + ")"; } } |
В итоге переопределённый метод toString() в классе Example попутно обратился и к родительскому методу toString(). Вот такие отцы и дети!
Кроме использования родительских методов через super, можно также обратиться к родительскому конструктору. Например, класс Example наследуется от класса Test (имена выбираем максимально произвольные). А у класса Test есть конструктор с одним int-ом, который будет сохранён в приватную переменную testValue.
1 2 3 4 5 6 7 |
public class Test { private int testValue; public Test(int testValue) { this.testValue = testValue; } } |
Тогда Example может выглядеть так:
1 2 3 4 5 6 7 8 9 10 |
public class Example extends Test { public Example() { super(10); } @Override public String toString() { return "Объект Example (" + super.toString() + ")"; } } |
Или вот так:
1 2 3 4 5 6 7 8 9 10 |
public class Example extends Test { public Example(int value) { super(value); } @Override public String toString() { return "Объект Example (" + super.toString() + ")"; } } |
Что ж, технические аспекты разобрали. Теперь можно идти за осмысленными примерами!
Примеры кода
Будет у нас программа для IT-предприятия. В ней нужно учитывать сотрудников и рассчитывать им зарплаты. Итак, базовый класс – сотрудник – Employee:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Employee { private String name; private int experienceInYears; public Employee(String name, int experienceInYears) { this.name = name; this.experienceInYears = experienceInYears; } public String getName() { return name; } public int calculateSalary() { int salaryBase = 2000; int yearlyBonus = 100; return salaryBase + experienceInYears * yearlyBonus; } } |
Кстати, переменная experienceInYears названа именно так, а не просто experience. Потому что содержательное имя переменной, которая может измеряться в разных величинах, должно отражать единицу измерения. Так не запутаешься.
Далее следуют два класса: менеджер (Manager) и разработчик (Developer). Они будут переопределять метод расчёта зарплаты. При этом, они всё равно будут использовать этот метод из базового класса.
1 2 3 4 5 6 7 8 9 10 11 |
public class Manager extends Employee { public Manager(String name, int experienceInYears) { super(name, experienceInYears); } @Override public int calculateSalary() { int bonus = 1000; return super.calculateSalary() + bonus; } } |
1 2 3 4 5 6 7 8 9 10 11 |
public class Developer extends Employee { public Developer(String name, int experienceInYears) { super(name, experienceInYears); } @Override public int calculateSalary() { int bonus = 1500; return super.calculateSalary() + bonus; } } |
Отлично. Давайте протестируем эти классы. Создадим класс Main и выведем результаты в консоль.
1 2 3 4 5 6 7 8 9 10 11 12 |
public class Main { public static void main(String[] args) { Manager manager = new Manager("Иван", 5); Developer developer = new Developer("Пётр", 3); int managerSalary = manager.calculateSalary(); int developerSalary = developer.calculateSalary(); System.out.println("Зарплата менеджера (" + manager.getName() + "): $" + managerSalary); System.out.println("Зарплата разработчика (" + developer.getName() + "): $" + developerSalary); } } |
Неплохие такие зарплаты у ребят. При этом классы-наследники ничего знать не знают про метод расчёта базовой части зарплаты, и как на неё влияет стаж. Разделение ответственностей, типа. Хотя этот вопрос, конечно, всегда спорный: где заканчивается одна ответственность и начинается другая? Но мы своего добились – классы написали и их работу проверили. Бинго!
Между делом, наследование в Java – это не только extends (с классами), но и implements (с интерфейсами). Гораздо чаще, чем от обычных классов, наследование используется от абстрактных классов или интерфейсов. В таком случае мы говорим об их реализации. И тогда можно построить целую иерархию классов. Но это – уже другая история.
Как минимум, наследование помогает избежать дублирования кода. Дублирование кода – это зло. Следовательно, наследование – добро?