Композиция и наследование ООП в PHP
05-06-2019Reading time ~ 5 min.PHP/ООП 22635
Базовым принципом ООП (объектно-ориентированное программирование) является наследование. С помощью наследования можно построить сложную иерархию классов, где потомки наследуют функциональность своих родителей. В теории это всё выглядит замечательно, но на практике часто оказывается так, что наследование ухудшает код и делает архитектуру приложения негибкой и сложной.
PHP универсальный язык, где ООП занимает незначительную часть. Но в других языках, где программирование происходит исключительно на на классах, ограничения оказываются довольно существенны, поэтому были разработаны алгоритмы, которые по сути вылились в известные шаблоны проектирования. Одним из методов проектирования классов является агрегация и композиция, которые очень часто противопоставляются наследованию.
Есть даже такое выражение «Композиция лучше наследования», которое хорошо известно программистам Java или С++. Попробуем посмотреть на эти вопросы с точки зрения PHP.
Для начала немного базовых знаний. Наследование — это когда один класс расширяет другой.
class A { public function run() { echo 'run'; } } class B extends A { public function stop() { echo 'stop'; } } $b = new B; $b->run(); // run $b->stop(); // stop
Это пример типового наследования, когда класс B расширяет функционал класса A. При этом класс B получает функциональность класса A.
Важный момент здесь в том, что мы практически ничего не сделали, только с помощью «extends» указали связь, но при этом нам не нужно было инстанцировать родительский класс: PHP всю работу выполнил самостоятельно.
Теперь попробуем усложнить задачу. Пусть нам нужно вывести в браузер части страницы: head и footer. Получится такой класс:
class Print { public function go() { $this->head(); $this->footer(); } public function head() { echo 'HEAD'; } public function footer() { echo 'FOOTER'; } } $print = new Print(); $print->go(); // HEAD FOOTER
Теперь нам нужно добавить ещё одну часть, например body. Простой вариант — расширить класс.
class PrintA extends Print { public function body() { echo 'BODY'; } } $print = new PrintA(); $print->go(); // HEAD FOOTER $print->body(); // BODY
Всё просто, но что делать, если нужно body вывести между head и footer? Очевидно, что единственным способом будет переопределить и метод go()
.
class PrintA extends Print { public function go() { $this->head(); $this->body(); $this->footer(); } public function body() { echo 'BODY'; } } $print = new PrintA(); $print->go(); // HEAD BODY FOOTER
В данном примере нарушен один из базовых принципов SOLID, а именно «Принцип подстановки Барбары Лиско»: наследующий класс должен дополнять, а не изменять базовый. В нашем случае мы переопределили метод родительского класса и теперь вообще не ясно зачем он там нужен. Если мы усложним задачу, и введём еще десяток условий, то получится довольно длинная цепочка классов, которые перекрывают друг друга самым произвольным образом. Работать с таким кодом будет проблематично.
Примеры кода утрированы, чтобы упростить их понимание.
Попробуем решить задачу с другой стороны. Пусть у head, footer и body будут свои классы, а сам вывод сделаем с помощью отдельного класса.
class PrintHead { public function print() { echo 'HEAD'; } } class PrintFooter { public function print() { echo 'FOOTER'; } } class PrintBody { public function print() { echo 'BODY'; } } class Print { public function go() { $head = new PrintHead(); $body = new PrintBody(); $footer = new PrintFooter(); $head->print(); $body->print(); $footer->print(); } } $print = new Print(); $print->go(); // HEAD BODY FOOTER
Такой подход и называется композицией (или «агрегирование по значению»). Здесь тонкость в том, что класс Print сам инстанцирует все нужные классы. После того как Print будет уничтожен, будут уничтожены и все созданные им объекты.
Очевидно данный подход более гибкий, и обратите внимание, что здесь нет наследования. Конечно, в более сложных задачах, мы можем использовать наследование, где лучшим вариантом будет задействовать интерфейсы или абстрактные классы, которые позволят «унифицировать» методы.
Например в классах PrintHead, PrintBody и PrintFooter используется одноименный метод print()
. Если стоит задача добавить какой-то новый вариант, то нужно будет следовать этой же схеме. Но, представим себе, что какой-то программист решил использовать другой метод, например out()
. В этом случае при создании класса Print волей-неволей придётся учитывать эту особенность.
Чтобы избегать таких ситуаций, используют интерфейсы. С их помощью гарантируется единый совместимый тип данных.
interface PrintInterface { public function print(); } class PrintHead implements PrintInterface class PrintFooter implements PrintInterface class PrintBody implements PrintInterface
Теперь рассмотрим «чистую» агрегацию (агрегирование по ссылке). В отличие от композиции, класс не создаёт другие объекты, а получает лишь ссылку на уже готовые экземпляры. В простом примере это может выглядеть так:
class Print { public function go($h, $b, $f) { $h->print(); $b->print(); $f->print(); } } $head = new PrintHead(); $body = new PrintBody(); $footer = new PrintFooter(); $print = new Print(); $print->go($head, $body, $footer); // HEAD BODY FOOTER
После того, как объект Print будет уничтожен, остальные останутся и будут доступны для использования. При агрегации также можно перенести логику выполнения из исполняемого класса Print: скажем поменять местами head и footer в параметрах.
Композиция и агрегация являются основой для многих шаблонов проектирования. Покажу для примера порождающий шаблон «Фабричный метод» (второе название «Виртуальный конструктор»).
abstract class CommonAbstract { public static function initial($class) { return new $class(); } abstract public function run(); } class Class1 extends CommonAbstract { public function run() { echo 'Class1 run'; } } class Class2 extends CommonAbstract { public function run() { echo 'Class2 run'; } } // demo $a = CommonAbstract::initial('Class1'); $a->run(); // Class1 run $b = CommonAbstract::initial('Class2'); $b->run(); // Class2 run
Каждый класс расширяет абстрактный CommonAbstract, где используется статический метод initial()
, через который инстанцируется нужный класс. Абстрактный класс задаёт единый тип поведения (это наследование), но при этом создаётся новый нужный объект (композиция). Такая архитектура позволяет без труда добавить новый класс без переделки остальных.
В других шаблонах проектирования используются другие алгоритмы, но все они также базируются на основных принципах ООП: наследование, композиция и агрегация.
В данном примере нарушен один из базовых принципов SOLID, а именно «Принцип подстановки Барбары Лиско»: наследующий класс должен дополнять, а не изменять базовый.
Может быть принцип открытости/закрытости?
Нет, здесь именно нарушен принцип Лискова.
А чем это плохо, что нарушен?
Если в классе PrintA вы заменяете методы из родительского класса Print, то зачем вообще они в Print созданы?