SOLID: I — Interface Segregation Principle
Makar M.Введение
Принцип разделения интерфейса был впервые сформулирован Робертом Мартином, широко известным в сообществе разработчиков как «Uncle Bob». А происхождение принципа тесно связано с его консультационным опытом в компании Xerox в середине 1990-х годов.
Компания Xerox в то время занималась разработкой сложной системы для принтеров, которая должна была выполнять множество разнообразных задач, таких как печать, сшивание, отправка факсов и т.п.. Изначально архитектура системы предполагала использование одного центрального класса Job для управления всеми этими задачами. Когда требовалось выполнить какую-либо операцию, например, задание на печать, то соответствующий модуль обращался к этому классу.
По мере роста программного обеспечения, класс Job становился всё более объёмным, приобретая методы для каждой новой задачи. Он превратился в так называемый God Object, содержащий огромное количество специфичных методов для разных клиентов. Проблема, возникшая в процессе разработки, заключалась в том, что даже самое незначительное изменение в системе требовало полного цикла развёртывания, который мог занимать до часа. Это существенно замедляло процесс разработки, делая её очень дорогой и неэффективной.
Анализ этой ситуации показал, что корень проблемы не столько заключался в размере класса Job, сколько в зависимостях, которые от него образуются. Длительный цикл развёртывания являлся прямым следствием высокого зацепления и низкой модульности системы. Модуль, которому требовалась всего одна функция (например, сшивание), вынужден был зависеть от всего интерфейса класса Job, включая методы печати, факса и других функций, которые он не использовал. Такая зависимость означала, что любое изменение в интерфейсе класса Job (например, добавление нового метода для функции факса) потенциально могло потребовать перекомпиляции и повторного развёртывания всех клиентов, зависящих от Job, даже тех, которые не имели отношения к функции факса. Этот эффект «ряби» изменений, распространяющийся на различные типы клиентов из-за общей, чрезмерно широкой зависимости, привел к сильному зацеплению. Сильное зацепление, в свою очередь, обуславливает жесткость (трудность внесения изменений) и хрупкость (изменения в одной области ломают несвязанные области) системы. Таким образом, основная проблема заключалась не просто в объеме кода, а в структуре зависимостей, которая делала систему жесткой и дорогостоящей в модификации. ISP возник как решение для устранения этих нежелательных зависимостей.
Понимание ISP
Определение принципа разделения интерфейса гласит:
«Клиенты не должны быть вынуждены зависеть от интерфейсов, которые они не используют.»
(«The Interface Segregation Principle» 1996 Object Mentor, Robert C. Martin)
Суть принципа сводится к тому, чтобы вместо использования одного большого, универсального интерфейса, который предоставляет всю возможную функциональность, иметь набор небольших, узконаправленных, более специфичных интерфейсов, которые предоставляют только необходимою функциональность для клиентов.
Как определить, что у вас получился «толстый» интерфейс? Попробуйте проанализировать, какие методы используют клиенты из предоставленного им интерфейса. Если вы заметите, что большое количество методов остаются неиспользованными и невостребованными, то вашему интерфейсу пора похудеть.
Следствием использования таких интерфейсов становится, так называемое «загрязнение интерфейсов». Загрязнение интерфейса происходит тогда, когда интерфейс становится перегружен методами, не относящимися к его основной ответственности или не используемыми всеми имплементациями интерфейса. Это часто бывает, когда требования, специфичные для подклассов, добавляются к базовому интерфейсу, заставляя базовый интерфейс требовать и предоставлять функциональность, которая относится к деталям реализации, а не абстракциям.
Важно понимать, что концепция «толстого» интерфейса относительна и зависит от потребностей клиента. Сам по себе интерфейс не является «толстым». Он становится таковым, когда клиент вынужден зависеть от того, что он не использует. Например, интерфейс IAnimal с методами eat(), sleep(), swim(), fly() может быть не «толстым» для класса Duck, который обладает всеми этими возможностями. Однако он будет «толстым» для класса Penguin, который не умеет летать, или для класса Parrot, который не умеет плавать. Нарушение ISP происходит именно тогда, когда класс Penguin вынужден реализовывать интерфейс IAnimal. Интерфейс становится «толстым» по отношению к возможностям Penguin.
ISP предлагает решение, заключающееся в создании интерфейсов, адаптированных к возможностям реализующего класса. Таким образом, определение ISP подразумевает, что структура интерфейса должна определяться потребностями его клиентов, а не монолитным представлением о полной функциональности реализующего класса. «Толщина» интерфейса — это мера несоответствия между предлагаемым интерфейсом и требуемым подмножеством функциональности клиента.
В контексте ISP часто используется понятие ролевых интерфейсов. Ролевой интерфейс определяется ролью, которую клиент играет по отношению к объекту, а не полным набором возможностей самого объекта. Например, вместо одного интерфейса IMultiFunctionDevice, который включает методы print(), scan(), fax(), могут быть созданы отдельные ролевые интерфейсы IPrinter, IScanner, IFax. Аналогично, для класса Worker вместо единого интерфейса могут быть интерфейсы IWorkable и IEatable, а для класса Animal – IEating, ISleeping, ISwimming, IFlying.
Проблемы, решаемые ISP
Одной из основных проблем является нежелательное зацепление, которое возникает, когда клиенты вынуждены зависеть от общего, слишком широкого интерфейса. В такой ситуации, изменения в одной части «толстого» интерфейса, которые могут быть востребованны только одним клиентов, вынуждают изменять или, как минимум, перекомпилировать классы, реализующие этот интерфейс, а так же всех клиентов, зависящих от него, даже если они не используют измененённые методы. Это повышает хрупкость системы, при которой изменения в одной области кода могут неожиданно вызвать ошибки или поломки в других, казалось бы, несвязанных частях. Так же повышается жёсткость системы, что делает процесс внесения изменений более трудным и рискованным.
Другая существенная проблема – это принуждение клиентов к зависимости от неиспользуемых методов. Клиенты вынуждены знать о существовании методов, которые им не нужны для выполнения их задач, что усложняет понимание и правильное использование интерфейса. Более того, классы, реализующие «толстый» интерфейс, обязаны предоставить реализации для всех его методов, даже если они не обладают соответствующей функциональностью. Это часто приводит к созданию «пустых» реализаций методов или методов, выбрасывающих исключение NotImplementedException. Такая практика может нарушать принцип подстановки Лисков (LSP), который требует, чтобы объекты подтипов обладали всеми теми же свойствами что и базовые типы, и могли быть использованы вместо объектов своих супертипов без нарушения корректности программы.
Наконец, «толстые» интерфейсы затрудняют тестирование классов, их реализующих. Тесты могут требовать создания заглушек или учета методов, которые не имеют прямого отношения к тестируемой функциональности, что увеличивает сложность тестового кода и снижает его читаемость.
Преимущества применения ISP
Одним из ключевых преимуществ является повышение модульности и гибкости системы. Разделение интерфейсов на более мелкие и сфокусированные единицы делает код более независимым. Это позволяет различным частям приложения развиваться автономно, снижая вероятность возникновения конфликтов при интеграции и слиянии кода. Кроме того, мелкие, узкоспециализированные интерфейсы легче повторно использовать в различных контекстах, так как они представляют собой четко определенные наборы функциональности.
ISP также способствует улучшению тестируемости и поддерживаемости кода. Меньшие интерфейсы легче понять и правильно реализовать. Это упрощает написание юнит-тестов, поскольку зависимости становятся более явными, и требуется меньше усилий для создания заглушек или имитаций поведения других компонентов. Снижается вероятность ошибок, связанных с неполной или некорректной реализацией методов, поскольку классы реализуют только ту функциональность, которой они действительно обладают. Код становится более читаемым и понятным, что облегчает его сопровождение и модификацию в будущем.
Применение ISP приводит к снижению влияния изменений. Когда интерфейсы разделены, изменение в одном специфичном интерфейсе затрагивает только те классы и клиентов, которые от него зависят напрямую. Это предотвращает распространение изменений по всей системе, делая ее более устойчивой к модификациям.
Одним из значительных, хотя и часто недооцениваемых, преимуществ ISP является снижение когнитивной нагрузки на разработчиков. Разработка программного обеспечения включает в себя чтение, понимание и модификацию кода людьми. Интерфейсы выступают в роли контрактов и документации, описывающих, как взаимодействовать с объектом. Большой, «толстый» интерфейс представляет разработчику длинный список методов. Чтобы правильно использовать или реализовать такой интерфейс, разработчику приходится обрабатывать и понимать назначение и сигнатуру всех этих методов, даже если лишь немногие из них имеют отношение к текущей задаче. Это требует значительных умственных усилий и может быть утомительным, особенно в сложных системах. Человеческая кратковременная память ограничена, обычно способна удерживать около семи элементов одновременно. Разделенные интерфейсы, напротив, являются небольшими, сфокусированными и согласованными. Каждый интерфейс представляет собой отдельную, связанную группу функциональности или специфическую роль. Когда разработчик взаимодействует с объектом через небольшой, специфический интерфейс, ему нужно сосредоточиться только на ограниченном наборе методов, непосредственно относящихся к текущей задаче. Такое «разбиение» функциональности на более мелкие, логические блоки снижает когнитивную нагрузку, необходимую для понимания и использования интерфейса. Снижение когнитивной нагрузки приводит к ускорению разработки, уменьшению количества ошибок и повышению общего качества кода, поскольку разработчикам становится легче рассуждать о коде, с которым они работают. Это прямое, ориентированное на человека преимущество применения ISP.
ISP в контексте SOLID
Существует тесная связь между ISP и принципом единственной ответственности (Single Responsibility Principle, SRP). SRP утверждает, что класс должен иметь только одну причину для изменения. «Толстый» интерфейс, имеющий множество методов, часто нарушает дух SRP на уровне интерфейса, поскольку он объединяет несколько, возможно, несвязанных обязанностей. Реализация такого интерфейса может вынудить класс принять на себя эти множественные обязанности, тем самым нарушая SRP уже на уровне класса. Разделение интерфейсов помогает привести их в соответствие с принципом единственной ответственности, создавая мелкие, сфокусированные интерфейсы, каждый из которых имеет одну четко определенную роль или набор функциональности. Таким образом, ISP поддерживает SRP, а некоторые источники даже рассматривают ISP как технику для достижения SRP.
ISP также тесно связан с принципом подстановки Лисков (Liskov Substitution Principle, LSP). LSP требует, чтобы объекты подтипов обладали всеми теми же свойствами что и базовые типы, и могли быть использованы вместо объектов своих супертипов без нарушения корректности программы. Как обсуждалось ранее, нарушение ISP, при котором класс вынужден реализовывать неиспользуемые методы, часто приводит к «пустым» реализациям или методам, выбрасывающим исключения. Такое поведение нарушает требование LSP о взаимозаменяемости, поскольку подтип не может быть корректно использован везде, где ожидается его супертип. Применение ISP, при котором классы реализуют только те интерфейсы, которые соответствуют их фактическим возможностям, помогает классам легче соответствовать LSP. ISP может рассматриваться как способ корректного применения LSP, поскольку он гарантирует, что клиенты, зависящие от определенного интерфейса, будут работать только с объектами, которые полностью поддерживают функциональность, определенную этим интерфейсом.
Наконец, ISP поддерживает принцип инверсии зависимостей (Dependency Inversion Principle, DIP). DIP утверждает, что модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций. ISP способствует DIP, предоставляя более мелкие, сфокусированные абстракции в виде интерфейсов. Модули верхнего уровня могут зависеть от этих узкоспециализированных интерфейсов, снижая зацепление с конкретными деталями реализации. Таким образом, ISP улучшает качество абстракций, используемых в DIP, делая зависимости более целевыми и менее связанными.
Взаимосвязь принципов SOLID означает, что нарушение одного часто затрудняет следование другим. ISP, способствуя созданию мелкозернистых интерфейсов, выступает в качестве фундаментального принципа, который упрощает применение SRP и LSP и поддерживает DIP, предоставляя подходящие абстракции. Это не просто отдельное правило, а фактор, способствующий хорошему LSP часто обсуждаются в терминах классов и наследования. Однако, как показывают практика, распространенным следствием нарушения ISP («толстые» интерфейсы) является создание классов, которые нарушают SRP (множественная ответственность) и LSP (отсутствие полного поведенческого подтипирования из-за фиктивных методов или исключений). Это указывает на причинно-следственную связь: нарушение ISP затрудняет соблюдение SRP и LSP в реализующих классах. И наоборот, следование ISP путем создания небольших, согласованных интерфейсов естественным образом побуждает реализующие классы иметь меньше, более сфокусированных обязанностей (поддерживая SRP). Кроме того, реализуя только интерфейсы, соответствующие фактическим возможностям, классы с меньшей вероятностью будут иметь методы, которые выбрасывают исключения или ничего не делают, что делает их более «подстановочными» в соответствии с LSP. DIP поощряет зависимость от абстракций (интерфейсов). ISP предоставляет лучшие абстракции — меньшие, более релевантные, делая зависимости, формируемые в соответствии с DIP, более целевыми и менее связанными. Следовательно, ISP действует как вспомогательный принцип, который облегчает правильное и эффективное применение других принципов SOLID, особенно SRP и LSP, путем улучшения качества и гранулярности используемых в качестве абстракций интерфейсов.
Заключение
Принцип разделения интерфейса (ISP) является важнейшим элементом современного объектно-ориентированного дизайна. Его основная идея, сформулированная Робертом С. Мартином на основе опыта решения практических проблем в компании Xerox, заключается в необходимости разделения «толстых», универсальных интерфейсов на множество мелких, специфичных для клиентов интерфейсов. Этот подход направлен на предотвращение ситуации, когда клиенты вынуждены зависеть от методов, которые они не используют.
Нарушение ISP приводит к увеличению зацепления и жесткости кода, затрудняет внесение изменений и тестирование, а также может способствовать нарушению других принципов дизайна, таких как SRP и LSP, заставляя классы реализовывать нерелевантную функциональность.
Применение ISP приносит значительные преимущества, включая повышение модульности и гибкости системы, улучшение ее тестируемости и поддерживаемости, снижение влияния изменений и, что не менее важно, уменьшение когнитивной нагрузки на разработчиков за счет предоставления более понятных и сфокусированных абстракций.
ISP является неотъемлемой частью принципов SOLID и тесно взаимодействует с SRP, LSP и DIP, поддерживая и усиливая их. Следуя ISP, разработчики создают более надежное, гибкое и легко поддерживаемое программное обеспечение, способное эффективно адаптироваться к меняющимся требованиям в сложных современных системах.