extends в объявлении класса стоит ровно один суперкласс (JLS, раздел 8.1.4), а наследовать сразу от нескольких классов запрещено из-за неоднозначности, известной как ромбовидная проблема. Зато класс может реализовывать сколько угодно интерфейсов, интерфейс может расширять несколько интерфейсов, а поведение нескольких классов собирается композицией: объект хранит ссылки на другие объекты и делегирует им вызовы. В статье: терминология и типы наследования, ромбовидная проблема на коде, интерфейсы, композиция и четыре довода за композицию вместо наследования.Кратко
- Класс в Java расширяет только один класс, зато реализует любое число интерфейсов, а интерфейс может расширять несколько интерфейсов.
- Ромбовидная проблема:
ClassC extends ClassA, ClassB, у обоих родителей естьdoSomething(), и компилятор не знает, чей метод вызывать. Поэтому множественного наследования классов в Java нет. - Интерфейсы наследуются множественно без конфликта:
InterfaceC extends InterfaceA, InterfaceBс одинаковым абстрактным методом компилируется, потому что реализацию все равно пишет класс. - С Java 8 у интерфейсов есть default-методы, и тогда конфликт возможен: класс обязан переопределить метод и может выбрать реализацию через
InterfaceA.super.doSomething(). - Композиция вместо множественного наследования:
ClassCхранит поля типовClassAиClassBи делегирует им вызовыmethodA()иmethodB(). - «Предпочитайте композицию наследованию»: она не ломается при изменении суперкласса, дает контроль над доступом к методам, позволяет подменять реализацию через конструктор и проще тестируется. Наследование оставьте для отношения «является» (is-a).
- Классы: фундаментальная структура шаблона в объектно-ориентированных языках программирования. Класс определяет общие свойства для группы объектов.
- Родительский класс: также известный как базовые классы или суперклассы. Родительский класс это расширяемый класс, который предоставляет функции дочернему классу. Он допускает возможность повторного использования. Определения и функции родительского класса повторно используются при создании дочерних классов.
- Дочерний класс: более обобщенно называемый подклассом, дочерний класс наследует функции от другого класса. Дочерние классы это расширенные или производные классы.
- Наследование: отношения между родительским и дочерним классами.
Типы наследования ООП
Сегодня используется множество популярных объектно-ориентированных языков программирования, включая Java, C++, JavaScript, Python, PHP, Ruby и Perl. Хотя наследование является общей концепцией для этих языков ООП, не все типы наследования существуют в каждом из этих языков. Крайне важно знать общие типы наследования и ограничения на наследование на конкретном языке, который вы используете. Чем больше вы знаете о наследовании, тем более эффективным разработчиком вы станете. Типы наследования, поддерживаемые Java, включают:- Одноуровневое наследование: когда дочерний класс наследует функции от единственного родительского класса.
- Многоуровневое наследование: это многоуровневая форма одноуровневого наследования. При многоуровневом наследовании дочерний класс также может выступать в качестве родительского класса для других дочерних классов. Отношения между уровнями линейны: никаких ветвей вверх, как при множественном наследовании, нет. В этом случае конечный дочерний класс имеет функции со всех уровней выше.
- Иерархическое наследование: противоположность множественного наследования. В иерархическом наследовании единственный родительский класс имеет более одного дочернего класса. Таким образом, вместо того, чтобы иметь ветви над ним, он разветвляется внизу.
- Гибридное наследование: как следует из названия, гибридное наследование представляет собой комбинацию других типов наследования.
- Множественное наследование: при множественном наследовании дочерний класс имеет более одного родительского класса. Хотя Java и JavaScript не поддерживают множественное наследование, такие языки ООП, как C++, поддерживают.
- Многопутевое наследование: гибрид множественного, многоуровневого и иерархического наследования, при многопутевом наследовании дочерний класс наследует свои характеристики и функции от родительского класса и нескольких дочерних классов родительского класса. Поскольку многопутевое наследование основано на множественном наследовании, Java не поддерживает его использование.
Множественное наследование в Java
Множественное наследование дает возможность создать класс, наследованный от нескольких суперклассов. В отличие от некоторых других популярных объектно-ориентированных языков программирования, таких как C++, в Java запрещено множественное наследование от классов. Java не поддерживает множественное наследование классов, потому что это может привести к ромбовидной проблеме. И вместо того, чтобы искать способы решения этой проблемы, есть лучшие варианты, как мы можем добиться того же самого результата как множественное наследование.Ромбовидная проблема
Для более легкого понимания ромбовидной проблемы, давайте предположим, что множественное наследование поддерживается в Java. В этом случае, у нас могла бы быть иерархия классов как показано на изображении ниже.SuperClass является абстрактным и в нем объявлен некоторый метод. И конкретные классы ClassA и ClassB.
package com.journaldev.inheritance;
public abstract class SuperClass {
public abstract void doSomething();
}
package com.journaldev.inheritance;
public class ClassA extends SuperClass{
@Override
public void doSomething(){
System.out.println("doSomething implementation of A");
}
//ClassA own method
public void methodA(){
}
}
package com.journaldev.inheritance;
public class ClassB extends SuperClass{
@Override
public void doSomething(){
System.out.println("doSomething implementation of B");
}
//ClassB specific method
public void methodB(){
}
}
Теперь предположим мы хотим реализовать ClassC и наследовать его от ClassA и ClassB.
package com.journaldev.inheritance;
public class ClassC extends ClassA, ClassB{
public void test(){
//calling super class method
doSomething();
}
}
Обратите внимание, что метод test() вызывает метод суперкласса doSomething(). Это приводит к неоднозначности, поскольку компилятор не знает, какой метод суперкласса выполнять. Это диаграмма классов ромбовидной формы, которая и называется ромбовидная проблема (diamond problem). Это главная причина, по которой в Java не поддерживается множественное наследование.
Заметьте, что описанная проблема с множественным наследованием классов возникает и всего с тремя классами: достаточно, чтобы у двух родителей был хотя бы один общий метод, ромб из четырех классов для этого не обязателен.
Множественное наследование интерфейсов
В Java множественное наследование не поддерживается в классах, но оно поддерживается в интерфейсах. И один интерфейс может расширять множество других интерфейсов. Ниже представлен простой пример.package com.journaldev.inheritance;
public interface InterfaceA {
public void doSomething();
}
package com.journaldev.inheritance;
public interface InterfaceB {
public void doSomething();
}
Обратите внимание, что оба интерфейса объявляют один и тот же самый метод. Теперь мы можем создать интерфейс, расширяющий оба эти интерфейса, как представлено в примере ниже.
package com.journaldev.inheritance;
public interface InterfaceC extends InterfaceA, InterfaceB {
//same method is declared in InterfaceA and InterfaceB both
public void doSomething();
}
Это отлично работает, потому что абстрактные методы интерфейсов только объявляются, а реализация будет выполнена в классах, реализующих интерфейс. Таким образом, при наследовании абстрактных методов неоднозначности не возникает.
package com.journaldev.inheritance;
public class InterfacesImpl implements InterfaceA, InterfaceB, InterfaceC {
@Override
public void doSomething() {
System.out.println("doSomething implementation of concrete class");
}
public static void main(String[] args) {
InterfaceA objA = new InterfacesImpl();
InterfaceB objB = new InterfacesImpl();
InterfaceC objC = new InterfacesImpl();
//all the method calls below are going to same concrete implementation
objA.doSomething();
objB.doSomething();
objC.doSomething();
}
}
Обратите внимание, что каждый раз, переопределяя любой метод суперкласса или реализуя метод интерфейса, используйте аннотацию @Override.С Java 8: default-методы и конфликт в интерфейсах
С Java 8 интерфейс может содержать default-методы с реализацией, и тогда неоднозначность возможна и у интерфейсов. Правило спецификации простое: если класс наследует два default-метода с одинаковой сигнатурой (или default-метод и абстрактный метод из другого интерфейса), он обязан переопределить этот метод сам, иначе ошибка компиляции (JLS, раздел 8.4.8.4). Внутри переопределения нужную реализацию можно вызвать явно:InterfaceA.super.doSomething(). Так Java допускает множественное наследование поведения, но всегда оставляет выбор за конкретным классом. Руководство Oracle называет это множественным наследованием реализации, а запрет расширять несколько классов объясняет прежде всего множественным наследованием состояния: у объекта не должно быть полей сразу от двух родителей с неясным порядком инициализации. Вот как это выглядит в коде:
package com.journaldev.inheritance;
public interface InterfaceA {
default void doSomething() {
System.out.println("default implementation of A");
}
}
package com.journaldev.inheritance;
public interface InterfaceB {
default void doSomething() {
System.out.println("default implementation of B");
}
}
package com.journaldev.inheritance;
public class DefaultImpl implements InterfaceA, InterfaceB {
//without this override the class does not compile
@Override
public void doSomething() {
InterfaceA.super.doSomething();
}
public static void main(String[] args) {
new DefaultImpl().doSomething();
}
}
Вывод:
default implementation of A
Уберите переопределение из DefaultImpl, и компилятор откажется собирать класс с сообщением вида class DefaultImpl inherits unrelated defaults for doSomething() from types InterfaceA and InterfaceB. Замените InterfaceA.super на InterfaceB.super, и программа напечатает вторую реализацию.Композиция вместо множественного наследования
Что делать, если мы хотим использовать функциюmethodA() класса ClassA и функцию methodB() класса ClassB в классе ClassC? Решение находится в использовании композиции. Ниже представлена версия класса ClassC, которая использует композицию, чтобы определить оба метода классов и метод doSomething(), одного из объектов.
package com.journaldev.inheritance;
public class ClassC{
ClassA objA = new ClassA();
ClassB objB = new ClassB();
public void test(){
objA.doSomething();
}
public void methodA(){
objA.methodA();
}
public void methodB(){
objB.methodB();
}
}
Композиция против наследования
Одна из лучших практик программирования на Java: «предпочитайте композицию наследованию» (так сформулировал Джошуа Блох в Effective Java). Разберем несколько аргументов в пользу этого подхода.Хрупкость наследования. Предположим, что у нас есть суперкласс и класс, расширяющий его:
package com.journaldev.inheritance; public class ClassC{ public void methodC(){ } } package com.journaldev.inheritance; public class ClassD extends ClassC{ public int test(){ return 0; } }Приведенный выше код компилируется и работает хорошо. Но, что, если мы изменим реализацию класса
ClassC, как показано ниже:package com.journaldev.inheritance; public class ClassC{ public void methodC(){ } public void test(){ } }Обратите внимание, что метод
test()уже существует в подклассе, но тип возвращаемого значения отличается. Теперь классClassDне будет компилироваться и если вы будете использовать какую-либо IDE, то она вам предложит изменить тип возвращаемого значения в суперклассе или подклассе.Теперь представьте себе ситуацию, когда мы имеем многоуровневую иерархию наследования классов и не имеем доступа к суперклассу. У нас не будет никакого выбора, кроме как изменить нашу сигнатуру метода подкласса или его имя, чтобы удалить ошибку компиляции. Также мы должны будем изменить метод подкласса во всех местах, где он вызывается. Таким образом, наследование делает наш код хрупким.
Вышеупомянутая проблема никогда не произойдет с композицией и это делает ее более привлекательной по отношению к наследованию.
Контроль доступа к методам. Другая проблема с наследованием состоит в том, что мы предоставляем все методы суперкласса клиенту и если наш суперкласс должным образом не разработан и есть пробелы в системе безопасности, то даже при том, что мы наилучшим образом выполняем реализацию нашего класса, на нас влияет плохая реализация суперкласса. Композиция помогает нам в обеспечении управляемого доступа к методам суперкласса, тогда как наследование не обеспечивает управления методами суперкласса. Это также одно из основных преимуществ композиции перед наследованием.
Гибкость вызова. Еще одно преимущество композиции: гибкость в вызове методов. Наша реализация класса
ClassC, представленная выше не оптимальна и обеспечивает связывание времени компиляции с методом, который будет вызываться. С минимальными изменениями мы можем сделать вызов метода гибким и динамичным.package com.journaldev.inheritance; public class ClassC{ SuperClass obj = null; public ClassC(SuperClass o){ this.obj = o; } public void test(){ obj.doSomething(); } public static void main(String args[]){ ClassC obj1 = new ClassC(new ClassA()); ClassC obj2 = new ClassC(new ClassB()); obj1.test(); obj2.test(); } }Результат программы представленной выше:
doSomething implementation of A doSomething implementation of BЭта гибкость при вызове метода не доступна при наследовании, что добавляет еще один плюс в пользу выбора композиции.
Тестирование. Модульное тестирование (unit-тесты) легче делать при композиции, потому что мы точно знаем, какие методы другого класса используем, и можем подменить их заглушками (mock) в тесте. Тогда как в наследовании мы зависим в большей степени от суперкласса и не знаем всех методов суперкласса, которые будут использоваться. Таким образом, мы должны тестировать все методы суперкласса, что является дополнительной работой из-за наследования.
В идеале мы должны использовать наследование только когда отношение подкласса к суперклассу определяется как «является» (is-a). Во всех остальных случаях, когда один объект скорее «содержит» другой (has-a), рекомендуется использовать композицию.
Наследование и композиция: сводная таблица
Соберем доводы выше в одну таблицу:| Критерий | Наследование | Композиция |
|---|---|---|
| Отношение между классами | «является» (is-a): ClassD extends ClassC |
«содержит» (has-a): поле ClassA objA внутри ClassC |
| Сколько «родителей» | ровно один класс | сколько угодно объектов разных классов |
| Изменение суперкласса | может сломать подкласс (пример с test() и разным типом результата) |
не влияет, пока не меняется вызываемый метод |
| Доступ к методам | все public-методы родителя открыты клиенту | наружу выходят только методы-обертки, которые вы написали |
| Подмена реализации | фиксируется при компиляции | любой объект через конструктор: new ClassC(new ClassB()) |
| Тестирование | зависимость от всего суперкласса | зависимости подменяются заглушками (mock) |
Заключение
Понимание наследования необходимо любому эффективному Java-разработчику. Также не менее важно знать ограничения наследования и встроенный обходной путь Java для традиционных проблем с множественным наследованием. Изучение того, как создавать интерфейсы для воссоздания эффектов множественного наследования в Java, повысит вашу эффективность и возможности найма.Вопросы и ответы
Почему в Java нет множественного наследования классов?
Потому что класс может расширять только один класс: по JLS 8.1.4 послеextends стоит ровно один тип. Причин две. Первая: ромбовидная проблема, когда два родителя содержат метод с одной сигнатурой и компилятор не знает, какой вызывать. Вторая, которую называет руководство Oracle: множественное наследование состояния, то есть полей сразу от нескольких классов с неясным порядком инициализации. Вместо этого Java дает множественное наследование типа через интерфейсы и композицию.
Что такое ромбовидная проблема (diamond problem)?
Ситуация, когда класс наследует от двух классов, у которых есть общий предок или просто одинаковый метод. На диаграмме получается ромб:SuperClass сверху, ClassA и ClassB по бокам, ClassC внизу. Если ClassC вызывает doSomething(), непонятно, чью реализацию брать: ClassA или ClassB. В Java код class ClassC extends ClassA, ClassB не компилируется.
Может ли класс реализовать несколько интерфейсов, а интерфейс расширять несколько интерфейсов?
Да и да.class InterfacesImpl implements InterfaceA, InterfaceB и interface InterfaceC extends InterfaceA, InterfaceB компилируются, даже если в интерфейсах объявлен один и тот же абстрактный метод: реализация все равно одна, в классе. Это и есть множественное наследование типа: объект InterfacesImpl можно присвоить переменной любого из трех интерфейсов.
Что будет, если два интерфейса содержат default-метод с одинаковой сигнатурой?
Класс, который реализует оба интерфейса, обязан переопределить этот метод, иначе ошибка компиляции (JLS 8.4.8.4). В переопределении можно явно вызвать нужную реализацию:InterfaceA.super.doSomething(). Так с Java 8 в интерфейсах появилось множественное наследование поведения, но выбор всегда остается за классом.
Чем композиция отличается от наследования?
Наследование это отношение «является» (is-a):ClassD extends ClassC и получает все его public-методы автоматически. Композиция это отношение «содержит» (has-a): ClassC хранит поле objA типа ClassA и сам решает, какие вызовы делегировать. Композиция не ломается при изменении чужого класса, скрывает лишние методы, позволяет подменить объект через конструктор и проще тестируется.