Зачем ограничивать наследование с помощью final? Часть 2

Зачем ограничивать наследование с помощью final? Часть 2

https://habr.com/ru/post/482154/


Часть 1 тут

https://telegra.ph/Zachem-ogranichivat-nasledovanie-s-pomoshchyu-final-01-08

Ключевое слово 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;
    }
}


Часть 3 тут

https://telegra.ph/Zachem-ogranichivat-nasledovanie-s-pomoshchyu-final-CHast-3-01-08

Report Page