Интерфейсы как тип абстрактного класса c

от admin

Разработка интерфейсных классов на С++

Интерфейсные классы весьма широко используются в программах на 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. Чисто виртуальный деструктор

В ряде случаев чисто виртуальным целесообразно сделать деструктор. Но такое решение имеет две особенности.

  1. Чисто виртуальный деструктор должен быть обязательно определен. (Обычно используется определение по умолчанию, то есть с использованием конструкции «=default» .) Деструктор производного класса вызывает деструкторы базовых классов по всей цепочке наследования и, следовательно, очередь гарантировано дойдет до корня — чисто виртуального деструктора.
  2. Если программист не переопределил чисто виртуальный деструктор в производном классе, компилятор сделает это за него (см. раздел 1.1). Таким образом, класс, производный от абстрактного класса с чисто виртуальным деструктором, может потерять абстрактность и без явного переопределения деструктора.

Пример использования чисто виртуального деструктора можно найти в разделе 4.4.

3. Интерфейсные классы

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

3.1. Реализации

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

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

3.2. Создание объекта

Недоступность класса реализации вызывает определенные проблемы при создании объектов. Клиент должен создать экземпляр класса реализации и получить указатель на интерфейсный класс, через который и будет осуществляться доступ к объекту. Так как класс реализации не доступен, то использовать конструктор нельзя, поэтому используется функция-фабрика, определяемая на стороне реализации. Эта функция обычно создает объект с помощью оператора new и возвращает указатель на созданный объект, приведенный к указателю на интерфейсный класс. Функция-фабрика может быть статическим членом интерфейсного класса, но это не обязательно, она, например, может быть членом специального класса-фабрики (который, в свою очередь, сам может быть интерфейсным) или свободной функцией. Функция-фабрика может возвращать не сырой указатель на интерфейсный класс, а интеллектуальный. Этот вариант рассмотрен в разделах 3.3.4 и 4.3.2.

3.3. Удаление объекта

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

Существуют четыре основных варианта:

  1. Использование оператора delete .
  2. Использование специальной виртуальной функции.
  3. Использование внешней функции.
  4. Автоматическое удаление с помощью интеллектуального указателя.

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, этот вариант имеет несколько дополнительных преимуществ.

  1. Позволяет использовать для класса реализации пользовательские функции выделения/освобождения памяти.
  2. Позволяет реализовать более сложную схему управления временем жизни объекта реализации, например с использованием счетчика ссылок.

В этом варианте попытка удаления объекта с помощью оператора 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]. К тому же присваивание выглядит весьма неестественно: доступ к объектам класса реализации обычно осуществляется через указатель на интерфейсный класс, поэтому присваивание будет выглядеть так:

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

Запретить присваивание можно двумя способами.

  1. Объявить оператор присваивания удаленным ( =delete ). Если интерфейсные классы образуют иерархию, то это достаточно сделать в базовом классе. Недостаток этого способа заключается в том, что это влияет на класс реализации, запрет распространяется и на него.
  2. Объявить защищенный оператор присваивания с определением по умолчанию ( =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. Итоги

Объект реализации интерфейсного класса создается функцией-фабрикой, которая возвращает указатель или интеллектуальный указатель на интерфейсный класс.

Для удаления объекта реализации интерфейсного класса существуют три варианта.

  1. Использование оператора delete .
  2. Использование специальной виртуальной функции.
  3. Автоматическое удаление с помощью интеллектуального указателя.

В первом варианте интерфейсный класс должен иметь открытый виртуальный деструктор.

Во втором варианте интерфейсный класс должен иметь защищенный деструктор, который предохраняет от ошибочного использования оператора 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.

Интерфейсы

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

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

Структура также как и класс может реализовывать любое количество интерфейсов.

Особенности интерфейсов

  • в интерфейсе нельзя вписывать реализацию его элементов;
  • невозможно создать экземпляр интерфейса;
  • можно создать ссылку на интерфейс;
  • в интерфейсе не может быть конструкторов;
  • интерфейс не может содержать поля;
  • в интерфейсе не может быть осуществлена перегрузка операторов;
  • все методы интерфейса по умолчанию объявлены как public .

При использовании интерфейсов в классах-наследниках:

  • запрещено изменять модификатор доступа для метода при его реализации;
  • невозможно объявить методы интерфейса как virtual ;
  • запрещено объявлять методы интерфейса с ключевым словом static (как статические).

2. Какое отличие между интерфейсами и абстрактными классами?

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

  1. В интерфейсе запрещено прописывать реализацию его членов. В абстрактном классе часть членов может иметь реализацию. Иными словами, интерфейс это тот же абстрактный класс, у которого все методы абстрактные.
  2. В интерфейсе запрещено описывать поля (переменные, объекты), в абстрактном классе можно.
  3. В интерфейсе запрещено объявление перегруженных операторов. В абстрактном классе при соблюдении некоторых условий допускается объявление перегруженных операторов.
  4. Интерфейс не может содержать конструктор. В абстрактном классе может быть объявлен конструктор.
  5. Любой класс может быть унаследован от нескольких интерфейсов. При этом любой класс может быть унаследован только от одного абстрактного класса (и не более).
  6. В интерфейсе запрещено объявлять статические элементы (с ключевым словом static ). В абстрактном классе допускается объявление статических элементов.
  7. В интерфейсе все элементы считаются как public , и поэтому модификаторы доступа в интерфейсе не используются. В абстрактном классе элементы могут быть объявлены с любым модификатором доступа ( private , protected , public ).

3. Сколько классов могут иметь реализацию методов интерфейса?

Если интерфейс определен, то он может быть реализован в любом количестве классов.

4. Сколько интерфейсов может быть реализовано в одном классе?

В одном классе может быть реализовано любое количество интерфейсов.

5. Какой общий вид описания интерфейса?

Интерфейсы объявляются с помощью ключевого слова interface . Общая форма описания интерфейса, в котором определяются методы, следующая:

  • имя – конкретное имя интерфейса;
  • имя_метода1 , имя_метода2 , …, имя_методаN – имена методов интерфейсов;
  • возвращаемый_тип1 , возвращаемый_тип2 , …, возвращаемый_типN – типы, которые возвращаются методами интерфейса;
  • параметры1 , параметры2 , …, параметрыN – списки параметров методов интерфейса.

Кроме методов, в интерфейсах можно указывать свойства, события и индексаторы.

6. Какие элементы языка программирования можно указывать в интерфейсах?

В интерфейсах можно указывать:

  • методы;
  • свойства;
  • индексаторы;
  • события.

7. Как выглядит общая форма реализации интерфейса в классе?

Общая форма реализации интерфейса в классе имеет следующий вид:

где имя_интерфейса – имя интерфейса, методы (свойства, индексаторы, события) которого реализуются в классе. Класс обязательно должен реализовать все методы интерфейса.

8. Какая общая форма класса реализующего несколько интерфейсов?

Класс может реализовать несколько интерфейсов. В этом случае все интерфейсы определяются списком через запятую.

Общая форма класса реализующего несколько интерфейсов:

где имя_интерфейса1 , имя_интерфейса2 , …, имя_интерфейсаN – имена интерфейсов, которые должен реализовать класс. Класс должен реализовать все методы всех интерфейсов.

9. Пример объявления интерфейса и класса наследующего этот интерфейс

В данном примере интерфейсу присваивается имя IMyInterface . Рекомендовано к имени интерфейса добавить префикс ‘ I ’ в соответствии с общераспространенной практикой.

Интерфейс объявлен как public .

В данном примере, в интерфейсе объявлено описание четырех методов, которые должны быть реализованы во всех классах, определяющих эти интерфейсы. Это методы: MyGetInt() , MyGetPi() , MySquare() , MySqrt() .

Пример описания класса использующего этот интерфейс.

Все методы, которые определяются в классе, должны иметь тип доступа public . Если установить другой тип доступа ( private или protected ), то Visual Studio выдаст следующее сообщение:

где MyFun() – название функции, которая реализована в классе с модификатором доступа private или protected .

Это связано с тем, что в самом интерфейсе эти методы неявно считаются открытыми ( public ). Поэтому их реализация должна быть открытой.

10. Пример объявления двух интерфейсов и класса, который реализует методы этих интерфейсов

В нижеследующем примере объявлено два интерфейса с именами MyInterface и MyInterface2. Первый интерфейс содержит 4 методы. Второй интерфейс содержит 1 метод.

Также объявлен класс MyClass, использующий эти два интерфейса. Класс обязательно должен реализовать все методы обоих интерфейсов, то есть в сумме 5 методов.

11. Пример использования ссылки на интерфейс для доступа к методам класса

В C# допускается описывать ссылки на интерфейс. Если описать переменную-ссылку на интерфейс, то с ее помощью можно вызвать методы класса, который использует этот интерфейс.

Пример.

В данном примере создается объект (экземпляр) класса MyClass с именем mc . Затем описывается ссылка на интерфейс IMyInterface с именем mi .

приводит к тому, что ссылка mi указывает на объект класса mc . Таким образом, через ссылку mi можно иметь доступ к методам класса MyClass , так как класс MyClass реализует методы интерфейса IMyInterface.

С помощью ссылки на интерфейс можно иметь доступ к методам классов, которые реализуют описанные в этом интерфейсе методы.

12. Каким образом в интерфейсе описывается свойство?

Свойство описывается в интерфейсе без тела. Общая форма объявления интерфейсного свойства следующая:

Если свойство предназначено только для чтения, то используется один только аксессор get .

Если свойство предназначено для записи, то используется только один аксессор set .

Пример. Описывается интерфейс и класс. Класс возвращает свойство MyPi .

13. Пример интерфейса, в котором описывается индексатор.

Общая форма объявления интерфейсного индексатора имеет вид:

Пример описания и использования интерфейсного индексатора, который считывает элемент из массива, состоящего из 5 элементов типа double .

14. Какие элементы программирования языка C# нельзя описывать в интерфейсах?

Интерфейсы не могут содержать:

  • члены данных;
  • конструкторы;
  • деструкторы;
  • операторные методы.

15. Как работает механизм наследования интерфейсов?

Интерфейс может наследовать другой интерфейс. Синтаксис наследования интерфейсов такой же, как и у классов.

Общая форма наследования интерфейса следующая:

где имя_интерфейса – имя интерфейса, который наследует другие интерфейсы;

имя_интерфейса1 , имя_интерфейса2 , …, имя_интерфейсаN – имена интерфейсов-предков.

Пример. В данном примере класс MyClass использует интерфейс, который наследует другой интерфейс. В классе нужно реализовать все методы (свойства, индексаторы, события) интерфейса MyInterface1 и интерфейса MyInterface2.

16. Что такое явная реализация члена интерфейса?

Если перед именем метода (свойства, индексатора, события) стоит имя интерфейса через разделитель ‘ . ‘ (точка), то это называется явной реализацией члена интерфейса.

Пример явной реализации.

17. Когда целесообразно применять явную реализацию члена интерфейса? Примеры.

Явная реализация члена интерфейса применяется в следующих случаях:

  • когда нужно, чтобы интерфейсный метод был доступен по интерфейсной ссылке, а не по объекту класса, реализующего данный интерфейс. В этом случае интерфейсный метод не является открытым ( public ) членом класса (см. пример 1);
  • когда в одном классе реализованы два интерфейса, в которых методы имеют одинаковые имена и сигнатуру (см. пример 2).

Пример 1. Явная реализация интерфейсного метода. По интерфейсной ссылке метод есть доступен, а по объекту класса недоступен.

Пример 2. Есть два интерфейса MyInterface1 и MyInterface2 . Каждый из них имеет методы с одинаковыми именами и сигнатурами. В данном случае это метод Method() , не возвращающий параметров ( void ). С помощью явной реализации класс распознает эти методы.

Читать:
Как вставить шлейф клавиатуры в ноутбуке

18. В каких случаях лучше использовать интерфейс, а в каких абстрактный класс?

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

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

Name already in use

csharp_for_beginners / c12.md

  • Go to file T
  • Go to line L
  • Copy path
  • Copy permalink

1 contributor

Users who have contributed to this file

  • Open with Desktop
  • View raw
  • Copy raw contents Copy raw contents

Copy raw contents

Copy raw contents

ГЛАВА 12. Интерфейсы, структуры и перечисления

В этой главе рассматривается одно из самых важных в C# средств: интерфейс, определяющий ряд методов для реализации в классе. Но поскольку в самом ин­ терфейсе ни один из методов не реализуется, интерфейс представляет собой чисто логическую конструкцию, опи­ сывающую функциональные возможности без конкретной их реализации. Кроме того, в этой главе представлены еще два типа данных С#: структуры и перечисления. Структуры подоб­ ны классам, за исключением того, что они трактуются как типы значений, а не ссылочные типы. А перечисления пред­ ставляют собой перечни целочисленных констант. Струк­ туры и перечисления расширяют богатый арсенал средств программирования на С#.

Иногда в объектно-ориентированном программиро­ вании полезно определить, что именно должен делать класс, но не как он должен это делать. Примером тому мо­ жет служить упоминавшийся ранее абстрактный метод. В абстрактном методе определяются возвращаемый тип и сигнатура метода, но не предоставляется его реализация. А в производном классе должна быть обеспечена своя соб­ ственная реализация каждого абстрактного метода, опреде­ ленного в его базовом классе. Таким образом, абстрактный метод определяет интерфейс, но не реализацию метода. Ко­ нечно, абстрактные классы и методы приносят известную пользу, но положенный в их основу принцип может быть развит далее. В C# предусмотрено разделение интерфейса класса и его реализации с помощью ключевого слова interface.

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

Для реализации интерфейса в классе должны быть предоставлены тела (т.е. кон­ кретные реализации) методов, описанных в этом интерфейсе. Каждому классу предо­ ставляется полная свобода для определения деталей своей собственной реализации интерфейса. Следовательно, один и тот же интерфейс может быть реализован в двух классах по-разному. Тем не менее в каждом из них должен поддерживаться один и тот же набор методов данного интерфейса. А в том коде, где известен такой интерфейс, могут использоваться объекты любого из этих двух классов, поскольку интерфейс для всех этих объектов остается одинаковым. Благодаря поддержке интерфейсов в C# мо­ жет быть в полной мере реализован главный принцип полиморфизма: один интер­ фейс — множество методов.

Интерфейсы объявляются с помощью ключевого слова interface. Ниже приведе­ на упрощенная форма объявления интерфейса.

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

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

Этому интерфейсу присваивается имя ISeries. Префикс I в имени интерфейса указывать необязательно, но это принято делать в практике программирования, чтобы как-то отличать интерфейсы от классов. Интерфейс ISeries объявляется как public и поэтому может быть реализован в любом классе какой угодно программы.

Помимо методов, в интерфейсах можно также указывать свойства, индексаторы и события. Подробнее о событиях речь пойдет в главе 15, а в этой главе основное вни­ мание будет уделено методам, свойствам и индексаторам. Интерфейсы не могут со­ держать члены данных. В них нельзя также определить конструкторы, деструкторы или операторные методы. Кроме того, ни один из членов интерфейса не может быть объявлен как static.

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

где имя_интерфейса — это конкретное имя реализуемого интерфейса. Если уж ин­ терфейс реализуется в классе, то это должно быть сделано полностью. В частности, реализовать интерфейс выборочно и только по частям нельзя.

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

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

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

Как видите, в классе ByTwos реализуются три метода, определяемых в интерфейсе ISeries. Как пояснялось выше, это приходится делать потому, что в классе нельзя реализовать интерфейс частично.

Ниже приведен код класса, в котором демонстрируется применение класса ByTwos, реализующего интерфейс ISeries.

Для того чтобы скомпилировать код класса SeriesDemo, необходимо включить в компиляцию файлы, содержащие интерфейс ISeries, а также классы ByTwos и SeriesDemo. Компилятор автоматически скомпилирует все три файла и сформирует из них окончательный исполняемый файл. Так, если эти файлы называются ISeries. cs, ByTwos.cs и SeriesDemo.cs, то программа будет скомпилирована в следующей командной строке:

В интегрированной среде разработки Visual Studio для этой цели достаточно ввести все три упомянутых выше файла в конкретный проект С#. Кроме того, все три компи­ лируемых элемента (интерфейс и оба класса) допускается включать в единый файл.

Ниже приведен результат выполнения скомпилированного кода.

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

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

Как пояснялось выше, интерфейс может быть реализован в любом количестве клас­ сов. В качестве примера ниже приведен класс Primes, генерирующий ряд простых чисел. Обратите внимание на то, реализация интерфейса ISeries в этом классе ко­ ренным образом отличается от той, что предоставляется в классе ByTwos.

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

Применение интерфейсных ссылок

Как это ни покажется странным, но в C# допускается объявлять переменные ссы­ лочного интерфейсного типа, т.е. переменные ссылки на интерфейс. Такая переменная может ссылаться на любой объект, реализующий ее интерфейс. При вызове метода для объекта посредством интерфейсной ссылки выполняется его вариант, реализован­ ный в классе данного объекта. Этот процесс аналогичен применению ссылки на базо­ вый класс для доступа к объекту производного класса, как пояснялось в главе 11.

В приведенном ниже примере программы демонстрируется применение интер­ фейсной ссылки. В этой программе переменная ссылки на интерфейс используется с целью вызвать методы для объектов обоих классов — ByTwos и Primes. Для ясности в данном примере показаны все части программы, собранные в единый файл.

Вот к какому результату приводит выполнение этой программы:

В методе Main() переменная ob объявляется для ссылки на интерфейс ISeries. Это означает, что в ней могут храниться ссылки на объект любого класса, реализующе­ го интерфейс ISeries. В данном случае она служит для ссылки на объекты twoOb и primeOb классов ByTwos и Primes соответственно, в которых реализован интерфейс ISeries.

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

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

Очевидно, что в определении интерфейсных свойств, доступных только для чтения или только для записи, должен присутствовать единственный аксессор: get или set соответственно.

Несмотря на то что объявление свойства в интерфейсе очень похоже на объявление автоматически реализуемого свойства в классе, между ними все же имеется отличие. При объявлении в интерфейсе свойство не становится автоматически реализуемым. В этом случае указывается только имя и тип свойства, а его реализация предоставляет­ ся каждому реализующему классу. Кроме того, при объявлении свойства в интерфейсе не разрешается указывать модификаторы доступа для аксессоров. Например, аксессор set не может быть указан в интерфейсе как private.

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

При выполнении этого кода получается следующий результат.

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

Как и прежде, в объявлении интерфейсных индексаторов, доступных только для чтения или только для записи, должен присутствовать единственный аксессор: get или set соответственно.

Ниже в качестве примера приведен еще один вариант реализации интерфейса ISeries, в котором добавлен индексатор только для чтения, возвращающий i-й эле­ мент числового ряда.

Вот к какому результату приводит выполнение этого кода.

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

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

Сокрытие имен при наследовании интерфейсов

Когда один интерфейс наследует другой, то в производном интерфейсе может быть объявлен член, скрывающий член с аналогичным именем в базовом интерфейсе. Такое сокрытие имен происходит в том случае, если член в производном интерфей­ се объявляется таким же образом, как и в базовом интерфейсе. Но если не указать в объявлении члена производного интерфейса ключевое слово new, то компилятор вы­ даст соответствующее предупреждающее сообщение.

При реализации члена интерфейса имеется возможность указать его имя полно­ стью вместе с именем самого интерфейса. В этом случае получается явная реализация члена интерфейса, или просто явная реализация. Так, если объявлен интерфейс IMyIF

то следующая его реализация считается вполне допустимой:

Как видите, при реализации члена MyMeth() интерфейса IMyIF указывается его полное имя, включающее в себя имя его интерфейса.

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

В приведенном ниже примере программы демонстрируется интерфейс IEven, в котором объявляются два метода: IsEven() и IsOdd(). В первом из них определяет­ ся четность числа, а во втором — его нечетность. Интерфейс IEven затем реализуется в классе MyClass. При этом метод IsOdd() реализуется явно.

В приведенном выше примере метод IsOdd() реализуется явно, а значит, он недо­ ступен как открытый член класса MyClass. Напротив, он доступен только по интер­ фейсной ссылке. Именно поэтому он вызывается посредством переменной о ссылоч­ ного типа IEven в реализации метода IsEven().

Ниже приведен пример программы, в которой реализуются два интерфейса, при­ чем в обоих интерфейсах объявляется метод Meth(). Благодаря явной реализации ис­ ключается неоднозначность, характерная для подобной ситуации.

Вот к какому результату приводит выполнение этой программы.

Анализируя приведенный выше пример программы, обратим прежде всего вни­ мание на одинаковую сигнатуру метода Meth() в обоих интерфейсах, IMyIF_A и IMyIF_B. Когда оба этих интерфейса реализуются в классе MyClass, для каждого из них в отдельности это делается явно, т.е. с указанием полного имени метода Meth(). А поскольку явно реализованный метод может вызываться только по интерфейсной ссылке, то в классе MyClass создаются две такие ссылки: одна — для интерфейса IMyIF_A, а другая — для интерфейса IMyIF_B. Именно по этим ссылкам происходит обращение к объектам данного класса с целью вызвать методы соответствующих ин­ терфейсов, благодаря чему и устраняется неоднозначность.

Выбор между интерфейсом и абстрактным классом

Одна из самых больших трудностей программирования на C# состоит в правиль­ ном выборе между интерфейсом и абстрактным классом в тех случаях, когда требу­ ется описать функциональные возможности, но не реализацию. В подобных случаях рекомендуется придерживаться следующего общего правила: если какое-то понятие можно описать с точки зрения функционального назначения, не уточняя конкретные детали реализации, то следует использовать интерфейс. А если требуются некоторые детали реализации, то данное понятие следует представить абстрактным классом.

Стандартные интерфейсы для среды .NET Framework

Для среды .NET Framework определено немало стандартных интерфейсов, которы­ ми можно пользоваться в программах на С#. Так, в интерфейсе System.IComparable определен метод CompareTo(), применяемый для сравнения объектов, когда требу­ ется соблюдать отношение порядка. Стандартные интерфейсы являются также важ­ ной частью классов коллекций, предоставляющих различные средства, в том числе стеки и очереди, для хранения целых групп объектов. Так, в интерфейсе System. Collections.ICollection определяются функции для всей коллекции, а в интер­ фейсе System.Collections.IEnumerator — способ последовательного обращения к элементам коллекции. Эти и многие другие интерфейсы подробнее рассматривают­ ся в части II данной книги.

Как вам должно быть уже известно, классы относятся к ссылочным типам данных. Это означает, что объекты конкретного класса доступны по ссылке, в отличие от значе­ ний простых типов, доступных непосредственно. Но иногда прямой доступ к объектам как к значениям простых типов оказывается полезно иметь, например, ради повыше­ ния эффективности программы. Ведь каждый доступ к объектам (даже самым мелким) по ссылке связан с дополнительными издержками на расход вычислительных ресурсов и оперативной памяти. Для разрешения подобных затруднений в C# предусмотрена структура, которая подобна классу, но относится к типу значения, а не к ссылочному типу данных.

Структуры объявляются с помощью ключевого слова struct и с точки зрения син­ таксиса подобны классам. Ниже приведена общая форма объявления структуры:

где имя обозначает конкретное имя структуры.

Одни структуры не могут наследовать другие структуры и классы или служить в качестве базовых для других структур и классов. (Разумеется, структуры, как и все остальные типы данных в С#, наследуют класс object.) Тем не менее в структуре мож­ но реализовать один или несколько интерфейсов, которые указываются после имени структуры списком через запятую. Как и у классов, у каждой структуры имеются свои члены: методы, поля, индексаторы, свойства, операторные методы и события. В струк­ турах допускается также определять конструкторы, но не деструкторы. В то же время для структуры нельзя определить конструктор, используемый по умолчанию (т.е. кон­ структор без параметров). Дело в том, что конструктор, вызываемый по умолчанию, определяется для всех структур автоматически и не подлежит изменению. Такой кон­ структор инициализирует поля структуры значениями, задаваемыми по умолчанию. А поскольку структуры не поддерживают наследование, то их члены нельзя указывать как abstract, virtual или protected.

Объект структуры может быть создан с помощью оператора new таким же обра­ зом, как и объект класса, но в этом нет особой необходимости. Ведь когда используется оператор new, то вызывается конструктор, используемый по умолчанию. А когда этот оператор не используется, объект по-прежнему создается, хотя и не инициализируется.

В этом случае инициализацию любых членов структуры придется выполнить вручную. В приведенном ниже примере программы демонстрируется применение структу­ ры для хранения информации о книге.

При выполнении этой программы получается следующий результат.

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

Когда одна структура присваивается другой, создается копия ее объекта. В этом за­ ключается одно из главных отличий структуры от класса. Как пояснялось ранее в этой книге, когда ссылка на один класс присваивается ссылке на другой класс, в итоге ссылка в левой части оператора присваивания указывает на тот же самый объект, что и ссылка в правой его части. А когда переменная одной структуры присваивается переменной другой структуры, создается копия объекта структуры из правой части оператора при­ сваивания. Рассмотрим в качестве примера следующую программу.

Вот к какому результату приводит выполнение этой программы.

Как показывает приведенный выше результат, после присваивания

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

Выполнение этой программы приводит к следующему результату.

Как видите, после того как переменная b будет присвоена переменной а, обе пере­ менные станут указывать на один и тот же объект, т.е. на тот объект, на который перво­ начально указывала переменная b.

О назначении структур

В связи с изложенным выше возникает резонный вопрос: зачем в C# включена структура, если она обладает более скромными возможностями, чем класс? Ответ на этот вопрос заключается в повышении эффективности и производительности про­ грамм. Структуры относятся к типам значений, и поэтому ими можно оперировать непосредственно, а не по ссылке. Следовательно, для работы со структурой вообще не требуется переменная ссылочного типа, а это означает в ряде случаев существенную экономию оперативной памяти. Более того, работа со структурой не приводит к ухуд­ шению производительности, столь характерному для обращения к объекту класса. Ведь доступ к структуре осуществляется непосредственно, а к объектам — по ссылке, поскольку классы относятся к данным ссылочного типа. Косвенный характер доступа к объектам подразумевает дополнительные издержки вычислительных ресурсов на каждый такой доступ, тогда как обращение к структурам не влечет за собой подобные издержки. И вообще, если нужно просто сохранить группу связанных вместе данных, не требующих наследования и обращения по ссылке, то с точки зрения производи­ тельности для них лучше выбрать структуру.

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

Вот к какому результату может привести выполнение этого кода.

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

Любопытно, что в С++ также имеются структуры и используется ключевое слово struct. Но эти структуры отличаются от тех, что имеются в С#. Так, в C++ структура относится к типу класса, а значит, структура и класс в этом языке практически равно­ ценны и отличаются друг от друга лишь доступом по умолчанию к их членам, которые оказываются закрытыми для класса и открытыми для структуры. А в С# структура от­ носится к типу значения, тогда как класс — к ссылочному типу.

Перечисление представляет собой множество именованных целочисленных констант. Перечислимый тип данных объявляется с помощью ключевого слова enum. Ниже при­ ведена общая форма объявления перечисления:

где имя — это имя типа перечисления, а список_перечисления — список идентифи­ каторов, разделяемый запятыми. В приведенном ниже примере объявляется перечисление Apple различных сортов яблок.

Следует особо подчеркнуть, что каждая символически обозначаемая константа в перечислении имеет целое значение. Тем не менее неявные преобразования пере­ числимого типа во встроенные целочисленные типы и обратно в C# не определены, а значит, в подобных случаях требуется явное приведение типов. Кроме того, приве­ дение типов требуется при преобразовании двух перечислимых типов. Но поскольку перечисления обозначают целые значения, то их можно, например, использовать для управления оператором выбора switch или же оператором цикла for.

Для каждой последующей символически обозначаемой константы в перечислении задается целое значение, которое на единицу больше, чем у предыдущей константы. По умолчанию значение первой символически обозначаемой константы в перечисле­ нии равно нулю. Следовательно, в приведенном выше примере перечисления Apple константа Jonathan равна нулю, константа GoldenDel — 1, константа RedDel — 2 и т.д.

Доступ к членам перечисления осуществляется по имени их типа, после которого следует оператор-точка. Например, при выполнении фрагмента кода

выводится следующий результат.

Как показывает результат выполнения приведенного выше фрагмента кода, для вы­ вода перечислимого значения используется его имя. Но для получения этого значения требуется предварительно привести его к типу int.

Ниже приведен пример программы, демонстрирующий применение перечисле­ ния Apple.

Ниже приведен результат выполнения этой программы.

Обратите внимание на то, как переменная типа Apple управляет циклами for. Значения символически обозначаемых констант в перечислении Apple начинаются с нуля, поэтому их можно использовать для индексирования массива, чтобы получить цвет каждого сорта яблок. Обратите также внимание на необходимость производить приведение типов, когда перечислимое значение используется для индексирования массива. Как упоминалось выше, в C# не предусмотрены неявные преобразования перечислимых типов в целочисленные и обратно, поэтому для этой цели требуется явное приведение типов.

И еще одно замечание: все перечисления неявно наследуют от класса System.Enum, который наследует от класса System.ValueType, а тот, в свою очередь, — от класса object.

Значение одной или нескольких символически обозначаемых констант в перечис­ лении можно задать с помощью инициализатора. Для этого достаточно указать после символического обозначения отдельной константы знак равенства и целое значение. Каждой последующей константе присваивается значение, которое на единицу больше значения предыдущей инициализированной константы. Например, в приведенном ниже фрагменте кода константе RedDel присваивается значение 10.

В итоге все константы в перечислении принимают приведенные ниже значения.

Jonathan 0
GoldenDel 1
RedDel 10
Winesap 11
Cortland 12
McIntosh 13

Указание базового типа перечисления

По умолчанию в качестве базового для перечислений выбирается тип int, тем не менее перечисление может быть создано любого целочисленного типа, за исключени­ ем char. Для того чтобы указать другой тип, кроме int, достаточно поместить этот тип после имени перечисления, отделив его двоеточием. В качестве примера ниже за­ дается тип byte для перечисления Apple.

Теперь константа Apple.Winesap, например, имеет количественное значение типа byte.

На первый взгляд перечисления могут показаться любопытным, но не очень нуж­ ным элементом С#, но на самом деле это не так. Перечисления очень полезны, когда в программе требуется одна или несколько специальных символически обозначаемых констант. Допустим, что требуется написать программу для управления лентой кон­ вейера на фабрике. Для этой цели можно создать метод Conveyor(), принимающий в качестве параметров следующие команды: «старт», «стоп», «вперед» и «назад». Вместо того чтобы передавать методу Conveyor() целые значения, например, 1 — в качестве команды «старт», 2 — в качестве команды «стоп» и так далее, что чревато ошибками, можно создать перечисление, чтобы присвоить этим значениям содержательные сим­ волические обозначения. Ниже приведен пример применения такого подхода.

Вот к какому результату приводит выполнение этого кода.

Метод Conveyor() принимает аргумент типа Action, и поэтому ему могут быть переданы только значения, определяемые в перечислении Action. Например, ниже приведена попытка передать методу Conveyor() значение 22.

Эта строка кода не будет скомпилирована, поскольку отсутствует предваритель­ но заданное преобразование типа int в перечислимый тип Action. Именно это и препятствует передаче неправильных команд методу Conveyor(). Конечно, такое преобразование можно организовать принудительно с помощью приведения типов, но это было бы преднамеренным, а не случайным или неумышленным действием. Кроме того, вероятность неумышленной передачи пользователем неправильных ко­ манд методу Conveyor() сводится с минимуму благодаря тому, что эти команды обо­ значены символическими именами в перечислении.

В приведенном выше примере обращает на себя внимание еще одно интересное обстоятельство: перечислимый тип используется для управления оператором switch. Как упоминалось выше, перечисления относятся к целочисленным типам данных, и поэтому их вполне допустимо использовать в операторе switch.

Отличия абстрактного класса от интерфейса (abstract class and interface)

Каковы отличия между абстрактным классом и интерфейсом?

13 ответов 13

Абстрактный класс — это класс, у которого не реализован один или больше методов (некоторые языки требуют такие методы помечать специальными ключевыми словами).

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

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

Абстрактный класс нужен, когда нужно семейство классов, у которых есть много общего. Конечно, можно применить и интерфейс, но тогда нужно будет писать много идентичного кода.

В некоторых языках (С++) специального ключевого слова для обозначения интерфейсов нет.

Можно считать, что любой интерфейс — это уже абстрактный класс, но не наоборот.

Arhadthedev's user avatar

tl;dr: Абстрактный класс — средство разработки классов на нижнем уровне, средство для повторного использования кода; интерфейс — средство выражения семантики класса. Таким образом, это совершенно разные, мало связанные между собой понятия.

Думайте об этом по-другому.

Абстрактный класс — это «заготовка» класса: реализовано большинство методов (включая внутренние), кроме нескольких. Эти несколько нереализованных методов вполне могут быть внутренними методами класса, они лишь уточняют детали имплементации. Абстрактный класс — средство для повторного использования кода, средство, чтобы указать, какой метод обязан быть перекрыт для завершения написания класса.

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

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

Интерфейсы идеологически схожи с заголовочными файлами C++: они раскрывают методы и скрывают конкретную реализацию.

Вопрос о том, является ли интерфейс или абстрактный класс собственно классом — техническая подробность реализации, зависящая от конкретного языка программирования. Например, в C++ интерфейсы отсутствуют вовсе, и их приходится эмулировать классами без данных. Абстрактный класс в C++ как таковой также отсутствует, но им можно считать любой класс с абстрактными методами. (Отсюда ограничение C++: как минимум 1 абстрактный метод в абстрактном классе.) Также в C++ можно (непрямо) инстанциировать абстрактный класс, вызвать абстрактный метод и (возможно) получить ошибку времени выполнения. В C# интерфейсы и абстрактные классы встроены в язык.

Пример (на C#, конкретный язык значения не имеет):

VladD's user avatar

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

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

Допустим, для примера, мы хотим смоделировать кнопки в интерфейсе пользователя. Поскольку мы говорим об ООП, то мы выделим некоторый тип Кнопки с некоторым набором операций (которые определяют поведение) и скрытого состояния, на которое опирается поведение (да, скрытого состояния может и не быть). При этом мы можем выделить три вида операции:

Конкретная фиксированная операция, которая должна быть абсолютно стабильно для всех типов кнопок.

Конкретная операция с поведением по умолчанию (т.е. операция, чье поведение подходит для многих типов кнопок, но могут быть кнопки с другим поведением).

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

Другими словами, тип Кнопки может содержать невиртуальные методы (non-virtual methods), виртуальные методы (virtual methods) и абстрактные методы (abstract methods).

Наличие разных типов методов является очень важным инструментом моделирования и позволяет весьма точно выражать намерения проектировщика. Например, мы можем добавить невиртуальную операцию «Нажатия на кнопку», которая будет делегировать часть своей работы виртуальному (или абстрактному методу) «Обработать нажатие», но при этом всегда выполнять определенную часть работы (прозорливый читатель увидит в этом описании паттерн «Шаблонный метод»).

После того, как мы определили базовый тип, пришло время определить произвольные типы. И тут начинаются вопросы. Точнее, вопросов никаких не возникает, когда у типа есть лишь один непосредственный базовый тип или все базовые типы содержат лишь декларации операций. Не проблема, унаследовать «Кнопку меню» от «Кнопки» и переопределить метод «Нажать на кнопку». Но что, если наш тип «Кнопка меню» будет отнаследован от двух типов с одной и той же виртуальной операцией? Как переопределить лишь одну, а оставить другую? А как быть клиенту нового типа и различить, какую операцию вызвать? А что если у двух базовых типов есть поле с одним именем? А что если у одного базового типа метод «Нажать кнопку» реализован, а у другого – лишь описан в виде декларации?

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

Теперь будет легко провести разницу между тремя понятиями – интферфейса, абстрактного базового класса и конкретного базового класса.

Интерфейс – описывает некоторое семейство типов и содержит лишь декларации операций (да, я осознанно пишу слово «декларация», а не использую слово «контракт», которое в ООП имеет вполне определенное значение).

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

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

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

Интерфейсы же, по своей природе обладают меньшей связностью (low coupling), поскольку не обладают конкретным поведением, которое может осложнить жизнь класса-наследника. Интерфейсы также могут моделировать отношение «Является» («Кнопка меню» ЯВЛЯЕТСЯ «IКнопкой»), но могут определять и менее жесткое отношение «Может выполнять роль» (CAN DO). Например, интерфейс IEquatable из BCL определяет «дополнительное» поведение, которое говорит о возможности типов сравнивать значения объектов.

Похожие статьи