Организация множественного наследования в Java

В отличие от своего объектно-ориентированного предшественника — языка С++, в языке Java не поддерживается множественное наследование (МН). Но рано или поздно любой программист, использующий парадигму объектно-ориентированного программирования (ООП), сталкивается с необходимостью опираться на методы от разных родительских классов. А опыт применения МН в С++ показал, что это приводит к большему количеству проблем, чем ожидалось. Поэтому в рамках построения парадигмы ООП создатели JAVA подошли с других позиций. Так как же этот вопрос был решён в JAVA?

Для начала рассмотрим как исторически развивался вопрос о проблемах наследования на примере С++.

Множественное наследование позволяет одному дочернему классу иметь несколько родителей. Предположим, что мы хотим написать программу для отслеживания работы учителей. Учитель — это Human. Тем не менее, он также является Сотрудником (Employee).

https://ravesli.com/wp-content/uploads/2018/09/diagram-lesson-161.jpg

Множественное наследование может быть использовано для создания класса Teacher, который будет наследовать свойства как Human, так и Employee. Для использования множественного наследования нужно просто указать через запятую тип наследования и второй родительский класс:

#include <string>
 
class Human {
private:
    std::string m_name;
    int m_age;
 
public:
    Human(std::string name, int age)
        : m_name(name), m_age(age)
    {
    }
 
    std::string getName() { return m_name; }
    int getAge() { return m_age; }
};
 
class Employee {
private:
    std::string m_employer;
    double m_wage;
 
public:
    Employee(std::string employer, double wage)
        : m_employer(employer), m_wage(wage)
    {
    }
 
    std::string getEmployer() { return m_employer; }
    double getWage() { return m_wage; }
};
 
// Класс Teacher открыто наследует свойства классов Human и Employee
class Teacher: public Human, public Employee {
private:
     int m_teachesGrade;
 
public:
    Teacher(std::string name, int age, std::string employer, double wage, int teachesGrade)
        : Human(name, age), Employee(employer, wage), m_teachesGrade(teachesGrade) {
    }
};

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

Во-первых, может возникнуть неоднозначность, когда несколько родительских классов имеют метод с одним и тем же именем. Например:

#include <iostream>
 
class USBDevice {
private:
    long m_id;
 
public:
    USBDevice(long id)
        : m_id(id)
    {
    }
 
    long getID() { return m_id; }
};
 
class NetworkDevice {
private:
    long m_id;
 
public:
    NetworkDevice(long id) : m_id(id) {
    }
 
    long getID() { return m_id; }
};
 
class WirelessAdapter: public USBDevice, public NetworkDevice {
public:
    WirelessAdapter(long usbId, long networkId) : USBDevice(usbId), NetworkDevice(networkId) {
    }
};
 
int main() {
    WirelessAdapter c54G(6334, 292651);
    std::cout << c54G.getID();
    // какую версию getID() здесь следует вызывать?
 
    return 0;
}

При компиляции c54G.getID() компилятор смотрит, есть ли у WirelessAdapter метод getID(). Этого метода у него нет, поэтому компилятор двигается по цепочке наследования вверх и смотрит есть ли этот метод в каком-либо из родительских классов. И здесь возникает проблема — getID() есть как у USBDevice, так и у NetworkDevice. Следовательно, вызов этого метода приведёт к неоднозначности и мы получим ошибку, так как компилятор не будет знать какую версию getID() вызывать.

Тем не менее, есть способ обойти эту проблему. Мы можем явно указать, какую версию getID() следует вызывать:

int main() {
    WirelessAdapter c54G(6334, 292651);
    std::cout << c54G.USBDevice::getID();
 
    return 0;
}

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

Во-вторых, более серьёзной проблемой является «алмаз смерти» (или ещё «алмаз обреченности», или «ромб»). Это ситуация, когда один класс имеет 2 родительских класса, каждый из которых, в свою очередь, наследует свойства одного и того же родительского класса. Иллюстративно мы получаем форму алмаза.

Например, рассмотрим следующие классы:

class PoweredDevice {
};
 
class Scanner: public PoweredDevice {
};
 
class Printer: public PoweredDevice {
};
 
class Copier: public Scanner, public Printer {
};

Сканеры и принтеры — это устройства, которые получают питание от розетки, поэтому они наследуют свойства PoweredDevice. Однако копировальный аппарат включает в себя функции как сканеров, так и принтеров.

https://ravesli.com/wp-content/uploads/2018/09/diamond-of-death-cpp.jpg

В этом контексте возникает много проблем, включая неоднозначность при вызове методов и копирование данных PoweredDevice в класс Copier дважды. Хотя большинство из этих проблем можно решить с помощью явного указания, поддержка и обслуживание такого кода может привести к непредсказуемым временным затратам.

Так стоит ли использовать множественное наследование?

Большинство задач, решаемых с помощью множественного наследования, могут быть решены и с использованием одиночного наследования. Многие объектно-ориентированные языки программирования (например: Smalltalk, PHP) даже не поддерживают множественное наследование. Многие, относительно современные языки, такие как Java и C#, ограничивают классы одиночным наследованием, но допускают множественное наследование интерфейсов (об этом поговорим позже). Суть идеи, запрещающей множественное наследование в этих языках, заключается в том, что это лишняя сложность, которая порождает больше проблем, чем удобств.

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

Правило С++: Используйте множественное наследование только в крайних случаях, когда задачу нельзя решить одиночным наследованием, либо другим альтернативным способом (без изобретения «велосипедов»).

Однако эту проблему можно частично решить с помощью интерфейсов.

Другими словами, для каждого класса в Java может существовать только один родительский класс. Тем не менее в каждом классе можно реализовать произвольное количество интерфейсов.

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

Как видите, с помощью интерфейсов создаются новые типы объектов без их реализации.

Как известно, в абстрактном классе допускается реализация некоторых методов, не объявленных абстрактными. В отличие от них, интерфейсы — это чистой воды шаблоны. С помощью интерфейса можно только определить функциональность, но не реализовать ее (исключения составляют дефолтные методы, которые появились в Java 8).

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

interface Printable {
    void print();
}

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

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

Данный интерфейс называется Printable. Интерфейс может определять константы и методы, которые могут иметь, а могут и не иметь реализации. Методы без реализации похожи на абстрактные методы абстрактных классов. Так, в данном случае объявлен один метод, который не имеет реализации.

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

public class Program{
      
    public static void main(String[] args) {
             
        Book b1 = new Book("Java. Complete Referense.", "H. Shildt");
        b1.print();
    }
}
interface Printable{
 
    void print();
}
class Book implements Printable{
  
    String name;
    String author;
  
    Book(String name, String author){
          
        this.name = name;
        this.author = author;
    }
      
    public void print() {
      
        System.out.printf("%s (%s) \n", name, author);
    }
}

В данном случае класс Book реализует интерфейс Printable. При этом надо учитывать, что если класс применяет интерфейс, то он должен реализовать все методы интерфейса, как в случае выше реализован метод print. Потом в методе main мы можем объект класса Book и вызвать его метод print. Если класс не реализует какие-то методы интерфейса, то такой класс должен быть определен как абстрактный, а его неабстрактные классы-наследники затем должны будут эти методы реализовать.

В тоже время мы не можем создавать объекты интерфейсов, поэтому следующий код не будет работать:

Printable pr = new Printable();
pr.print();

Одним из преимуществ использования интерфейсов является то, что они позволяют добавить в приложение гибкости. Например, в дополнение к классу Book определим еще один класс, который будет реализовывать интерфейс Printable:

class Journal implements Printable {
 
    private String name;
  
    String getName(){
        return name;
    }
  
    Journal(String name){
          
        this.name = name;
    }
    public void print() {
        System.out.println(name);
    }  
}

Класс Book и класс Journal связаны тем, что они реализуют интерфейс Printable. Поэтому мы динамически в программе можем создавать объекты Printable как экземпляры обоих классов:

public class Program {
      
    public static void main(String[] args) {
             
        Printable printable = new Book("Java. Complete Reference", "H. Shildt");
        printable.print();      //  Java. Complete Reference (H. Shildt)
        printable = new Journal("Foreign Policy");
        printable.print();      // Foreign Policy
    }
}
interface Printable {
 
    void print();
}
class Book implements Printable{
  
    String name;
    String author;
  
    Book(String name, String author) {
          
        this.name = name;
        this.author = author;
    }
      
    public void print() {
      
        System.out.printf("%s (%s) \n", name, author);
    }
}
class Journal implements Printable {
 
    private String name;
  
    String getName(){
        return name;
    }
  
    Journal(String name){
          
        this.name = name;
    }
    public void print() {
        System.out.println(name);
    }  
}

Интерфейсы в преобразованиях типов

Интерфейс — это ссылочный тип. Например, так как класс Journal реализует интерфейс Printable, то переменная типа Printable может хранить ссылку на объект типа Journal:

Printable p =new Journal("Foreign Affairs");
p.print();  
// Интерфейс не имеет метода getName, необходимо явное приведение
String name = ((Journal)p).getName();
System.out.println(name);

И если мы хотим обратиться к методам класса Journal, которые определены не в интерфейсе Printable, а в самом классе Journal, то нам надо явным образом выполнить преобразование типов:

((Journal)p).getName();

Методы по умолчанию

Ранее до JDK 8 при реализации интерфейса мы должны были обязательно реализовать все его методы в классе. А сам интерфейс мог содержать только определения методов без конкретной реализации. В JDK 8 была добавлена такая функциональность как методы по умолчанию. И теперь интерфейсы кроме определения методов могут иметь их реализацию по умолчанию, которая используется, если класс, реализующий данный интерфейс, не реализует метод. Например, создадим метод по умолчанию в интерфейсе Printable:

interface Printable {
    default void print() {
        System.out.println("Undefined printable");
    }
}

Метод по умолчанию — это обычный метод без модификаторов, который помечается ключевым словом default. Затем в классе Journal нам необязательно этот метод реализовывать, хотя мы можем его и переопределить:

class Journal implements Printable {
    private String name;
  
    String getName() {
        return name;
    }

    Journal(String name) {
        this.name = name;
    }
}

Статические методы

Начиная с JDK 8 в интерфейсах доступны статические методы — они аналогичны методам класса:

interface Printable {
    void print();
     
    static void read() {
        System.out.println("Read printable");
    }
}

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

public static void main(String[] args) {
    Printable.read();
}

Приватные методы

По умолчанию все методы в интерфейсе фактически имеют модификатор public. Однако начиная с Java 9 мы также можем определять в интерфейсе методы с модификатором private. Они могут быть статическими и нестатическими, но они не могут иметь реализации по умолчанию.

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

public class Program {
    public static void main(String[] args) {
        Calculable c = new Calculation();
        System.out.println(c.sum(1, 2));
        System.out.println(c.sum(1, 2, 4));
    }
}

class Calculation implements Calculable{
}

interface Calculable {
    default int sum(int a, int b) {
        return sumAll(a, b);
    }

    default int sum(int a, int b, int c) {
        return sumAll(a, b, c);
    }
     
    private int sumAll(int... values) {
         int result = 0;
         for(int n : values){
             result += n;
         }
         return result;
    }
}

Константы в интерфейсах

Кроме методов в интерфейсах могут быть определены статические константы:

interface Stateable{
    int OPEN = 1;
    int CLOSED = 0;
     
    void printState(int n);
}

Хотя такие константы также не имеют модификаторов, но по умолчанию они имеют модификатор доступа public static final, и поэтому их значение доступно из любого места программы.

Применение констант:

public class Program {
    public static void main(String[] args) {
        WaterPipe pipe = new WaterPipe();
        pipe.printState(1);
    }
}

class WaterPipe implements Stateable {
    public void printState(int n) {
        if(n==OPEN)
            System.out.println("Water is opened");
        else if(n==CLOSED)
            System.out.println("Water is closed");
        else
            System.out.println("State is invalid");
    }
}

interface Stateable {
    int OPEN = 1;
    int CLOSED = 0;
     
    void printState(int n);
}

Множественная реализация интерфейсов

Если нам надо применить в классе несколько интерфейсов, то они все перечисляются через запятую после слова implements:

interface Printable {
    // методы интерфейса
}
 
interface Searchable {
    // методы интерфейса
}
 
class Book implements Printable, Searchable{
    // реализация класса
}

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

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

interface BookPrintable extends Printable {
    void paint();
}

При применении этого интерфейса класс Book должен будет реализовать как методы интерфейса BookPrintable, так и методы базового интерфейса Printable.

Вложенные интерфейсы

Как и классы, интерфейсы могут быть вложенными, то есть могут быть определены в классах или других интерфейсах. Например:

class Printer{
    interface Printable {
        void print();
    }
}

При применении такого интерфейса нам надо указывать его полное имя вместе с именем класса:

public class Journal implements Printer.Printable {
 
    String name;
  
    Journal(String name) {
        this.name = name;
    }
    public void print() {
        System.out.println(name);
    }  
}

Использование интерфейса будет аналогично предыдущим случаям:

Printer.Printable p =new Journal("Foreign Affairs");
p.print();

Интерфейсы как параметры и результаты методов

И также как и в случае с классами, интерфейсы могут использоваться в качестве типа параметров метода или в качестве возвращаемого типа:

public class Program {
    public static void main(String[] args) {
        Printable printable = createPrintable("Foreign Affairs",false);
        printable.print();
              
        read(new Book("Java for impatiens", "Cay Horstmann"));
        read(new Journal("Java Daily News"));
    }
          
    static void read(Printable p) {
        p.print();
    }
          
    static Printable createPrintable(String name, boolean option) {
        if(option)
            return new Book(name, "Undefined");
        else
            return new Journal(name);
    }
}

interface Printable{
    void print();
}

class Book implements Printable {
    String name;
    String author;
  
    Book(String name, String author) {
        this.name = name;
        this.author = author;
    }
      
    public void print() {
        System.out.printf("%s (%s) \n", name, author);
    }
}

class Journal implements Printable {
    private String name;
  
    String getName(){
        return name;
    }
  
    Journal(String name) {
        this.name = name;
    }

    public void print() {
        System.out.println(name);
    }  
}

Метод read() в качестве параметра принимает любой объект, реализующий интерфейс Printable, поэтому в этот метод мы можем передать как объект Book, так и объект Journal.

Метод createPrintable() возвращает объект, реализующий Printable, поэтому также мы можем возвратить как объект Book, так и Journal.

Консольный вывод:

Foreign Affairs
Java for inpatients (Cay Horstmann)
Java Daily News

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

https://javadevblog.com/wp-content/uploads/2015/05/multiinh.png

Давайте создадим абстрактный суперкласс SuperClass с методом doSomething(), а также два класса ClassA, ClassB

public abstract class SuperClass {
    public abstract void doSomething();
}

public class ClassA extends SuperClass{
    @Override
    public void doSomething(){
        System.out.println("doSomething реализуется в классе A");
    }
     
    //Собственный метод класса ClassA
    public void methodA(){
    }
}

public class ClassB extends SuperClass{
    @Override
    public void doSomething(){
        System.out.println("doSomething реализуется классом B");
    }
     
    //Свой метод класса ClassB
    public void methodB(){
    }
}

А теперь давайте создадим класс ClassC, который наследует классы ClassA и ClassB

public class ClassC extends ClassA, ClassB {
    public void test() {
        //вызываем метод суперкласса
        doSomething();
    }
}

Обратите внимание, что метод test() вызывает метод суперкласса doSomething()

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

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

МН не поддерживается в классах, но оно поддерживается в интерфейсах и единый интерфейс может наследовать несколько интерфейсов, ниже простой пример.

public interface InterfaceA {
    public void doSomething();
}



public interface InterfaceB {
    public void doSomething();
}

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

public interface InterfaceC extends InterfaceA, InterfaceB {
    //один и тот же метод объявлен в интерфейсах InterfaceA и InterfaceB
    public void doSomething();
}

И это отличный выход, потому что интерфейсы только объявляют методы, а фактическая реализация будет сделана в конкретных классах, которые реализуют интерфейсы, так что нет никакой возможности двусмысленно трактовать множественное наследование в интерфейсе. Все усложняется, если некий класс реализует более одного (скажем, два) интерфейса, а они реализуют один и тот же самый метод по умолчанию. Какой из методов унаследует класс? Ответ — никакой. В таком случае класс должен реализовать метод самостоятельно (напрямую, либо унаследовав его от другого класса).

Теперь давайте посмотрим на код ниже:

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();
    }
 
}

Используем Композицию (Composition)?

Так что же делать, если мы хотим использовать метод methodA() класса ClassA и метод methodB() класса ClassB  в ClassC? 

Решение заключается в использовании композиции. Ниже представлена версия класса ClassC с использованием композиции:

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 гласит «Используйте композицию чаще наследования». Давайте рассмотрим этот подход:

Предположим, у нас есть суперкласс и подкласс:

public class ClassC{
    public void methodC(){
    }
}

public class ClassD extends ClassC{
    public int test(){
        return 0;
    }
}

Код выше компилируется и работает нормально, но что будет, если реализация класса ClassC изменяется, как показано ниже:

public class ClassC{
 
    public void methodC(){
    }
 
    public void test(){
    }
}

Обратите внимание, что метод test() уже существует в подклассе, но тип возвращаемого отличается, теперь ClassD не будет компилироваться, и если вы используете какую-то IDE, то вам будет предложено изменить тип возвращаемого значения на тип суперкласса или подкласса.

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

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

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


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

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

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 реализуется классом A
doSomething реализуется классом B

Эта гибкость в вызове методов не доступна в наследовании.

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

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

Константин Кишкин, после окончания базового курса, июль 2020

Использованные источники: