Смертоносные интерфейсы

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

Введение

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

Интерфейс  —  это контракт, сообщающий, что класс будет иметь определенные характеристики (в первую очередь открытые методы и свойства), с которыми могут взаимодействовать другие компоненты. Задача интерфейса  —  дать языку программирования некоторую степень гибкости, ослабляя связи компонентов.

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

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

Что плохого в интерфейсах?

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

Неудобство навигации

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

Может показаться, что это не так уж много. Но если вы “в потоке” и обдумываете процесс, то это как если бы вы зашли за угол и нашли стену там, где ожидали дверь.

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

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

Обфускация через интерфейсы

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

Правда, очень приятно, что наш код гибок и отделен от конкретных реализаций. Но иногда, когда я смотрю на класс и вижу интерфейс, могу потерять представление о специфике среды выполнения.
Допустим, у нас есть только один тип IEmalSender в приложении. Если все, что я вижу при навигации  —  ссылка на IEmailSender, то могу потерять представление о том, с каким отправителем мы работаем. И, конечно, забыть о подробностях его реализации.

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

Архитектурный цемент

Мне нравится думать об интерфейсах как об ”архитектурном цементе“ разработки ПО.

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

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

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

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

Моё мнение таково: мы платим за интерфейсы во время обслуживания ПО неудобствами. Плата небольшая, но все же больше, чем вы думаете. Чем больше используется интерфейсов, тем более выраженной становится эта проблема.

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

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

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

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

Это довольно просто. Но путь наименьшего сопротивления приводит к гигантским интерфейсам, таким как IUserRepository, вместо интерфейсов меньше, вроде IUserValidator и IUserCreator. У больших интерфейсов много проблем, включая следующие:

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

Итак, большие интерфейсы — это плохая идея, они приводят к долгосрочным проблемам.

Альтернатива интерфейсам

Что я предлагаю в качестве альтернативы? Часто, когда системам требуется определенная степень гибкости в реализации, они не нуждаются в полной гибкости, обеспечиваемой интерфейсом. Нужен базовый класс, который может служить чем-то вроде контракта для внедрения зависимостей или тестирования.

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

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

Конечно, у базовых классов свои недостатки:

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

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

Заключение

Ваш выбор должен соответствовать вашим потребностям. Все, что я предлагаю,— не просто автоматически предположить: “Это должен быть интерфейс”. Или “Это должен быть базовый класс”. Или даже: “Я не должен передавать конкретный класс в этот метод”.

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

Читайте также:


Перевод статьи Matt Eland: Death by Interfaces