Оглавление


    Введение
    1. Специальные функции-члены, создание и удаление объектов
        1.1. Специальные функции-члены
        1.2. Создание и удаление объектов — основные подробности
        1.3. Уровень доступа деструктора
        1.4. Создание и удаление в одном модуле
        1.5. Полиморфное удаление
        1.6. Удаление при неполном объявлении класса
    2. Чисто виртуальные функции и абстрактные классы
        2.1. Чисто виртуальные функции
        2.2. Абстрактные классы
        2.3. Чисто виртуальный деструктор
    3. Интерфейсные классы
        3.1. Реализации
        3.2. Создание объекта
        3.3. Удаление объекта
            3.3.1. Использование оператора delete
            3.3.2. Использование специальной виртуальной функции
            3.3.3. Использование внешней функции
            3.3.4. Автоматическое удаление с помощью интеллектуального указателя
        3.4. Другие варианты управления временем жизни экземпляра класса реализации
        3.5. Семантика копирования
        3.6. Конструктор интерфейсного класса
        3.7. Двунаправленное взаимодействие
        3.8. Интеллектуальные указатели
        3.9. Константные функции-члены
        3.10. COM-интерфейсы
        3.11. Интерфейсные классы и библиотеки
    4. Пример интерфейсного класса и его реализации
        4.1. Интерфейсный класс
        4.2. Класс реализации
        4.3. Стандартные интеллектуальные указатели
            4.3.1. Создание на стороне клиента
            4.3.2. Создание на стороне реализации
        4.4. Альтернативная реализация базового класса
    5. Исключения и коллекции, реализованные с помощью интерфейсных классов
        5.1 Исключения
        5.2 Коллекции
    6. Интерфейсные классы и классы-обертки
    7. Итоги
    Список литературы

Введение


Интерфейсным классом называется класс, не имеющий данных и состоящий в основном из чисто виртуальных функций. Такое решение позволяет полностью отделить реализацию от интерфейса — клиент использует интерфейсный класс, — в другом месте создается производный класс, в котором переопределяются чисто виртуальные функции и определяется функция-фабрика. Детали реализации полностью скрыты от клиента. Таким образом реализуется истинная инкапсуляция, невозможная при использовании обычного класса. Про интерфейсные классы можно почитать у Скотта Мейерса [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* или к указателю на класс, который имеет неполное (упреждающее) объявление. В этом случае ошибки не происходит, просто вызов деструктора пропускается, вызывается только функция освобождения памяти. Рассмотрим пример:


class X; // неполное объявление
X* CreateX();

void Foo()
{
    X* p = CreateX();
    delete p;
}

Этот код компилируется, даже если в точке вызова 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", называется чисто виртуальной.


class X
{
// ...
    virtual void Foo() = 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. Интерфейсный класс


Так как интерфейсный класс редко бывает один, то обычно целесообразно создать базовый класс.


class IBase
{
protected:
    virtual ~IBase() = default; // защищенный деструктор

public:
    virtual void Delete() = 0;  // удаляет объект

    IBase& operator=(const IBase&) = delete; // запрет присваивания
};

Вот демонстрационный интерфейсный класс.


class IActivatable : public IBase
{
protected:
    ~IActivatable() = default; // защищенный деструктор

public:
    virtual void Activate(bool activate) = 0;

    static IActivatable* CreateInstance(); // функция-фабрика
};

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



4.2. Класс реализации


class Activator : private IActivatable
{
// ...
private:
    Activator();

protected:
    ~Activator();

public:
    void Delete() override;
    void Activate(bool activate) override;

    friend IActivatable* IActivatable::CreateInstance();
};

Activator::Activator() {/* ... */}

Activator::~Activator() {/* ... */}

void Activator::Delete() { delete this; }

void Activator::Activate(bool activate) {/* ... */}

IActivatable* IActivatable::CreateInstance()
{
    return static_cast<IActivatable*>(new Activator());
}

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



4.3. Стандартные интеллектуальные указатели



4.3.1. Создание на стороне клиента


При создании интеллектуального указателя на стороне клиента необходимо использовать пользовательский удалитель. Класс-удалитель очень простой (он может быть вложен в IBase):


struct BaseDeleter
{
    void operator()(IBase* p) const { p->Delete(); }
};

Для std::unique_ptr<> класс-удалитель является шаблонным параметром:


template <class I> // I — наследник IBase
using UniquePtr = std::unique_ptr<I, BaseDeleter>;

Отметим, что благодаря тому, что класс-удалитель не содержит данных, размер UniquePtr равен размеру сырого указателя.


Вот шаблон функции-фабрики:


template <class I> // I — имеет функцию-фабрику CreateInstance()
UniquePtr<I> CreateInstance()
{
    return UniquePtr<I>(I::CreateInstance());
}

Вот шаблон преобразования из сырого указателя в интеллектуальный:


template <class I> // I — наследник IBase
UniquePtr<I> ToPtr(I* p)
{
    return UniquePtr<I>(p);
}

Экземпляры std::shared_ptr<> можно инициализировать экземплярами std::unique_ptr<>, поэтому специальные функции, возвращающие std::shared_ptr<> определять не нужно. Вот пример создания объектов типа Activator.


auto un1 = CreateInstance<IActivatable>();
un1->Activate(true);

auto un2 = ToPtr(IActivatable::CreateInstance());
un2->Activate(true);

std::shared_ptr<IActivatable> sh = CreateInstance<IActivatable>();
sh->Activate(true);

А этот ошибочный код благодаря защищенному деструктору не компилируется (конструктор должен принимать второй аргумент — объект-удалитель):


std::shared_ptr<IActivatable> sh2(IActivatable::CreateInstance());

Также нельзя использовать шаблон std::make_shared<>(), он не поддерживает пользовательские удалители (соответствующий код не будет компилироваться).


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



4.3.2. Создание на стороне реализации


Интеллектуальный указатель можно создавать на стороне реализации. В этом случае клиент получает его в качестве возвращаемого значения функциии-фабрики. Если использовать std::shared_ptr<> и в его конструктор передать указатель на класс реализации, который имеет открытый деструктор, то пользовательский удалитель не нужен (и не требуется специальная виртуальная функция для удаления объекта реализации). В этом случае конструктор std::shared_ptr<> (а это шаблон) создает объект-удалитель по умолчанию, который базируется на типе аргумента и при удалении применяет оператор delete к указателю на объект реализации. Для std::shared_ptr<> объект-удалитель входит в состав экземпляра интеллектуального указателя (точнее его управляющего блока) и тип объекта-удалителя не влияет на тип интеллектуального указателя. В этом варианте предыдущий пример можно переписать так.


#include <memory>

class IActivatable;
using ActPtr = std::shared_ptr<IActivatable>;

// интерфейсный класс
class IActivatable
{
protected:
    virtual ~IActivatable() = default; // защищенный деструктор
    IActivatable& operator=(const IActivatable&) = default; // запрет присваивания

public:

    virtual void Activate(bool activate) = 0;

    static ActPtr CreateInstance(); // функция-фабрика
};

// класс реализации
class Activator : public IActivatable
{
// ...
public:
    Activator();  // открытый конструктор
    ~Activator(); // открытый деструктор

    void Activate(bool activate) override;
};

Activator::Activator() {/* ... */}

Activator::~Activator() {/* ... */}

void Activator::Activate(bool activate) {/* ... */}

ActPtr IActivatable::CreateInstance()
{
    return ActPtr(new Activator());
}

Для функции-фабрики более оптимальным является вариант с использованием шаблона std::make_shared<>():


ActPtr IActivatable::CreateInstance()
{
    return std::make_shared<Activator>();
}

В описанном сценарии нельзя использовать std::unique_ptr<>, так как у него несколько иная стратегия удаления, класс-удалитель является шаблонным параметром, то есть является составной частью типа интеллектуального указателя.



4.4. Альтернативная реализация базового класса


В отличие от C# или Java в C++ нет специального понятия «интерфейс», необходимое поведение моделируется с помощью виртуальных функций. Это дает дополнительную гибкость при реализации интерфейсного класса. Рассмотрим еще один вариант реализации IBase.


class IBase
{
protected:
    IBase() = default;
    virtual ~IBase() = 0;  // защищенный, чисто виртуальный деструктор
    virtual void Delete(); // удаляет объект

public:
    IBase(const IBase&) = delete;            // запрет копирования
    IBase& operator=(const IBase&) = delete; // запрет присваивания

    struct Deleter         // класс-удалитель
    {
        void operator()(IBase* p) const { p->Delete(); }
    };

    friend struct IBase::Deleter;
};

Чисто виртуальный деструктор нужно определить, Delete() не чисто виртуальная функция, поэтому ее также нужно определить.


IBase::~IBase() = default;
void IBase::Delete() { delete this; }

Остальные интерфейсные классы наследуются от IBase. Теперь при реализации интерфейсного класса не требуется переопределять Delete(), она определена в базовом классе и благодаря виртуальному деструктору обеспечивает вызов деструктора класса реализации. Класс-удалитель также естественно сделать вложенным в IBase. Delete() объявлена защищенной, класс-удалитель другом. Это запрещает непосредственный вызов Delete() на стороне клиента и тем самым снижает вероятность ошибок, связанных с удалением объекта. Рассмотренный вариант ориентирован на использование интеллектуальных указателей, описанное в разделе 4.3.1.



5. Исключения и коллекции, реализованные с помощью интерфейсных классов



5.1 Исключения


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


В заголовочном файле, доступном клиенту, объявляется интерфейсный класс IException и обычный класс Exception.


class IException
{
    friend class Exception;

    virtual IException* Clone() const = 0;
    virtual void Delete() = 0;

protected:
    virtual ~IException() = default;

public:

    virtual const char* What() const = 0;
    virtual int Code() const = 0;

    IException& operator=(const IException&) = delete;
};

class Exception
{
    IException* const m_Ptr;

public:
    Exception(const char* what, int code);
    Exception(const Exception& src) : m_Ptr(src.m_Ptr->Clone()) {}
    ~Exception() { m_Ptr->Delete(); }
    const IException* Ptr() const { return m_Ptr; }
};

При возникновении исключительной ситуации модуль выбрасывает исключение типа Exception, клиент перехватывает это исключение и получает информацию через доступный указатель на IException. При необходимости клиент может пробросить исключение дальше, путем вызова оператора throw, или сохранить исключение. Первый конструктор класса Exception используется только в точке выброса исключения, его экспортировать из модуля не надо. Остальные функции-члены являются встраиваемыми и доступны как модулю, так и клиенту.


Реализовать Exception можно, например, следующим образом.


Класс реализации IException:


class ExcImpl : IException
{
    friend class Exception;

    const std::string m_What;
    const int m_Code;

    ExcImpl(const char* what, int code);
    ExcImpl(const ExcImpl&) = default;

    IException* Clone() const override;

    void Delete() override;

protected:
    ~ExcImpl() = default;

public:
    const char* What() const override;
    int Code() const override;
};


ExcImpl::ExcImpl(const char* what, int code)
    : m_What(what), m_Code(code) {}

IException* ExcImpl::Clone() const { return new ExcImpl(*this); }

void ExcImpl::Delete() { delete this; }

const char* ExcImpl::What() const { return m_What.c_str(); }

int ExcImpl::Code() const { return m_Code; }

Определение конструктора Exception:


Exception::Exception(const char* what, int code)
    : m_Ptr(new ExcImpl(what, code)) {}

Обратим внимание на то, что при программировании в смешанных решениях — .NET — родные модули, — такое исключение корректно проходит границу между родным и управляемым модулем, если он написан на C++/CLI. Таким образом, это исключение может быть выброшено в родном модуле, а перехвачено в управляемом классе, написанном на C++/CLI.



5.2 Коллекции


Шаблон интерфейсного класса-коллекции может выглядеть следующим образом:


template <typename T>
class ICollect
{
protected:
    virtual ~ICollect() = default;

public:
    virtual ICollect<T>* Clone() const = 0;
    virtual void Delete() = 0;

    virtual bool IsEmpty() const = 0;
    virtual int GetCount() const = 0;
    virtual T& GetItem(int ind) = 0;
    virtual const T& GetItem(int ind) const = 0;

    ICollect<T>& operator=(const ICollect<T>&) = delete;
};

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


template <typename T> class ICollect;

template <typename T> class Iterator;

template <typename T>
class Contain
{
    typedef ICollect<T> CollType;

    CollType* m_Coll;

public:
    typedef T value_type;

    Contain(CollType* coll);
    ~Contain();

// копирование
    Contain(const Contain& src);
    Contain& operator=(const Contain& src);

// перемещение
    Contain(Contain&& src);
    Contain& operator=(Contain&& src);

    bool еmpty() const;
    int size() const;
    T& operator[](int ind);
    const T& operator[](int ind) const;

    Iterator<T> begin();
    Iterator<T> end();
};

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




Комментарии (27)


  1. Utopia
    22.10.2018 12:25

    Может кто-то объяснить простыми словами не программисту — зачем нужны интерфейсы?
    Это как список ToDo? Просто для удобства? Программист-руководитель написал интерфейс, другой программист им пользуется пока третий пишет реализацию? В чем "реализуется истинная инкапсуляция, невозможная при использовании обычного класса"? Это без относительно С++, в Java я так понимаю они тоже есть.

    Как там в простых примерах — есть у меня интерфейс Animal — в нем виртуальные функции Jump, Run, Eat — которые где то реализованы в производном классе. Зачем этот промежуточный этап в виде интерфейса?


    1. dopusteam
      22.10.2018 12:55

      Интерфейс позволяет зависеть от абстракции, а не от реализации.


      Лучше пример с логированием подойдёт, например


      У Вас есть интерфейс ILogger с методом Log(message: string)


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


    1. Antervis
      22.10.2018 14:09

      вот поставляете вы библиотеку. Без интерфейсов, вы раскрываете часть деталей реализации классов — protected/private методы и т.д. Есть вероятность такого использования класса юзером, что изменения реализации в библиотеке сломают бинарную совместимость с пользовательским кодом (банально поменялся sizeof класса-реализации). Либо же вам придется прятать их за другими слоями абстракций, например PIMPL.

      У интерфейсов есть и другие достоинства. Один класс может реализовывать несколько разных интерфейсов. Интерфейсы хорошо версионируются:

      class IFoo_v1
      {
      public:
          virtual void bar() = 0;
      };
      
      class IFoo_v2 : public IFoo_v1
      {
      public:
          virtual void baz() = 0; // Метод, появившийся только во второй версии
          [[deprecated]] void bar() override = 0; // Устаревший во второй версии метод
      };
      
      using IFoo = IFoo_v2; // Алиас на последнюю версию. Пользователи могут использовать его или конкретную
      
      // implementation
      class Foo : public IFoo
      {
      //...
      };
      


    1. dm_frox Автор
      22.10.2018 14:52

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


    1. mkshma
      22.10.2018 16:29

      Представьте себе геймпад. Вы тыкаете кнопки, а на другом конце провода персонаж в игре что-то делает. А как это реализовано? Есть системный API (читайте интерфейс) и игроделам не надо разрабатывать управление под каждый геймпад отдельно — им важно лишь, чтобы геймпад дергал это API, тогда все будет работать как задумано. Так же и производителям геймпадов не надо рассылать свои спецификации и софт куче игровых компаний, чтобы те реализовали поддержку их геймпада. Достаточно реализовать API и готово — ваше устройство работает со всеми этими играми. В итоге обе стороны дергают абстракцию и от конкретной реализации не зависят.


  1. ianzag
    22.10.2018 13:52

    Мне кажется, что «умный указатель» предпочтительнее. Вполне устоявшийся термин.


    1. dm_frox Автор
      22.10.2018 15:00

      Мне кажется, «умный» можно отнести к устаревшим терминам. В [Josuttis], [Meyers3] и ряде других последних книг, посвященных современному C++, используется термин «ителлектуальный».


      1. ianzag
        22.10.2018 15:09

        Заглянем в «Effective Modern C++ 42 SPECIFIC WAYS TO IMPROVE YOUR USE OF C++11 AND C++14» ISBN: 978-1-491-90399-5 товарища Меерса:

        Chapter 4: Smart Pointers
        Smart pointers are one way to address these issues. Smart pointers are wrappers
        around raw pointers that act much like the raw pointers they wrap, but that avoid
        many of their pitfalls. You should therefore prefer smart pointers to raw pointers.
        Smart pointers can do virtually everything raw pointers can, but with far fewer
        opportunities for error.

        Мне кажется, что термин умные указатели никуда не делся. Как они были smart pointer так и остались.


        1. dm_frox Автор
          22.10.2018 15:49

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


          1. ianzag
            22.10.2018 15:56

            Если smart pointer во всем мире как был smart poniter-ом так им и остался даже не думая перерождаться в intellectual — возможно, это лишь дилетантский произвол переводчика/корректора в указанных издательствах?


            1. dm_frox Автор
              22.10.2018 16:33

              Ну на счет «дилетантский» я бы категорически возразил. Мейерса переводил и редактировал И.В.Красиков, авторитетнейший переводчик, он переводит и редактирует практически все книги по тематике C++ в издательстве «Вильямс» (Герб Саттер, Андрей Александреску, Николаи Джосаттис). Его переводы стали стандартом де-факто. В технической терминологии достаточно много тонкостей и не всегда первый попавшийся вариант перевода будет лучше.


              1. Antervis
                23.10.2018 13:27
                +1

                авторитет одного переводчика — ничто по сравнению с общепринятой в мире терминологией, как бы сильно он не любил пафосные слова


                1. dm_frox Автор
                  23.10.2018 15:10

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


                  1. Antervis
                    23.10.2018 17:33

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


              1. elmm
                23.10.2018 13:38

                Сколько себя помню, они были умными. Указатели уже поумнели на столько, что стали проявлять собственный интеллект и их теперь переименовывать необходимо?


  1. alexeiz
    23.10.2018 00:02
    +1

    Похоже на студенческую курсовую работу. Какая здесь новая информация?


    1. Punk_Joker
      23.10.2018 00:51

      Просто удобная выжимка для общего ознакомления


    1. dm_frox Автор
      23.10.2018 09:53

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


  1. kovserg
    23.10.2018 00:40

    > COM-интерфейсы являются примером интерфейсных классов
    Очень хотелось бы увидеть примеры «комфортной» реализации COM интерфейсов (interface+dispinterface) на C++.


    1. VCoder
      23.10.2018 06:10

      Если это не просто праздный интерес, то рекомендую поискать книгу «Essential COM» by Don Box. Книга не новая (~20 лет), но и COM тоже :)

      books.google.com/books/about/Essential_COM.html?id=kfRWvKSePmAC


    1. dm_frox Автор
      23.10.2018 09:39

      Для реализации COM на С++ существует специальная библиотека — ATL от Microsoft (входит в состав Visual Studio). По ней есть книги. Раньше у меня была отличная книга, где демонстрировалось создание COM объектов на C++ «руками», без всяких библиотек, но кто-то зачитал. Ну, вообще, COM уже не модная технология.


      1. kovserg
        23.10.2018 12:05

        Дело не в моде. Просто на C++ с этим COM и ATL грсуть и печаль почучается (книги). Хочется увидеть как в современном C++17,20… справляются с такой старой задачей.


        1. qw1
          23.10.2018 14:45

          Дело как раз в моде. Заинтересованное лицо — Microsoft — продвигает инфраструктуру .NET вместо COM, потому и не развивает COM. Авторы стандартов C++17,20 вообще не думают о COM, потому для COM ничего не меняется.

          С другой стороны, в Delphi, VB, и скриптовых языках от MS (jscript, vbs) за счёт IDispatch уже довольно удобно пользоваться COM, и потому никаких изменений не нужно.


        1. dm_frox Автор
          23.10.2018 15:32

          Тут ничего не поделать, COM медленная (в смысле разработки) и печальная технология, C++17 уж точно не планировался для нее. Раньше COM объекты можно было писать на Visual Basic 6, Delphi. До сих пор можно писать на Qt. Это, конечно, побыстрее.


  1. IGR2014
    23.10.2018 11:13
    +1

    Не увидел Non-Virtual Interface (Не виртуальный интерфейс).
    Удобная вещь когда нужно подготовить «контекст» перед вызовом виртуальной ф-ции


    1. dm_frox Автор
      23.10.2018 15:20

      NVI это другая модель использования виртуальных, разновидность Шаблонного Метода Банды Четырех. Интерфейсные классы носят более технологический характер, с их помощью реализуется доступ к непрозрачному модулю. В C++ нет понятия интерфейс и его приходится моделировать с помощью виртуальных функций.


  1. AndrewSu
    24.10.2018 10:08

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


    Следует с осторожностью объявлять функции-члены интерфейсных классов как const.

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