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

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

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

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


Гибкость – это конечно полезная черта дизайна. Однако при выборе архитектуры нас интересуют в первую очередь сопровождаемость, тестируемость, читабельность кода, повторное использование модулей. Так вот с этими критериями хорошего дизайна у наследования тоже проблемы. «И что же теперь, не использовать наследование вообще?» – спросите Вы.


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


Проблема хрупкого базового класса



Проблема хрупкого базового класса


Одним из основных критериев хорошей архитектуры является слабое зацепление (loose coupling), которое характеризует степень взаимосвязи между программными модулями. Не зря слабое зацепление входит в перечень паттернов GRASP, описывающих базовые принципы для распределения ответственности между классами.


Слабое зацепление имеет массу преимуществ.


  • Ослабив зависимости между программными модулями, вы облегчаете сопровождение и поддержку системы за счет формирования более гибкой архитектуры.
  • Появляется возможность параллельной разработки слабозацепленных модулей без риска нарушить их функционирование.
  • Логика работы класса становится более очевидной, легче становится использовать класс правильно и по назначению, и сложно использовать – неправильно.


Традиционно под зависимостями в системе подразумеваются прежде всего связи между используемым объектом (сервисом) и использующим объектом (клиентом). Такая связь моделирует отношение агрегации (aggregation), когда сервис «является частью» клиента (has-a relationship), а клиент передаёт ответственность за выполнение поведения вложенному в него сервису. Ключевую роль в ослаблении связей между клиентом и сервисом играет принцип инверсии зависимостей (dependency inversion principleDIP), предлагающий преобразовать прямую зависимость между модулями в обоюдную зависимость модулей от общей абстракции.


Однако существенно улучшить архитектуру приложения можно также ослабив зависимости в рамках отношения наследования (is-a relationship). Отношение наследования по умолчанию создает сильное зацепление (tight coupling), наиболее сильное среди всех возможных форм зависимостей, а потому должно использоваться очень осторожно.


Сильное зацепление в отношении наследования



Сильное зацепление в отношении наследования


Количество кода, разделяемого между родительским и дочерним классами, очень велико. Особенно сильно эта проблема начинает проявляется при злоупотреблении концепцией наследования – использовании наследования исключительно для горизонтального повторного использования кода, а не для создания специализированных подклассов. Ведь наследование – это самый простой способ повторного использования кода. Вам достаточно просто написать extends ParentClass и все! Ведь это гораздо проще агрегаций, внедрения зависимостей (dependency injection, DI), выделения интерфейсов.


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


Часть 2 тут https://telegra.ph/Zachem-ogranichivat-nasledovanie-s-pomoshchyu-final-CHast-2-01-08

Report Page