Интерфейсы как тип абстрактного класса c
Один из принципов проектирования гласит, что при создании системы классов надо программировать на уровне интерфейсов, а не их конкретных реализаций. Под интерфейсами в данном случае понимаются не только типы C#, определенные с помощью ключевого слова interface , а определение функционала без его конкретной реализации. То есть под данное определение попадают как собственно интерфейсы, так и абстрактные классы, которые могут иметь абстрактные методы без конкретной реализации.
В этом плане у абстрактных классов и интерфейсов много общего. Нередко при проектировании программ в паттернах мы можем заменять абстрактные классы на интерфейсы и наоборот. Однако все же они имеют некоторые отличия.
Когда следует использовать абстрактные классы:
Если надо определить общий функционал для родственных объектов
Если мы проектируем довольно большую функциональную единицу, которая содержит много базового функционала
Если нужно, чтобы все производные классы на всех уровнях наследования имели некоторую общую реализацию. При использовании абстрактных классов, если мы захотим изменить базовый функционал во всех наследниках, то достаточно поменять его в абстрактном базовом классе.
Если же нам вдруг надо будет поменять название или параметры метода интерфейса, то придется вносить изменения и также во всех классы, которые данный интерфейс реализуют.
Когда следует использовать интерфейсы:
Если нам надо определить функционал для группы разрозненных объектов, которые могут быть никак не связаны между собой.
Если мы проектируем небольшой функциональный тип
Ключевыми здесь являются первые пункты, которые можно свести к следующему принципу: если классы относятся к единой системе классификации, то выбирается абстрактный класс. Иначе выбирается интерфейс. Посмотрим на примере.
Допустим, у нас есть система транспортных средств: легковой автомобиль, автобус, трамвай, поезд и т.д. Поскольку данные объекты являются родственными, мы можем выделить у них общие признаки, то в данном случае можно использовать абстрактные классы:
Абстрактный класс Vehicle определяет абстрактный метод перемещения Move() , а классы-наследники его реализуют.
Но, предположим, что наша система транспорта не ограничивается вышеперечисленными транспортными средствами. Например, мы можем добавить самолеты, лодки. Возможно, также мы добавим лошадь — животное, которое может также выполнять роль транспортного средства. Также можно добавить дирижабль. Вобщем получается довольно широкий круг объектов, которые связаны только тем, что являются транспортным средством и должны реализовать некоторый метод Move() , выполняющий перемещение.
Так как объекты малосвязанные между собой, то для определения общего для всех них функционала лучше определить интерфейс. Тем более некоторые из этих объектов могут существовать в рамках параллельных систем классификаций. Например, лошадь может быть классом в структуре системы классов животного мира.
Возможная реализация интерфейса могла бы выглядеть следующим образом:
Теперь метод Move() определяется в интерфейсе IMovable, а конкретные классы его реализуют.
Говоря об использовании абстрактных классов и интерфейсов можно привести еще такую аналогию, как состояние и действие. Как правило, абстрактные классы фокусируются на общем состоянии классов-наследников. В то время как интерфейсы строятся вокруг какого-либо общего действия.
Например, солнце, костер, батарея отопления и электрический нагреватель выполняют функцию нагревания или излучения тепла. По большому счету выделение тепла — это единственный общий между ними признак. Можно ли для них создать общий абстрактный класс? Можно, но это не будет оптимальным решением, тем более у нас могут быть какие-то родственные сущности, которые мы, возможно, тоже захотим использовать. Поэтому для каждой вышеперечисленной сущности мы можем определить свою систему классификации. Например, в одной системе классов, которые наследуются от общего астрактного класса, были бы звезды, в том числе и солнце, планеты, астероиды и так далее — то есть все те объекты, которые могут иметь какое-то общее с солнцем состояние. В рамках другой системы классов мы могли бы определить электрические приборы, в том числе электронагреатель. И так, для каждой разноплановой сущности можно было бы составить свою систему классов, исходяющую от определенного абстрактного класса. А для общего действия определить интерфейс, например, IHeatable, в котором бы был метод Heat, и этот интерфейс реализовать во всех необходимых классах.
Таким образом, если разноплановые классы обладают каким-то общим действием, то это действие лучше выносить в интерфейс. А для одноплановых классов, которые имеют общее состояние, лучше определять абстрактный класс.
Абстрактные классы и интерфейсы в C#
Очень часто на собеседованиях спрашивают в чём разница между абстрактным классом и интерфейсом. И на первый взгляд вопрос-то не сильно сложный, но при этом даже здесь есть интересные подводные камни.
Для того чтобы понять в чём разница давайте определимся с понятиями.
Абстрактный класс – это класс, содержащий один или несколько абстрактных методов. Особенностью такого класса является то, что нельзя создать объект данного класса. Это связано с тема, что абстрактный метод, как правило, не имеет реализации. Чтобы методы был абстрактным его следует пометить ключевым словом abstract, также класс должен быть тем же служебным словом. Абстрактные классы обычно используются для определения базового класса в иерархии классов.
Для того чтобы реализовать метод необходимо в дочернем классе объявить метод с той же сигнатурой, но заменить при этом ключевое словно abstract на override.
Таким образом при вызове созданного объекта мы сможем вызвать как метод, объявленный в базовом, так и в дочернем классе. В итоге, для нашего примера мы получим следующий вывод:
Теперь поговорим об интерфейсах.
Интерфейс – это абстрактный ссылочный тип, который может содержать некоторое количество методов, свойств, событий и индексаторов. Начиная с версии C# 8.0 ещё и статические поля и константы. Также как и абстрактный класс нельзя создать объект данного типа, но мы можем объявить, например, сигнатуры нужных нам методов.
Чтобы объявить интерфейс достаточно использовать служебное слово interface вместо class. Затем создадим класс который будет наследовать интерфейс и реализовывать его.
Обратите внимание, что в отличии от абстрактного метода мы должны реализовать здесь все методы, сигнатуры которых объявлены в интерфейсе, при это служебное слово override не требуется. Ну или почти все, но об это чуть позже. В итоге мы получим уже такой вывод:
И вот до версии C# 8.0 тут можно было бы сделать вывод, что абстрактный класс отличается от интерфейса тем, что может содержать в себе реализацию метода и тем, что от интерфейса и тем что класс может быть унаследован от только от одного класса, но реализовывать несколько интерфейсов одновременно.
Но, что если в более новой версии C# мы попробуем сделать вот так?
Что ж, в таком случае мы увидим, что метод объявленный и реализованный в самом интерфейсе вполне себе будет доступен для использования:
Но в чём тогда смысл? Во-первых, разница в наследовании так и осталась. Мы не можем унаследоваться сразу от двух и более классов, но можем реализовать сразу нескольких интерфейсов. Во-вторых, если у нас имеется большее количество классов, которые уже реализуют интерфейс который мы хотим дополнить каким-нибудь методом, то у нас нет необходимости трогать каждый из них. Это безусловно удобно при работе с legacy-кодом, но не стоит бездумно использовать эту возможность где попало. Кроме того, если мы создадим объект указав его тип как интерфейс, а не дочерний класс, то уже не сможем обратиться к методу реализованному по умолчанию, в отличии от метода который реализован в абстрактном классе, но не переопределён в дочернем. Ну и по прежнему мы не можем объявить конструктор, деструктор и не публичные члены в интерфейсе (поскольку интерфейс реализуется, а не наследуется). Также стоит отметить, что статические члены обязательно требуют реализации как в случае абстрактного класса, так и в случае интерфейса.
Интерфейсы как тип абстрактного класса c
Абстрактный класс очень похож на интерфейс, но концепция немного запутанна для новичков ООП. Концептуально абстрактный класс выглядит, как интерфейс, конечно, без какой-либо реализации, однако они имеют свою долю различий. Хотя абстрактный класс может быть частично или полностью реализован, интерфейс должен быть реализован полностью. Наилучшая разница между ними состоит в том, что абстрактный класс может иметь реализацию по умолчанию, а интерфейс — это просто определение методов, которое содержит только объявления участников. Давайте обсудим теоретические аспекты и в деталях.
Что такое абстрактный класс?
Абстрактный класс — это особый тип класса, который действует как основа других классов и не может быть инстанцирован. Логика реализации абстрактного класса обеспечивается его производными классами. Для создания абстрактного класса используется «абстрактный» модификатор, который означает, что в классе, производном от него, должна быть реализована некоторая недостающая реализация. Он содержит как абстрактные, так и не абстрактные элементы. Абстрактный класс предназначен для обеспечения базовых функций, которые могут быть дополнительно разделены и переопределены несколькими производными классами. Полезно избегать любого дублирования кода. Они очень похожи на интерфейсы, но с добавленной функциональностью.
Что такое интерфейс?
С другой стороны, интерфейс не является классом, который содержит только сигнатуру функциональности. Это шаблон без реализации. Концептуально говоря, это просто определение методов, которое содержит только объявление членов. Это пустая оболочка, которая не содержит реализацию ее элементов. Это как абстрактный базовый класс, который содержит только абстрактные элементы, такие как методы, события, индексы, свойства и т. Д. Он не может быть создан непосредственно, а его члены могут быть реализованы любым классом. Кроме того, несколько классов могут быть реализованы классом, однако класс может наследовать только один класс.
Абстрактный класс против интерфейса: разница между абстрактным классом и интерфейсом в C #
- Многократное наследование — Класс может использовать только один абстрактный класс, поэтому множественное наследование не поддерживается. С другой стороны, интерфейс может поддерживать множественное наследование, что означает, что класс может наследовать любое количество наследований.
- Определение из Абстрактный класс и интерфейс в C #— Абстрактный класс — это особый тип класса, который может содержать определение без реализации. Логика реализации обеспечивается его производными классами. Он может иметь как абстрактные, так и не абстрактные методы. С другой стороны, интерфейс — это всего лишь шаблон, который ничего не может сделать. Технически это просто пустая оболочка.
- Реализация — Абстрактный класс может содержать как определение, так и его реализацию. Это неполный класс, который не может быть создан. Интерфейс может иметь только подпись функций без какого-либо кода.
- Модификаторы доступа — Абстрактный класс может иметь несколько модификаторов доступа, таких как subs, functions, properties и т. Д., В то время как интерфейсу не разрешено иметь модификаторы доступа, и все методы должны быть неявно определены как общедоступные.
- гомогенность — Абстрактный класс используется для реализаций одного типа, поведения и состояния, тогда как интерфейс используется для реализаций, которые используют только сигнатуры методов.
- декларация — Абстрактный класс действует как базовый класс для всех других классов, поэтому он может объявлять или использовать любую переменную, в то время как интерфейс не может объявлять какие-либо переменные.
- Декларация конструктора — Хотя абстрактный класс может иметь объявление конструктора, интерфейс не может иметь объявления конструктора.
- Ядро против периферии — абстрактный класс используется для определения основного идентификатора класса и может использоваться для объектов одного и того же типа данных. С другой стороны, интерфейс используется для определения периферийной способности класса.
- Жесткий против Сильного — Абстрактный класс более гибкий с точки зрения функциональности, по крайней мере, с точки зрения разработчика, в то время как интерфейс более жесткий.
Абстрактный класс против интерфейса: форма таблицы
Абстрактный класс
Интерфейс
Резюме
В чем разница между абстрактным классом и интерфейсом? Это, вероятно, один из самых распространенных вопросов, задаваемых в любом техническом интервью. Вы, скорее всего, найдете множество информации об абстрактных классах и интерфейсах в любом учебном пособии по C #, однако понимание разницы между ними — довольно сложная часть. Вы можете консолидировать всю информацию, которую вы можете найти, и все еще не можете получить достаточно. Ну, концептуально оба являются самыми важными терминами в программировании и являются совершенно такими же, однако они отличаются многообразием функциональности. Хотя абстрактный класс является особым типом класса, который выступает в качестве основы для других классов, с другой стороны, интерфейс представляет собой просто пустую оболочку с объявлениями только членов.
18.7 – Чисто виртуальные функции, абстрактные базовые классы и интерфейсные классы
Чисто виртуальные (абстрактные) функции и абстрактные базовые классы
Пока что у всех виртуальных функций, которые мы написали, было тело (определение). Однако C++ позволяет создавать особый вид виртуальных функций, называемый чисто виртуальной функцией (или абстрактной функцией), которая вообще не имеет тела! Чисто виртуальная функция действует как просто заполнитель, который должен быть переопределен производными классами.
Чтобы создать чисто виртуальную функцию и не определять тело функции, мы просто присваиваем функции значение 0.
Когда мы добавляем в наш класс чисто виртуальную функцию, мы фактически говорим: «Реализовать эту функцию должны производные классы».
Использование чисто виртуальной функции имеет два основных последствия: во-первых, любой класс с одной или несколькими чисто виртуальными функциями становится абстрактным базовым классом, что означает, что его экземпляр не может быть создан! Подумайте, что бы произошло, если бы мы могли создать экземпляр Base :
Поскольку для getValue() нет определения, во что будет разрешаться вызов base.getValue() ?
Во-вторых, любой производный класс должен определять тело этой функции, иначе этот производный класс также будет считаться абстрактным базовым классом.
Пример чисто виртуальной функции
Давайте посмотрим на пример чисто виртуальной функции в действии. В предыдущем уроке мы написали простой базовый класс Animal и унаследовали от него классы Cat и Dog . Вот код в том виде, в каком мы его оставили:
Мы запретили пользователям размещать объекты типа Animal , сделав конструктор защищенным. Однако по-прежнему можно создавать объекты производных классов, которые не переопределяют функцию speak() .
Этот код напечатает:
Что произошло? Мы забыли переопределить функцию speak() , поэтому вызов cow.speak() преобразовался в Animal::speak() , чего мы не хотели.
Лучшее решение этой проблемы – использовать чисто виртуальную функцию:
Здесь нужно отметить несколько моментов. Во-первых, speak() теперь чисто виртуальная функция. Это означает, что Animal теперь является абстрактным базовым классом, и его экземпляр не может быть создан. Следовательно, нам больше не нужно делать конструктор защищенным (хотя это не повредит). Во-вторых, поскольку наш класс Cow был производным от Animal , но мы не определили Cow::speak() , Cow также является абстрактным базовым классом. Теперь, когда мы пытаемся скомпилировать этот код:
Компилятор выдаст нам предупреждение, что Cow является абстрактным базовым классом, и мы не можем создавать экземпляры абстрактных базовых классов (номера строк неверны, потому что класс Animal в приведенном выше примере был опущен):
Это говорит нам о том, что мы сможем создать экземпляр Cow , только если Cow предоставит тело для speak() .
Давайте продолжим и сделаем следующее:
Теперь эта программа скомпилируется и распечатает:
Чисто виртуальная функция полезна, когда у нас есть функция, которую мы хотим поместить в базовый класс, но только производные классы знают, что она должна возвращать. Чисто виртуальная функция делает так, что экземпляр базового класса не может быть создан, а производные классы вынуждены определять эту функцию, чтобы могли быть созданы их экземпляры. Это помогает гарантировать, что производные классы не забудут переопределить функции, которые базовый класс ожидал от них.
Чисто виртуальные функции с телом
Оказывается, мы можем определять чисто виртуальные функции, у которых есть тело:
В этом случае speak() по-прежнему считается чисто виртуальной функцией из-за " = 0 " (несмотря на то, что ей было присвоено тело), а Animal по-прежнему считается абстрактным базовым классом (и, следовательно, его экземпляр не может быть создан). Любой класс, наследованный от Animal , должен предоставить для speak() собственное определение, иначе он также будет считаться абстрактным базовым классом.
При предоставлении тела для чисто виртуальной функции тело должно быть предоставлено отдельно (не встроенным).
Для пользователей Visual Studio
Visual Studio ошибочно допускает, чтобы объявления чисто виртуальных функций были определениями, например
Это неправильно и не может быть отключено.
Эта парадигма может быть полезна, когда вы хотите, чтобы ваш базовый класс предоставлял для функции реализацию по умолчанию, но при этом заставлял любые производные классы предоставлять свои собственные реализации. Однако, если производный класс удовлетворен реализацией по умолчанию, предоставленной базовым классом, он может просто вызвать реализацию базового класса напрямую. Например:
Приведенный выше код печатает:
Эта возможность используется не очень часто.
Интерфейсные классы
Интерфейсный класс – это класс, не имеющий переменных-членов, а все функции в котором являются чисто виртуальными! Другими словами, этот класс имеет только определение и не имеет реальной реализации. Интерфейсы полезны, когда вы хотите определить функциональные возможности, которые должны реализовывать производные классы, но оставляете детали того, как производный класс реализует эту функциональность, полностью на усмотрение производного класса.
Интерфейсные классы часто называют именами, начинающимися с I . Вот пример класса интерфейса:
Любой класс, наследованный от IErrorLog , чтобы его экземпляры можно было создавать, должен предоставлять реализации для всех трех функций. Вы можете создать класс с именем FileErrorLog , где openLog() открывает файл на диске, closeLog() закрывает файл, а writeError() записывает сообщение в файл. Вы можете создать другой класс под названием ScreenErrorLog , где openLog() и closeLog() ничего не делают, а writeError() печатает сообщение во всплывающем окне на экране.
Теперь предположим, что вам нужно написать код, который использует журнал ошибок. Если вы напишете свой код так, чтобы он напрямую включал FileErrorLog или ScreenErrorLog , то вы, по сути, застряли в использовании только такого типа журнала ошибок (по крайней мере, без переписывания вашей программы). Например, следующая функция фактически заставляет вызывающих mySqrt() использовать FileErrorLog , который может быть им нужен, а может быть и не нужен.
Намного лучший способ реализовать эту функцию – использовать IErrorLog :
Теперь вызывающий может передать любой класс, соответствующий интерфейсу IErrorLog . Если он захочет, чтобы сообщение об ошибке шло в файл, он может передать экземпляр FileErrorLog . Если он захочет, чтобы оно отображалось на экране, он может передать экземпляр ScreenErrorLog . Или, если он захочет сделать что-то, о чем вы даже не задумывались, например, отправить кому-то электронное письмо при возникновении ошибки, он может получить из IErrorLog новый класс (например, EmailErrorLog ) и использовать его экземпляр! Используя IErrorLog , ваша функция становится более независимой и гибкой.
Не забудьте добавлять для интерфейсных классов виртуальный деструктор, чтобы при удалении через указатель на интерфейс вызывался соответствующий производный деструктор.
Интерфейсные классы стали чрезвычайно популярными, потому что их легко использовать, легко расширять и легко поддерживать. Фактически, некоторые современные языки, такие как Java и C#, добавили ключевое слово " interface ", которое позволяет программистам напрямую определять класс интерфейса без необходимости явно отмечать все функции-члены как абстрактные. Более того, хотя Java (до версии 8) и C# не позволяют использовать множественное наследование с обычными классами, они допускают множественное наследование любого количества интерфейсов. Поскольку интерфейсы не имеют данных и тел функций, они позволяют избежать многих традиционных проблем с множественным наследованием, сохраняя при этом большую гибкость.
Чисто виртуальные функции и виртуальная таблица
В абстрактных классах по-прежнему есть виртуальные таблицы, поскольку их можно использовать, если у вас есть указатель или ссылка на абстрактный класс. Запись виртуальной таблицы для чисто виртуальной функции, если не предусмотрено переопределение, обычно либо содержит нулевой указатель, либо указывает на универсальную функцию, которая выводит ошибку (иногда эта функция называется __purecall ).
Интерфейсы
Типичный рекомендованный стиль в объектно-ориентированном программировании на C# cиспользованием инкапсуляции, наследования и полиморфизма состоит из:
Описание абстрактного базового типа.
Реализация функциональности базового типа в производных классах.
Создания и использования экземпляров производных классов в клиентском коде.
Что такое интерфейс и зачем нужен
На этапе описания программист в качестве базового объекта создает абстрактный тип или тип с виртуальными членами. Виртуальный члены могут менять свою реализацию в производных классах, а абстрактные ее определяют.
Совокупность виртуальных членов в базовом классе называется полиморфным интерфейсом, поскольку именно к ним работает программист пользовательского кода. Совокупность абстрактных членов – абстрактным интерфейсом, поскольку он не имеет реализации.
Интерфейсы, как языковой инструмент C# – это возможность вынести объявления абстрактного интерфейса за пределы класса в отдельный тип. Это необходимо в двух случаях:
Если пользовательский тип должен реализовать функционал более чем одного класса.
Для имитирования наследования структур.
C#, как часть технологии .NET — строго типизированный язык не поддерживающий множественного наследования. Структуры, как типы значений, не участвуют в наследовании. Классы наследуются, но только от одного источника, то есть каждый класс может иметь только один родительский. Интерфейсы позволяют программисту безопасно преодолеть эти ограничения.
Синтаксис объявления
Также как абстрактный класс, интерфейс несёт в себе объявление общего функционала, которое скрыто в объявлении абстрактных членов. Интерфейсы объявляются с помощью ключевого слова interface:
Внутри области видимости объявления интерфейса (между <>) объявляются члены интерфейса по тем же правилам, что и для абстрактных классов:
Нет никаких требований к именам интерфейсов, программист волен их придумывать сам. Однако для того, чтобы в коде легко было догадаться, что обращение происходит к интерфейсу рекомендуется использовать латинскую букву «I», как заглавную в имени:
Все члены интерфейса открыты и не требуют специально прописывать для каждого из них модификатор доступа public.
Можно объявлять в интерфейсе
Нельзя объявлять в интерфейсе
Сочетания вышеперечисленных функций-членов
Ограничения на объявления (прописанные в таблице выше) объясняются тем, что интерфейсы – абстрактные типы. Они не могут создаваться, не могут содержать данные, в них нет реализации, они представляют собой каркас будущей реализации.
Реализация членов интерфейса
Интерфейс указывает только сигнатуру реализации. Методы или другие члены интерфейса, которые объявил программист в блоке объявления интерфейса предназначены для реализации в типе. Этим типом может быть структура (тип значений) или класс (ссылочный тип).
Тип, реализующий интерфейс должен содержать реализацию всех членов интерфейса. Все члены, реализованные в типе должны:
Быть открытыми (public);
Совпадать по сигнатуре с соответствующими им членами интерфейса.
Сигнатура указания того, что тип реализует интерфейс похожа на сигнатуру наследования между типами:
Указание на реализацию классом более одного интерфейса:
Для программиста С# язык предоставляет возможность реализации интерфейса явно или неявно.
Неявная реализация
Неявная реализация в типе производится без указания на имя интерфейса. Она лаконична, поскольку не требует явного указания конкретного члена интерфейса в его иерархии наследования. Используется, когда в типе, который реализует несколько интерфейсов нет конфликтов имен членов интерфейсов:
Явная реализация
Когда один тип реализует несколько интерфейсов, которые содержат одноименные абстрактные члены возникает ситуация, когда компилятор не понимает какой именно реализованный член интерфейса ему вызвать. В этом случае применяется явная реализация.
Правила наследования
Если родительский класс реализует какой-либо интерфейс, то класс-наследник наследует реализацию этого интерфейса. Однако допустимо в дочерних классах эту реализацию изменять, главное, чтобы сигнатуры членов совпадали.
Интерфейсы – абстрактные ссылочные типы, поэтому участвуют в наследовании. Но функциональности интерфейсы не несут и, если один интерфейс наследуется от другого, то говорят, что первый реализует второй, как если бы речь шла о типе и интерфейсе:
IMetrDimention : IDimention <> // Интерфейс IMetrDimention реализует интерфейсIDimention.
Отличие интерфейса от абстрактного класса
Абстрактные классы и интерфейсы очень схожи по своему назначению. Они несут только объявления функций-членов, которые должны быть реализованы. Их объекты невозможно создать. Оба – ссылочные типы (хранятся в куче во время выполнения).
Отличия в реализации механизма наследования:
Классы могут иметь только один родительский класс, соответственно наследовать только одно направление функциональности. В случае реализации классом интерфейса таких направлений может быть сколько угодно, на количество реализуемых типом интерфейсов нет ограничений.
Если у класса, реализующего интерфейс имеются наследники, то они наследуют и реализацию интерфейса, которую они могут переопределить (если реализация в базовом классе виртуальная) или изменить (по-своему реализовать интерфейс).
Интерфейсы могут быть контейнером для объявлений методов и свойств, которые программист использует как абстрактный интерфейс для структур. Создав собственную иерархию интерфейсов с такими абстрактными интерфейсами и реализуя их в нужных структурах, он может связать иерархию интерфейсов с несвязанными между собой структурами (структуры не участвуют в наследовании) представив все как имитацию наследования.
Использование стандартных интерфейсов
Библиотека встроенных типов C# носит в себе множество встроенных интерфейсов, доступных для реализации пользователем. Хрестоматийным примером использования стандартных интерфейсов является реализация интерфейса IEquatable<T>, который описан в сборке System библиотеки .NET(начиная с версии 1.0).
Этот интерфейс содержит в себе объявление метода Equals(T). Сравнивает между собой объект переданного ему объекта типа в качестве параметра и объекта типа из которого он вызван. Метод возвращает объект типа bool.
Теперь мы можем использовать эту реализацию: передавая в метод объекта типа Box другой экземпляр однотипного объекта, сравнивать между собой значения их полей.
Разработка интерфейсных классов на С++
Интерфейсные классы весьма широко используются в программах на C++. Но, к сожалению, при реализации решений на основе интерфейсных классов часто допускаются ошибки. В статье описано, как правильно проектировать интерфейсные классы, рассмотрено несколько вариантов. Подробно описано использование интеллектуальных указателей. Приведен пример реализации класса исключения и шаблона класса коллекции на основе интерфейсных классов.
Оглавление
Введение
Интерфейсным классом называется класс, не имеющий данных и состоящий в основном из чисто виртуальных функций. Такое решение позволяет полностью отделить реализацию от интерфейса — клиент использует интерфейсный класс, — в другом месте создается производный класс, в котором переопределяются чисто виртуальные функции и определяется функция-фабрика. Детали реализации полностью скрыты от клиента. Таким образом реализуется истинная инкапсуляция, невозможная при использовании обычного класса. Про интерфейсные классы можно почитать у Скотта Мейерса [Meyers2]. Интерфейсные классы также называют классами-протоколами.
Использование интерфейсных классов позволяет ослабить зависимости между разными частями проекта, что упрощает командную разработку, снижается время компиляции/сборки. Интерфейсные классы делают более простой реализацию гибких, динамических решений, когда модули подгружаются выборочно во время исполнения. Использование интерфейсных классов в качестве интерфейса (API) библиотек (SDK) упрощает решение проблем двоичной совместимости.
Интерфейсные классы используются достаточно широко, с их помощью реализуют интерфейс (API) библиотек (SDK), интерфейс подключаемых модулей (plugin’ов) и многое другое. Многие паттерны Банды Четырех [GoF] естественным образом реализуются с помощью интерфейсных классов. К интерфейсным классам можно отнести COM-интерфейсы. Но, к сожалению, при реализации решений на основе интерфейсныx классов часто допускаются ошибки. Попробуем навести ясность в этом вопросе.
1. Специальные функции-члены, создание и удаление объектов
В этом разделе кратко описывается ряд особенностей C++, которые надо знать, чтобы полностью понимать решения, предлагаемые для интерфейсных классов.
1.1. Специальные функции-члены
Если программист не определил функции-члены класса из следующего списка — конструктор по умолчанию, копирующий конструктор, оператор копирующего присваивания, деструктор, — то компилятор может сделать это за него. С++11 добавил к этому списку перемещающий конструктор и оператор перемещающего присваивания. Эти функции-члены называются специальные функции-члены. Они генерируются, только если они используются, и выполняются дополнительные условия, специфичные для каждой функции. Обратим внимание, на то, что это использование может оказаться достаточно скрытым (например, при реализации наследования). Если требуемая функция не может быть сгенерирована, выдается ошибка. (За исключением перемещающих операций, они заменяются на копирующие.) Генерируемые компилятором функции-члены являются открытыми и встраиваемыми.
Специальные функции-члены не наследуются, если в производном классе требуется специальная функция-член, то компилятор всегда будет пытаться ее генерировать, наличие определенной программистом соответствующей функции-члена в базовом классе на это не влияет.
Программист может запретить генерацию специальных функций-членов, в С++11 надо применить при объявлении конструкцию «=delete» , в С++98 объявить соответствующую функцию-член закрытой и не определять. При наследовании классов, запрет генерации специальной функции-члена, сделанный в базовом классе, распространяется на все производные классы.
Если программиста устраивает функции-члены, генерируемые компилятором, то в С++11 он может обозначить это явно, а не просто опустив объявление. Для этого при объявлении надо использовать конструкцию «=default» , код при этом лучше читается и появляется дополнительные возможности, связанные с управлением уровнем доступа.
Подробности о специальных функциях-членах можно найти в [Meyers3].
1.2. Создание и удаление объектов — основные подробности
Создание и удаление объектов с помощью операторов new/delete — это типичная операция «два в одном». При вызове new сначала выделяется память для объекта. Если выделение прошло успешно, то вызывается конструктор. Если конструктор выбрасывает исключение, то выделенная память освобождается. При вызове оператора delete все происходит в обратном порядке: сначала вызывается деструктор, потом освобождается память. Деструктор не должен выбрасывать исключений.
Если оператор new используется для создания массива объектов, то сначала выделяется память для всего массива. Если выделение прошло успешно, то вызывается конструктор по умолчанию для каждого элемента массива начиная с нулевого. Если какой-нибудь конструктор выбрасывает исключение, то для всех созданных элементов массива вызывается деструктор в порядке, обратном вызову конструктора, затем выделенная память освобождается. Для удаления массива надо вызвать оператор delete[] (называется оператор delete для массивов), при этом для всех элементов массива вызывается деструктор в порядке, обратном вызову конструктора, затем выделенная память освобождается.
Внимание! Необходимо вызывать правильную форму оператора delete в зависимости от того, удаляется одиночный объект или массив. Это правило надо соблюдать неукоснительно, иначе можно получить неопределенное поведение, то есть может случиться все, что угодно: утечки памяти, аварийное завершение и т.д. Подробнее см. [Meyers2].
Стандартные функции выделения памяти при невозможности удовлетворить запрос выбрасывают исключение типа std::bad_alloc .
Любую форму оператора delete безопасно применять к нулевому указателю.
В приведенном выше описании необходимо сделать одно уточнение. Для так называемых тривиальных типов (встроенные типы, структуры в стиле С), конструктор может не вызываться, а деструктор в любом случае ничего не делает. См. также раздел 1.6.
1.3. Уровень доступа деструктора
Когда оператор delete применяется к указателю на класс, деструктор этого класса должен быть доступен в точке вызова delete . (Есть некоторое исключение из этого правила, рассмотренное в разделе 1.6.) Таким образом, делая деструктор защищенным или закрытым, программист запрещает использование оператора delete там, где деструктор недоступен. Напомним, что если в классе не определен деструктор, компилятор это сделает самостоятельно, и этот деструктор будет открытым (см. раздел 1.1).
1.4. Создание и удаление в одном модуле
Если оператор new создал объект, то вызов оператора delete для его удаления должен быть в том же модуле. Образно говоря, «положи туда, где взял». Это правило хорошо известно, см., например [Sutter/Alexandrescu]. При нарушении этого правила может произойти «нестыковка» функций выделения и освобождения памяти, что, как правило, приводит к аварийному завершению программы.
1.5. Полиморфное удаление
Если проектируется полиморфная иерархия классов, экземпляры которых удаляются с помощью оператора delete , то в базовом классе должен быть открытый виртуальный деструктор, это гарантирует вызов деструктора фактического типа объекта при применении оператора delete к указателю на базовый класс. При нарушении этого правила может произойти вызов деструктора базового класса, из-за чего возможна утечка ресурсов.
1.6. Удаление при неполном объявлении класса
Определенные проблемы может создать «всеядность» оператора delete , его можно применить к указателю типа void* или к указателю на класс, который имеет неполное (упреждающее) объявление. В этом случае ошибки не происходит, просто вызов деструктора пропускается, вызывается только функция освобождения памяти. Рассмотрим пример:
Этот код компилируется, даже если в точке вызова delete не доступно полное объявление класса X . Правда, при компиляции (Visual Studio) выдается предупреждение:
warning C4150: deletion of pointer to incomplete type ‘X’; no destructor called
Если есть реализация X и CreateX() , то код компонуется, если CreateX() возвращает указатель на объект, созданный оператором new , то вызов Foo() успешно выполняется, деструктор при этом не вызывается. Понятно, что это может привести к утечке ресурсов, так что еще раз о необходимости внимательно относится к предупреждениям.
Ситуация эта не надумана, она легко может возникнуть при использовании классов типа интеллектуального указателя или классов-дескрипторов. Скотт Мейерс разбирается с этой проблемой в [Meyers3].
2. Чисто виртуальные функции и абстрактные классы
Концепция интерфейсных классов базируется на таких понятиях С++ как чисто виртуальные функции и абстрактные классы.
2.1. Чисто виртуальные функции
Виртуальная функция, объявленная с использованием конструкции «=0» , называется чисто виртуальной.
В отличии от обычной виртуальной функции, чисто виртуальную функцию можно не определять (за исключением деструктора, см. раздел 2.3), но она должна быть переопределена в одном из производных классов.
Чисто виртуальные функции могут быть определены. Герб Саттер предлагает несколько полезных применений для этой возможности [Shutter].
2.2. Абстрактные классы
Абстрактным классом называется класс, имеющий хотя бы одну чисто виртуальную функцию. Абстрактным будет также класс, производный от абстрактного класса и не переопределяющий хотя бы одну чисто виртуальную функцию. Стандарт С++ запрещает создавать экземпляры абстрактного класса, можно создавать только экземпляры производных не абстрактных классов. Таким образом, абстрактный класс создается, чтобы использоваться в качестве базового класса. Соответственно, если в абстрактном классе определяется конструктор, то его не имеет смысла делать открытым, он должен быть защищенным.
2.3. Чисто виртуальный деструктор
В ряде случаев чисто виртуальным целесообразно сделать деструктор. Но такое решение имеет две особенности.
- Чисто виртуальный деструктор должен быть обязательно определен. (Обычно используется определение по умолчанию, то есть с использованием конструкции «=default» .) Деструктор производного класса вызывает деструкторы базовых классов по всей цепочке наследования и, следовательно, очередь гарантировано дойдет до корня — чисто виртуального деструктора.
- Если программист не переопределил чисто виртуальный деструктор в производном классе, компилятор сделает это за него (см. раздел 1.1). Таким образом, класс, производный от абстрактного класса с чисто виртуальным деструктором, может потерять абстрактность и без явного переопределения деструктора.
Пример использования чисто виртуального деструктора можно найти в разделе 4.4.
3. Интерфейсные классы
Интерфейсным классом называется абстрактный класс, не имеющий данных и состоящий в основном из чисто виртуальных функций. Такой класс может иметь обычные виртуальные функции (не чисто виртуальные), например деструктор. Также могут быть статические функции-члены, например функции-фабрики.
3.1. Реализации
Реализацией интерфейсного класса будем называть производный класс, в котором переопределены чисто виртуальные функции. Реализаций одного и того же интерфейсного класса может быть несколько, причем возможны две схемы: горизонтальная, когда несколько разных классов наследуют один и тот же интерфейсный класс, и вертикальная, когда интерфейсный класс является корнем полиморфной иерархии. Конечно, могут быть и гибриды.
Ключевым моментом концепции интерфейсных классов является полное отделение интерфейса от реализации — клиент работает только с интерфейсным классом, реализация ему не доступна.
3.2. Создание объекта
Недоступность класса реализации вызывает определенные проблемы при создании объектов. Клиент должен создать экземпляр класса реализации и получить указатель на интерфейсный класс, через который и будет осуществляться доступ к объекту. Так как класс реализации не доступен, то использовать конструктор нельзя, поэтому используется функция-фабрика, определяемая на стороне реализации. Эта функция обычно создает объект с помощью оператора new и возвращает указатель на созданный объект, приведенный к указателю на интерфейсный класс. Функция-фабрика может быть статическим членом интерфейсного класса, но это не обязательно, она, например, может быть членом специального класса-фабрики (который, в свою очередь, сам может быть интерфейсным) или свободной функцией. Функция-фабрика может возвращать не сырой указатель на интерфейсный класс, а интеллектуальный. Этот вариант рассмотрен в разделах 3.3.4 и 4.3.2.
3.3. Удаление объекта
Удаление объекта является чрезвычайно ответственной операцией. При ошибке возникает либо утечка памяти, либо двойное удаление, которое обычно приводит к аварийному завершению программы. Ниже этот вопрос рассматривается максимально подробно, причем много внимания уделяется предупреждению ошибочных действий клиента.
Существуют четыре основных варианта:
- Использование оператора delete .
- Использование специальной виртуальной функции.
- Использование внешней функции.
- Автоматическое удаление с помощью интеллектуального указателя.
3.3.1. Использование оператора delete
Для этого в интерфейсном классе необходимо иметь открытый виртуальный деструктор. В этом случае оператор delete , вызванный для указателя на интерфейсный класс на стороне клиента обеспечивает вызов деструктора класса-реализации. Этот вариант может работать, но удачным его признать трудно. Мы получаем вызовы операторов new и delete по разные стороны «барьера», new на стороне реализации, delete на стороне клиента. А если реализация интерфейсного класса сделана в отдельном модуле (что достаточно обычное дело), то получаем нарушение правила из раздела 1.4.
3.3.2. Использование специальной виртуальной функции
Более прогрессивным является другой вариант: интерфейсный класс должен иметь специальную виртуальную функцию, которая и удаляет объект. Такая функция, в конце концов, сводится к вызову delete this , но это происходит уже на стороне реализации. Называться такая функция может по-разному, например Delete() , но используются и другие варианты: Release() , Destroy() , Dispose() , Free() , Close() , etc. Кроме соблюдения правила из раздела 1.4, этот вариант имеет несколько дополнительных преимуществ.
- Позволяет использовать для класса реализации пользовательские функции выделения/освобождения памяти.
- Позволяет реализовать более сложную схему управления временем жизни объекта реализации, например с использованием счетчика ссылок.
В этом варианте попытка удаления объекта с помощью оператора delete может компилироваться и даже выполняться, но это является ошибкой. Для ее предотвращения в интерфейсном классе достаточно иметь пустой или чисто виртуальный защищенный деструктор (см. раздел 1.3). Отметим, что использование оператора delete может оказаться достаточно сильно замаскированным, например, стандартные интеллектуальные указатели для удаления объекта по умолчанию используют оператор delete и соответствующий код глубоко «зарыт» в их реализации. Защищенный деструктор позволяет обнаружить все такие попытки на этапе компиляции.
3.3.3. Использование внешней функции
Этот вариант может привлечь определенной симметрией процедур создания и удаления объекта, но реально он никаких преимуществ по сравнению с предыдущим вариантом не имеет, а вот дополнительных проблем появляется много. Этот вариант не рекомендуется к использованию и в дальнейшем не рассматривается.
3.3.4. Автоматическое удаление с помощью интеллектуального указателя
В этом случае функция-фабрика возвращает не сырой указатель на интерфейсный класс, а соответствующий интеллектуальный указатель. Этот интеллектуальный указатель создается на стороне реализации и инкапсулирует объект-удалитель, который автоматически удаляет объект реализации, когда интеллектуальный указатель (или последняя его копия) выходит из области видимости на стороне клиента. В этом случае специальная виртуальная функция для удаления объекта реализации может не потребоваться, но защищенный деструктор по-прежнему нужен, необходимо предотвратить ошибочное использование оператора delete . (Правда, надо отметить, что вероятность такой ошибки заметно снижается.) Более подробно этот вариант рассмотрен в разделе 4.3.2.
3.4. Другие варианты управления временем жизни экземпляра класса реализации
В ряде случаев клиент может получать указатель на интерфейсный класс, но не владеть им. Управления временем жизни объекта реализации находится полностью на стороне реализации. Например, объект может быть статическим объектом-синглтоном (такое решение характерно для фабрик). Другой пример связан с двунаправленным взаимодействием, см. раздел 3.7. Удалять такой объект клиент не должен, но защищенный деструктор для такого интерфейсного класса нужен, необходимо предотвратить ошибочное использование оператора delete .
3.5. Семантика копирования
Для интерфейсного класса создание копии объекта реализации с помощью копирующего конструктора невозможно, поэтому если требуется копирование, то в классе должна быть виртуальная функция, создающая копию объекта реализации и возвращающая указатель на интерфейсный класс. Такую функцию часто называют виртуальным конструктором, и традиционное имя для нее Clone() или Duplicate() .
Использование оператора копирующего присваивания не запрещено, но нельзя признать удачной идеей. Оператор копирующего присваивания всегда является парным, он должен идти в паре с копирующим конструктором. Оператор, генерируемый компилятором по умолчанию, бессмыслен, он ничего не делает. Теоретически можно объявить оператор присваивания чисто виртуальным с последующим переопределением, но виртуальное присваивание является не рекомендуемой практикой, подробности можно найти в [Meyers1]. К тому же присваивание выглядит весьма неестественно: доступ к объектам класса реализации обычно осуществляется через указатель на интерфейсный класс, поэтому присваивание будет выглядеть так:
Оператор присваивания лучше всего запретить, а при необходимости подобной семантики иметь в интерфейсном классе соответствующую виртуальную функцию.
Запретить присваивание можно двумя способами.
- Объявить оператор присваивания удаленным ( =delete ). Если интерфейсные классы образуют иерархию, то это достаточно сделать в базовом классе. Недостаток этого способа заключается в том, что это влияет на класс реализации, запрет распространяется и на него.
- Объявить защищенный оператор присваивания с определением по умолчанию ( =default ). Это не влияет на класс реализации, но в случае иерархии интерфейсных классов такое объявление нужно делать в каждом классе.
3.6. Конструктор интерфейсного класса
Часто конструктор интерфейсного класса не объявляется. В этом случае компилятор генерирует конструктор по умолчанию, необходимый для реализации наследования (см. раздел 1.1). Этот конструктор открытый, хотя достаточно, чтобы он был защищенным. Если в интерфейсном классе копирующий конструктор объявлен удаленным ( =delete ), то генерация компилятором конструктора по умолчанию подавляется, и необходимо явно объявить такой конструктор. Естественно его сделать защищенным с определением по умолчанию ( =default ). В принципе, объявление такого защищенного конструктора можно делать всегда. Пример находится в разделе 4.4.
3.7. Двунаправленное взаимодействие
Интерфейсные классы удобно использовать для организации двунаправленного взаимодействия. Если некоторый модуль доступен через интерфейсные классы, то клиент также может создать реализации некоторых интерфейсных классов и передать указатели на них в модуль. Через эти указатели модуль может получать сервисы от клиента а также передавать клиенту данные или нотификации.
3.8. Интеллектуальные указатели
Так как доступ к объектам класса реализации обычно осуществляется через указатель, то для управления их временем жизни естественно воспользоваться интеллектуальными указателями. Но следует иметь в виду, что если используется второй вариант удаления объектов, то стандартным интеллектуальным указателем необходимо передать пользовательский удалитель (тип) или экземпляр этого типа. Если этого не сделать, то для удаления объекта интеллектуальный указатель будет использовать оператор delete , и код просто не будет компилироваться (благодаря защищенному деструктору). Стандартные интеллектуальные указатели (включая использование пользовательских удалителей) подробно рассмотрены в [Josuttis], [Meyers3]. Пример использования пользовательского удалителя можно найти в разделе 4.3.1.
Если интерфейсный класс поддерживает счетчик ссылок, то целесообразно использовать не стандартные интеллектуальные указатели, а специально написанный для такого случая, это сделать достаточно легко.
3.9. Константные функции-члены
Следует с осторожностью объявлять функции-члены интерфейсных классов как const. Одним из важных достоинств интерфейсных классов является возможность максимально полного отделения интерфейса от реализации, но ограничения, связанные с константностью функции-члена, могут создать проблемы при разработке класса реализации.
3.10. COM-интерфейсы
COM-интерфейсы являются примером интерфейсных классов, но следует иметь в виду, что COM — это независимый от языка программирования стандарт, и COM-интерфейсы можно реализовывать на разных языках, например на C, где нет ни деструкторов, ни защищенных членов. Разработка COM-интерфейсов на C++ должна вестись в соответствии с правилами, определяемыми технологией COM.
3.11. Интерфейсные классы и библиотеки
Достаточно часто интерфейсные классы используются в качестве интерфейса (API) для целых библиотек (SDK). В этом случае целесообразно следовать следующей схеме. Библиотека имеет доступную функцию-фабрику, которая возвращает указатель на интерфейсный класс-фабрику, с помощью которого и создаются экземпляры классов реализации других интерфейсных классов. В этом случае для библиотек, поддерживающих явную спецификацию экспорта (Windows DLL), требуется всего одна точка экспорта: вышеупомянутая функция-фабрика. Весь остальной интерфейс библиотеки становится доступным через таблицы виртуальных функций. Именно такая схема позволяет максимально просто реализовывать гибкие, динамические решения, когда модули подгружаются выборочно во время исполнения. Модуль загружается с помощью LoadLibrary() или ее аналогом на других платформах, далее получается адрес функции-фабрики, и после этого библиотека становится полностью доступной.
4. Пример интерфейсного класса и его реализации
4.1. Интерфейсный класс
Так как интерфейсный класс редко бывает один, то обычно целесообразно создать базовый класс.
Вот демонстрационный интерфейсный класс.
Отметим, что защищенный деструктор должен быть как в базовом классе, так и в интерфейсном классе. В базовом классе он нужен, потому что в некоторых сценариях клиент может использовать указатель на IBase . В интерфейсном классе он нужен, потому что при его отсутствии компилятор сгенерирует открытый деструктор по умолчанию (см. раздел 1.3). Запрет присваивания достаточно сделать в базовом классе, он будет распространяться на все производные классы.
4.2. Класс реализации
В классе реализации деструктор является защищенным, конструктор, а также наследование от интерфейсного класса являются закрытыми, а функция-фабрика объявлена другом, это обеспечивает максимальную инкапсуляцию процедуры создания и удаления объекта.
4.3. Стандартные интеллектуальные указатели
4.3.1. Создание на стороне клиента
При создании интеллектуального указателя на стороне клиента необходимо использовать пользовательский удалитель. Класс-удалитель очень простой (он может быть вложен в IBase ):
Для std::unique_ptr<> класс-удалитель является шаблонным параметром:
Отметим, что благодаря тому, что класс-удалитель не содержит данных, размер UniquePtr равен размеру сырого указателя.
Вот шаблон функции-фабрики:
Вот шаблон преобразования из сырого указателя в интеллектуальный:
Экземпляры std::shared_ptr<> можно инициализировать экземплярами std::unique_ptr<> , поэтому специальные функции, возвращающие std::shared_ptr<> определять не нужно. Вот пример создания объектов типа Activator .
А этот ошибочный код благодаря защищенному деструктору не компилируется (конструктор должен принимать второй аргумент — объект-удалитель):
Также нельзя использовать шаблон std::make_shared<>() , он не поддерживает пользовательские удалители (соответствующий код не будет компилироваться).
Описанная схема имеет недостаток: через интеллектуальный указатель можно вызвать виртуальную функцию удаления объекта реализации, что приведет к двойному удалению. Эту проблему можно решить так: сделать виртуальную функцию удаления защищенной, а класс-удалитель другом. Пример находится в разделе 4.4.
4.3.2. Создание на стороне реализации
Интеллектуальный указатель можно создавать на стороне реализации. В этом случае клиент получает его в качестве возвращаемого значения функциии-фабрики. Если использовать std::shared_ptr<> и в его конструктор передать указатель на класс реализации, который имеет открытый деструктор, то пользовательский удалитель не нужен (и не требуется специальная виртуальная функция для удаления объекта реализации). В этом случае конструктор std::shared_ptr<> (а это шаблон) создает объект-удалитель по умолчанию, который базируется на типе аргумента и при удалении применяет оператор delete к указателю на объект реализации. Для std::shared_ptr<> объект-удалитель входит в состав экземпляра интеллектуального указателя (точнее его управляющего блока) и тип объекта-удалителя не влияет на тип интеллектуального указателя. В этом варианте предыдущий пример можно переписать так.
Для функции-фабрики более оптимальным является вариант с использованием шаблона std::make_shared<>() :
В описанном сценарии нельзя использовать std::unique_ptr<> , так как у него несколько иная стратегия удаления, класс-удалитель является шаблонным параметром, то есть является составной частью типа интеллектуального указателя.
4.4. Альтернативная реализация базового класса
В отличие от C# или Java в C++ нет специального понятия «интерфейс», необходимое поведение моделируется с помощью виртуальных функций. Это дает дополнительную гибкость при реализации интерфейсного класса. Рассмотрим еще один вариант реализации IBase .
Чисто виртуальный деструктор нужно определить, Delete() не чисто виртуальная функция, поэтому ее также нужно определить.
Остальные интерфейсные классы наследуются от IBase . Теперь при реализации интерфейсного класса не требуется переопределять Delete() , она определена в базовом классе и благодаря виртуальному деструктору обеспечивает вызов деструктора класса реализации. Класс-удалитель также естественно сделать вложенным в IBase . Delete() объявлена защищенной, класс-удалитель другом. Это запрещает непосредственный вызов Delete() на стороне клиента и тем самым снижает вероятность ошибок, связанных с удалением объекта. Рассмотренный вариант ориентирован на использование интеллектуальных указателей, описанное в разделе 4.3.1.
5. Исключения и коллекции, реализованные с помощью интерфейсных классов
5.1 Исключения
Если модуль, доступный через интерфейсные классы, проектируется как модуль, использующий исключения для сообщения об ошибках, то можно предложить следующий вариант реализации класса исключения.
В заголовочном файле, доступном клиенту, объявляется интерфейсный класс IException и обычный класс Exception .
При возникновении исключительной ситуации модуль выбрасывает исключение типа Exception , клиент перехватывает это исключение и получает информацию через доступный указатель на IException . При необходимости клиент может пробросить исключение дальше, путем вызова оператора throw , или сохранить исключение. Первый конструктор класса Exception используется только в точке выброса исключения, его экспортировать из модуля не надо. Остальные функции-члены являются встраиваемыми и доступны как модулю, так и клиенту.
Реализовать Exception можно, например, следующим образом.
Класс реализации IException :
Определение конструктора Exception :
Обратим внимание на то, что при программировании в смешанных решениях — .NET — родные модули, — такое исключение корректно проходит границу между родным и управляемым модулем, если он написан на C++/CLI. Таким образом, это исключение может быть выброшено в родном модуле, а перехвачено в управляемом классе, написанном на C++/CLI.
5.2 Коллекции
Шаблон интерфейсного класса-коллекции может выглядеть следующим образом:
С такой коллекцией уже можно достаточно комфортно работать, но при желании указатель на такой шаблонный класс можно обернуть в шаблон класса-контейнера, который предоставляет интерфейс в стиле контейнеров стандартной библиотеки.
Такой контейнер реализовать совсем не сложно. Он владеет коллекцией, то есть выполняет ее освобождение в деструкторе. Возможно, это контейнер не полностью удовлетворяет требованиям, предъявляемым к стандартным контейнерам, но это не особенно нужно, главное он имеет функции-члены begin() и end() , которые возвращают итератор. А вот если итератор определен в соответствии со стандартом итератора (см. [Josuttis]), то с этим контейнером можно использовать диапазонный цикл for и стандартные алгоритмы. Определение итератора в соответствии с правилами стандартной библиотеки является достаточно объемным и поэтому здесь не приводится. Определения шаблонов классов контейнера и итератора полностью находится в заголовочных файлах и, следовательно, никаких функций дополнительно экспортировать не надо.
6. Интерфейсные классы и классы-обертки
Интерфейсные классы являются достаточно низкоуровневыми средствами программирования. Для более комфортной работы их желательно обернуть в классы-обертки, обеспечивающие автоматическое управление временем жизни объектов. Также обычно желательно иметь стандартные решения типа исключений и контейнеров. Выше было показано, как это можно сделать для программирования в среде С++. Но интерфейсные классы могут служить функциональной основой для реализации решений и на других платформах, таких как .NET, Java или Pyton. На этих платформах используются другие механизмы управления временем жизни объектов и другие стандартные интерфейсы. В этом случае надо создавать обертку, используя технологию, обеспечивающую интеграцию с целевой платформой и учитывающую особенности платформы. Например для .NET Framework такая обертка пишется на C++/CLI и она будет отличаться от предложенной выше обертки для C++. Пример можно посмотреть здесь.
7. Итоги
Объект реализации интерфейсного класса создается функцией-фабрикой, которая возвращает указатель или интеллектуальный указатель на интерфейсный класс.
Для удаления объекта реализации интерфейсного класса существуют три варианта.
- Использование оператора delete .
- Использование специальной виртуальной функции.
- Автоматическое удаление с помощью интеллектуального указателя.
В первом варианте интерфейсный класс должен иметь открытый виртуальный деструктор.
Во втором варианте интерфейсный класс должен иметь защищенный деструктор, который предохраняет от ошибочного использования оператора delete . Если в этом варианте для управления временем жизни объектов реализации интерфейсного класса используются стандартные интеллектуальные указатели, то им необходимо передать пользовательский удалитель.
В третьем варианте функция-фабрика возвращает интеллектуальный указатель, который создается на стороне реализации и инкапсулирует процедуру удаления объекта реализации. В этом случае специальная виртуальная функция для удаления объекта реализации может не потребоваться, но защищенный деструктор нужен, необходимо предотвратить ошибочное использование оператора delete .
Семантика копирования для объектов реализации интерфейсного класса реализуется с помощью специальных виртуальных функций.
Интерфейсные классы позволяют упростить компоновку модулей, почти весь интерфейс модуля становится доступным через таблицы виртуальных функций, поэтому не сложно реализовывать гибкие, динамические решения, когда модули подгружаются выборочно во время исполнения.
Список литературы
[GoF]
Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Приемы объектно-ориентированного проектирования. Паттерны проектирования.: Пер. с англ. — СПб.: Питер, 2001.
[Josuttis]
Джосаттис, Николаи М. Стандартная библиотека C++: справочное руководство, 2-е изд.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2014.
[Dewhurst]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.
[Meyers1]
Мейерс, Скотт. Наиболее эффективное использование C++. 35 новых рекомендаций по улучшению ваших программ и проектов.: Пер. с англ. — М.: ДМК Пресс, 2000.
[Meyers2]
Мейерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.
[Meyers3]
Мейерс, Скотт. Эффективный и современный C++: 42 рекомендации по использованию C++11 и C++14.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2016.
[Sutter]
Саттер, Герб. Решение сложных задач на C++.: Пер. с англ. — М: ООО «И.Д. Вильямс», 2015.
[Sutter/Alexandrescu]
Саттер, Герб. Александреску, Андрей. Стандарты программирования на С++.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2015.