четверг, 11 декабря 2014 г.

Интерфейсы vs. Абстрактные классы

DISCLAIMER: ниже речь идет об интерфейсах и абстрактных классах на платформе .NET. Большая часть рассуждений применима и к другим языкам, с разделением понятий интерфейсов и классов.

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

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

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

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

Интерфейс vs. Абстрактный класс – это компромисс между свободой клиента интерфейса/класса и свободой разработчика.

  • Интерфейс «гибче» с точки зрения клиентов: любой класс может реализовать любой интерфейс. Но интерфейс «жёстче» с точки зрения его разработчика: его сложнее изменять (нарушится работа всех клиентов), нельзя наложить ограничения на конструктор клиентов, нельзя использовать код повторно.
  • Абстрактный класс «жёстче» с точки зрения клиентов: клиент будет вынужден отказаться от текущего базового класса. Но абстрактный класс «гибче» с точки зрения его разработчика: он позволяет использовать код повторно, ограничить конструктор наследников, позволяет вносить изменения (легко добавить виртуальный метод, не сломав существующих клиентов), четче задавать «контракт» с наследниками с помощью Шаблонных Методов.

Разница для библиотек

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

Квалина и Абрамс в своей книге “Framework Design Guidelines” дают такой совет:

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

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

Различия для моделирования

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

Иногда бывает сложным понять, что должно быть «главным», а что – «второстепенным». Что важнее при моделировании кондиционеров? Иерархия кондиционеров с «вкраплением» интерфейсов бытовой техники, или иерархия бытовой техники с аспектами IAirConditioner?

На этот вопрос нет однозначного ответа, и иногда приходится перепрыгивать с одной «проекции» на другую.

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

«Второстепенность» интерфейсов не значит, что они играют второстепенную роль в моделировании. Интерфейсы моделируют семейство типов, и не нужны, если семейство типов отсутствует.

Именно поэтому Марк Сииманн (автор замечательной книги “Dependency Injection in .NET”) вводит специальный принцип: “Reuse Abstraction Principle”. Принцип говорит, что пока у интерфейса не появится как минимум 3 реализации (моки – не всчет!), разработчик не может сказать, чем же является эта «абстракция», и какое такое «общее поведение» она моделирует.

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

Также Марк критикует подход слепого выделения интерфейса для класса. Такие интерфейсы называются “Header Interfaces” (по аналогии с заголовочными файлами в С++). Их проблема в том, что у них всегда лишь одна полноценная реализация + реализация в тестах. Клиенты таких интерфейсов обычно содержат неявные предположения о реализации, что сделает невозможным еще одну реализацию, которая не будет нарушать принцип замещения Лисков.

Подведем итог:

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

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

  1. Вот нигде в статье не указано, в терминологии какого языка мы пляшем. Кроме того "интерфейс" как синоним "публичный контракт" используется ни чуть не реже, чем идиома в джава/сишарп.
    Вы, я полагаю, про эту идиому и пишете. Но тут надо помнить, что в джава8 интерфейсы обзавелись реализацией по умолчанию, а CIL всегда умел в имлементацию методов интерфейсов, просто это до сих пор не выставляют наружу.
    Грубо говоря, над требованиями (контрактами/интерфейсами) определена операция сложения, а над реализациями - нет. В джаже этот парадокс уже осознали, в мс занимались другими вопросами. Но, как мне кажется, время рассуждений на тему, чем отличается интерфейс от абстрактного класса уже прошло.

    ОтветитьУдалить
    Ответы
    1. В основном, это был ответ на вопрос, заданный в комментариях к предыдущей заметке.

      Но в Вашем комментарии столько опечаток, что его смысл несколько ускользает от меня. Про CIL вообще ничего не понял.

      Удалить
    2. Я думаю, он хотел сказать, что С# реализует не все возможности CLR.
      Например CLR допускает наличие в интерфейсах статических методов, статических
      полей и конструкторов, а также констант. C# не позволяет определять в интерфейсе
      статические члены.

      Удалить
    3. В Java, мне кажется, как раз усугубили "парадокс" ради некоторой гибкости уже в техническом плане: "жирность" интерфейса растёт за счёт тривиальных операций, которые можно было би просто подмешивать по надобности, как это можно делать в C# 3.0. Кроме того, я (например, как разработчик расширений) лишаюсь возможности какой-либо возможности расширить своими операциями уже существующий интерфейс, потому как операция уже определена, а у меня нет какой-либо возможности изменить существующий интерфейс или реализацию. Частично это решается созданием пачки декораторов для интерфейсов с методами по умолчанию, в которых уже можно переопределить некоторые default-методы.

      Также я не могу добавить поддержку типов с другой экосистемы. Возмём, например, Collections.removeIf(Predicate filter) из JDK 8: единственное преимущество такого подхода, когда я использую Predicate из JDK -- оно работает как инстанс-метод. Если нужно использовать Predicate из Google Guava -- я должен или написать адаптер типа "Guava-предикат --> JDK-предикат" (количество объектов в куче снова растёт, причём за ненадобностью), или использовать алгоритм фильтрации в виде статического метода из Collections2, что в выглядит весьма уродливо и привело к созданию "chaining" FluentIterable в Guava.

      И это просто потому, что в Java нет возможности "подмешивать" методы в уже существующие типы. Честно говоря, мне не нравится синтаксис описания методов-расширений в C# с помощью "первый параметр должен быть this" (а также, механизм "добавления" с помощью одного using, что похоже больше на своего рода взрыв). Но, если бы в Java синтаксически можно было бы использовать статические методы (при условии, что первый параметр имеет тип, который нужно расширить) как инстанс-методы, всякие FluentIterable никогда бы и не появились. Кстати, методы расширения не страдают от null-pointer/reference-exception при прямом вызове (впрочем, это можно и минусом считать в определённых случаях).

      Пока единственную гибкость Java-подхода я вижу в том, что метод по умолчанию я могу переопределить в суб-типе, если у меня есть возможность это сделать, и таким образом гарантировать полиморфную реализацию этого метода, даже возвращая на call-site супер-тип. Например, я могу возвратить Iterable, где forEach по умолчанию полагается на итератор. В то же время, возвращая ArrayList под видом Iterable (переопределив forEach в ArrayList, или он там не переопределён?), я могу избежать работы с итератором и могу просто пройтись по всем элементам внутренного backing-массива, думая, что вызываю forEach с Iterable. Это да, это плюс, несомненно. Но, в то же время, в C# я могу с лёгкостью просто "подмешать" другую реализацию метода-расширения, если у меня есть возможность узнать тип и применить более удачную реализацию forEach (что, мне кажется, может быть сложной задачей).

      Удалить
  2. Серёжа, спасибо огромное! Данная статья аргументировано сдвинула моё мировозрение в сторону схожести интерфейсов и абстрактных классов. Раньше у меня были какие-то предрассудки в отношении интерфейсов в качестве основы для семейства классов. Я их использовал для добавления аспектов к различным семейтвам, но при этом не считал все реализации одного и того же интерфейса - семейтвом. Т.е. сортируемый автомобиль, человек и компьютер я не воспринимал, как семейство "сортируемых" типов. А теперь понимаю, что "в мире всё относительно" и зависит от того, с какого ракурса ты смотришь на ситуацию.

    ОтветитьУдалить
    Ответы
    1. Леша, не за что! Хорошо, что баянистый пост оказался полезным!

      Удалить
  3. как то странно выглядит цитата из “Framework Design Guidelines”: с одной стороны классы лучше, но интерфейсы используются чаще, потому что классы хуже :)

    Квалина и Абрамс в своей книге “Framework Design Guidelines” дают такой совет:

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

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

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

      Удалить
  4. Сергей, моя точка зрения прозвучит возможно несколько вразрез с общепринятыми канонами, но весьма интересно было бы услышать твое мнение.

    Рассуждая о интерфейсах или абстрактных классах (или о множественном наследовании), прежде всего нужно учитывать контекст, в котором мы о них говорим. Я вижу как минимум два ключевых отличия: 1) с точки зрения языка/среды/имплементации; 2) с точки зрения моделирования/дизайна приложения.

    Если привнести сюда разделение "клиентский код - библиотечный код" - это еще ортогональное измерение для первых двух, как пишут те же Абрамс и Цвалина, на которых ты любишь ссылаться. Соответственно пытаться в небольшой заметке покрыть все сразу мне кажется не есть хорошо. Будет либо слишком поверхностно и позволять вольные толкования (для тех, кому есть что сказать), либо слишком много и запутанно (для тех кто учится на твоих заметках и статьях).

    Я в своем комментарии попытаюсь сосредоточиться на Моделировании/Дизайне обычного Клиентского кода. (увы, комментарий получился настолько большим, что пришлось бить на 2)

    ОтветитьУдалить
  5. Итак, на мой взгляд с точки зрения моделирования и дизайна приложений (в противовес другим плоскостям дискуссии) разницы между абстрактным классом и интерфейсом нет абсолютно никакой (разница в более низкоуровневых деталях, имплементации, специфике языка/среды). Более того, и тот и тот случай являются единственно правильными формами "наследования" как такового (на мой скромный взгляд). Говоря о наследовании нужно четко разделять понятия "наследование имплементации" и "наследование/реализация публичного интерфейса". В первом случае у нас будут проблемы вне зависимости то того имеем ли мы дело с множественным или одиночным наследованием. Во втором случае опять же - все отлично (если конечно наш инструментарий это позволяет - язык С# как мы знаем позволяет это только с интерфейсами).

    Тут нужно оглянуться на то, чему нас учит классическое ООП (или мы все привыкли думать, что именно этому оно учит): наследование в общем своем виде, когда речь идет о наследовании "живых" классов (в противовес абстрактным) и построении так называемой иерархии классов, моделирует собой отношение IS A, то есть "является". Возвращаясь к твоему примеру из прошлой статьи (комментарий к одному из ответов), "Кондиционер является охладительным прибором, бытовой техникой, прямоугольным устройством, устройством, потребляющим электричество и т.п.".

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

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

    Именно поэтому, я считаю, любая общая имплементация должна агрегироваться, а не наследоваться. И любое моделирование должно строиться на "публичных свойствах" того или иного объекта. И наше IS A должно на самом деле звучать как CAN BE A (for his clients). То есть исходя из контекста потребителя нашего класса, наш класс может выступать как: прямоугольная коробка, охладительный прибор, изделие из пластика, товар и т.п. При этом как он это делает внутри нам совершенно без разницы. Реализует он эту часть своего публичного API сам или делегирует какому-то общеизвестному хорошо описанному типу картонных коробок.

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


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

    ОтветитьУдалить
  6. Игорь, спасибо за комментарий, он вполне тянет на отдельный пост в блоге.

    Мне кажется, что наши точки зрения по большинству вопросов совпадает:)

    > Более того, и тот и тот случай являются единственно правильными формами "наследования" как такового (на мой скромный взгляд)
    Согласен!

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

    Я там где-то специально говорю о том, что разработчик должен выбрать нужную модель (проекцию): что важно с его точки зрения сейчас - что кондиционер - это охладительное устройство, электроприбор или прямоугольник. Именно поэтому очень полезным является понятие Bounded Context из DDD, которое говорит, что одно и то же понятие предметной области может быть представлено разными сущностями в программном коде в зависимости от требований моделирования. И попытка использовать повторно одно и тоже понятие (класс) в разных контекстах обязательно приведет к ненужному увеличению сложности.

    Наверное, единственный момент, с которым я не очень соглашусь, это роль интерфейсов для моделирования CAN BE A отношения. (Я думаю, что несогласие вызвано особенностями письменной коммуникации, а не нашими разногласиями в понимании этого вопроса).

    Так вот, добавление даже CAN BE A отношений в большом количестве (т.е. большого числа интерфейсов для класса), все равно губительно для дизайна. Как я написал выше по поводу Bounded Context-ов, зачастую проще вообще не рассматривать кондиционер как коробку или электрический прибор, даже если в дизайне приложения и есть понятия IКоробка, и IПрибор.

    Несмотря на то, что дублирование является краеугольным камнем для многих разработчиков (и даже некоторых методологий, как TDD), связанности (coupling) - иногда бывает более серьезной проблемой. Иногда лучше не вводить даже CAN BE A отношения, а на сделать две совершенно разные иерархии, связав которых с помощью фасадов/адаптеров, которые в терминах DDD называются Anti Corruption Layer. Да, интерфейсы менее интрузивны (кажется, это так называется), но это все равно лишняя нагрузка - как на клиента, так и на разработчика класса.

    ОтветитьУдалить
  7. Ай-ай-ай.. На движке блога кажется проблемы с сохранением набранного текста.. Написал комментарий, при отправке попросило авторизации и весь текст слетел :( Сейчас попробую заново наваять.. Сохранить надо было :(

    ОтветитьУдалить
    Ответы
    1. Да, блогспот - довольно странное место. У меня пару раз такое тоже было:(. Но слезать пока что нет особого времени:(

      Удалить
    2. Заслуживает уважения и даже удивления, что у тебя в принципе еще хватает времени на блог после всех перемен в жизни :)

      Удалить
    3. Это для меня важно, а рас так, то и время на это находится:))

      Удалить
  8. Итак, попытка номер 2 написать ответ на твой ответ ))

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

    Собственно твой ответ касательно CAN BE A породил дальнейшие размышления на эту тему в моей голове. Безусловно, это лучше вариант чем глубокие иерархии, а также может быть неплохим учебным примером. Но в реальных системах применение по-прежнему будет ограниченным. Интуитивно я подразумевал подобное использование прежде всего в application/service layer. Здесь я думаю вполне допустимо, чтобы один класс FileDataManager реализовывал ролевые интерфейсы IDataReader, IDataWriter, однако в случае с обсуждаемыми нами кондиционерами, действительно вряд ли хорошей идеей будет реализовывать интерфейсы IКоробка и IПрибор, так как в рамках DDD кондиционер с очень большой вероятностью будет являться Entity со всеми вытекающими. Какой-нибудь ISortable еще может быть уместен для выполнения механических операций над объектами. Но серьезные доменные вещи врядли мы станем лепить в один контекст и смешивать их на одной сущности.

    Изначально мне такой вариант использования и не пришел бы в голову ))) Однако почитав твои статьи, где ты не рекомендуешь "выделять интерфейсы где попало и для чего попало" и в качестве примера приводишь ValueObjects, я понял, что и такое возможно ))) думаю ты с этим реально сталкивался, раз об этом пишешь )) поэтому да, лучше предусмотреть любые возможные НЕПРАВИЛЬНЫЕ варианты использования и явно об этом сказать. Дабы неповадно.

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

    ОтветитьУдалить
    Ответы
    1. Игорь, по поводу использования value objects и всего такого. Можно посмотреть мой код на гитхабе (https://github.com/SergeyTeplyakov). Тот же плагин для решарпера - вполне себе большое приложение, в котором нет ни единого места, где интерфейс был бы выделен для тестирования.

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

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

      З.Ы. Пиьсменная коммуникация - злая штука. Очень легко понять замысел статьи двояко:))

      Удалить
  9. Второй раз вернулся к этой статье и вот.... понял! Спасибо тебе, Сергей)

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