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


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


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


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


  1. Любой класс вводится в архитектуру с ключевым словом final и с ограничением наследования:


    final class SimpleCommentBlock
    {
       /* ... */ 
    
       public function getCommentKeys(): array
       {
           /* ... */
       }
    
       public function viewComment(string $key): string
       {
           /* ... */
       }
    
       public function viewComments(): string
       {
           /* ... */
       }
    }

  2. Далее вы сталкиваетесь с задачей расширить функциональность этого класса. Как сказано выше, final ограничивает вас в наследовании и заставляет пойти по пути агрегации. Вы анализируете исходный класс и выделяете поведение в базовом классе, на которое будет опираться производный класс. Далее формируете контракт этого поведения и описываете его формально в виде интерфейса.


    В крайнем случае, вы можете собрать сигнатуры всех public методов и составить интерфейс из них. Однако, чаще всего имеет смысл ограничить размер интерфейса и зацепить производные классы только на то поведение, которое они действительно будут использовать.


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


    interface CommentBlock
    {
       public function getCommentKeys(): array;
    
       public function viewComment(string $key): string;
    
       public function viewComments(): string;
    }
    

  3. Реализуете интерфейс в исходном классе.


    final class SimpleCommentBlock implements CommentBlock
    {
       /* ... */ 
    }

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


Правка встроенного шаблона `PHP Class`


Теперь при создании класса через 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. Класс правил реализует интерфейс PHPStan\Rules\Rule и контролирует наличие ключевого слова в методе processNode().


Довольно просто реализуются и параметры правил. Например, в правиле FinalRule можно включить возможность использования абстрактных классов и паттерна «Шаблонный метод» через параметр allowAbstractClasses. А имена классов, для которых наследование разрешено, можно указать через параметр classesNotRequiredToBeAbstractOrFinal.


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


composer require --dev phpstan/phpstan
composer require --dev localheinz/phpstan-rules

Подключаем правило FinalRule в конфигурационном файле phpstan.neon и указываем параметры:


services:
   -
      class: Localheinz\PHPStan\Rules\Classes\FinalRule
      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) классов. А вашему проджект менеджеру понравится скорость внедрения новых фич, которые не поломали ничего вокруг себя.

Комментарии (31)


  1. gof
    27.12.2019 00:40
    +1

    ну хоть кто-то расписал это на несколько страниц более менее прилично, с адекватными аргументами. Благодарю.


  1. symbix
    27.12.2019 03:53

    Есть простой, но эффективный принцип — abstract or final.


    Суть понятна из названия.


  1. customlabs
    27.12.2019 09:33

    Очень длинная статья. Но в итоговом виде, порядок я бы поменял, так как в случае публичного api не имеет смысла вообще выпускать final классы, без интерфейсов. Поэтому, если хочешь написать final — будь добр одновременно с этим описать и контракт в виде интерфейса.

    Очень часто встречаю код, коллег, где они видимо прочитали про принцип закрытости и низкого зацепления, и все свои классы всегда объявляют финальными (забывая выделять контракт). И получаем в коде вот такое:

    final class Logger { /* какая-то реализация */ }
    


    final class SomeService { 
        public function setLogger(Logger $logger);
    }
    


    Специально взял пример с логером у которого может быть множество реализаций, но контракт един (см. psr/logger)

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

    Поэтому повторюсь, финальные классы без контрактов — это больше антипатерн, аля Singletone. Финальные классы нужно использовать всегда совместно с контрактом. Тогда код будет легко переиспользовать, он будет менее сцеплен, и более гибок.

    Также стоит добавить и ложку дегтя про final. Не все библиотеки и фрейморки умеют корректно работать с таким классами, хорошим примером является symfony lazy сервис, symfony не сможет сделать сервис lazy если класс сервиса финальный.


    1. symbix
      27.12.2019 10:46
      +1

      Поэтому повторюсь, финальные классы без контрактов — это больше антипатерн, аля Singletone.

      Если речь о логгере, то да. Впрочем, в качестве контракта может быть и абстрактный класс; более того, считаю pure abstract class более уместным, чем интерфейс, поскольку тут все же отношение is-a, а не can.


      Но есть ещё огромный пласт domain modeling, где в рамках определенного bounded context совершенно четко и однозначно известно, что такое какой-нибудь User или Address, и пихать туда контракты — идиотизм высшей степени, поскольку контракт сущности единственен и определяется ей самой.


      1. Deosis
        27.12.2019 12:03

        pure abstract class допустим, если язык поддерживает множественное наследование.
        Иначе, один класс не сможет реализовать 2 интерфейса.


        1. symbix
          27.12.2019 16:09

          Мне разделение на классы и интерфейсы вообще видится скорее ошибочным. Появление default interface methods в Java тому свидетельство.


          Хотя на практике ситуация, когда отношение is-a сразу к двум базовым классам не является архитектурной ошибкой, довольно редкая.


    1. parshikov_pavel Автор
      27.12.2019 11:32

      Тут стоит учитывать, что есть два типа зависимостей: стабильные (stable) и изменчивые (volatile) зависимости. И в зависимости от типа зависимости нужно решать — выделять интерфейс или нет.


      Logger — классическая volatile зависимость. Он может иметь несколько конкретных реализаций. А значит для него необходимо выделить интерфейс и организовать зацепление через интерфейс. В соответствии с принципом инверсии зависимостей (dependency inversion principle, DIP).


      И если SomeService зависит от конкретной реализации зависимости Logger, то тут явное нарушение DIP. Клиент должен зависеть от абстракции и не зависеть от конкретных деталей реализации.


      В случае же стабильных (stable) зависимостей — объектов-данных (Data Object), сущностей предметной области (типа Post, Comment), — нет смысла выделять интерфейс и использовать DIP. Т.к. они не являются абстракциями, а объекты вполне конкретного типа. Использование интерфейсов для них только усложняет код.


      1. customlabs
        27.12.2019 12:30

        Не соглашусь. Вы статью писали с прицелом на слабое зацепление, и упоминали, что слабое зацепление ведет к такому плюсу как пере использование кода. Если код при этом качественно разбит по принципам SOLID на классы — и все они финальны без контракта, то такой код не может быть качественно пере использован, так как хоть он и классный, но он полностью фиксирован реализацией.

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

        Но даже DTO объекты в рамках какой-либо vendor библиотеки, не могут быть финальными без контракта, если хотя бы один публичный метод любого класса этой библиотеки принимает его в качестве аргумента. Принцип подстановки Барбары Лисков. Вы сами говорили о SOLID, но при этом этот принцип полностью вычеркиваете. Финальный класс без контракта требуемый в качестве аргумента, это запрещение использования данного принципа. Это запрещение разработчику который хочет воспользоваться вашим кодом, расширения его частей (даже через композицию), для его задач в рамках его программной инфрастуктуры.

        Пример из жизни, это многострадальный usb разъем и то, что было до него. А был зоопарк разъемов. Этот зоопарк разъемов — это ваши финальные классы без контракта. Производитель выпускал оборудование с фиксированной реализацией иных аксессуаров к нему, только под своей маркой.

        Usb же это контракт, который позволил пользоваться каким-то оборудованием одного производителя, но и заменой части аксессуаров от иных производителей. Это и есть пере использование.


        1. rjhdby
          27.12.2019 13:34
          -1

          Принцип подстановки Барбары Лисков — это про ограничения при наследовании, а не призыв его использовать, дабы себя соблюсти.
          "Не садись пьяным за руль" — это Барбара Лисков, "Хорошо бы вообще не пить" — это автор статьи.


          1. customlabs
            27.12.2019 14:04
            +1

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

            Ну по такой аналогии, можно еще кучу статей написать и вычеркнуть оставшиеся.


            1. rjhdby
              27.12.2019 14:28
              +1

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


              1. customlabs
                27.12.2019 14:56
                +4

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

                Мне жаль если вы не понимаете разницу.


                Если вы посмотрите на принципы SOLID, то они все про ограничения, а ограничения вводятся не от хорошей жизни. И если используемый подход обеспечивает соблюдение этих ограничений «из коробки», то ничего плохого в этом уж точно нет.

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


                Вы читали мою мысль? Давайте на примере, библиотеки с http клиентом и набором классов (сервисов)


                final class SomeHttpClient {
                    // some implementation
                
                   public function request(/* Some arguments */) {
                       // some implementation
                   }
                }

                final class SomeService {
                   public function __construct(SomeHttpClient $httpClient) {
                      // ...
                   }
                
                   public function getComment(int $authorId) {
                   }
                
                   // etc ...
                }

                Так, вот я говорю, что таким кодом нельзя пользоваться. Так как он полностью закрывает возможность его расширения, в том числе в рамках SOLID.


                В коде есть метод который в аргументе принимает final класс. То есть закрыта даже композиция.


                Допустим мы подключили эту библиотеку в проект. А через некоторое время приходит бизнес требование. К примеру: журналировать все внешние обращения, мерить время при внешних обращениях и т.д.


                Самое простое и правильное решить это композицией над SomeHttpClient


                public function ProfiledSomeHttpClient {
                   private SomeHttpClient $httpClient;
                
                   public function __constuct(SomeHttpClient $httpClient) {
                       $this->httpClient = $httpClient;
                   }
                
                   public function  request(/* Some arguments */) {
                       // Фиксируем время старта
                       // ...
                
                      $result = $this->httpClient->request(...$arguments);
                
                      // Замеряем итоговое время и что то с ним делаем
                       // ...
                      return $result;
                   }
                }

                И в коде где конфигурируется SomeService инжектим туда нашу обертку.


                $profiledHttpClient = new ProfiledSomeHttpClient(
                    new SomeHttpClient()
                );
                
                $service = new SomeService($profiledHttpClient);

                Все задача решена в лучших традициях SOLID.


                НО!!! Нет, у нас же требуется передать в SomeService именно финальный HttpClient. То есть жестко фиксированная реализация. И вместо подобного решения, все, что нам остается, это во всех местах в проекте, где вызывается методы из someService — там мерить время. Это полный ужас.


                Именно поэтому я написал, да можно финалить классы, НО, если этот финальный класс где-то ожидается как аргумент метода иного класса, ВЫ обязаны закрыть его контрактом и ожидать именно контракт!


                В итоге должно быть:


                interface SomeHttpClientInterface {
                    public function request(/* Some arguments */) 
                }

                final class SomeHttpClient implements SomeHttpClientInterface {
                    // some implementation
                
                   public function request(/* Some arguments */) {
                       // some implementation
                   }
                }

                final class SomeService {
                   public function __construct(SomeHttpClientInterface $httpClient) {
                      // ...
                   }
                
                   public function getComment(int $authorId) {
                   }
                
                   // etc ...
                }

                Только так. Иначе, то что описывает автор, приведет к ухудшению, а не улучшению кодовой базы.


                1. rjhdby
                  27.12.2019 15:28

                  Мне жаль если вы не понимаете разницу.

                  Мне жаль, если вы не видите общего за частностями


                  Предлагаемый подход не решает, SOLID из коробки, он его кастрирует

                  SOLID — это не парадигма программирования — это этакий забор вокруг ООП, помогающий не пуститься во все тяжкие. Если на месте одной из секций этого забора будет уже готовое естественное препятствие, то это не повод страдать и плакать, что забор остался не завершенным.


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


                1. GreedyIvan
                  27.12.2019 22:09
                  +1

                  Вы тут неправы.

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

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

                  Автор может в следующей минорной версии выпилить эту зависимость, поэтому что она была не под интерфейсом, а просто частью реализации.
                  Вот если бы он внедрил её интерфейсом, то он бы взял на себя обязательство, что его библиотека зависит от некого интерфейса, является его клиентом. И вы вправе использовать её с любой реализацией этого интерейса.

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


                  1. customlabs
                    28.12.2019 08:41

                    И да, далеко не всегда попадаются внешние библиотеки, где интересный нам функционал, который нам надо модифицировать, вынесен под интерфейс.

                    Именно об этом я и говорю, ценность таких библиотек сильно низка, так как авторы не заботятся о контрактах, и не гарантируют его соблюдение в будущем. Отсюда или фокри или постоянные проблемы при обновлении версий.


                    И именно это и вызывает основную боль. А голословно утверждать что парни давайте все зафиналим, это плохо. У любого подхода есть плюсы и минусы, это надо четко понимать.


            1. parshikov_pavel Автор
              27.12.2019 15:04

              Финальный класс без контракта требуемый в качестве аргумента, это запрещение использования данного принципа. Это запрещение разработчику который хочет воспользоваться вашим кодом, расширения его частей

              Конечно, как и любая ограничивающая конструкция, final снижает гибкость и "отключает" некоторые возможности ООП.
              Зато с помощью final Вы можете взять в свои руки управление гибкостью использования своих классов. И если Вы не предусмотрели интерфейс для финального класса, который передается в качестве параметра в некоторый метод, то значит данный метод и разрабатывался для работы с этим конкретным классом, stable зависимостью. Если вы просто уберете final и разрешите создавать наследников для некоторой зависимости, то обязаны, по сути, гарантировать, что метод сможет с ними взаимодействовать. При том что у Вас будет отсутствовать явный контракт для этих дочерних классов.
              Если формально подходить к LSP, то его реализация и невозможна без контракта.


              It is a semantic rather than merely syntactic relation, because it intends to guarantee semantic interoperability of types in a hierarchy.
              In addition to the signature requirements, the subtype must meet a number of behavioural conditions.

              Нет интерфейса и контракта — нет никаких гарантий того, что в результате наследования будет получен поведенческий подтип (behavioural subtyping) и поэтому нет LSP.


        1. symbix
          27.12.2019 16:15
          +1

          Давайте на конкретном примере.


          Единственный разумный случай, когда в сущности может появится принимаемый в аргументах интерфейс — это double dispatch.


          Все остальное, что мне приходит в голову — сплошные антипаттерны, смешение слоев domain и infrastructure.


  1. 411
    27.12.2019 11:06
    +1

    Двоякое впечатление. Пример с подсчетом ссылок явно должен решаться композицией, а не наследованием, чтобы не смешивать запрос данных с подсчётом ссылок и/или презентационной логикой и сохранить SRP. final тут никак не помогает, если не желает хуже даже.


    Мне кажется использование final как защиту от дурака вообще не оправдано. Скорей всего это как раз знак проблем в архитектуре. Выше в комментариях правильно обозначили, что есть domain modelling, и там использование final — показатель того, что сущность полна(по сути финальна, отсюда и final), её не надо расширять. Думаю это единственно верное использование final с точки зрения проектирования.


    Еще использование final может быть оправдано с точки зрения оптимизации кода, но не уверен, применимо ли это к php.


    1. rjhdby
      27.12.2019 13:27

      Пример с подсчетом ссылок явно должен решаться композицией

      Ну это пример всё же. Подобрать для статьи такой пример, к которому никто не докопается — эта задача посложнее инвалидации кеша и придумывания имен :)


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

      final, как минимум, говорит нам, что у класса точно нет наследников и мы никому ничего не поломаем изменив внутреннюю логику (не нарушая контракта). Посмотрите, например, на Kotlin — там все классы изначально закрыты и требуют специального модификатора open для разрешения наследования. И это как минимум не мешает, а в целом заставляет каждый раз задуматься — не совершаю ли я ошибку, используя наследование в данном конкретном случае.


      1. 411
        27.12.2019 21:15
        +1

        Kotlin — хороший пример. Я бы сказал, что final по умолчанию имеет право на жизнь и это лучше конвенции писать везде final. (Логичным вопросом будет "в чём разница?" Ответом будут затраты на анализ, ревью и поиск ошибок в случае, где final был не нужен или не был поставлен неправильно).


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


        Если переходить к практическому применению


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

        Это хороший бонус, но это нужно делать и без такой возможности. На самом деле всё очень сильно лежит в плоскости стадии проекта. В начальных стадиях очень многое ещё неизвестно и лишние запреты увеличивают затраты на поддержку и расширение. Когда проект большой, наоборот, нужно менять что либо как можно более явно, тут я бы сказал, что final по умолчанию — хороший помощник. Но это никак не отменяет самой идеи final — пометить класс как "окончательный". Это не ради защиты от дурака.


        final, как минимум, говорит нам, что у класса точно нет наследников и мы никому ничего не поломаем изменив внутреннюю логику (не нарушая контракта).

        Это следствие "окончательности". Спросите себя, почему в какой-то момент вы можете захотеть это сделать. Ну то есть та ситуация, где вы решили, что решением будет занаследоваться, при этом это нарушит L в SOLID. Либо класс был неправильно спроектирован (а тогда какое тут final?). Либо другие компоненты были неверно спроектированы (а тогда какой final у них?), вероятнее всего и то и то. Это ведь явно не похоже на ситуацию, где final решает проблему?


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


        Подобрать для статьи такой пример, к которому никто не докопается — эта задача посложнее инвалидации кеша и придумывания имен :)

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


        1. E_STRICT
          28.12.2019 09:08

          Ещё я за отсутствие final в библиотеках, никогда не знаешь, как твой код будут использовать и какие могут там быть баги. Дать возможность разработчикам исправить их — меньшее зло по сравнению с запретами неправильных наследований.
          Как раз в библиотеках final нужен больше всего. Отсутствие final у классов это косвенный признак того, что наследование является точкой расширения данной библиотеки. Соответственно разработчик такой библиотеки (если он следует семантическому версионированию) обязан соблюдать обратную совместимость при изменении таких классов. На практике, это приводит к тому, что такие классы очень сложно поддаются рефакторингу. Нельзя просто так менять сигнатуры у protected методов и тем более удалить целиком класс. Фактически класс будет «заморожен» как минимум до следующего мажорного релиза.
          Другой важный момент это то, что удалить final никогда не поздно. Если появится use case для того, чтобы открыть класс для наследования, то сделать это можно будет в любой момент, потому что это не ломает обратную совместимость.


        1. rjhdby
          28.12.2019 11:51

          Либо класс был неправильно спроектирован (а тогда какое тут final?). Либо другие компоненты были неверно спроектированы (а тогда какой final у них?), вероятнее всего и то и то. Это ведь явно не похоже на ситуацию, где final решает проблему?

          Как раз наоборот. Если в один прекрасный момент final начинает мешать — это такой очень хороший маркер, что надо бы задуматься над причинами.


          Еще раз попробую донести свою мысль.
          final — это один из множества инструментов ограничения языка/разработчика. Ровно так же как модификаторы области видимости, строгость типизации, автоматические проверки code-style, следование SOLID и т.д. — artificial constraint. Сами по себе они не плохи и не хороши, рассуждать можно только в контексте задачи — что нам важнее, скорость написания или стоимость поддержки (ну и все остальные аргументы из вечного спора статическая/динамическая типизация, как основного поставщика аргументов).


  1. E_STRICT
    27.12.2019 13:38

    Стоит сказать, что написание дополнительной строки кода – довольно невысокая цена за получаемые с агрегацией преимущества

    Да ладно. Даже в вашем примере без док. блока, это не одна строка, а четыре.

    public function getCommentKeys(): array
    {
      return $this->commentBlock->getCommentKeys();
    }

    Если в новом классе нужно подменить всего один метод из десяти, то этот класс будет процентов на 90 состоять из таких вот суррогатных декораций. Вроде ничего страшного, но остаётся ощущение что с наследованием это выглядело бы намного красивее, по крайней мере внешне.


    1. Kunokput
      27.12.2019 16:45

      Поэтому форвардящий декоратор можно вынести в отдельный класс и наследоваться от него.

      // Wrapper class - uses composition in place of inheritance
      public class InstrumentedSet<E> extends ForwardingSet<E> {
       private int addCount = 0;
       public InstrumentedSet(Set<E> s) {
       super(s);
       }
       @Override public boolean add(E e) {
       addCount++;
       return super.add(e);
       }
       @Override public boolean addAll(Collection<? extends E> c) {
       addCount += c.size();
       return super.addAll(c);
       }
       public int getAddCount() {
       return addCount;
       }
      }
      // Reusable forwarding class
      public class ForwardingSet<E> implements Set<E> {
       private final Set<E> s;
       public ForwardingSet(Set<E> s) { this.s = s; }
       public void clear() { s.clear(); }
       public boolean contains(Object o) { return s.contains(o); }
       public boolean isEmpty() { return s.isEmpty(); }
       public int size() { return s.size(); }
       public Iterator<E> iterator() { return s.iterator(); }
       public boolean add(E e) { return s.add(e); }
       public boolean remove(Object o) { return s.remove(o); }
       public boolean containsAll(Collection<?> c)
       { return s.containsAll(c); }
       public boolean addAll(Collection<? extends E> c)
       { return s.addAll(c); }
       public boolean removeAll(Collection<?> c)
       { return s.removeAll(c); }
       public boolean retainAll(Collection<?> c)
       { return s.retainAll(c); }
       public Object[] toArray() { return s.toArray(); }
       public <T> T[] toArray(T[] a) { return s.toArray(a); }
       @Override public boolean equals(Object o)
       { return s.equals(o); }
       @Override public int hashCode() { return s.hashCode(); }
       @Override public String toString() { return s.toString(); }
      }
      


      Статья очень большая, ее трудно осилить.
      Автор не упомянул простое правило, которым можно руководствоваться, при создании иерархии типов: хочешь наследования? Напиши 3 тестовые реализации сам и дай 1 реализацию написать соседу. Лень? извольте в композицию


    1. parshikov_pavel Автор
      28.12.2019 01:27

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


      В статье лишь утверждается, что нужно "по умолчанию" ограничить возможность наследования с помощью final. А уж если Вы пошли по пути создания дочерних классов, то старайтесь уменьшить зацепление на детали реализации настолько, насколько это возможно. Например, используя описанные в статье техники — abstract классы и final методы. А уж если реализация может быть переопределена в дочернем классе, то задокументируйте в PHPDoc все существенные нюансы.


  1. IvanNochnoy
    27.12.2019 13:45
    -1

    Если Вы будете относиться к protected не как к private-не-для-всех, а как к public-не-для-всех, то все рассуждения в статье потеряют смысл.


  1. rboots
    27.12.2019 20:18

    Мне одному режет слух «слабое зацепление» вместо «слабой связанности» и «сокрытие» вместо «инкапсуляции»?


    1. parshikov_pavel Автор
      28.12.2019 00:39
      +2

      "Связность" и "зацепление" — несколько неустоявшиеся переводы английских cohesion и coupling. Ориентировался при использовании терминов на википедию. Хотя разные авторы и переводчики используют для coupling как зацепление, так и связность.
      По поводу "инкапсуляции" и "сокрытия", тоже существуют разные мнения. В википедии написано, что они часто используются взаимозаменяемо:


      The term encapsulation is often used interchangeably with information hiding.

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


  1. Laraan
    28.12.2019 00:17
    +1

    На поддержке старых проектов, иногда возникает желание по-отрывать руки за private и final. Требования бизнеса постоянно меняются, и лучше иметь в арсенале гибкий класс, логику которого всегда можно изменить наследованием, чем кучу черных ящиков, в которых ничего уже не изменить. На теории все красиво, но в реальной жизни следование академическим принципам означает повышение стоимости поддержки такого кода.


    1. Cerberuser
      28.12.2019 07:36
      +2

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


  1. Killarium
    28.12.2019 00:36
    +2

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