Перейти к содержанию

Наследование и композиция (отношение «является» против «имеет») в Java

Наследование и композиция – наряду с абстракцией, инкапсуляцией и полиморфизмом – являются краеугольными камнями объектно-ориентированного программирования (ООП).

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

Основы наследования

Наследование – это мощный, но чрезмерно и неправильно используемый механизм.

Проще говоря, при наследовании базовый класс (также известный как базовый тип) определяет состояние и поведение, общие для данного типа, и позволяет подклассам (также известным как подтипы) предоставлять специализированные версии этого состояния и поведения.

Чтобы иметь четкое представление о том, как работать с наследованием, создадим простой пример: базовый класс Person, который определяет общие поля и методы для человека, а подклассы Waitress и Actress предоставляют дополнительные, детализированные реализации методов.

Вот класс Person:

public class Person {
    private final String name;

    // другие поля, стандартные конструкторы, геттеры
}

А это подклассы:

public class Waitress extends Person {

    public String serveStarter(String starter) {
        return "Serving a " + starter;
    }
    
    // дополнительные методы/конструкторы
}
public class Actress extends Person {
    
    public String readScript(String movie) {
        return "Reading the script of " + movie;
    } 
    
    // дополнительные методы/конструкторы
}

Кроме того, создадим модульный тест, чтобы убедиться, что экземпляры классов Waitress и Actress также являются экземплярами класса Person, тем самым показав, что условие «является» выполняется на уровне типа:

@Test
public void givenWaitressInstance_whenCheckedType_thenIsInstanceOfPerson() {
    assertThat(new Waitress("Мария", "mary@domain.com", 22))
      .isInstanceOf(Person.class);
}
    
@Test
public void givenActressInstance_whenCheckedType_thenIsInstanceOfPerson() {
    assertThat(new Actress("Елена", "elena@domain.com", 30))
      .isInstanceOf(Person.class);
}

Здесь важно подчеркнуть семантический аспект наследования. Помимо повторного использования реализации класса Person, мы создали четко определенное отношение «является» между базовым типом Person и подтипами Waitress и Actress. Официантки и актрисы, по сути, личности.

Это может заставить нас задаться вопросом: в каких случаях наследование является правильным подходом?

Если подтипы удовлетворяют условию «является» и в основном предоставляют дополнительную функциональность ниже по иерархии классов, то наследование – это правильный путь.

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

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

Наследование в шаблонах проектирования

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

Шаблон слоя супертипа

В этом случае используем наследование для переноса общего кода в базовый класс (супертип) для каждого уровня.

Вот базовая реализация этого шаблона на уровне предметной области:

public class Entity {
    
    protected long id;
    
    // сеттеры
}
public class User extends Entity {
    
    // дополнительные поля и методы   
}

Можно применить тот же подход к другим уровням в системе, таким как service и persistence.

Шаблонный метод

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

public abstract class ComputerBuilder {
    
    public final Computer buildComputer() {
        addProcessor();
        addMemory();
    }
    
    public abstract void addProcessor();
    
    public abstract void addMemory();
}
public class StandardComputerBuilder extends ComputerBuilder {

    @Override
    public void addProcessor() {
        // реализация метода
    }
    
    @Override
    public void addMemory() {
        // реализация метода
    }
}

Основы композиции

Композиция – это еще один механизм, предоставляемый ООП для повторного использования реализации.

В двух словах, композиция позволяет моделировать объекты, состоящие из других объектов, тем самым определяя отношение «имеет» между ними.

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

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

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

public class Computer {

    private Processor processor;
    private Memory memory;
    private SoundCard soundCard;

    // стандартные геттеры/сеттеры/конструкторы
    
    public Optional<SoundCard> getSoundCard() {
        return Optional.ofNullable(soundCard);
    }
}

Следующие классы моделируют микропроцессор, память и звуковую карту (интерфейсы для краткости опущены):

public class StandardProcessor implements Processor {

    private String model;
    
    // стандартные геттеры/сеттеры
}
public class StandardMemory implements Memory {
    
    private String brand;
    private String size;
    
    // стандартные конструкторы, геттеры, toString
}
public class StandardSoundCard implements SoundCard {
    
    private String brand;

    // стандартные конструкторы, геттеры, toString
}

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

В приведенном выше примере компьютер удовлетворяет условию «имеет» с классами, которые моделируют его части.

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

Композиция без абстракции

В качестве альтернативы можно определить отношение композиции, жестко запрограммировав зависимости класса Computer вместо объявления их в конструкторе:

public class Computer {

    private StandardProcessor processor
      = new StandardProcessor("Intel I3");
    private StandardMemory memory
      = new StandardMemory("Kingston", "1TB");
    
    // дополнительные поля/методы
}

Конечно, это была бы жесткая, тесно связанная конструкция, поскольку Computer сильно зависел бы от конкретных реализаций Processor и Memory.

Мы бы не воспользовались уровнем абстракции, обеспечиваемым интерфейсами и внедрением зависимостей.

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

Заключение

В этой статье мы изучили основы наследования и композиции в Java и различия между двумя типами отношений («является» и «имеет»).

Все примеры кода, показанные в этом руководстве, доступны на GitHub.

Оригинал