вторник, 5 августа 2014 г.

Interface Segregation Principle

Цикл статей о SOLID принципах

--------------------------------------------------

Вы никогда не ловили себя на мысли, что Принцип разделения интерфейса (ISP, Interface Segregation Principle) вам не вполне понятен или, что он является лишь разновидностью принципа единой ответственности (SRP, Single Responsibility Principle)? До недавнего времени у меня было подобное отношение, но оно несколько изменилось, после того, как я посмотрел на него с другой точки зрения.

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

Давайте начнем с определения. Вот формулировка принципа ISP из любимой мною книги Боба Мартина «Принципы, паттерны и методики гибкой разработки»:

"Этот принцип относится к недостаткам "жирных" интерфейсов. Говорят, что класс имеет жирный интерфейс, если функции этого интерфейса недостаточно сцепленные (not cohesive). Иными словами, интерфейс класса можно разбить на группу методов. Каждая группа предназначена для обслуживания разных клиентов. Одним клиентам нужна одна группа методов, другим – другая."

В чем его проблема этого определения? Акцент на «жирности». «Жирный» интерфейс, который содержит несколько групп методов по своему определению нарушает принцип единой ответственности! А как еще разбить интерфейс класса, кроме как путем разделения этого класса на несколько? Например, путем разбития этого класса на несколько! Таким образом, принцип ISP избыточен и является всего лишь частным случаем SRP!

Однако на самом деле, это не совсем так, более того, любой программист Java или C# пользуется этим принципом постоянно.

ISP идет со стороны клиентов класса

Теперь давайте посмотрим, с какой стороны следует смотреть на принцип ISP. Но вначале давайте вернемся ненадолго к принципу SRP. Как мы определяем, что класс или модуль нарушает принцип единой ответственности? Путем анализа самого класса или модуля. Класс пользовательского интерфейса лезет в базу? Нарушение SRP! WCF сервис содержит бизнес-логику? Нарушение SRP! Модель знает о UI? Нарушение SRP! И т.д.

Но можем ли мы сказать глядя на сам класс сказать, что он нарушает ISP?

Вот, например, у нас есть класс репозитория, который содержит CRUD операции. Нарушает ли он ISP? Мы не знаем! Нарушение этого принципа зависит не столько от самого класса, сколько от сценариев его использования. Если в нашей бизнес-модели четко разделяются операции чтения и обновления данных (такой себе CQRS), то наличие одного класса со всеми операциями однозначно делает интерфейс слишком толстым. С другой стороны, если наше приложение напичкано кучей простых форм, которые мапятся 1 к 1 с нашими репозиториями, то тогда принцип ISP не нарушается.

SRP vs. ISP

Из предыдущего обсуждения вытекает еще одно важное отличие принципов SRP и ISP. Мы хотим следовать SPR, чтобы наш класс был связанным внутри (highly cohesive), что позволит с меньшими усилиями его понимать и развивать. Следование же принципу ISP уменьшает связанность (low coupling) между классом и его клиентом, ведь теперь клиент завязан не на весь интерфейс класса, а лишь на его часть.

[Кэп мод он] Множественная реализация интерфейсов

Далеко не все понимают принцип ISP, но каждый программист C# или Java пользовался им сотни раз.

Любой класс в языке C# (или Java), который реализует более одного интерфейса следует принципу ISP!

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

Вот небольшой пример. Я сейчас занимаюсь разработкой плагина под R# для контрактного программирования. Одной из его задач является поддержка ошибок и предупреждений компилятора Code Contract во время редактирования кода (аналогично тому, что делает R# для ошибок и предупреждений компилятора C#).

clip_image002

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

Помимо отображения ошибок/предупреждений, инфраструктура R# SDK позволяет создавать фисы (Quick Fixes). Например, мы можем переместить невалидный вызов метода за блок контракта или изменить Contract.Result<string>() на Contract.Result<object>().

R# SDK имеет определенные ограничения, поэтому классы ошибок и предупреждений должны быть разными. Но поскольку «фиксы» могут быть одинаковыми, мы могли бы использовать все «предупреждения» полиморфным образом.

image

Классы ошибок и предупреждений имеют два вида клиентов: (1) инфраструктуру R# и (2) кастомные фиксы. В результате, классы предупреждений реализуют два интерфейса IHighlighting и ICodeContractFixableIssue, соответственно. При этом можно четко говорить, что классы CodeContractWarningHighlighing и CodeContractErrorHighlighting не нарушают SRP и соответствуют принципу ISP.

Когда следовать принципу ISP?

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

ПРИМЕЧАНИЕ
Ниже в статье под термином «интерфейс» без уточнения всегда будет подразумеваться .NET-интерфейс, выделенный с помощью ключевого слова interface.

Существует несколько причин для выделения у класса дополнительных интерфейсов.

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

Во-вторых, мы можем выделить некий аспект текущего класса, который покрывает не весь функционал, а лишь его часть, т.е. его отдельный аспект. Примером может служить добавление классу «ролевых» интерфейсов (Role Interface), типа ICloneable, IComparable<T>, IEquatable<T> и т.п. При этом выделение таких интерфейсов обычно требуется для использования класса в новом контексте (класс Person реализуется IComparable<T> для корректного использования в SortedList).

В-третьих, может потребоваться выделение специализированных бизнес-интерфейсов, когда станет очевидным, что наш класс используется в нескольких разных контекстах. Например, когда репозиторий используется разными клиентами двумя способами: одна группа клиентов использует лишь операции для чтения данных, а другая группа – для обновления. Обычно это говорит не столько о нарушении ISP, сколько о наличии скрытой абстракции (IRepositoryReader и IRepositoryWriter).

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

«Ага, вот у нас есть два класса, которые мы могли бы использовать совместно, но они уже унаследованы от разных базовых классов. Давайте выделим для каждого из них единый интерфейс и избавимся от богомерзких if-else».

Заключение

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

Если же интерфейс класса «жирный» и непонятный, то класс не просто нарушает ISP, он нарушает SRP и ждет рефакторинга и упрощения.

Дополнительные ссылки

З.Ы. Если есть желание услышать мое мнение об остальных принципах SOLID, пишите об этом в комментах!

З.Ы.Ы. Понравилась статья? Поделитесь ею с друзьями. Вам не сложно, а мне приятно;)

9 комментариев:

  1. Мне всегда казалось, что маркерные интерфейсы не содержат вообще никаких членов, а в ICloneable, IComparable и IEquatable очевидно они есть.

    Желание услышать мнение про остальные принципы, конечно, есть.

    ОтветитьУдалить
    Ответы
    1. Владимир, да, ICloneable, IComparable, IEquatable действительно не совсем маркерные интерфейсы. Класс, реализующий такие интерфейсы говорят, что они "Can Do" something.
      Но приведенные интерфейсы скорее "аспектные", нежели "маркерные".

      З.Ы. Раз есть желание услышать про остальные принципы, значит будем писать:)

      Удалить
    2. Так это же Role Interface, а не Marker :)
      http://martinfowler.com/bliki/RoleInterface.html

      P.S. Будет потом интересно сравнить мнения: http://pluralsight.com/training/courses/TableOfContents?courseName=encapsulation-solid

      Удалить
    3. Владимир, спасибо за ссылку на Role Interface. Я поправил пост:)

      Да, и я видел ссылку от Марка на его новый курс, но хочу вначале написать эту серию постов про SOLID, и только потом буду его смотреть:)

      Удалить
  2. Сергей, спасибо. Добавлю, в теории, поклон Бертрану Мейеру за его труд, программная модель начинается с выделения АТД (абстрактный тип данных), интерфейс это первое приближение АТД к программной модели, далее вниз по иерархии идет уже спецификация типа. Выделение нового АТД - это уточнение/спецификация программной модели, даже если один АТД наследует несколько других.
    Как пример ISP можно привести в STL разделение контейнеров и алгоритмов, где выделен интерфейс итератора контейнера, в .NET Extension методы реализованы по такому же принципу, в сигнатуре задекларирован интерфейс который необходим для работы.
    Я бы еще добавил к выводу, что правильное использование ISP позволяет создавать оптимальную программную модель: выдержать равновесие между минимальной гранулярностью и максимальным повторным использованием кода.

    ОтветитьУдалить
    Ответы
    1. Слава, спасибо за дельный комментарий.

      Удалить
  3. Косательно ISP - полностью согласен с тем, что этот принцип клиентский.
    Фактически это один из вариантов реализации принципа low-coupling, мы просто предоставляем пользователям класса лишь то, что им необходимо, не более.

    З.Ы. С удовольствием бы почитал о других принципах SOLID в интепритации автора!

    ОтветитьУдалить
  4. "Помимо отображения ошибок/предупреждений, инфраструктура R# SDK позволяет создавать фисы (Quick Fixes)."

    Может подразумевалось "фиксы", а не "фисы"?

    ОтветитьУдалить