Некоторое время назад я написал несколько постов о наследовании, интерфейсах и композиции в Java. В этой статье мы рассмотрим множественное наследование, а затем узнаем о преимуществах композиции перед наследованием.
Множественное наследование в Java. Сравнение композиции и наследования - 1

Множественное наследование в Java

Множественное наследование – способность создавать классы с множеством классов-родителей. В отличии от других популярных объектно-ориентированных языков, вроде С++, язык Java не поддерживает множественное наследование классов. Не поддерживает он его из-за вероятности столкнуться с «проблемой алмаза» и вместо этого предпочитает обеспечивать некий комплексный подход для его решения, используя лучшие варианты из тех, которыми мы можем достичь аналогичный результат наследования.

«Проблема Алмаза»

Для более простого понимания проблемы алмаза допустим, что множественное наследование поддерживается в Java. В этом случае, мы можем получить классы с иерархией показанной на рисунке ниже. иерархия классов "алмаз"Предположим ,что SuperClass — это абстрактный класс, описывающий некоторый метод, а классы ClassA и ClassB — реальные классы. SuperClass.java
package com.journaldev.inheritance;
public abstract class SuperClass {
   	public abstract void doSomething();
}
ClassA.java
package com.journaldev.inheritance;
public class ClassA extends SuperClass{
    @Override
 public void doSomething(){
        System.out.println("Какая-то реализация класса A");
    }
  //собственный метод класса  ClassA
    public void methodA(){
    }
}
Теперь, давайте предположим, что класс ClassC наследуется от ClassA и ClassB одновременно, и при этом имеет следующую реализацию:
package com.journaldev.inheritance;
public class ClassC extends ClassA, ClassB{
    public void test(){
        //вызов метода родительского класса
        doSomething();
    }
}
Заметьте, что метод test() вызывает метод doSomething() родительского класса, что приведет к неопределенности, так как компилятор не знает о том, метод какого именно суперкласса должен быть вызван. Благодаря очертаниям диаграммы наследования классов в этой ситуации, напоминающим очертания граненого алмаза проблема получила название «проблема Алмаза». Это и есть основная причина, почему в Java нет поддержки множественного наследования классов. Отметим, что указанная проблема с множественным наследованием классов также может возникнуть с тремя классами, имеющими как минимум один общий метод.

Множественное наследование и интерфейсы

Вы могли обратить внимание, что я всегда говорю: «множественное наследование не поддерживается между классами», но оно поддерживается между интерфейсами. Ниже показан простой пример: InterfaceA.java
package com.journaldev.inheritance;
public interface InterfaceA {

    public void doSomething();
}
InterfaceB.java
package com.journaldev.inheritance;

public interface InterfaceB {

    public void doSomething();
}
Заметьте, что оба интерфейса, имеют метод с одинаковым названием. Теперь, допустим, у нас есть интерфейс, унаследованный от обоих интерфейсов. InterfaceC.java
package com.journaldev.inheritance;

public interface InterfaceC extends InterfaceA, InterfaceB {

    //метод, с тем же названием описан в  InterfaceA и InterfaceB
    public void doSomething();
Здесь, все идеально, поскольку интерфейсы – это только резервирование/описание метода, а реализация самого метода будет в конкретном классе, реализующем эти интерфейсы, таким образом нет никакой возможности столкнуться с неопределенностью при множественном наследовании интерфейсов. Вот почему, классы в Java могут наследоваться от нескольких интерфейсов. Покажем на примере ниже. InterfacesImpl.java
package com.journaldev.inheritance;

public class InterfacesImpl implements InterfaceA, InterfaceB, InterfaceC {

    @Override
    public void doSomething() {
        System.out.println("doSomething реализация реального класса ");
    }

    public static void main(String[] args) {
        InterfaceA objA = new InterfacesImpl();
        InterfaceB objB = new InterfacesImpl();
        InterfaceC objC = new InterfacesImpl();

        //все вызываемые ниже методы получат одинаковую реализацию конкретного класса

        objA.doSomething();
        objB.doSomething();
        objC.doSomething();
    }
}
Возможно, вы обратили внимание, что я каждый раз, когда я переопределяю метод описанный в суперклассе или в интерфейсе я использую аннотацию @Override. Это одна из трех встроенных Java-аннотаций и вам следует всегда использовать ее при переопределении методов.

Композиция как спасение

Так что же делать, если мы хотим использовать функцию methodA() класса ClassA и methodB() класса ClassB в ClassС? Решением этого может стать композиция – переписанная версия ClassC реализующая оба метода классов ClassA и ClassB, также имеющая реализацию doSomething() для одного из объектов. ClassC.java
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 является – использовать преимущества композиции перед наследованием. Мы рассмотрим некоторые аспекты в пользу такого подхода.
  1. Предположим, мы имеем следующую связку классов родитель-наследник:

    ClassC.java

    package com.journaldev.inheritance;
    
    public class ClassC{
    
    public void methodC(){
      	}
    
    }

    ClassD.java

    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, не будет скомпилирован. Вам будет рекомендовано изменить тип возвращаемого результата в классе наследнике или в суперклассе.

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

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

  2. Следующая проблема с наследованием заключается в том, что мы демонстрируем все методы родителя клиенту. И если суперкласс разработан не очень корректно и содержит дыры в «безопасности». Тогда, несмотря на то, что мы полностью позаботимся о безопасности при реализации нашего подкласса, мы все равно будем зависеть от ущербной реализации класса-родителя.

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

  3. Еще одно преимущество композиции то, что оно добавляет гибкости при вызове методов. Реализация класса ClassC, описанная выше, не оптимальна и использует раннее связывание с вызываемым методом. Минимальные изменения позволят нам сделать вызов методов гибким и обеспечить позднее связывание (связывание во время выполнения).

    ClassC.java

    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

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

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

    В идеале, наследование следует использовать только тогда, когда отношение ”is-a” справедливо для классов родителя и наследника, иначе стоит предпочесть композицию.

Oригинал статьи