что означает модификатор final к чему он может быть применим

Использование Final, Finally и Finalize на Java

Если у вас есть какой-либо опыт интервью, вы могли заметить, что интервьюеры, как правило, задают сложные вопросы, которые обычно берутся из базовых понятий. Один из таких вопросов, который чаще всего задают, заключается в различении между Final, Finally and Finalize на Java.

Что такое Java final?

В Java final – это ключевое слово, которое также можно использовать в качестве модификатора доступа. Другими словами, final ключевое слово используется для ограничения доступа пользователя. Он может использоваться в различных контекстах, таких как:

С каждым из них последнее ключевое слово имеет разный эффект.

1. Переменная

Всякий раз, когда ключевое слово final в Java используется с переменной, полем или параметром, это означает, что после передачи ссылки или создания экземпляра его значение не может быть изменено во время выполнения программы. Если переменная без какого-либо значения была объявлена как final, тогда она называется пустой / неинициализированной конечной переменной и может быть инициализирована только через конструктор.

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

2. Метод

В Java всякий раз, когда метод объявляется как final, он не может быть переопределен никаким дочерним классом на протяжении всего выполнения программы.

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

3. Класс

Когда класс объявляется как final, он не может наследоваться ни одним подклассом. Это происходит потому, что, как только класс объявлен как final, все члены-данные и методы, содержащиеся в классе, будут неявно объявлены как final.

Кроме того, как только класс объявлен как final, он больше не может быть объявлен как абстрактный. Другими словами, класс может быть одним из двух, конечным или абстрактным.

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

Блок Finally

В Java, Finally, является необязательным блоком, который используется для обработки исключений. Обычно ему предшествует блок try-catch. Блок finally используется для выполнения важного кода, такого как очистка ресурса или освобождение использования памяти и т. д.

Блок finally будет выполняться независимо от того, обрабатывается ли исключение или нет. Таким образом, упаковка кодов очистки в блок finally считается хорошей практикой. Вы также можете использовать его с блоком try без необходимости использовать блок catch вместе с ним.

Метод Finalize

Finalize – это защищенный нестатический метод, который определен в классе Object и, таким образом, доступен для всех без исключения объектов в Java. Этот метод вызывается сборщиком мусора до полного уничтожения объекта.

Иногда, объекту может потребоваться выполнить какую-то важную задачу, такую как закрытие открытого соединения, освобождение ресурса и т. д., Прежде чем он будет уничтожен. Если эти задачи не будут выполнены, это может снизить эффективность программы.

Таким образом, сборщик мусора вызывает его для объектов, на которые больше нет ссылок и которые помечены для сбора мусора.

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

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

Источник

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

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

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

что означает модификатор final к чему он может быть применим

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

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

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

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

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

что означает модификатор final к чему он может быть применим

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

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

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

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

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

Проблемы наследования

Начнем с самой очевидной проблемы, которая в первую очередь приводится в литературе по архитектуре.

Наследование нарушает принцип сокрытия

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

Хотя в классической книге «Банды четырех» речь идет о нарушении инкапсуляции, точнее будет сказать, что «наследование нарушает принцип сокрытия». Ведь инкапсуляция – это сочетание данных с методами, предназначенными для их обработки. А вот принцип сокрытия как раз обеспечивает ограничение доступа одних компонентов системы к деталям реализации других.

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

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

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

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

Родительский класс теперь вынужден поддерживать стабильность не только public интерфейса, но и protected интерфейса, так как любые изменения в нем будут приводить к проблемам в работе дочерних классов. При этом отказаться от использования protected членов класса невозможно. Если protected интерфейс будет полностью совпадать с внешним public интерфейсом, т.е. родительский класс будет использовать только public и private члены, то наследование вообще теряет смысл.

Фактически, ключевое слово protected на самом деле никакой защиты членов класса не обеспечивает. Чтобы получить доступ к таким членам, достаточно унаследоваться от класса, и в рамках дочернего класса Вы имеете все возможности по нарушению принципа сокрытия. Классом становится очень просто пользоваться неправильно, что является одним из первых признаков плохой архитектуры.

что означает модификатор final к чему он может быть применим

Нарушение принципа сокрытия через protected интерфейс

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

Для примера, дополним функциональность класса CommentBlock :

Частые случаи нарушений сокрытия таковы:

Проблема банан-обезьяна-джунгли

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

Joe Armstrong, создатель Erlang

Зависимости всегда присутствуют в архитектуре системы. Однако наследование несет за собой ряд осложняющих факторов.

Вы наверняка сталкивались с ситуацией, когда по мере развития программного продукта иерархии классов существенно разрастались. Например,

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

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

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

Как же теперь тестировать дочерние классы где-то в глубине дерева иерархии, ведь их реализация разбросана по родительским классам? Для тестирования вам понадобятся все родительские классы, и вы никаким образом не можете их замокать, т.к. имеете зацепление не по поведению, а по реализации. Так как ваш класс не может быть легко изолирован и протестирован, вы получаете в наследство массу проблем – с сопровождаемостью, расширяемостью, повторным использованием.

Открытая рекурсия по умолчанию

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

Self-call приводит к вызовам методов в текущем классе, либо может динамически перенаправляться вверх или вниз по иерархии наследования на основе позднего связывания (late binding). В зависимости от этого self-call подразделяют на:

Частое использование down-call и up-call в реализации методов еще более тесно зацепляет классы, делает архитектуру жесткой и хрупкой.

Из-за сильного зацепления, при внесении правок в класс Вы не можете сосредоточиться только на его поведении. Вы вынуждены учитывать внутреннюю логику работы всех классов, вниз и вверх по иерархии. Перечень и порядок выполнения down-call в родительском классе должны быть известны и документированы, что существенно нарушает принцип сокрытия. Детали реализации становятся частью контракта родительского класса.

Контроль побочных эффектов

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

Функция с побочными эффектами (function with side effects) изменяет некоторое состояние системы, помимо основного эффекта – возвращения результата в точку вызова. Примеры побочных эффектов:

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

Представим, что в класс CommentBlock потребовалось включить метод viewComment() для получения текстового представления одного из комментариев.

Однако родительский класс ничего не знает об особенностях реализации дочерних классов. Автоматически унаследованная реализация метода viewComments() не учитывает ответственность (responsibility) класса CountingCommentBlock – вести подсчет просмотров комментариев в кеше.

не учтет просмотр комментариев в кеше. Счетчики просмотров комментариев станут работать неверно, логика работы дочернего класса нарушена.

При любой незначительной модификации родительского класса вы должны «держать в голове» ответственности и связанные с ними побочные эффекты всех дочерних классов. В нашем случае, требуется переопределение метода viewComments() с добавлением побочного эффекта (инкрементирования значения счетчика).

Хрупкость базового класса

Таким образом, вся иерархия классов начинает жить одной общей жизнью. Кажущиеся, с первого взгляда, безопасными изменения в реализации родительского класса могут вызвать проблемы в работе дочерних классов, которые завязаны на эту реализацию. Для этой проблемы даже был введен термин – «Хрупкий базовый класс» («Fragile base class»). Что намекает о наличии в отношении «родительский-дочерний класс» одного из признаков проблемного дизайна – хрупкости (fragility).

Дочерний класс CountingCommentBlock переопределяет методы родительского класса и ведет учет просмотров комментариев в кеше.

Настало время рефакторинга и меткий взгляд программиста падает на следующую строку в методе CommentBlock::viewComments() :

Изменился только родительский класс CommentBlock и он выглядит, в целом, изолированным от остальной системы. Разработчик прогоняет автоматизированные тесты для CommentBlock – все работает исправно, тесты «зеленые». Программист считать эту правку корректной и закрывает задачу.

инициирует следующую последовательность вызовов:

Дочерний класс тесно завязан на детали реализации родительского класса, которые просачиваются через protected интерфейс родительского класса. И когда эти детали изменяются, логика работы дочернего класса может быть нарушена. Родительский класс не может рассматриваться изолированно. При изменении его реализации вы должны просмотреть все дочерние классы, которые эту реализацию наследуют.

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

Ключевое слово final

Замечание: Свойства и константы не могут быть объявлены финальными, только классы и методы.

Довольно лаконичное официальное руководство по PHP в этом разделе особенно немногословно. Остается совершенно неясным для каких целей вообще стоит применять это ключевое слово и как оно может помочь вашему приложению. Многие разработчики не используют final при определении классов и методов, не понимая необходимости сознательного ограничения возможностей наследования.

Действительно, использование final – это явное ограничение доступных программисту возможностей языка PHP, уменьшающее доступный арсенал архитектурных конструкций и снижающее гибкость архитектуры. Однако всегда ли нужна эта гибкость, ведь многие нововведения современного PHP как раз нацелены на ее ограничение: растущее число typehints, модификаторы области видимости констант и т.д.

Конечно, в первую очередь, защищать код имеет смысл в том случае, если он является частью публичного API. Вы не контролируете сценарии использования вашего кода, поэтому обязаны закрыть все возможные «двери» для создания зависимостей, особенно зависимостей на реализацию. При этом вы можете открыть «дверь» в любой момент без каких-либо проблем с клиентами класса. А вот закрыть такую «дверь» уже невозможно и это наверняка вызовет нарушение в работе стороннего кода, не говоря уже об усложнении поддержки кода.

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

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

Применение final для улучшения архитектуры

Паттерн «Шаблонный метод»

Причина возникновения сильного зацепления между классами в отношении наследования – повторное использование реализации. Родительский и дочерний класс разделяют большое количество кода, которое наследуется из методов с модификаторами public и protected.

Поэтому ослабить зацепление, оставаясь в рамках отношения наследования, можно лишь сократив объем наследуемого кода. И для этого в PHP (как и во многих ООП-языках) имеется механизм абстрактных классов и абстрактных методов в их составе. Абстрактный класс можно рассматривать как промежуточный вариант – между обычным классом, который делится полностью своей реализацией с дочерними классами, и интерфейсом, который лишь описывает контракт.

С поведенческой точки зрения, абстрактный класс определяет шаблон (скелет) общего алгоритма и предоставляет дочерним классам возможность конкретизировать некоторые его шаги. Такая архитектурная конструкция известна как паттерн «Шаблонный метод» (Template method).

В соответствии с этим паттерном, поведение абстрактного родительского класса разделяют на две части:

Уместно провести аналогию с биологическими видами. Биологическая классификация, как и наследование в ООП, строится на основе выделения некоторых общих функций и поведения. При этом стоит заметить, что в такой классификации каждый конкретный вид животного является листовым узлом в иерархии классов. А все нелистовые узлы являются собирательными абстрактными классами. Т.е., например, не существует какой-то конкретной птицы вообще, однако есть конкретные орлы, соколы, аисты. Применительно к построению архитектуры классов эта метафора позволяет сделать следующие интересные выводы:

Вернемся к примеру с блоками комментариев. Приведем иерархию наследования в соответствие со структурой паттерна «Шаблонный метод» и разделим поведение на abstract и final методы.

Простой блок комментариев оформим в виде дочернего класса SimpleCommentBlock :

Блок комментариев, подсчитывающий просмотры, теперь выглядит так:

За счет необходимости следовать общему «шаблону», мы сокращаем количество доступных приемов для зацепления классов по реализации. Любая реализация не может быть переопределена дочерними классами за счет использования final методов и final классов.

Однако мы остаемся в рамках концепции наследования и большинство ранее описанных проблем остается актуальной. Например, проблема открытой рекурсии. По сути, вся идея паттерна «Шаблонный метод» строится на down-call, выполняемых из шаблонных методов абстрактного родительского класса, к кастомизированным методам дочерних классов. Это существенно запутывает порядок выполнения программы.

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

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

Возьмем пример с блоками комментариев и исключим из него полностью разделение какого-либо кода между классами. Для этого выделим интерфейс CommentBlock :

Реализуем интерфейс в финальном классе простого блока комментариев:

А также в финальном классе блока комментариев, подсчитывающего просмотры:

Разберем преимущества и недостатки подобной чистой реализации интерфейса через implements без какой-либо ассоциации (association) между двумя классами.

А что насчет принципа открытости/закрытости (OCP)? Да ведь ограничивающие конструкции final и implements – это готовые средства языка PHP для обеспечения закрытости класса.

Классы в такой архитектуре можно сравнить со «строительными блоками», готовыми к конструированию приложения. С помощью implements мы указываем к какому типу принадлежит блок, а с помощью final делаем класс законченным и готовым к употреблению.

Осталось только разобраться, как же использовать эти «строительные блоки», если они ограничены в отношении наследования, и как их открыть для расширения функциональности. Также у предыдущего примера есть проблема – в классах SimpleCommentBlock и CountingCommentBlock имеется одинаковое поведение в методе viewComments() и неплохо было бы его разместить в одном месте.

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

Класс-декоратор CountingCommentBlock вызывает соответствующие методы базового класса и при необходимости дополняет их поведение. Например, метод viewComment() дополняет базовое поведение инкрементированием ключей в кэше. Такие методы называют методами передачи (forwarding methods).

Некоторые разработчики именно по этой причине отдают предпочтение наследованию, которое сокращает объем кода. Особенно в случае, если при агрегации классы-декораторы большей частью состояли бы из таких «однострочных» методов передачи. Стоит сказать, что написание дополнительной строки кода – довольно невысокая цена за получаемые с агрегацией преимущества (слабое зацепление, следование SOLID) и избегаемые недостатки наследования (нарушение сокрытия, хрупкость архитектуры, зацепление на реализацию).

Финальные классы SimpleCommentBlock и CountingCommentBlock становятся для разработчика чем-то вроде «черного ящика», внутрь которого невозможно забраться через создание дочернего класса и переопределить некоторый код. С таким «черным ящиком» мы взаимодействуем через интерфейс, без необходимости учитывать особенности реализации. Класс готов к применению и не требует никакой конкретизации и уточнения поведения, как в случае наследования и паттерна «Шаблонный метод». Тем самым исключается часть проблем наследования – нарушение принципа сокрытия и зацепление на детали реализации.

Вместе с этим решается и проблема «Хрупкого базового класса» – изменение любого из компонентов архитектуры (джунглей, обезьян, бананов), не нарушающее заявленный контракт, не окажет влияния на внешнюю среду. Мы получаем архитектуру из набора изолированных блоков, в которой изменения в реализации одного класса не приводят к каскадным эффектам и нарушениям в работе других классов.

Посмотрите пример ниже.

Класс должен быть подготовлен к наследованию

Каждый класс имеет набор типичных вариантов использования: создание и уничтожение объектов, вызов методов, доступ к объектам класса через доступные интерфейсы, сериализация и десериализация, преобразование в строку, клонирование и т.д. Однако о доступности класса для наследования разработчик думает в последнюю очередь.

Для того чтобы избежать перечисленных проблем, нефинальные классы должны раскрыть для своих потомков все существенные нюансы внутренней реализации. Это подразумевает документирование в PHPDoc таких деталей кода, которые позволят без последствий переопределить в процессе наследования нефинальные методы класса. Для этого документация должна раскрывать: как работают нефинальные методы и как они используются через открытую рекурсию.

То есть с одной стороны, вам следует указать ожидаемую реализацию поведения для каждого метода, доступного для переопределения (т.е. нефинального, с модификатором public или protected ). В PSR-19: PHPDoc tags, находящемся в состоянии черновика, не предусмотрен тег для описания требований к реализации. И часто эти требования явно не выделяются в PHPDoc, а просто предваряются фразой «Реализация этого метода делает то-то».

Однако предлагаю позаимствовать из JavaDoc тег @implSpec, который как раз предназначен для описания спецификации реализации и отделения ее от остальной документации. Как правило, PHPDoc является спецификацией API и описывает контракт между методом класса и его клиентом, т.е. внешний public интерфейс. Тег @implSpec предназначен для раскрытия подробностей того, как реализован этот API. Именно здесь предлагаю разместить текстовое описание деталей реализации, которые являются частью protected интерфейса между методом и дочерними классами.

И если в дочернем классе потребуется переопределить метод, то спецификация реализации в @implSpec подскажет:

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

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

Поэтому при проектировании класса вам следует выбрать один из двух вариантов:

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

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

final заставляет задуматься о необходимости наследования

Запрещая наследование для своих классов «по умолчанию», вы получаете массу преимуществ, особенно на этапе поддержки приложения, который, как известно, гораздо длиннее самой разработки. Упрощается рефакторинг, снимается бремя поддержки обратной совместимости protected интерфейса для дочерних классов. В конце концов, ключевое слово final – это важное знание о том, что дочерние классы отсутствуют, а значит код и поведение, которые не входят в его public контракт, могут изменяться без последствий.

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

final заставляет задуматься разработчика о том, стоит ли вообще идти по пути такого сильно зацепления двух классов. Может быть стоит предпочесть агрегацию и слабое зацепление через public интерфейс? А может быть архитектура приложения выстроена неверно и необходимость наследования – один из первых запахов кода, построенного на антипаттернах?

final теперь является важным инструментом вашей кодовой базы, а значит способ и применение этого инструмента могут стать предметом обсуждения на этапе code review (ведь вы же делаете code review? ;). Например, таких.

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

И еще небольшое замечание. Ключевое слово final появилось вместе с новой объектной моделью в PHP5. И, скорее всего, по множеству различных причин (обратной совместимости, низкого порога вхождения) классы и методы по умолчанию доступны для наследования.

Класс должен быть подготовлен к агрегации

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

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

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

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

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

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

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

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

Добавляете в архитектуру производный класс-декоратор, расширяющий функциональность базового класса и реализующий тот же самый интерфейс. В конструктор класса-декоратора вводите через интерфейс CommentBlock экземпляр базового класса. Методы класса-декоратора выполняют вызовы методов базового класса и при необходимости дополняют его поведение.

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

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

Использование final классов в тестах

В этой отличной идее создания слабозацепленной архитектуры на final классах и агрегации есть небольшая загвоздка – большинство библиотек модульного тестирования (PHPUnit, Mockery) используют наследование для создания тестовых «двойников» (test doubles). И это может стать проблемой для тех, кто в модульных тестах мокает зависимости для имитации контекста тестируемого класса.

Например, следующий тест:

завершается с ошибкой:

И это естественно, так как «под капотом» PHPUnit пытается создать дочерний класс, такого вида:

Наследование очень удобно для создания тестовых «двойников». Во-первых, полученная сымитированная реализация может быть подставлена в код вместо оригинальной реализации, т.к. является ее подтипом. А, во-вторых, наследование нарушает принцип сокрытия, а значит PHPUnit может с легкостью подменить методы тестируемого класса. Например, заменить их заглушками или дополнить ожиданиями.

Ключевое слово final пресекает попытки библиотек модульного тестирования залезть в реализацию оригинального класса и подправить поведение его методов. И тут есть два подхода к преодолению этого ограничения: архитектурный и магический. Рассмотрим их поподробней.

Архитектурный подход

Но допустим, что ваш класс является изменчивой (volatile) зависимостью и может иметь несколько возможных реализаций. В этом случае, нужно понять, что тестовый «двойник» – это всего лишь еще одна, упрощенная фиктивная реализация. А это значит, что тестовый «двойник» не должен наследовать и переопределять поведение оригинального класса, он должен разделять с ним общий интерфейс. И если архитектура системы построена на базе принципа инверсии зависимостей (DIP), а элементы зависят только от абстракций, то тестовый «двойник» сможет полиморфно замещать оригинальный класс без наследования.

А значит, если вы использовали описанную в предыдущем разделе схему подготовки класса к агрегации. Т.е. описали контракт в виде интерфейса:

и реализовали его в классе:

То сможете без проблем создать тестовый «двойник»:

И если у вас возникла потребность создать тестовый «двойник» на базе конкретной реализации, то это сигнал о проблеме в архитектуре. Наиболее частые из них:

Магический подход

Но встречаются кейсы, когда создание тестового «двойника» – это отдельный тестовый вариант использования класса, не связанный с решением бизнес-задач. Например, вам нужно проверить ожидания для сущности предметной области (количество вызовов метода, переданные параметры). И если вы специально для этой задачи введете интерфейс в архитектуру, то он вместо «контракта для задач клиентов» становится «набором методов для проверки ожиданий».

Получается, что на этапе тестирования приложения ограничение наследования с помощью final должно быть снято.

Один вариант – использовать агрегацию и тестовый прокси-двойник. В этом случае экземпляр оригинального класса помещается внутрь тестового прокси-двойника. Вызовы, поступающие к прокси-объекту, дополняются ожиданиями и перенаправляются к оригинальному объекту. Эта идею можно реализовать вручную или воспользоваться уже готовой реализацией в библиотеке Mockery.

Инструменты для удобной работы с final классами

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

PHPStorm для генерации кода

что означает модификатор final к чему он может быть применим

Теперь при создании класса через File | New | PHP Class автоматически получаем заготовку класса вида:

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

И здесь у PHPStorm также есть удобный инструмент Refactor | Extract | Interface. В окне указываем имя извлекаемого интерфейса и методы, включаемые в него. Включаем замену ссылок на класс по коду ссылками на интерфейс (опция Replace class reference with interface where possible) и перемещение PHPDoc в интерфейс (опция Move PHPDoc).

В результате рефакторинга получаем интерфейс вида:

К исходному классу автоматически добавляется сгенерированный интерфейс:

Далее через инструмент создания File | New | PHP Class по шаблону создаем финальный класс-декоратор, расширяющий функциональность. Вручную вписываем приватное свойство для хранения экземпляра декорируемого класса и реализацию сгенерированного интерфейса:

Теперь воспользуемся инструментом для генерации конструктора Code | Generate | Constructor. В результате получаем готовый конструктор для инъекции декорируемого класса.

И последний шаг – генерация заготовок для реализации методов интерфейса. Воспользуемся инструментом Code | Generate | Implement Methods. К сожалению, сейчас мы можем сгенерировать только пустые заглушки методов. Возможно в будущем в PHPStorm появится инструмент для генерации готовых «однострочных» методов передачи для делегирования поведения вложенному объекту, как это уже реализовано в родственных IntelliJ IDEA и ReSharper.

PHPDoc для методов также сгенерированы автоматически. Осталось только наполнить методы поведением.

PHPStan для контроля стиля кодирования

А что если вы хотели бы автоматически контролировать, что добавляемые в архитектуру классы изначально ограничены в использовании наследования. Например, в самом простейшем случае, проверять, что классы объявлены с модификатором final и нацелены на агрегацию. И тут на помощь приходят инструменты статического анализа кода (подробный обзор на хабр).

Самый популярный из таких инструментов PHPStan. И «из коробки» он умеет выявлять в вашем коде типичные ошибки. Однако PHPStan позволяет довольно легко расширять функциональность и писать собственные правила проверки кодовой базы. Эту фичу как раз можно задействовать для контроля стиля своего кода.

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

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

И запускаем анализ кодовой базы с указанием уровня строгости (здесь max ):

В результате получаем ошибки вида:

Удобнее настроить вывод в JSON файл и использовать результат в Continuous Integration.

Заключение

Итак, мораль сей статьи такова: добавляйте к своим классам final по умолчанию! А лучше настройте шаблон в своей IDE, чтобы это происходило автоматически.

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

Однако, путь агрегации не легок и требует четкого следования принципам ослабления зависимостей. Теперь вам следует:

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

Возможно вам кажется, что все эти модные принципы – всего лишь теоретические формулировки, не имеющие отношения к реальности. Однако построение архитектуры на интерфейсах и final классах имеет конкретный практический смысл. Борьба со сложностью системы и сокращение когнитивной нагрузки (cognitive load) – вот ради чего мы выполняем декомпозицию системы на изолированные блоки. И слабое зацепление классов – один из важных шагов в этом направлении.

Теперь вы можете разделить процесс проектирования на два этапа: выделение интерфейсов и их реализация в виде final классов. На первом этапе вы размышляете на уровне контрактов и взаимоотношений между классами. На втором – на уровне конкретной изолированной реализации контракта. Это позволяет сосредоточиться на небольшом количестве деталей одновременно. Ведь мы не в состоянии держать в голове одновременно слишком много сущностей. И при увеличении количества, сложность их совместного поведения растет экспоненциально. А значит, растет и число случайных ошибок.

Начните использовать final как один из важных инструментов построения кодовой базы. Вам понравится твердая (SOLID) архитектура без всяких хрупких (fragile) классов. А вашему проджект менеджеру понравится скорость внедрения новых фич, которые не поломали ничего вокруг себя.

Источник


Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *