Вы наверняка слышали это знаменитое высказывание от GoF: «Предпочитайте композицию наследованию класса». И дальше, как правило, шли длинные размышления на тему того, как статически определяемое наследование не настолько гибко по сравнению с динамической композицией.
Гибкость – это конечно полезная черта дизайна. Однако при выборе архитектуры нас интересуют в первую очередь сопровождаемость, тестируемость, читабельность кода, повторное использование модулей. Так вот с этими критериями хорошего дизайна у наследования тоже проблемы. «И что же теперь, не использовать наследование вообще?» – спросите Вы.
Давайте посмотрим на то, как сильная зависимость между классами через наследование может сделать архитектуру вашей системы чрезмерно жесткой и хрупкой. И зачем использовать одно из самых загадочных и неуловимых в коде ключевых слов – final
. Сформулированные идеи демонстрируются на простом сквозном примере. В конце статьи приведены приемы и инструменты для удобной работы с final
классами.
Проблема хрупкого базового класса
Одним из основных критериев хорошей архитектуры является слабое зацепление (loose coupling), которое характеризует степень взаимосвязи между программными модулями. Не зря слабое зацепление входит в перечень паттернов GRASP, описывающих базовые принципы для распределения ответственности между классами.
Слабое зацепление имеет массу преимуществ.
- Ослабив зависимости между программными модулями, вы облегчаете сопровождение и поддержку системы за счет формирования более гибкой архитектуры.
- Появляется возможность параллельной разработки слабозацепленных модулей без риска нарушить их функционирование.
- Логика работы класса становится более очевидной, легче становится использовать класс правильно и по назначению, и сложно использовать – неправильно.
Традиционно под зависимостями в системе подразумеваются прежде всего связи между используемым объектом (сервисом) и использующим объектом (клиентом). Такая связь моделирует отношение агрегации (aggregation), когда сервис «является частью» клиента (has-a relationship), а клиент передаёт ответственность за выполнение поведения вложенному в него сервису. Ключевую роль в ослаблении связей между клиентом и сервисом играет принцип инверсии зависимостей (dependency inversion principle, DIP), предлагающий преобразовать прямую зависимость между модулями в обоюдную зависимость модулей от общей абстракции.
Однако существенно улучшить архитектуру приложения можно также ослабив зависимости в рамках отношения наследования (is-a relationship). Отношение наследования по умолчанию создает сильное зацепление (tight coupling), наиболее сильное среди всех возможных форм зависимостей, а потому должно использоваться очень осторожно.
Сильное зацепление в отношении наследования
Количество кода, разделяемого между родительским и дочерним классами, очень велико. Особенно сильно эта проблема начинает проявляется при злоупотреблении концепцией наследования – использовании наследования исключительно для горизонтального повторного использования кода, а не для создания специализированных подклассов. Ведь наследование – это самый простой способ повторного использования кода. Вам достаточно просто написать extends ParentClass
и все! Ведь это гораздо проще агрегаций, внедрения зависимостей (dependency injection, DI), выделения интерфейсов.
Снижение зацепления классов в иерархии наследования традиционно достигается использованием ограничивающих модификаторов области видимости (private
, protected
). Существует даже мнение, что свойства класса должны объявляться исключительно с модификатором private
. А модификатор protected
должен применяться очень осторожно и только к методам, т.к. он поощряет возникновение зависимостей между родительским и дочерним классом.
Однако проблемы наследования не только в сокрытии свойств и методов, они гораздо глубже. Множество литературы по архитектуре приложений, в том числе и классическая книга GoF, пронизаны скептическим отношением к наследованию и предлагают смотреть в сторону более гибких конструкций. Но только ли в гибкости дело? Предлагаю ниже систематизировать проблемы наследования, а уже после это подумать о том, как их избежать.
Возьмем в качестве «подопытного кролика» простейший класс блока комментариев с массивом комментариев внутри. Подобные классы с коллекцией внутри встречаются в большом количестве в любом проекте.
class CommentBlock
{
/** @var Comment[] Массив комментариев */
private $comments = [];
}
Примеры ниже сознательно упрощены в KISS-стиле для того, чтобы показать в коде сформулированные идеи. Примеры кода из статьи и подробную аннотацию по их использованию, Вы можете найти в этом репозитории.
Проблемы наследования
Начнем с самой очевидной проблемы, которая в первую очередь приводится в литературе по архитектуре.
Наследование нарушает принцип сокрытия
Поскольку подклассу доступны детали реализации родительского класса, то часто говорят, что наследование нарушает инкапсуляцию.
GoF, Design Patterns
Хотя в классической книге «Банды четырех» речь идет о нарушении инкапсуляции, точнее будет сказать, что «наследование нарушает принцип сокрытия». Ведь инкапсуляция – это сочетание данных с методами, предназначенными для их обработки. А вот принцип сокрытия как раз обеспечивает ограничение доступа одних компонентов системы к деталям реализации других.
Следование принципу сокрытия в архитектуре позволяет обеспечить зацепления модулей через стабильный интерфейс. И если класс допускает наследование, то он автоматически предоставляет следующие виды стабильных интерфейсов:
- публичный интерфейс (public interface), используемый всеми клиентами данного класса;
- защищенный интерфейс (protected interface), используемый всеми дочерними классами.
Т.е. дочерний класс обладает гораздо большим арсеналом возможностей, чем предоставляет публичный API. Например, может влиять на внутреннее состояние родительского класса, сокрытое в его protected
свойствах.
Зачастую дочерний класс не нуждается в доступе ко всем, доступным в рамках наследования, элементам родительского класса, однако вы не можете избирательно предоставлять доступ к protected
членам классам для некоторых из подклассов. Дочерний класс начинает зависеть от protected интерфейса родительского класса.
Основной причиной наследования классов, чаще всего, является расширение функциональных возможностей родительского класса путем повторного использования его реализации. Если изначально доступ к реализации родительского класса через protected интерфейс не предвещал проблем, то по мере развития системы программист начинает пользоваться этим доступом в методах дочернего класса и усиливать зацепление в иерархии.
Родительский класс теперь вынужден поддерживать стабильность не только public интерфейса, но и protected интерфейса, так как любые изменения в нем будут приводить к проблемам в работе дочерних классов. При этом отказаться от использования protected
членов класса невозможно. Если protected интерфейс будет полностью совпадать с внешним public интерфейсом, т.е. родительский класс будет использовать только public
и private
члены, то наследование вообще теряет смысл.
Фактически, ключевое слово protected
на самом деле никакой защиты членов класса не обеспечивает. Чтобы получить доступ к таким членам, достаточно унаследоваться от класса, и в рамках дочернего класса Вы имеете все возможности по нарушению принципа сокрытия. Классом становится очень просто пользоваться неправильно, что является одним из первых признаков плохой архитектуры.
Нарушение принципа сокрытия через protected интерфейс
Что еще важнее, инкапсулированные элементы (константы, свойства, методы) становятся не просто доступными для чтения и вызова в дочернем классе, но и могут быть переопределены. Такая возможность таит в себе скрытую опасность – вследствие подобных изменений, поведение объектов дочернего класса может стать несовместимым с объектами родительского класса. В этом случае подстановка объектов дочернего класса в те точки кода, где предполагалось поведение объектов родительского класса, приведет к непредвиденным последствиям.
Для примера, дополним функциональность класса CommentBlock
:
class CommentBlock
{
/** @var Comment[] Массив комментариев */
protected $comments = [];
/** Получить комментарий по ключу в массиве `$comments` */
public function getComment(string $key): ?Comment
{
return $this->comments[$key] ?? null;
}
}
и унаследуем от него кастомизированный класс CustomCommentBlock
, в котором воспользуемся всеми возможностями по нарушению сокрытия.
class CustomCommentBlock extends CommentBlock
{
/**
* Задать массив комментариев
*
* Нарушение принципа сокрытия (information hiding)
* Метод позволяет изменять свойство `CommentBlock::$comments`,
* сокрытое в родительском классе
*/
public function setComments(array $comments): void
{
$this->comments = $comments;
}
/**
* Получить комментарий по ключу, возвращаемому методом `Comment::getKey()`
*
* Логика работы метода родительского класса изменена
*/
public function getComment(string $key): ?Comment
{
foreach ($this->comments as $comment) {
if ($comment->getKey() === $key) {
return $comment;
}
}
return null;
}
}
Частые случаи нарушений сокрытия таковы:
- методы дочернего класса раскрывают состояние родительского класса и предоставляют доступ к сокрытым членам родительского класса. Такой сценарий наверняка не предусматривался при проектировании родительского класса, а значит логика работы его методов возможно будет нарушена.
В примере, дочерний класс предоставляет метод-сеттерCustomCommentBlock::setComments()
для изменения защищенного свойстваCommentBlock::$comments
, сокрытого в родительском классе. - переопределение поведения метода родительского класса в дочернем классе. Иногда разработчики воспринимают эту возможность, как способ решения проблем родительского класса, создавая дочерние классы с измененным поведением.
В примере, методCommentBlock::getComment()
в родительском классе опирается на ключи в ассоциативном массивеCommentBlock::$comments
. А в дочернем классе – на ключи самих комментариев, доступные через методComment::getKey()
.
Проблема банан-обезьяна-джунгли
Проблема с объектно-ориентированными языками заключается в том, что они тянут за собой всё своё неявное окружение. Вы хотели всего лишь банан, но в результате получили гориллу, держащую этот банан, и все джунгли в придачу.
Joe Armstrong, создатель Erlang
Зависимости всегда присутствуют в архитектуре системы. Однако наследование несет за собой ряд осложняющих факторов.
Вы наверняка сталкивались с ситуацией, когда по мере развития программного продукта иерархии классов существенно разрастались. Например,
class Block { /* ... */ }
class CommentBlock extends Block { /* ... */ }
class PopularCommentBlock extends CommentBlock { /* ... */ }
class CachedPopularCommentBlock extends PopularCommentBlock { /* ... */ }
/* .... */
Вы наследуете и наследуете, однако не можете решить, какие члены наследовать. Вы наследуете всё и целиком, получая в наследство члены всех классов по всему дереву иерархии. В придачу вы получаете сильную зависимость от реализации родительского класса, от родительского класса родительского класса и так далее. И эти зависимости никак не могут быть ослаблены (в отличии от агрегации в комплекте с DIP).
Не говоря уже о том, что листовой класс в такой глубокой иерархии почти наверняка будет нарушать принцип единственной ответственности (single responsibility principle, SRP), знать и делать слишком много. Вы начинали разработку с простого класса Block
, затем добавили к нему функции для выборки комментариев, потом возможности для сортировки по популярности, приделали кеширование… В итоге получили класс с массой ответственностей и, к тому же, слабо связный (low cohesion)
Вы просто хотели получить банан (создать листовой объект в иерархии) и вам все равно, как он добрался до ближайшего супермаркета (как реализовано поведение, результат работы которого – этот объект). Однако с наследованием вы вынуждены нести за собой реализацию всей иерархии, начиная с самых джунглей. Вы должны держать в голове особенности джунглей и нюансы их реализации, в то время как вы хотели бы сосредоточиться на банане.
В результате состояние вашего класса оказывается размазано по множеству родительских классов. Решить эту проблему можно только ограничив воздействие внешней среды (джунглей) на ваш класс через инкапсуляцию и сокрытие. Однако с наследованием достичь этого невозможно, т.к. наследование нарушает принцип сокрытия.
Как же теперь тестировать дочерние классы где-то в глубине дерева иерархии, ведь их реализация разбросана по родительским классам? Для тестирования вам понадобятся все родительские классы, и вы никаким образом не можете их замокать, т.к. имеете зацепление не по поведению, а по реализации. Так как ваш класс не может быть легко изолирован и протестирован, вы получаете в наследство массу проблем – с сопровождаемостью, расширяемостью, повторным использованием.
Открытая рекурсия по умолчанию
Однако дочерний класс не просто зависит от protected интерфейса родителя. Он также частично разделяет с ним физическую реализацию, зависит от нее и может влиять на нее. Это не только нарушает принцип сокрытия, но и делает поведение дочернего класса особенно запутанным и непредсказуемым.
Объектно-ориентированные языки обеспечивают открытую рекурсию (open recursion) по умолчанию. В PHP открытая рекурсия реализована с помощью псевдопеременной $this
. Вызов метода через $this
в литературе называют self-call.
Self-call приводит к вызовам методов в текущем классе, либо может динамически перенаправляться вверх или вниз по иерархии наследования на основе позднего связывания (late binding). В зависимости от этого self-call подразделяют на:
- down-call – вызов метода, реализация которого переопределена в дочернем классе, ниже по иерархии.
- up-call – вызов метода, реализация которого унаследована из родительского класса, выше по иерархии. Явно сделать в PHP up-call можно через конструкцию
parent::method()
.
Частое использование down-call и up-call в реализации методов еще более тесно зацепляет классы, делает архитектуру жесткой и хрупкой.
Разберем на примере. Реализуем в родительском классе CommentBlock
метод getComments()
, возвращающий массив комментариев.
class CommentBlock
{
/* ... */
/**
* Получить массив комментариев путем их сбора через `getComment()`.
*
* Этот метод некорректно работает в дочернем классе `CustomCommentBlock`,
* т.к. логика работы `CommentBlock::getComment()` и
* `CustomCommentBlock::getComment()` отличаются.
*/
public function getComments(): array
{
$comments = [];
foreach ($this->comments as $key => $comment) {
$comments[] = $this->getComment($key);
}
return $comments;
}
}
Этот метод опирается на логику работы CommentBlock::getComment()
и перебирает комментарии по ключам ассоциативного массива $comments
. В контексте класса CustomCommentBlock
из метода CommentBlock::getComments()
будет выполнен down-call метода CustomCommentBlock::getComment()
. Однако метод CustomCommentBlock::getComment()
имеет поведение, отличающееся от ожидаемого в родительском классе. В качестве параметра этот метод ожидает свойство key
самого комментария.
В результате автоматически унаследованный из родительского класса CommentBlock::getComments()
оказался несовместимым по поведению с CustomCommentBlock::getComment()
. Вызов getComments()
в контексте CustomCommentBlock
скорее всего вернет массив значений null
.
Из-за сильного зацепления, при внесении правок в класс Вы не можете сосредоточиться только на его поведении. Вы вынуждены учитывать внутреннюю логику работы всех классов, вниз и вверх по иерархии. Перечень и порядок выполнения down-call в родительском классе должны быть известны и документированы, что существенно нарушает принцип сокрытия. Детали реализации становятся частью контракта родительского класса.
Контроль побочных эффектов
В предыдущем примере проблема проявилась из-за различия в логике работы методов getComment()
в родительском и дочернем классах. Однако контролировать сходство поведения методов в иерархии классов недостаточно. Вас могут ожидать проблемы, если эти методы обладают побочными эффектами.
Функция с побочными эффектами (function with side effects) изменяет некоторое состояние системы, помимо основного эффекта – возвращения результата в точку вызова. Примеры побочных эффектов:
- изменение переменных, внешних для метода (например, свойств объекта);
- изменение статических переменных, локальных для метода;
- взаимодействие с внешними сервисами.
Так вот эти побочные эффекты также являются той деталью реализации, которая также не может быть эффективно сокрыта в процессе наследования.
Представим, что в класс CommentBlock
потребовалось включить метод viewComment()
для получения текстового представления одного из комментариев.
class CommentBlock
{
/** @var Comment[] Массив комментариев */
protected $comments = [];
/** Получить строковое представление комментария для вывода в шаблон */
public function viewComment(string $key): string
{
return $this->comments[$key]->view();
}
}
Добавим побочный эффект к дочернему классу и конкретизируем его назначение. Реализуем класс CountingCommentBlock
, который дополняет CommentBlock
возможностью подсчета просмотров отдельных комментариев в кеше. Пусть класс принимает инъекцию PSR-16-совместимого кеша в конструкторе (constructor injection) через интерфейс CounterInterface
(который, правда, в итоге был исключен из PSR-16). Воспользуемся методом increment()
, чтобы атомарно инкрементировать значение счетчика в кеше.
class CountingCommentBlock extends CommentBlock
{
/** @var CounterInterface Кеш */
private $cache;
public function __construct(CounterInterface $cache)
{
$this->cache = $cache;
}
/** Получить строковое представление комментария с инкрементом счетчика */
public function viewComment(string $key): string
{
$this->cache->increment($key);
return parent::viewComment($key);
}
}
Все работает хорошо. Однако в какой-то момент принимается решение добавить функцию viewComments()
для формирования текстового представления всех комментариев в блоке. Этот метод добавляется в базовый класс CommentBlock
, и, с первого взгляда, наследование реализации этого метода всеми дочерними классами выглядит очень удобным и позволяет избежать написания дополнительного кода в дочерних классах.
class CommentBlock
{
/* ... */
/** Получить представление всех комментариев в блоке в виде одной строки */
public function viewComments(): string
{
$view = '';
foreach ($this->comments as $key => $comment) {
$view .= $comment->view();
}
return $view;
}
}
Однако родительский класс ничего не знает об особенностях реализации дочерних классов. Автоматически унаследованная реализация метода viewComments()
не учитывает ответственность (responsibility) класса CountingCommentBlock
– вести подсчет просмотров комментариев в кеше.
Следующий код:
$commentBlock = new CountingCommentBlock(new SomeCache());
/* ... */
$commentBlock->viewComments();
не учтет просмотр комментариев в кеше. Счетчики просмотров комментариев станут работать неверно, логика работы дочернего класса нарушена.
При любой незначительной модификации родительского класса вы должны «держать в голове» ответственности и связанные с ними побочные эффекты всех дочерних классов. В нашем случае, требуется переопределение метода viewComments()
с добавлением побочного эффекта (инкрементирования значения счетчика).
Хрупкость базового класса
Таким образом, вся иерархия классов начинает жить одной общей жизнью. Кажущиеся, с первого взгляда, безопасными изменения в реализации родительского класса могут вызвать проблемы в работе дочерних классов, которые завязаны на эту реализацию. Для этой проблемы даже был введен термин – «Хрупкий базовый класс» ("Fragile base class"). Что намекает о наличии в отношении «родительский-дочерний класс» одного из признаков проблемного дизайна – хрупкости (fragility).
Как же так получается, что малейшая правка деталей реализации родительского класса ломает дочерние классы? Посмотрим на примере. Итак, у нас есть родительский класс CommentBlock
, который хранит массив комментариев и умеет получать их строковое представление по одиночке и всех сразу.
class CommentBlock
{
/** @var Comment[] Массив комментариев */
protected $comments = [];
/** Получить строковое представление комментария для вывода в шаблон */
public function viewComment(string $key): string
{
return $this->comments[$key]->view();
}
/** Получить представление всех комментариев в блоке в виде одной строки */
public function viewComments(): string
{
$view = '';
foreach ($this->comments as $key => $comment) {
$view .= $comment->view();
}
return $view;
}
}
Дочерний класс CountingCommentBlock
переопределяет методы родительского класса и ведет учет просмотров комментариев в кеше.
class CountingCommentBlock extends CommentBlock
{
/** @var CounterInterface Кеш */
private $cache;
public function __construct(CounterInterface $cache)
{
$this->cache = $cache;
}
/** Получить строковое представление комментария с инкрементом счетчика */
public function viewComment(string $key): string
{
$this->cache->increment($key);
return parent::viewComment($key);
}
/** Получить представление всех комментариев с инкрементом счетчиков */
public function viewComments(): string
{
foreach ($this->comments as $key => $comment) {
$this->cache->increment($key);
}
return parent::viewComments();
}
}
Настало время рефакторинга и меткий взгляд программиста падает на следующую строку в методе CommentBlock::viewComments()
:
$view .= $comment->view();
Так ведь эта строка дублирует поведение, реализованное в методе viewComment()
, – получать строковое представление одного комментария. А тут еще и бизнес требует добавить дополнительную обработку строкового представления комментария. Не дублировать же код в viewComment()
и viewComments()
. Разработчик делает логичную правку одной строки, выполняя вызов CommentBlock::viewComment()
из CommentBlock::viewComments()
:
class CommentBlock
{
/* ... */
public function viewComments(): string
{
$view = '';
foreach ($this->comments as $key => $comment) {
$view .= $this->viewComment($key); // вместо `$comment->view()`
}
return $view;
}
}
Изменился только родительский класс CommentBlock
и он выглядит, в целом, изолированным от остальной системы. Разработчик прогоняет автоматизированные тесты для CommentBlock
– все работает исправно, тесты «зеленые». Программист считать эту правку корректной и закрывает задачу.
Однако хрупкая система поломалась там, где мы не ожидали. Правка существенно меняет цепочку вызовов дочернего класса CountingCommentBlock
. Следующий код:
$commentBlock = new CountingCommentBlock(new SomeCache());
/* ... */
$commentBlock->viewComments();
инициирует следующую последовательность вызовов:
CountingCommentBlock::viewComments() -> CommentBlock::viewComments() -> (n раз) CountingCommentBlock::viewComment()
В результате инкрементирование счетчика для каждого комментария в кеше будет выполнено дважды: в методах CountingCommentBlock::viewComments()
и CountingCommentBlock::viewComment()
. Т.е. счетчик просмотров стал работать неверно – один просмотр каждого комментария он считает за два. Хотя никаких правок в дочерний класс CountingCommentBlock
, который взаимодействует с кешем, не вносилось!
Дочерний класс тесно завязан на детали реализации родительского класса, которые просачиваются через protected интерфейс родительского класса. И когда эти детали изменяются, логика работы дочернего класса может быть нарушена. Родительский класс не может рассматриваться изолированно. При изменении его реализации вы должны просмотреть все дочерние классы, которые эту реализацию наследуют.
При широком использовании наследования рефакторинг существенно затрудняется. Реализация дочернего класса оказывается размазана по родительским классам, что существенно снижает читабельность кода. При этом вам нужно еще постоянно держать в голове, какие конкретно детали реализации родительского класса учтены в поведении дочерних классов. Разработчик должен постоянно совмещать реализацию по всей иерархии наследования, перемещаясь между определениями классов в коде, что получило название «Проблемы йо-йо».
Подобные проблемы практически невозможно устранить, оставаясь в рамках концепции наследования. Можно найти несколько теоретических исследований, в результате которых сформулирован ряд требований к разработчикам для исключения проблемы «Хрупкого базового класса». Эти требования предлагают существенно ограничить использование «открытой рекурсии» через $this
, ограничить совместное использование кода между классами за счет его размещения в private
методах, вести контроль побочных эффектов.
Очевидно, что в реальных проектах эти требования практически невыполнимы. Поэтому, если вы хотите ослабить зацепление между классами и за счет этого существенно уменьшить хрупкость архитектуры, необходимо сознательно ограничить некоторые возможности наследования. Для этого в арсенале PHP помимо общеизвестных модификаторов области видимости (public
, protected
, private
) имеется ключевое слово final
.
Ключевое слово final
Многие базовые конструкции языка PHP используются повсеместно и составляют основу для построения даже простейших приложений. Однако есть ряд элементов языка, которые игнорируются большинством разработчиков, т.к. делают код немного более многословным, не являются обязательными для реализации основной логики и не привносят, с первого взгляда, значительных преимуществ. Наверняка, одно из первых мест в этом списке принадлежит ключевому слову final
.
PHP 5 предоставляет ключевое слово
final
, разместив которое перед объявлениями методов класса, можно предотвратить их переопределение в дочерних классах. Если же сам класс определяется с этим ключевым словом, то он не сможет быть унаследован.Пример #1.
…
Пример #2
…Замечание: Свойства и константы не могут быть объявлены финальными, только классы и методы.
Руководство по PHP, «Ключевое слово final»
Довольно лаконичное официальное руководство по PHP в этом разделе особенно немногословно. Остается совершенно неясным для каких целей вообще стоит применять это ключевое слово и как оно может помочь вашему приложению. Многие разработчики не используют final
при определении классов и методов, не понимая необходимости сознательного ограничения возможностей наследования.
Действительно, использование final
– это явное ограничение доступных программисту возможностей языка PHP, уменьшающее доступный арсенал архитектурных конструкций и снижающее гибкость архитектуры. Однако всегда ли нужна эта гибкость, ведь многие нововведения современного PHP как раз нацелены на ее ограничение: растущее число typehints, модификаторы области видимости констант и т.д.
Ключевое слово final
, как и другие ограничивающие конструкции, следует рассматривать как один из механизмов защитного программирования. Т.е. оно позволяет сделать программные конструкции более устойчивыми к случайным злоупотреблениям со стороны клиентского кода, использующего их. Классы становится сложнее использовать неправильно.
Уже давно известно, что каждый член класса должен использовать как можно более строгий модификатор доступности, по умолчанию private
, для того чтобы скрыть детали реализации от внешнего окружения. Так почему же многие до сих пор не ограничивают наследование, подталкивая к созданию дочерних классов.
Конечно, в первую очередь, защищать код имеет смысл в том случае, если он является частью публичного API. Вы не контролируете сценарии использования вашего кода, поэтому обязаны закрыть все возможные «двери» для создания зависимостей, особенно зависимостей на реализацию. При этом вы можете открыть «дверь» в любой момент без каких-либо проблем с клиентами класса. А вот закрыть такую «дверь» уже невозможно и это наверняка вызовет нарушение в работе стороннего кода, не говоря уже об усложнении поддержки кода.
Однако и для непубличного API крайне полезно защитить код «от самого себя». Ведь сложно сказать, сможете ли вы оценить готовность класса или его метода для использования в рамках отношения наследования через некоторое время.
Далее разберем на примерах, каким образом можно ослабить зацепление в рамках отношения наследования и каким образом ограничивающий final
подталкивает нас в правильном направлении в процессе выбора оптимальной архитектуры.
Применение final для улучшения архитектуры
Паттерн «Шаблонный метод»
Причина возникновения сильного зацепления между классами в отношении наследования – повторное использование реализации. Родительский и дочерний класс разделяют большое количество кода, которое наследуется из методов с модификаторами public и protected.
Поэтому ослабить зацепление, оставаясь в рамках отношения наследования, можно лишь сократив объем наследуемого кода. И для этого в PHP (как и во многих ООП-языках) имеется механизм абстрактных классов и абстрактных методов в их составе. Абстрактный класс можно рассматривать как промежуточный вариант – между обычным классом, который делится полностью своей реализацией с дочерними классами, и интерфейсом, который лишь описывает контракт.
С поведенческой точки зрения, абстрактный класс определяет шаблон (скелет) общего алгоритма и предоставляет дочерним классам возможность конкретизировать некоторые его шаги. Такая архитектурная конструкция известна как паттерн «Шаблонный метод» (Template method).
В соответствии с этим паттерном, поведение абстрактного родительского класса разделяют на две части:
- Поведение в неабстрактных методах. Это общий код, формирующий шаблон (скелет) алгоритма. Дочерний класс наследует реализацию этих методов. Неабстрактные методы рекомендуется объявлять с модификатором
final
. Это позволяет избавиться от одной из проблем с сокрытием – исключить возможность переопределения поведения в дочерних классах. - Поведение в абстрактных методах. Конкретная реализация этого поведения выполняется дочерними классами. В теле метода размещается код, который описывают специфичную для дочернего класса реализацию некоторых шагов алгоритма. Дочерний класс наследует только интерфейс (сигнатуру) абстрактного метода.
Этот паттерн снижает силу зацепления по реализации в рамках иерархии, за счет разделения кода на abstract методы, реализованные в дочерних классах, и final методы, реализованные в абстрактном родительском классе. За счет ограничивающих ключевых слов вы, в принципе, запрещаете переопределять реализацию в процессе наследования. Пределы изменения реализации в дочерних классах четко ограничены абстрактными методами, т.к. остальные методы помечены ключевым словом final
.
Уместно провести аналогию с биологическими видами. Биологическая классификация, как и наследование в ООП, строится на основе выделения некоторых общих функций и поведения. При этом стоит заметить, что в такой классификации каждый конкретный вид животного является листовым узлом в иерархии классов. А все нелистовые узлы являются собирательными абстрактными классами. Т.е., например, не существует какой-то конкретной птицы вообще, однако есть конкретные орлы, соколы, аисты. Применительно к построению архитектуры классов эта метафора позволяет сделать следующие интересные выводы:
- все родительские классы следует объявлены как
abstract
или даже делать интерфейсами без реализации; - все конкретные классы следует помечать ключевым словом
final
и, тем самым, не допускать наследования.
Вернемся к примеру с блоками комментариев. Приведем иерархию наследования в соответствие со структурой паттерна «Шаблонный метод» и разделим поведение на abstract и final методы.
Получаем родительский абстрактный класс CommentBlock
.
abstract class CommentBlock
{
/** Массив комментариев */
protected $comments = [];
/** Получить строковое представление комментария для вывода в шаблон */
abstract public function viewComment(string $key): string;
/** Получить представление всех комментариев в блоке в виде одной строки */
final public function viewComments(): string
{
$view = '';
foreach ($this->comments as $key => $comment) {
$view .= $this->viewComment($key);
}
return $view;
}
}
Простой блок комментариев оформим в виде дочернего класса SimpleCommentBlock
:
final class SimpleCommentBlock extends CommentBlock
{
public function viewComment(string $key): string
{
return $this->comments[$key]->view();
}
}
Блок комментариев, подсчитывающий просмотры, теперь выглядит так:
final class CountingCommentBlock extends CommentBlock
{
/** Кеш */
private $cache;
public function __construct(CounterInterface $cache)
{
$this->cache = $cache;
}
/** Получить комментарий и инкрементировать счетчик в кеше */
public function viewComment(string $key): string
{
$this->cache->increment($key);
return $this->comments[$key]->view();
}
}
За счет необходимости следовать общему «шаблону», мы сокращаем количество доступных приемов для зацепления классов по реализации. Любая реализация не может быть переопределена дочерними классами за счет использования final методов и final классов.
Однако мы остаемся в рамках концепции наследования и большинство ранее описанных проблем остается актуальной. Например, проблема открытой рекурсии. По сути, вся идея паттерна «Шаблонный метод» строится на down-call, выполняемых из шаблонных методов абстрактного родительского класса, к кастомизированным методам дочерних классов. Это существенно запутывает порядок выполнения программы.
Жесткость структуры приводит к тому, что дочерние классы вынуждены реализовывать все абстрактные методы, даже если они не используются и не имеют смысла. В итоге, дочерние классы часто включают реализации методов с пустым телом.
Предпочитай реализацию интерфейса наследованию
Если продолжить двигаться в направлении снижения зацепления и вообще убрать наследование какой-либо реализации, мы приходим к абстрактному классу со всеми абстрактными методами. И в этом случае логичным становится использовать не наследование классов через extends
, а реализацию интерфейса через implements
.
Возьмем пример с блоками комментариев и исключим из него полностью разделение какого-либо кода между классами. Для этого выделим интерфейс CommentBlock
:
interface CommentBlock
{
/** Получить строковое представление комментария для вывода в шаблон */
public function viewComment(string $key): string;
/** Получить представление всех комментариев в блоке в виде одной строки */
public function viewComments(): string;
}
Реализуем интерфейс в финальном классе простого блока комментариев:
final class SimpleCommentBlock implements CommentBlock
{
/** Массив комментариев */
private $comments = [];
public function viewComment(string $key): string
{
return $this->comments[$key]->view();
}
public function viewComments(): string
{
$view = '';
foreach ($this->comments as $key => $comment) {
$view .= $this->viewComment($key);
}
return $view;
}
}
А также в финальном классе блока комментариев, подсчитывающего просмотры:
final class CountingCommentBlock implements CommentBlock
{
/** Массив комментариев */
private $comments = [];
/** Кеш */
private $cache;
public function __construct(CounterInterface $cache)
{
$this->cache = $cache;
}
/** Получить комментарий и инкрементировать счетчик в кеше */
public function viewComment(string $key): string
{
$this->cache->increment($key);
return $this->comments[$key]->view();
}
public function viewComments(): string
{
$view = '';
foreach ($this->comments as $key => $comment) {
$view .= $this->viewComment($key);
}
return $view;
}
}
Разберем преимущества и недостатки подобной чистой реализации интерфейса через implements
без какой-либо ассоциации (association) между двумя классами.
Во-первых, мы полностью убрали зацепление классов по реализации. В этом случае классы разделяют только сигнатуры методов и полностью отсутствует какое-либо унаследованное поведение. Классы объявлены как final
, а значит исключаются все связанные с наследованием реализации проблемы: хрупкость базового класса, запутанность потока выполнения при использовании открытой рекурсии и т.д.
Идем дальше. Мы явно определили интерфейс блока комментариев и скрыли все нюансы реализации за этим интерфейсом. Стоит сказать, что любой класс всегда определяет неявный интерфейс из всех его public
методов. Однако явно реализуя интерфейс через отношение implements
, мы фиксируем спецификацию (контракт) класса и впоследствии может гибко управлять этим контрактом, например, в соответствии с принципом разделения интерфейсов (ISP). Детали реализации поведения надежно скрыты за интерфейсом и теперь не являются частью контракта, что существенно повышает качество архитектуры приложения.
А что насчет принципа открытости/закрытости (OCP)? Да ведь ограничивающие конструкции final
и implements
– это готовые средства языка PHP для обеспечения закрытости класса.
После публикации интерфейса, контракт, который он описывает, закрывается от дальнейшей модификации. А значит и классы, которые реализуют интерфейс через implements
, также закрыты от модификации контракта. При этом детали реализации этого контракта остаются открытыми для изменений.
Ключевое слово final
запрещает наследование, а значит и создание дочерних классов с измененным поведением. Возможна только реализация опубликованного интерфейса. И это существенное преимущество – любое развитие архитектуры не влияет на существующий код, клиенты продолжают взаимодействовать с классом с закрытым от модификации контрактом через implements
и закрытым от модификации поведением через final
.
Классы в такой архитектуре можно сравнить со «строительными блоками», готовыми к конструированию приложения. С помощью implements
мы указываем к какому типу принадлежит блок, а с помощью final
делаем класс законченным и готовым к употреблению.
Осталось только разобраться, как же использовать эти «строительные блоки», если они ограничены в отношении наследования, и как их открыть для расширения функциональности. Также у предыдущего примера есть проблема – в классах SimpleCommentBlock
и CountingCommentBlock
имеется одинаковое поведение в методе viewComments()
и неплохо было бы его разместить в одном месте.
Предпочитай агрегацию наследованию
Понятно, что реализацию поведения метода viewComments()
необходимо разместить в одном классе, а затем использовать это поведение в другом, и, желательно, без сильного зацепления. Самым слабым типом отношений между классами является агрегация, и она может полноценно заменить наследование. Для этого агрегацию следует применить в форме паттерна декоратор (decorator pattern). И в этом случае мы сохраняем все преимущества классов, как законченных «строительных блоков», – запрет наследования через final
и зацепление через интерфейс, реализованный с помощью implements
.
Как и в предыдущем примере, введем интерфейс CommentBlock
, явно определяющий контракт реализующих его классов.
interface CommentBlock
{
/** Получить ключи комментариев в блоке */
public function getCommentKeys(): array;
/** Получить строковое представление комментария для вывода в шаблон */
public function viewComment(string $key): string;
/** Получить представление всех комментариев в блоке в виде одной строки */
public function viewComments(): string;
}
Обратите внимание, что интерфейс включает дополнительный метод getCommentKeys()
для получения ключей комментариев. Это и есть плата за использование явного контракта между взаимодействующими классами. Если в случае наследования, подобные взаимодействия между классами осуществлялись скрытно через protected интерфейс, то теперь все возможные виды доступа задокументированы явно в виде интерфейса CommentBlock
.
SimpleCommentBlock
содержит основную функциональность и является чем-то «вроде» родительского класса. Однако, в отличии от наследования, закрыт от модификации контракта через implements
и от создания дочерних классов через final
.
final class SimpleCommentBlock implements CommentBlock
{
/** Массив комментариев */
private $comments = [];
public function getCommentKeys(): array
{
return array_keys($this->comments);
}
public function viewComment(string $key): string
{
return $this->comments[$key]->view();
}
public function viewComments(): string
{
$view = '';
foreach ($this->comments as $key => $comment) {
$view .= $this->viewComment($key);
}
return $view;
}
}
CountingCommentBlock
является чем-то «вроде» дочернего класса и позволяет добавить функциональность к базовому классу без его модификации – в полном соответствии с OCP. CountingCommentBlock
реализован как декоратор: принимает в конструкторе декорируемый объект через интерфейс CommentBlock
и хранит его в приватном свойстве.
final class CountingCommentBlock implements CommentBlock
{
/** Декорируемый CommentBlock */
private $commentBlock;
/** Кеш */
private $cache;
public function __construct(CommentBlock $commentBlock, CounterInterface $cache)
{
$this->commentBlock = $commentBlock;
$this->cache = $cache;
}
public function getCommentKeys(): array
{
return $this->commentBlock->getCommentKeys();
}
public function viewComment(string $key): string
{
$this->cache->increment($key);
return $this->commentBlock->viewComment($key);
}
public function viewComments() : string
{
$commentKeys = $this->getCommentKeys();
foreach ($commentKeys as $commentKey) {
$this->cache->increment($commentKey);
}
return $this->commentBlock->viewComments();
}
}
Класс-декоратор CountingCommentBlock
вызывает соответствующие методы базового класса и при необходимости дополняет их поведение. Например, метод viewComment()
дополняет базовое поведение инкрементированием ключей в кэше. Такие методы называют методами передачи (forwarding methods).
Однако методы передачи могут и не включать никакой дополнительной функциональности, а просто возвращать результат «как есть». Как метод getCommentKeys()
. При использовании наследования, такие «однострочные» методы не требовалось бы включать в дочерний класс, а поведение было бы автоматически унаследовано из родительского класса, что в некоторой степени сократило бы объем кода.
Некоторые разработчики именно по этой причине отдают предпочтение наследованию, которое сокращает объем кода. Особенно в случае, если при агрегации классы-декораторы большей частью состояли бы из таких «однострочных» методов передачи. Стоит сказать, что написание дополнительной строки кода – довольно невысокая цена за получаемые с агрегацией преимущества (слабое зацепление, следование SOLID) и избегаемые недостатки наследования (нарушение сокрытия, хрупкость архитектуры, зацепление на реализацию).
Агрегация покрывает все возможности наследования. Классы SimpleCommentBlock
и CountingCommentBlock
реализуют общий интерфейс CommentBlock
, а потому могут полиморфно замещаться в коде. Разместив основное поведение в базовом классе и дополнив его в классе-декораторе, мы можем избежать дублирования кода.
Однако, если открытость класса к наследованию подталкивает нас к зацеплению на особенности реализации и повторному использованию кода, то использование модификатора final
подталкивает разработчика к выбору механизма агрегации и зацеплению на поведение класса, контракт которого описан в виде интерфейса CommentBlock
.
Финальные классы SimpleCommentBlock
и CountingCommentBlock
становятся для разработчика чем-то вроде «черного ящика», внутрь которого невозможно забраться через создание дочернего класса и переопределить некоторый код. С таким «черным ящиком» мы взаимодействуем через интерфейс, без необходимости учитывать особенности реализации. Класс готов к применению и не требует никакой конкретизации и уточнения поведения, как в случае наследования и паттерна «Шаблонный метод». Тем самым исключается часть проблем наследования – нарушение принципа сокрытия и зацепление на детали реализации.
За счет снижения степени зацепления, законченные final
классы довольно подвижны, их легко переиспользовать. Детали реализации сокрыты за интерфейсом, а потому актуальность проблемы «банан-обезьяна-джунгли» существенно снижается – воздействие внешней среды на каждый класс ограничено его контрактом. В соответствии с DIP, все классы теперь зависят только от абстракций.
В итоге мы получаем два узкоспециализированных класса: SimpleCommentBlock
– для основного функционала блока комментариев; и CountingCommentBlock
– опирающийся на SimpleCommentBlock
, но отвечающий только за дополнительную функциональность (кеширование). Т.е. мы не только ослабили зацепление классов, но и сохранили разделение ответственности между ними – в соответствии с SRP. Финальные классы гарантированно остаются компактными, сфокусированными на основной задаче, с высокой степенью связности (cohesion) и не могут разрастаться до неуправляемых размеров в результате наследования.
Вместе с этим решается и проблема «Хрупкого базового класса» – изменение любого из компонентов архитектуры (джунглей, обезьян, бананов), не нарушающее заявленный контракт, не окажет влияния на внешнюю среду. Мы получаем архитектуру из набора изолированных блоков, в которой изменения в реализации одного класса не приводят к каскадным эффектам и нарушениям в работе других классов.
Посмотрите пример ниже.
Теперь добавление нового метода viewRandomComment()
в базовый класс SimpleCommentBlock
никак не влияет на структуру и поведение изолированного класса-декоратора CountingCommentBlock
. Если бы использовалось наследование, то метод был бы неявно включен в состав дочернего класса и нарушил логику его работы – в реализации viewRandomComment()
не предусмотрен подсчет количества просмотров. Вызовы CountingCommentBlock::viewRandomComment()
не учитывали бы просмотры в кеше.
Кроме того, изменение деталей реализации viewComments()
в базовом классе SimpleCommentBlock
не повлияет на зависящие от него классы. CountingCommentBlock
не опирается на реализацию поведения в базовом классе SimpleCommentBlock
, он зависит только от контракта.
final class SimpleCommentBlock implements CommentBlock
{
/* ... */
/** Новый метод для получения строкового представление случайного комментария */
public function viewRandomComment(): string
{
$key = array_rand($this->comments);
return $this->comments[$key]->view();
}
/** Метод с измененными деталями реализации */
public function viewComments() : string
{
$view = '';
foreach ($this->comments as $key => $comment) {
/* Вместо вызова метода `$this->viewComment()` сделан
непосредственный вызов метода комментария */
$view .= $this->comments[$key]->view();
}
return $view;
}
}
Класс должен быть подготовлен к наследованию
Каждый класс имеет набор типичных вариантов использования: создание и уничтожение объектов, вызов методов, доступ к объектам класса через доступные интерфейсы, сериализация и десериализация, преобразование в строку, клонирование и т.д. Однако о доступности класса для наследования разработчик думает в последнюю очередь.
По умолчанию в PHP все классы доступны для наследования, а значит, если вы не пометили класс ключевым словом final
, то обязаны предусмотреть все варианты его использования в дочерних классах. А таких вариантов очень много, ведь, как говорилось выше, наследование нарушает принцип сокрытия. Больше всего проблем в будущем может возникнуть с дочерними классами, которые переопределяют поведение методов родительского класса. Особенно если это не предусматривалось создателем родительского класса. Такие дочерние классы могут вызвать как проблемы внутри – целостность класса и его инварианты могут быть нарушены, так и проблемы извне – они будут несовместимы с ожидаемым поведением объектов родительского класса.
Для того чтобы избежать перечисленных проблем, нефинальные классы должны раскрыть для своих потомков все существенные нюансы внутренней реализации. Это подразумевает документирование в PHPDoc таких деталей кода, которые позволят без последствий переопределить в процессе наследования нефинальные методы класса. Для этого документация должна раскрывать: как работают нефинальные методы и как они используются через открытую рекурсию.
То есть с одной стороны, вам следует указать ожидаемую реализацию поведения для каждого метода, доступного для переопределения (т.е. нефинального, с модификатором public
или protected
). В PSR-19: PHPDoc tags, находящемся в состоянии черновика, не предусмотрен тег для описания требований к реализации. И часто эти требования явно не выделяются в PHPDoc, а просто предваряются фразой «Реализация этого метода делает то-то».
Однако предлагаю позаимствовать из JavaDoc тег @implSpec, который как раз предназначен для описания спецификации реализации и отделения ее от остальной документации. Как правило, PHPDoc является спецификацией API и описывает контракт между методом класса и его клиентом, т.е. внешний public интерфейс. Тег @implSpec
предназначен для раскрытия подробностей того, как реализован этот API. Именно здесь предлагаю разместить текстовое описание деталей реализации, которые являются частью protected интерфейса между методом и дочерними классами.
class CommentBlock
{
/* ... */
/**
* Получить строковое представление комментария для вывода в шаблон
* Переопределение данного метода позволяет добавить дополнительную
* функциональность (например, логирование или подсчет просмотров)
*
* @implSpec Реализация извлекает комментарий с ключом `$key`
* из внутреннего массива `$this->comments` и вызывает для него метод `view()`
*
* @param string $key Ключ комментария
* @return string Строковое представление комментария
*/
public function viewComment(string $key): string
{
return $this->comments[$key]->view();
}
}
В PHPDoc с помощью тега @implSpec
включено описание семантики нефинального метода. Разработчик дочернего класса проинформирован о смысле параметра $key
и особенностях его использования внутри метода. Деталью реализации является также наличие побочного эффекта – вызов для комментария метода view()
.
И если в дочернем классе потребуется переопределить метод, то спецификация реализации в @implSpec
подскажет:
- каким минимальным требованиям должна удовлетворять реализация перекрывающего метода (в части использования параметров, побочных эффектов, последовательности вызова нефинальных методов, поддерживаемых инвариантов и т.д.).
- какое воздействие будет оказано на дочерний класс при вызове метода родительского класса через
parent::method()
.
Теперь разберем, как задокументировать «самоиспользование» нефинальных методов. Документация методов, выполняющих self-call вызов (через $this
) нефинальных методов, доступных для перекрытия в дочерних классах, должна объяснять:
- какие нефинальные методы вызываются в теле через
$this
; - порядок вызова нефинальных методов;
- подробности использования возвращенного значения.
class CommentBlock
{
/* ... */
/**
* Получить представление всех комментариев в блоке в виде одной строки
*
* @implSpec Выполняет итерирование массива `$this->comments`
* и для каждого элемента выполняет вызов метода `$this->viewComment()`.
* Возвращенные строковые значения конкатенируются в одну строку.
*
* @return string Строковое представление всех комментариев
*/
final public function viewComments(): string
{
$view = '';
foreach ($this->comments as $key => $comment) {
$view .= $this->viewComment($key);
}
return $view;
}
}
Обратите внимание, что метод viewComments()
описан как final
и его поведение не может быть переопределено. Но он использует собственный нефинальный метод viewComment()
, а потому документация должна разъяснять порядок этого использования. И разработчик дочернего класса из документации может понять:
- как влияет переопределение
viewComment()
на поведениеviewComments()
; - как может подключаться дополнительное поведение к
viewComments()
через переопределениеviewComment()
.
Это частично решает проблемы наследования, выявленные в начале статьи. Сокрытые детали поведения методов теперь явно задокументированы, в том числе использование открытой рекурсии, и должны приниматься во внимание при проектировании классов. Проблема «хрупкости базового класса», показанная ранее на примере CommentBlock
и CountingCommentBlock
, становится контролируемой. Разработчик дочернего класса явно осведомлен о вызовах метода viewComment()
в реализации метода viewComments()
и, владея этой информацией, может предотвратить задвоение просмотров в счетчике.
Но не увлеклись ли мы слишком, документируя все подряд, вплоть до нюансов реализации. Ведь PHPDoc должен описывать только публичное API и контракты между методами класса и его клиентами: что делает метод и что не делает, какие принимает параметры и возвращает результаты, как обрабатывает исключительные ситуации.
К сожалению, наследование нарушает принцип сокрытия и, чтобы хрупкая архитектура наследования, зацепленная на детали реализации, не была случайно поломана, мы вынуждены раскрыть в документации внутреннюю логику работы нефинальных методов и методов, их «самоиспользующих». И это еще одна проблема наследования – проблема его документирования. Ведь PHPDoc зачастую уже и так перегружены подробностями, и дополнительная информация по реализации еще существенней засоряет ее.
Итак, открытость к наследованию не только предъявляет дополнительные требования к документированию класса, но и заставляет программиста следовать спецификации реализации в течении всего его существования. Гораздо проще – просто ограничить создание дочерних классов с помощью ключевого слова final
, особенно если класс целенаправленно не разрабатывался для наследования. А ведь таковыми являются большинство обычных неабстрактных классов. Тем самым вы даете в его отношении четкий сигнал – «этот класс не разрабатывался для наследования и не документирован для такого варианта использования».
Поэтому при проектировании класса вам следует выбрать один из двух вариантов:
- класс спроектирован для наследования. И в этом случае он должен документировать все возможные варианты использования в дочерних классах;
- класс отмечен с помощью ключевого слова
final
. Наследование невозможно.
Многие разработчики, однако, считают написание final
чересчур громоздким и ухудшающим читаемость кода. Открытые для наследования классы без final
как будто уже заранее подразумевают, что будут впоследствии использованы в качестве родительского класса. Не подготовив такие классы к наследованию и не ограничив его, вы делаете их потенциально уязвимыми в будущем. И стоит ли держать в голове мысленное ограничение наследования, когда вы его можете явно выразить в коде?
С помощью final
вы очень легко отсекаете всю массу проблем с наследованием и ее поддержкой. Это чрезвычайно актуально для классов, которые распространяются как часть публичной библиотеки, использование которой вы не контролируете. Как ни странно, если впоследствии вам потребуется создать дочерние классы, используя final
вы все равно получаете преимущества, о которых ниже.
final заставляет задуматься о необходимости наследования
Запрещая наследование для своих классов «по умолчанию», вы получаете массу преимуществ, особенно на этапе поддержки приложения, который, как известно, гораздо длиннее самой разработки. Упрощается рефакторинг, снимается бремя поддержки обратной совместимости protected интерфейса для дочерних классов. В конце концов, ключевое слово final
– это важное знание о том, что дочерние классы отсутствуют, а значит код и поведение, которые не входят в его public контракт, могут изменяться без последствий.
Но вот вы действительно столкнулись с сущностью, которая является подтипом другой, иерархически и неразрывно связана с ней. Вы оценили, что эти сущности будут изменяться вместе и сильное зацепление через отношение наследования, как нельзя лучше, выражает эту связь. Никаких проблем – вы просто удаляете final
.
Можно ли также легко закрыть класс для наследования, если по умолчанию все классы в вашем приложении не используют final
? Нет. Возможно кто-то из ваших коллег, а может быть и членов Opensource комьюнити, если ваш проект публичный, уже создал дочерний класс. Но чтобы точно это выяснить потребуется уже анализ кода или использование автоматических средств IDE. И то, это возможно только в случае закрытого кода в рамках внутренних проектов.
Однако, более важно, что удаление ключевого слова final
сигнализирует о запуске процесса наследования класса. Если ваши классы не используют final
по умолчанию, то вы никаким образом не можете выявить момент начала использования класса в качестве родительского. И это момент может быть важен для дальнейшего развития архитектуры.
final
заставляет задуматься разработчика о том, стоит ли вообще идти по пути такого сильно зацепления двух классов. Может быть стоит предпочесть агрегацию и слабое зацепление через public интерфейс? А может быть архитектура приложения выстроена неверно и необходимость наследования – один из первых запахов кода, построенного на антипаттернах?
Делая осознанный выбор в пользу наследования, разработчик также принимает на себя все обязательство по подготовке родительского класса. Как вы помните, запуск наследование не ограничивается удалением final
, требуется еще дополнить документацию деталями реализации, существенными для protected контракта между классами.
final
теперь является важным инструментом вашей кодовой базы, а значит способ и применение этого инструмента могут стать предметом обсуждения на этапе code review (ведь вы же делаете code review? ;). Например, таких.
- Почему новый класс не использует
final
? Планируется создание дочерних классов? Каких? - Почему у класса был удален
final
? Действительно ли дочерний класс является подтипом родительского и должен изменяться вместе с ним? Возможно стоило выбрать слабое зацепление и агрегацию?
И все это подталкивает нас к лучшей архитектуре и более продуманным решениям. В особенности в больших проектах и больших командах, когда строгие руководства, ограничения и подробные code review способны направить развитие архитектуры в правильном направлении. Даже в руках начинающих разработчиков.
И еще небольшое замечание. Ключевое слово final
появилось вместе с новой объектной моделью в PHP5. И, скорее всего, по множеству различных причин (обратной совместимости, низкого порога вхождения) классы и методы по умолчанию доступны для наследования.
А вот если бы все было наоборот, то есть классы или методы сразу были закрыты для наследования и переопределения поведения, как в некоторых других языках. Тогда мы бы получали «по умолчанию» объекты с малым количеством точек зацепления, составляющие слабозацепленную архитектуру. Например, в C# изменение поведения метода требует указания в родительском классе модификатора virtual
, а в дочернем – override
. Если бы и в PHP вместо final
было ключевое слово extandable
, то вместо вопроса «зачем мне ограничивать гибкость этого класса через final
», многие разработчики в момент необходимости открыть класс для наследования выбрали бы вместо отношения extend
более слабый тип связи.
Класс должен быть подготовлен к агрегации
Итак, грамотно подготовить класс к наследованию – значит задокументировать спецификации реализаций. И это еще не конец. Гораздо большая внимательность требуется также и в момент самого наследования. Требуется принимать во внимание спецификации реализаций и учитывать взаимозависимости деталей поведения родительского и дочернего классов.
Агрегация также требует подготовки. Однако, здесь все наоборот. Документирование реализации не требуется, детали реализации в этом случае не просачиваются наружу. Для того чтобы функциональность final
класса могла быть расширена, в коде требуется описать контракт класса, которому будут следовать классы-декораторы. Другими словами, обязательно должен быть предусмотрен интерфейс, который будут через implements
реализовывать все связанные через агрегацию классы.
В целом, общая схема создания слабозацепленной архитектуры приложения без наследования, на базе final
классов и агрегации состоит из следующих шагов.
-
Любой класс вводится в архитектуру с ключевым словом
final
и с ограничением наследования:final class SimpleCommentBlock { /* ... */ public function getCommentKeys(): array { /* ... */ } public function viewComment(string $key): string { /* ... */ } public function viewComments(): string { /* ... */ } }
-
Далее вы сталкиваетесь с задачей расширить функциональность этого класса. Как сказано выше,
final
ограничивает вас в наследовании и заставляет пойти по пути агрегации. Вы анализируете исходный класс и выделяете поведение в базовом классе, на которое будет опираться производный класс. Далее формируете контракт этого поведения и описываете его формально в виде интерфейса.В крайнем случае, вы можете собрать сигнатуры всех
public
методов и составить интерфейс из них. Однако, чаще всего имеет смысл ограничить размер интерфейса и зацепить производные классы только на то поведение, которое они действительно будут использовать.В случае наследования вы пропустили бы этот шаг, не выразили в коде контракт и затащили в дочерний класс всю реализацию из родительского.
interface CommentBlock { public function getCommentKeys(): array; public function viewComment(string $key): string; public function viewComments(): string; }
-
Реализуете интерфейс в исходном классе.
final class SimpleCommentBlock implements CommentBlock { /* ... */ }
-
Добавляете в архитектуру производный класс-декоратор, расширяющий функциональность базового класса и реализующий тот же самый интерфейс. В конструктор класса-декоратора вводите через интерфейс
CommentBlock
экземпляр базового класса. Методы класса-декоратора выполняют вызовы методов базового класса и при необходимости дополняют его поведение.final class CountingCommentBlock implements CommentBlock { /* ... */ private $commentBlock; public function __construct(CommentBlock $commentBlock /* ,... */) { $this->commentBlock = $commentBlock; } /* ... */ }
Только в случае выполнения всех шагов этой схемы вы получаете расширяемую архитектуру системы, классы которой зацеплены через интерфейс. В частности, если вы не предоставите для своего final
класса интерфейс, включающий полный публичный контракт, то не сможете создать классы-декораторы и использовать агрегацию. А это значит, что придется удалить из описания класса ключевое слово final
и использовать наследование.
Особенно важно наличие интерфейсов для final
классов, которые поставляются как часть публичной библиотеки. Если интерфейс отсутствует, то пользователь библиотеки не сможет расширить функциональность через агрегацию в форме паттерна «Декоратор». То есть так, чтобы базовый и производный класс могли полиморфно замещаться в коде. При этом добавление интерфейса или удаление ключевого слова final
во внешней подключаемой библиотеке будут невозможны.
Использование final классов в тестах
В этой отличной идее создания слабозацепленной архитектуры на final
классах и агрегации есть небольшая загвоздка – большинство библиотек модульного тестирования (PHPUnit, Mockery) используют наследование для создания тестовых «двойников» (test doubles). И это может стать проблемой для тех, кто в модульных тестах мокает зависимости для имитации контекста тестируемого класса.
Например, следующий тест:
final class SimpleCommentBlockTest extends TestCase
{
public function testCreatingTestDouble(): void
{
$mock = $this->createMock(SimpleCommentBlock::class);
}
}
завершается с ошибкой:
Class "SimpleCommentBlock" is declared "final" and cannot be mocked.
И это естественно, так как «под капотом» PHPUnit пытается создать дочерний класс, такого вида:
class Mock_SimpleCommentBlock_591bc3f3 extends SimpleCommentBlock
{
/* ... */
}
Наследование очень удобно для создания тестовых «двойников». Во-первых, полученная сымитированная реализация может быть подставлена в код вместо оригинальной реализации, т.к. является ее подтипом. А, во-вторых, наследование нарушает принцип сокрытия, а значит PHPUnit может с легкостью подменить методы тестируемого класса. Например, заменить их заглушками или дополнить ожиданиями.
Ключевое слово final
пресекает попытки библиотек модульного тестирования залезть в реализацию оригинального класса и подправить поведение его методов. И тут есть два подхода к преодолению этого ограничения: архитектурный и магический. Рассмотрим их поподробней.
Архитектурный подход
Вначале нужно подумать – стоит ли вообще создавать тестовый «двойник» для этого класса. Например, сущности предметной области (вроде Post
, Comment
) или объекты-значения (value object) являются стабильными (stable) внутренними зависимостями с одной конкретной реализацией. А потому должны в тестах использоваться напрямую. Это соответствует стилю classical TDD (а не mockist TDD)
Но допустим, что ваш класс является изменчивой (volatile) зависимостью и может иметь несколько возможных реализаций. В этом случае, нужно понять, что тестовый «двойник» – это всего лишь еще одна, упрощенная фиктивная реализация. А это значит, что тестовый «двойник» не должен наследовать и переопределять поведение оригинального класса, он должен разделять с ним общий интерфейс. И если архитектура системы построена на базе принципа инверсии зависимостей (DIP), а элементы зависят только от абстракций, то тестовый «двойник» сможет полиморфно замещать оригинальный класс без наследования.
А значит, если вы использовали описанную в предыдущем разделе схему подготовки класса к агрегации. Т.е. описали контракт в виде интерфейса:
interface CommentBlock
{
/* ... */
}
и реализовали его в классе:
final class SimpleCommentBlock implements CommentBlock
{
/* ... */
}
То сможете без проблем создать тестовый «двойник»:
final class CommentBlockTest extends TestCase
{
public function testCreatingTestDouble(): void
{
$mock = $this->createMock(CommentBlock::class);
}
}
И если у вас возникла потребность создать тестовый «двойник» на базе конкретной реализации, то это сигнал о проблеме в архитектуре. Наиболее частые из них:
- отсутствует необходимый интерфейс для создания тестового «двойника»;
- интерфейс не включает необходимые методы и его необходимо расширить;
- тестовый «двойник» не может быть подставлен вместо конкретной реализации, т.к. нарушен DIP и классы зависят не от абстракций.
Магический подход
Но встречаются кейсы, когда создание тестового «двойника» – это отдельный тестовый вариант использования класса, не связанный с решением бизнес-задач. Например, вам нужно проверить ожидания для сущности предметной области (количество вызовов метода, переданные параметры). И если вы специально для этой задачи введете интерфейс в архитектуру, то он вместо «контракта для задач клиентов» становится «набором методов для проверки ожиданий».
Получается, что на этапе тестирования приложения ограничение наследования с помощью final
должно быть снято.
Один вариант – использовать агрегацию и тестовый прокси-двойник. В этом случае экземпляр оригинального класса помещается внутрь тестового прокси-двойника. Вызовы, поступающие к прокси-объекту, дополняются ожиданиями и перенаправляются к оригинальному объекту. Эта идею можно реализовать вручную или воспользоваться уже готовой реализацией в библиотеке Mockery.
class SimpleCommentBlockTest extends TestCase
{
public function testCreatingProxyDouble()
{
/* Экземпляр оригинального класса */
$simpleCommentBlock = new SimpleCommentBlock();
/* Тестовый прокси-двойник */
$proxy = Mockery::mock($simpleCommentBlock);
/* Подмена поведения метода */
$proxy->shouldReceive('viewComment')
->andReturn('text');
/* Проверка подмены поведения метода */
$this->assertEquals('text', $proxy->viewComment('1'));
/* `$proxy` прокси не является экземпляром `SimpleCommentBlock` класса */
$this->assertNotInstanceOf(SimpleCommentBlock::class, $proxy);
}
}
Ограничение такой реализации очевидно – прокси-двойник не является подтипом оригинального класса и поэтому не может его замещать в коде. Как подтверждает тест, он не проходит проверку оператором instanceof
. И если вы активно пользуетесь объявлениями типов (type declarations), то прокси-класс использовать в коде не получится.
Остается применить «швейцарский нож» – магию PHP, с помощью которой вы можете вскрыть любые ограничения, декларированные в коде. И уже имеется готовая маленькая библиотека Bypass Finals, которая ставит хак на загрузку файла с классом и на лету удаляет final
из исходного кода. Достаточно подключить библиотеку через composer и включить удаление final
до загрузки файла с тестируемым классом:
public function testUsingBypassFinals(): void
{
/* Включить удаление `final` */
BypassFinals::enable();
$mock = $this->createMock(SimpleCommentBlock::class);
}
Инструменты для удобной работы с final классами
Итак, в PHP все классы по умолчанию открыты для наследования. Чтобы их закрыть от наследования «по умолчанию» и подтолкнуть к слабому зацеплению, требуется каждый раз набирать этот final
в заголовке. А что, если заставить IDE автоматически добавлять ключевое слово final
в заголовок каждого нового класса.
PHPStorm для генерации кода
В PHPStorm для этого необходимо настроить шаблон нового класса. Для этого в окне настроек File | Settings | Editor | File and Code Templates на закладке Files правим встроенный шаблон PHP Class. Дополняем заголовок класса в шаблоне ключевым словом final
.
Теперь при создании класса через File | New | PHP Class автоматически получаем заготовку класса вида:
final class SimpleCommentBlock
{
}
Допустим, вы наполнили класс методами. Далее вы сталкиваетесь с задачей расширения функционала класса через агрегацию. И следующий шаг для этого – извлечение интерфейса.
И здесь у PHPStorm также есть удобный инструмент Refactor | Extract | Interface. В окне указываем имя извлекаемого интерфейса и методы, включаемые в него. Включаем замену ссылок на класс по коду ссылками на интерфейс (опция Replace class reference with interface where possible) и перемещение PHPDoc в интерфейс (опция Move PHPDoc).
В результате рефакторинга получаем интерфейс вида:
interface CommentBlock
{
/** PHPDoc */
public function viewComment(string $key): string;
}
К исходному классу автоматически добавляется сгенерированный интерфейс:
final class SimpleCommentBlock implements CommentBlock
{
public function viewComment(string $key): string
{
/* ... */
}
}
Далее через инструмент создания File | New | PHP Class по шаблону создаем финальный класс-декоратор, расширяющий функциональность. Вручную вписываем приватное свойство для хранения экземпляра декорируемого класса и реализацию сгенерированного интерфейса:
final class CountingCommentBlock implements CommentBlock
{
/** @var CommentBlock */
private $commentBlock;
}
Теперь воспользуемся инструментом для генерации конструктора Code | Generate | Constructor. В результате получаем готовый конструктор для инъекции декорируемого класса.
final class CountingCommentBlock implements CommentBlock
{
/* ... */
public function __construct(CommentBlock $commentBlock)
{
$this->commentBlock = $commentBlock;
}
}
И последний шаг – генерация заготовок для реализации методов интерфейса. Воспользуемся инструментом Code | Generate | Implement Methods. К сожалению, сейчас мы можем сгенерировать только пустые заглушки методов. Возможно в будущем в PHPStorm появится инструмент для генерации готовых «однострочных» методов передачи для делегирования поведения вложенному объекту, как это уже реализовано в родственных IntelliJ IDEA и ReSharper.
final class CountingCommentBlock implements CommentBlock
{
/* ... */
/**
* @inheritDoc
*/
public function viewComment(string $key): string
{
// TODO: Implement viewComment() method.
}
}
PHPDoc для методов также сгенерированы автоматически. Осталось только наполнить методы поведением.
PHPStan для контроля стиля кодирования
А что если вы хотели бы автоматически контролировать, что добавляемые в архитектуру классы изначально ограничены в использовании наследования. Например, в самом простейшем случае, проверять, что классы объявлены с модификатором final
и нацелены на агрегацию. И тут на помощь приходят инструменты статического анализа кода (подробный обзор на хабр).
Самый популярный из таких инструментов PHPStan. И «из коробки» он умеет выявлять в вашем коде типичные ошибки. Однако PHPStan позволяет довольно легко расширять функциональность и писать собственные правила проверки кодовой базы. Эту фичу как раз можно задействовать для контроля стиля своего кода.
В качестве базовой заготовки можно взять правило FinalRule
из сторонней библиотеки localheinz/phpstan-rules
. Класс правил реализует интерфейс PHPStanRulesRule
и контролирует наличие ключевого слова в методе processNode()
.
Довольно просто реализуются и параметры правил. Например, в правиле FinalRule
можно включить возможность использования абстрактных классов и паттерна «Шаблонный метод» через параметр allowAbstractClasses
. А имена классов, для которых наследование разрешено, можно указать через параметр classesNotRequiredToBeAbstractOrFinal
.
Чтобы воспользоваться этими библиотеками для контроля стиля кодирования в проекте, устанавливаем их через composer:
composer require --dev phpstan/phpstan
composer require --dev localheinz/phpstan-rules
Подключаем правило FinalRule
в конфигурационном файле phpstan.neon
и указываем параметры:
services:
-
class: LocalheinzPHPStanRulesClassesFinalRule
arguments:
allowAbstractClasses: true
classesNotRequiredToBeAbstractOrFinal: []
tags:
- phpstan.rules.rule
И запускаем анализ кодовой базы с указанием уровня строгости (здесь max
):
vendor/bin/phpstan -lmax analyse src
В результате получаем ошибки вида:
------ ------------------------------------------------------------------------
Line CommentBlock.php
------ ------------------------------------------------------------------------
10 Class CommentBlock is neither abstract nor final.
------ ------------------------------------------------------------------------
Удобнее настроить вывод в JSON файл и использовать результат в Continuous Integration.
Заключение
Итак, мораль сей статьи такова: добавляйте к своим классам final
по умолчанию! А лучше настройте шаблон в своей IDE, чтобы это происходило автоматически.
Кажется, что это лишь незначительное ограничение на наследование, но оно существенно влияет на развитие архитектуры приложения. И влияет в лучшую сторону – в направлении к SOLID и слабому зацеплению. Связь между элементами в классе усиливается, а сами классы становятся похожи на компактные зафиналенные блоки, поддающиеся тестированию и готовые к использованию в других местах кода.
Однако, путь агрегации не легок и требует четкого следования принципам ослабления зависимостей. Теперь вам следует:
- явно формировать контракт взаимодействия между классами и оформлять его в коде в виде интерфейса;
- использовать только
final
классы, которые, при необходимости, реализуют поведение некоторых контрактов; - использовать агрегацию для зацепления классов и внедрять зависимости в конструктор через интерфейс.
И суть не в том, чтобы следовать каким-то установленным формальным правилам и придуманным кем-то принципам. Вы тратите большую часть своего времени на анализ и обслуживание ранее написанного кода. И от его качества напрямую зависит скорость разработки и внедрения фич. Каждый раз, когда я видел в коде нарушение базовых принципов построения слабозацепленного дизайна, например, повсеместное использование наследования и нарушение инкапсуляции, это выливалось в конце концов в его полный рефакторинг. Хотя бы потому, что неизолированные блоки кода не поддаются тестированию. Разве у вас есть лишнее время переписывать код?
Возможно вам кажется, что все эти модные принципы – всего лишь теоретические формулировки, не имеющие отношения к реальности. Однако построение архитектуры на интерфейсах и final
классах имеет конкретный практический смысл. Борьба со сложностью системы и сокращение когнитивной нагрузки (cognitive load) – вот ради чего мы выполняем декомпозицию системы на изолированные блоки. И слабое зацепление классов – один из важных шагов в этом направлении.
Теперь вы можете разделить процесс проектирования на два этапа: выделение интерфейсов и их реализация в виде final
классов. На первом этапе вы размышляете на уровне контрактов и взаимоотношений между классами. На втором – на уровне конкретной изолированной реализации контракта. Это позволяет сосредоточиться на небольшом количестве деталей одновременно. Ведь мы не в состоянии держать в голове одновременно слишком много сущностей. И при увеличении количества, сложность их совместного поведения растет экспоненциально. А значит, растет и число случайных ошибок.
Начните использовать final
как один из важных инструментов построения кодовой базы. Вам понравится твердая (SOLID) архитектура без всяких хрупких (fragile) классов. А вашему проджект менеджеру понравится скорость внедрения новых фич, которые не поломали ничего вокруг себя.
Автор: Паршиков Павел