[Из песочницы] Зачем ограничивать наследование с помощью final?

Вы наверняка слышали это знаменитое высказывание от 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 интерфейс

Нарушение принципа сокрытия через 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, выполняемых из шаблонных методов абстрактного родительского класса, к кастомизированным методам дочерних классов. Это существенно запутывает порядок выполнения программы.

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


Предпочитай реализацию интерфейса наследованию

Если продолжить двигаться в направлении снижения зацепления и вообще убрать наследование какой-либо реализации, мы приходим к абстрактному классу со всеми абстр

© Habrahabr.ru