swift
February 3, 2021

Композиция > наследование

Диаграмма наглядно показывает, как можно удобно задизайнить гибкое поведение объекта animal с поведением fly и sound с помощью композиции

У меня давно назревала подобная статья, еще примерно с момента осознания того, почему синглтон это плохо (может когда-нибудь будет статья и на эту тему).

Для умников в комментах: глобальный мутабельный стейт – это плохо :)

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

Полиморфизм в Swift

По сути, обе концепции (и композиция и наследование) позволяют достичь полиморфизма. Но для начала, давайте вспомним что такое полиморфизм. Существует множество различных видов полиморфизмов, что прекрасно описано, например, на википедии. Я же сосредоточусь скорее на том, что будет являться наиболее важным в контексте Swift.

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

Рассмотрим на примере:

пример с наследованием

Логика следующая. Допустим есть:

  • Player - который Solid, Movable и Visible
  • Cloud - который Movable и Visible, но не Solid
  • Building - который Solid и Visible, но не Movable
  • Trap - который Solid, но не Visible и не Movable

И тут мы действительно попадаем в ловушку. В каком-нибудь с++, например, не запрещено наследоваться от множества классов. В Swift множественное наследование классов запрещено (что в целом достаточно логично, если вспомнить о том, что класс – это reference type).

ловушка Inheritance

Подобное множественное наследование довольно опасно использовать без должного понимания сути проблемы, и обычно оно приводит к тому, что в англоязычном мире зовут the diamond problem. Кроме того, даже с идеальным пониманием механизмов наследования, данный код получится, откровенно говоря, довольно дерьмовым. Как же быть? В Swift данная проблема решается с помощью механизма протоколов и расширений.

Рассмотрим на примере Visible:

protocol-oriented programming

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

Логика здесь четко разнесена, есть Player, который Visible, Solid и Movable. Всё это интерфейсы, протокол содержит только описательную часть, а сама реализация протокола подвешивается в extension. И в таком случае достаточно написать extension к Visible, содержащий данную конкретную реализацию метода draw. В случае, когда нам понадобится логика Invisible, мы просто добавим этот протокол к конечной структуре/классу. Все просто!

В случае с Movable, например, всё делается абсолютно аналогично:

extension Movable содержит в себе всю необходимую реализацию поведения Movable
Далее создаем Building и добавляем в него соответствие протоколу Irremovable. И все. Никаких тебе the diamond problem.

В с++ данная проблема решается немного иначе, с помощью virtual inheritance. По сути, если не вдаваться в детали, аналогом протоколов там являются pure virtual functions. Хотя протокол это конечно скорее элемент интерфейса, и там ничего виртуального нет.

Player в данном случае будет выглядеть так:

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

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

Выводы

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

С помощью композиции легко изменить поведение на лету с помощью dependency injection. В свою очередь, наследование это более "жесткий" подход, поскольку большинство (нормальных) языков не позволяют наследовать более одного типа.

Кстати, принцип "композиция вместо наследования" (или принцип "составного повторного использования") в ООП – это принцип, согласно которому классы должны достигать полиморфного поведения и повторного использования кода посредством их композиции (путем включения экземпляров других классов, реализующих желаемую функциональность), а не путём наследования от базового класса. Это часто упоминаемый принцип ООП, например, в книге Design Patterns (1994).

Кстати а вообще причем тут синглтон? Вместо синглтона лучше использовать dependency injection. Используя композицию вместо наследования, гораздо проще менять поведение "на лету", используя например всё тот же dependency injection.

Полезные ссылки:

Inheritance vs Composition (Swift)

Composition over Inheritance (с примерами на с++)


Статья подготовлена для канала Hello World.