После опытов с трейтами мне захотелось вспомнить об основных способах выделения интерфейсов из-за их частого конфликта с абстрактными классами. Я не буду ворошить иерархии геометрических фигур, концентрируясь лишь на анализе проектной модели.
Обычно под классическим интерфейсом подразумевают разделение описания функциональности от её реализации. Для наследования основной семантикой будет отношение "является", когда как для интерфейса - "реализует какой-то контракт".
Интерфейс, как и наследование, может использоваться в полиморфизме, поэтому обычной практикой бывает замена по обстоятельствам их одно на другое с получением относительно равнозначного результата. Преимущество в меньшем количестве классов в иерархии при интерфейсном способе моделирования обычно сходит на нет из-за необходимости постоянного сбрасывания дублирующего кода в какой-либо класс и интерфейс начинает плавно перетекать в наследование.
Проанализируем различие наследования и интерфейса с помощью инструментов формальной логики. Отношение "является" может определять родо-видовое подчинение - отношение двух совместимых понятий, полный объем одного из которых составляет часть объема другого в случае отсутствия множественного наследования. Под совместимостью здесь понимается частичное или полное совпадение объемов - совокупности предметов, охватываемых понятием.
Вспомним, что полиморфизм — способность функции обрабатывать данные разных типов, при этом характер отношений между такими типами не конкретизируется.
Кроме подчинения существуют и другие виды отношений, которые невозможно смоделировать наследованием. Можно рассмотреть отношения между интерфейсом и реализующим его типом, а также отношения между разными типами, реализующими интерфейс.
Пересечение - отношение различных по содержанию совместимых понятий, объемы которых имеют общее подмножество элементов. Если часть прямоугольников реализует Exportable, то между Exportable и Rectangle улавливается отношение пересечения, поскольку часть прямоугольников могут экспортироваться в какой-либо формат через реализацию интерфейса, но Exportable реализуют и другие классы.
С другой стороны, отношение между членами интерфейса, например, между экспортируемым прямоугольниками и каким-нибудь экспортируемым отчетом можно рассмотреть как соподчинение - они подчинены интерфейсу Exportable, но между собой несовместимы из-за отсутствия общих элементов в объемах: отчет не прямоугольник, а прямоугольник не отчет. Данные отношения нельзя описать наследованием и если бы полиморфизм опирался бы только на наследование, то был бы конфуз. Наверное, частным случаем соподчинения можно рассмотреть тестирование, для которого интерфейсы чрезвычайно удобны.
Отношение соподчинения может продемонстрировать и неочевидную зависимость: если A и B реализуют интерфейс C, то A зависит от B, как и B зависит от A через зависимость от интерфейса.
Если на устоявшемся коде окажется, что экспортируемый отчет при экспорте требует дополнительных данных, то в случае изменения интерфейса произойдет изменение экспорта и всего остального, в т.ч. прямоугольников.
Конечно же, маловероятно, что кто-то пойдет на поломку интерфейса и будет попытка выкрутиться через ассоциацию, но тем не менее. Тоже самое работает и для пересечения, вводя некий предел полиморфизма, поскольку чем более универсален функционал, тем больше рисков он должен нести на своих плечах, подстраиваясь под постоянно изменяющиеся требования, все как в реальности.
Из анализа отношений можно вывести основной недостаток интерфейса: в отношениях соподчинения и пересечения интерфейс распространяет свое влияние на множество самых разных типов и любое изменение может быть очень болезненным.
Классический труд по проектированию в .NET "Инфраструктура программных проектов" (Цвалина, Абрамс) содержит похожий пример этой проблемы уменьшения гибкости и вообще рекомендует использовать определения абстрактных классов вместо интерфейсов (раздел "Выбор между классом и интерфейсом"), используя последние лишь в некоторых случаях: для эмуляции множественного наследования или в целях полиморфизма. В Effective Java (Блох), наоборот, интерфейс предпочитается абстрактному классу, хотя опять же есть упоминание о болезненности изменений публичного интерфейса.
По сугубо личным наблюдениям, недостаток раздутой иерархии заменяется на когнитивную сложность со множеством интерфейсов. При изучении класса оказывается, что он реализует интерфейсы A, B, C, D, E... до бесконечности, которые нужно дополнительно изучать, понимание логики его работы становится задачей нетривиальной.
Для отношения подчинения выделение интерфейса может вообще закончиться его удалением. Во-первых, если IDE не поддерживает рефакторинг методов из класса в интерфейс, то при любом изменении класса нужно править и его интерфейс. Во-вторых, если интерфейс разрастается, то его реализация напрямую часто становится невозможной и заменяется наследованием от ближайшего члена или использованием ассоциации с проксированием методов.
При разработке достаточно проблематично выявить отношения между понятиями. Определение объема и содержания - задача сложная в малознакомой предметной области и сопряжена с многочисленными граблями и ошибками, часто используются более упрощенный поиск целей для интерфейса. Так, на предмет универсальности рассматриваются:
- Ассоциации.
- Сообщения.
- Операции\методы.
- Роли классов и сами классы.
- Соединения слоев приложения или boundary-классы, располагающиеся на границах систем.
Если вспомнить совет из архитектурного мануала, то универсальность тут скорее нужно рассматривать как полиморфизм, который повлечет за собой неприятности в случае изменений. Вероятно, правило с трейтами и их служебной логикой вполне можно притянуть и к интерфейсам: чем дальше контракт интерфейса располагается от бизнес-логики конкретных классов, тем безопаснее его реализация, хотя это не точно.
Принимая риск проблем в случаях изменений, сразу вспоминается модульность Мейера, где можно встретить правило минимума интерфейсов (Few Interfaces). Если провести аналогию концептуального интерфейса с контрактным, то его можно рассмотреть, например, как попытку предотвратить массивные изменения в системе и соблюдения принципа модульной непрерывности. Или даже модульной понятности, поскольку унификация интерфейсом обычно приводит к очень сжатым или малоинформативным методам, не отражающим все побочные эффекты.
С другой стороны, минимум это уже количественная характеристика, а тут в игру вступает один из принципов SOLID - The Interface Segregation Principle, для прямой реализации которого может практиковаться создание большого количества мелких интерфейсов.
Он тесно связан с GRASP: уменьшение ненужных зависимостей понижает связь с другими модулями, а также позволяет создать высокую специализацию, не занимаясь ничем лишним. С другой стороны, SOLID оставляет за кадром решение проблем из-за возможной смены наследования на другое отношение, а также когнитивные проблемы, например, ограничения "оперативной" памяти человека или сложности восприятия запутанных иерархий с многочисленными реализациями интерфейсов. Кроме того, в случае реализации из разных пакетов или мук выбора из большого разнообразия интерфейсов могут появиться другие проблемы.
Думается, что выделение интерфейсов наиболее критично для библиотеки, где наличие абстрактного класса ограничивает наследование, с другой стороны, большое количество интерфейсов может понизить её гибкость. Логично, что риски с интерфейсами выше для предметной области, трудно поддающейся анализу, например графический интерфейс, отношения между элементами которого часто бывают спорными если подходить с разных сторон, оценивая поведение и отображение по-разному.
Любопытно, насколько теряется польза интерфейсов в случае наличия в языке трейтов. В спорах о предпочтении трейта или интерфейса обычно упоминается более контрактный характер последнего, но, как упоминалось выше, описание интерфейсом контракта тоже можно поставить под вопрос. Трейт вполне может выглядеть как интерфейс, не имея никаких реализаций. По аналогии может быть и NVI-трейт, который будет стараться дополнительно разделить контракт и реализацию. Да и функционал трейта становится очень похожим на обычный класс.
Предположим, что в языке отсутствуют трейты, интерфейсы и допускается множественное наследование. Отношения между понятиями все так же можно описать и при одиночном наследовании рождается подчинение с родо-видовой структурой, а контракты теперь описывают абстрактные классы. В таком случае, проблемы множественного наследования исходят от опасностей некорректной замены подчинения на пересечение\соподчинение и отличаются от проблем с интерфейсами наличием реализации (если она есть, конечно же).
С учетом того, что повсеместно в интерфейсах начали появляться дефолтные методы, появились на свет трейты, а также возможности добавления реализации в каждом классе банальным дублированием кода или переносом его в классы-утилитки, то ограничение одиночного наследования уже не выглядит удачной идеей вне технических ограничений или особенностей устройства компилятора.
Есть еще очень интересный нюанс в месте реализации интерфейсного метода, что зависит уже от конкретного языка. Некоторые языки позволяют поместить интерфейсный метод не только в сам класс, но и в базовый. В Groovy это все может запутываться динамическими возможностями и автореализацией интерфейсов, но будет что-то вроде:
interface Writable {
void write()
}
abstract class Writer {
void write() {
println "hello world"
}
}
class SimpleWriter extends Writer implements Writable {}
Writable writer = new SimpleWriter()
//hello world
writer.write()
Как всегда, такое поведение имеет свои преимущества и недостатки. С одной стороны, это может сильно облегчить работу со сторонним кодом и библиотеками, когда иерархии там независимы друг от друга и требуется их связать интерфейсом. С другой стороны, появляется шанс случайной реализации интерфейса существующим где-то вверху иерархии базовым классом, что может привести к достаточно коварным багам.
Если же интерфейс будет иметь дефолтный метод, то тогда все в порядке - будет вызываться он:
interface Writable {
default void write(){
println "hello"
}
}
abstract class Writer {
void write() {
println "world"
}
}
class SimpleWriter extends Writer implements Writable {}
Writable writer = new SimpleWriter()
//hello
writer.write()
Таким образом, для себя сделаю несколько выводов:
- Мнения об использовании интерфейсов несколько расходятся.
- Публичный интерфейс для пересечения или соподчинения может доставить проблем в случае изменений, предсказать которые трудно.
- Только для отношения подчинения интерфейс может быть избыточным. С другой стороны, если при эволюции кода отношение может измениться на другое, то отсутствие интерфейса также может доставить проблем.
- Для интерфейсов, вероятно, может выполняться подход для трейтов - чем менее логика пересекается с бизнес-логикой и уходит от подчинения, тем риски меньше.
Если вспомнить проблему с самыми простыми классами на самом нижнем уровне приложения - логируемый компонент, конфигурируемый компонент и т.п. где ограничение одиночного наследования ведет к дублированию кода, то кроме явно служебных целей вроде интерфейсов Cloneable, Comparable и т.п., отношение пересечения можно расширить до инфраструктурной сквозной логики приложения, не связанной с предметной областью, хотя это достаточно смелое обобщение.
Например, в случае лингвистического приложения или приложения-логгера пакеты интернационализации или логирования могут пересекаться с предметной областью. С другой стороны, эта сквозная логика может относиться лишь к интерфейсу самого приложения... кгм... На это может влиять также односложный характер компонентов, их всех объединяет то, что над ними нельзя выделить надкласс.
Интерфейсы не так просты, как кажется на первый взгляд и требуют аккуратности в обращении. Подходы по их выделению не отличаются универсальностью, а значит вероятность архитектурной ошибки очень высока. Считать ли их костылем на фоне фундаментальной ошибки в дизайне языка или очередным инструментом объектной парадигмы - вопрос очень холиварный, а вывод будет субъективным. Так или иначе, но буду поаккуратнее с ними... на всякий случай.