Композиция vs Наследование в Java

Image via Shutterstock.

В чём отличие между абстрактным классом и интерфейсом?
В чём отличие между композицией и наследованием?

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

Немного теории

С композицией всё просто. Большой объект состоит из меньших и выполняет (делегирует) какую-то работу с их помощью. Например, автомобиль состоит из кузова, двигателя, колёс и т.д. А метод ехать() реализован что-то вроде: двигатель.работает(), колёса.крутятся() и поэтому кузов.перемещается().

С наследованием, на мой взгляд, сложнее. То есть определение «механизм языка, позволяющий описать новый класс на основе уже существующего» — это, конечно, хорошо, а когда его использовать?

Есть критерий, что композиция — это отношение has-a, тогда как наследование — is-a. Есть принцип Лисков, третья буква в абревиатуре SOLID, который утверждает, что наследуемый класс должен дополнять, а не замещать поведение базового класса. Об этом, кстати, прямо намекает ключевое слово extends в Java. Есть Джошуа Блох, который в Effective Java говорит, что наследование — это сильная связь.

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

Переходим к практике

А теперь от игры словами давайте перейдём к написанию кода.

Итак, задача. Нужно разработать модуль генерации отчётов для банка. Каждый отчёт состоит из трёх частей — заголовка (header), собственно тела отчёта (body) и колонтитула (footer). Формируют их некие методы. Формирование header и footer для всех отчётов одинаково и меняться не будет. Поэтому их код разумно переиспользовать. Body для каждого отчёта, естественно, специфично. Начинаем с двух отчётов, дальнейшие пока согласовываются с заказчиком.

Собственно, вопрос сводится к такому: есть четыре блока кода — header(), footer(), body1() и body2(). Как разложить их по классам?

И тут происходит переломный момент. Несмотря на все правильные определения вначале, многие почему-то предлагают такое решение.

class BaseReport {
  void printHeader() {
    // 100 lines of header code
  }
  void printFooter() {
    // 50 lines of footer code
  }
  abstract void printBody() {}
  
  void print() {
    printHeader();
    printBody();
    printFooter();
  }
}

сlass Report1 extends BaseReport {
  @Overrride
  void printBody() {
    // specific body of Report1
  }
}

BaseReport report1 = new Report1();
report1.print();

Аналогично для второго отчёта. Вроде как хороший вариант, применён шаблон проектирования Template Method, но есть нюанс.

Почему так делает большинство кандидатов, я не знаю. Думаю, дело в литературе, где наследование объясняется на неудачных примерах, в результате формируется убеждение, что главное в ООП — это наличие иерархии классов, а о Лисков и Блохе сразу не упоминают.

Если вы видите проблему, поздравьте себя, уровень вашего мастерства явно выше Junior, если не видите — сейчас проблема будет :-)

По этой схеме пишутся второй, третий и так далее отчёты, а, допустим, в пятом появляется уточнение — колонтитул (footer) не нужен. Окей, ломать не строить, его можно убрать.

class Report5 extends BaseReport {
  @Overrride
  void printBody() {
    // specific body of Report 5
  }

  @Overrride
  void printFooter() {
  }
}

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

Идём дальше (в следующих примерах я буду опускать body). В шестом отчёте нужно после колонтитула добавить ещё какой-то блок, там, список использованной литературы (appendix). Есть два варианта — либо по аналогии с printBody() добавить абстрактный метод printAppendix() в базовый класс, в этом классе его переопределить, а во всех предыдущих отчётах добавить его пустым, либо исхитриться так:

class Report6 extends BaseReport {
  @Override
  void printFooter() {
    super.printFooter();
    printAppendix();
  }
  
  void printAppendix() {
    // 50 lines of appendix code
  }
}

Коряво, но пусть будет. Дальше, в седьмом отчёте нужно сделать полностью другой заголовок. Тут (или даже раньше) можно, конечно, начать возмущаться. Как же так? Ведь в условиях задачи было недвусмысленно сказано, заголовок и колонтитул изменяться никогда не будут. Да, небольшой подвох. Даже не подвох, а обычная рабочая ситуация. Ну, изменились требования, бывает. Что же теперь делать?

class Report7 extends BaseReport {
  @Override
  void printHeader() {
    // 30 lines of completely another header code
  }
}

Окей, ещё один отчёт с таким же другим заголовком. Так, что ли?

class Report8 extends Report7 {
  // using printHeader() from the parent class

  @Overrride
  void printBody() {
    // specific body of Report 8
  }
}

А если заголовок как в 7-ом отчёте, а колонтитул как в 10-ом? От кого наследоваться?

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

Скопипастить и оставить 99 строк? Плохо!

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

Очевидно, наш дизайн зашёл в тупик, и виной этому, увы, неправильное применение наследования. Мы вовсю противоречим принципу Лисков и только тем и занимаемся, что переопределяем поведение.

Как исправить ситуацию

Давайте посмотрим на задачу с самого начала. От «отчёты состоят из трёх блоков, два из которых неизменны» она превратилась в «разные отчёты состоят из неких частей, какие-то из них общие или похожи, но не всегда». Требования эволюционировали (так обычно и бывает), а наследование вроде как и помогло нам избежать дублирования кода в первых двух классах, но мешало дальше, причём всё больше. Вот и проявилась сильная связность — все последующие отчёты мы буквально запихивали в «схему».

Как быть? Собственно, слово «состоит» в условии задачи уже намекает, что композиция будет более подходящим решением в этом случае.

Итак, у нас будет не базовый класс, а интерфейс.

interface Report {
  void print();
}

Далее, у нас будут, допустим, классы DefaultHeader и DefaultFooter тоже с методами print(), что наводит на мысль и его вынести в интерфейс ReportSection, а может Report и ReportSection будут одним и тем же.

class Report1 implements Report {
  DefaultHeader header;
  Body body;
  DefaultFooter footer;

  @Override
  void print() {
    header.print();
    body.print();
    footer.print();
  }
  
  // nested class
  class Body {
  }
}

И ответы на все заданные выше вопросы — отчёт без колонтитула, с списком после колонтитула, с другим заголовком, снова с другим заголовком — решаются элементарно.

А, у нас был стандартный заголовок, совсем нестандартный и стандартный, но чуть-чуть другой (без даты)? Значит нам нужна фабрика заголовков, которая по параметру будет возращать нужный.

class Report8 implements Report {
  HeaderFactory headers;
  Body body;
  DefaultFooter footer;

  @Override
  void print() {
    ReportSection header = headers.create(WITHOUT_DATE);

    header.print();
    body.print();
    footer.print();
  }
}

Главное, что композиция даёт нам полную гибкость. Очередной отчёт, какой бы странный он ни был (может заголовок и колонтитул надо местами поменять), никак не связан с предыдущими и нет риска их сломать.

Также хотелось бы обратить внимание, что код стал объекто-ориентированным header.print(), а не процедурным printHeader(), как раньше.

Резюме

Итак, нельзя сказать, что какие-то подходы правильные или неправильные, всё зависит от ситуации, но

1) если общая функциональность выносится в родительский класс;

2) появляются слова Base и abstract;

3) в наследниках переопределяются или используются методы родительского класса

... скорее всего, что-то пошло не так и проблемы не за горами.

Можно сформулировать даже проще. Наследование — один из базовых принципов ООП. Не используйте наследование! (имеется в виду наследование классов; дополнять интерфейсы можно).

Методом исключения получается, что «хорошее» наследование — это добавление новых методов, которые используют исключительно вновь добавленные поля этого класса, но никак не родительские методы.

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

Вернёмся к примеру с автомобилем. Допустим, есть класс Автомобиль с методами ездить(), сигналить() и т.д. Как бы могло выглядеть его наследование? Что значит дополнение автомобиля не с точки зрения программирования, а в обычном житейском понимании?

Предположим, мы хотим сделать Боевой Автомобиль, который умеет всё то же самое, что и обычный (отношение is-a), но кроме того на нём будет установлен пулемёт с методом стрелять().

Наследование? Выше рассмотрено, к чему это может привести, особенно, когда окажется, что есть и другие транспортные средства, и различное вооружение, и их всевозможные комбинации. Нет, Боевой Автомобиль — это всё таки композиция Автомобиля и Пулемёта с реализацией двух интерфейсов — Транспорт и Оружие.

Можно было бы обратиться к биологии с её, на первый взгляд, незыблемой иерархией классов, но даже там всякие рыбообразные дельфины и нелетающие пингвины подпортят концепцию :-)

Рекомендую также почитать статьи:
— Интерфейс vs. Классы;
— Принцип подстановки Барбары Лисков;
— Я не знаю ООП.


P.S. Для усвоения материала напомню классическую задачу про наследование.

Для геометрических фигур есть методы подсчёта периметра и площади. Рассмотрим прямоугольник со сторонами a и b, т. е. класс с двумя сеттерами. Его периметр определяется по формуле P = 2a + 2b, а площадь S = ab. Квадрат — это частный случай прямоугольника, его единственная характеристика — длина стороны a (достаточно одного сеттера), а формулы периметра и площади можно переиспользовать, полагая b = a.

Вопрос. Как, учитывая эти факты, построить иерархию классов: унаследовать Прямоугольник от Квадрата или Квадрат от Прямоугольника?

Авторский ответ на задачу

То, что квадрат является частным случаем прямоугольника никоим образом не означает, что должна быть иерархия классов и наследование. Нам достаточно интерфейса Фигура и класса Прямоугольник, который его реализует. Нет причин не сделать его иммутабельным, без сеттеров, с конструктором на два аргумента. Для частного случая — квадрата — может быть либо конструктор с одним аргументом, либо, что лучше, как рекомендует Блох, статический фабричный метод с названием.

Похожие статьи:
Меня зовут Максим, я работаю тестировщиком ПО, с интересом слежу за событиями в мире тестирования и IT. Самое полезное собираю вместе...
Голова Комітету з питань фінансів, податкової та митної політики Верховної Ради Данило Гетманцев вважає, що настав час відмовитися...
Український уряд отримав від аерокосмічної компанії SpaceX Ілона Маска вже 5000 терміналів Starlink, які забезпечують швидкісним...
У новому випуску приділили багато уваги історії дівчини, яка потрапила на роботу в Google, а ще трішки посперечалися щодо...
В рубрике DOU Labs мы приглашаем IT-компании делиться опытом собственных интересных разработок и внутренних...
Яндекс.Метрика