Что такое шаблон для безопасного интерфейса в C ++

22

Примечание: ниже приведен код C ++ 03, но мы ожидаем перехода на C ++ 11 в ближайшие два года, поэтому мы должны помнить об этом.

Я пишу руководство (для новичков, среди прочего) о том, как написать абстрактный интерфейс на C ++. Я прочитал обе статьи Саттера на эту тему, искал в Интернете примеры и ответы и провел несколько тестов.

Этот код не должен компилироваться!

void foo(SomeInterface & a, SomeInterface & b)
{
   SomeInterface c ;               // must not be default-constructible
   SomeInterface d(a);             // must not be copy-constructible
   a = b ;                         // must not be assignable
}

Все приведенные выше поведения находят источник своей проблемы в нарезке : абстрактный интерфейс (или неконечный класс в иерархии) не должен быть ни конструируемым, ни копируемым / присваиваемым, даже если производный класс может быть.

0-е решение: базовый интерфейс

class VirtuallyDestructible
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Это решение простое и несколько наивное: оно не отвечает всем нашим ограничениям: оно может быть построено по умолчанию, построено при копировании и назначено при копировании (я даже не уверен насчет конструкторов перемещения и присваивания, но у меня есть еще 2 года, чтобы понять это из).

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

Так что нет ...

1-е решение: boost :: noncopyable

class VirtuallyDestructible : boost::noncopyable
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Это лучшее решение, потому что оно простое, понятное и C ++ (без макросов)

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

  1. Мы не можем объявить деструктор чистым виртуальным, потому что нам нужно поддерживать его встроенным, а некоторые наши компиляторы не будут его переваривать.
  2. Да, единственная цель этого класса - сделать реализаторы практически разрушаемыми, что является редким случаем.

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

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

2-е решение: сделать их защищенными!

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      // With C++11, these methods would be "= default"
      MyInterface() {}
      MyInterface(const MyInterface & ) {}
      MyInterface & operator = (const MyInterface & ) { return *this ; }
} ;

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

Кроме того, он не накладывает искусственных ограничений на реализацию классов , которые затем могут свободно следовать правилу нуля или даже объявлять несколько конструкторов / операторов как «= default» в C ++ 11/14 без проблем.

Теперь, это довольно многословно, и альтернативой будет использование макроса, что-то вроде:

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      DECLARE_AS_NON_SLICEABLE(MyInterface) ;
} ;

Защищенный должен оставаться вне макроса (потому что у него нет области видимости).

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

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

Вывод

  • Я параноик, чтобы хотеть, чтобы код был защищен от нарезки в интерфейсах? (Я верю, что нет, но никто не знает ...)
  • Что является лучшим решением среди вышеперечисленных?
  • Есть ли другое, лучшее решение?

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

Щедрость и результаты

Я присудил награду coredump из-за времени, потраченного на ответы на вопросы, и актуальности ответов.

Мое решение проблемы, вероятно, пойдет примерно так:

class MyInterface
{
   DECLARE_CLASS_AS_INTERFACE(MyInterface) ;

   public :
      // the virtual methods
} ;

... со следующим макросом:

#define DECLARE_CLASS_AS_INTERFACE(ClassName)                                \
   public :                                                                  \
      virtual ~ClassName() {}                                                \
   protected :                                                               \
      ClassName() {}                                                         \
      ClassName(const ClassName & ) {}                                       \
      ClassName & operator = (const ClassName & ) { return *this ; }         \
   private :

Это жизнеспособное решение для моей проблемы по следующим причинам:

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

Обратите внимание, что другие ответы дали ценную информацию. Спасибо всем, кто дал ему шанс.

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

paercebal
источник
5
Разве вы не можете просто использовать чисто виртуальные функции в интерфейсе? virtual void bar() = 0;например? Это предотвратит создание вашего интерфейса.
Морвенн
@Morwenn: Как сказано в вопросе, это решило бы 99% случаев (я стремлюсь к 100%, если это возможно). Даже если мы решим игнорировать пропущенный 1%, это также не решит проблему среза присваивания. Так что нет, это не очень хорошее решение.
paercebal
@Morwenn: Серьезно? ... :-D ... Сначала я написал этот вопрос в StackOverflow, а затем передумал перед отправкой. Вы верите, что я должен удалить его здесь и отправить в SO?
paercebal
Если я прав, все, что вам нужно, это virtual ~VirtuallyDestructible() = 0виртуальное наследование интерфейсных классов (только с абстрактными членами). Вы можете опустить это VirtuallyDestructible, скорее всего.
Дитер Люкинг
5
@paercebal: если компилятор захлебывается чисто виртуальными классами, он попадает в корзину. Реальный интерфейс по определению является чисто виртуальным.
Никто,

Ответы:

13

Канонический способ создания интерфейса в C ++ - дать ему чистый виртуальный деструктор. Это гарантирует, что

  • Никакие экземпляры самого интерфейсного класса не могут быть созданы, потому что C ++ не позволяет вам создавать экземпляр абстрактного класса. Это учитывает неконструктивные требования (как по умолчанию, так и для копирования).
  • Вызов deleteуказателя на интерфейс делает правильную вещь: он вызывает деструктор самого производного класса для этого экземпляра.

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

Любой компилятор C ++ должен иметь возможность обрабатывать класс / интерфейс следующим образом (все в одном заголовочном файле):

class MyInterface {
public:
  virtual ~MyInterface() = 0;
protected:
  MyInterface& operator=(const MyInterface&) { return *this; } // or = default for C++14
};

inline MyInterface::~MyInterface() {}

Если у вас есть компилятор, который блокирует это (что означает, что он должен быть до C ++ 98), то ваш вариант 2 (с защищенными конструкторами) является хорошим вторым вариантом.

Использование boost::noncopyableне рекомендуется для этой задачи, потому что оно отправляет сообщение о том, что все классы в иерархии не подлежат копированию, и это может создать путаницу для более опытных разработчиков, которые не знакомы с вашими намерениями использовать его таким образом.

Барт ван Инген Шенау
источник
If you need [prevent assignment] to fail as well, then you must add a protected assignment operator to your interface.: Это корень моей проблемы. Случаи, когда мне нужен интерфейс для поддержки назначения, должны быть действительно редкими. С другой стороны, случаи, когда я хочу передать интерфейс по ссылке (случаи, когда NULL не является приемлемым), и, таким образом, хотят избежать запрета операций или срезов, которые компилируются, намного больше.
paercebal
Поскольку оператор присваивания никогда не должен вызываться, почему вы даете ему определение? Как в стороне, почему бы не сделать это private? Кроме того, вы можете иметь дело с default- и copy-ctor.
Дедупликатор
5

Я параноик ...

  • Я параноик, чтобы хотеть, чтобы код был защищен от нарезки в интерфейсах? (Я верю, что нет, но никто не знает ...)

Разве это не проблема управления рисками?

  • боитесь ли вы, что ошибка, связанная с нарезкой, может появиться?
  • Как вы думаете, это может остаться незамеченным и спровоцировать неисправимые ошибки?
  • в какой степени вы готовы пойти на нарезку?

Лучшее решение

  • Что является лучшим решением среди вышеперечисленных?

Ваше второе решение («сделать их защищенными») выглядит хорошо, но имейте в виду, что я не эксперт по C ++.
По крайней мере, мой компилятор (g ++), по-видимому, правильно сообщает, что неверное использование ошибочно.

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

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

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

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

Лучшее решение

  • Есть ли другое, лучшее решение?

Нарезка в C ++ - не что иное, как особенность языка. Так как вы пишете руководство (особенно для новичков), вы должны сосредоточиться на обучении, а не просто перечислять «правила кодирования». Вы должны убедиться, что вы действительно объясняете, как и почему происходит нарезка, а также примеры и упражнения (не изобретайте колесо, черпайте вдохновение в книгах и учебных пособиях).

Например, заголовок упражнения может быть « Какой шаблон для безопасного интерфейса в C ++

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

О компиляторе

Ты говоришь :

Я не имею права выбирать компиляторы для этого продукта,

Часто люди говорят: «Я не имею права делать [X]» , «Я не должен делать [Y] ...» , ... потому что они думают, что это невозможно, и не потому, что они попробовал или спросил.

Вероятно, это часть вашего описания работы, чтобы высказать свое мнение по техническим вопросам; если вы действительно думаете, что компилятор является идеальным (или уникальным) выбором для вашей проблемной области, то используйте его. Но вы также сказали, что «чистые виртуальные деструкторы со встроенной реализацией - не самая худшая проблема, которую я видел» ; Насколько я понимаю, компилятор настолько особенный, что даже знающие разработчики C ++ испытывают трудности с его использованием: ваш старый / собственный компилятор теперь является техническим долгом, и вы имеете право (обязанность?) обсуждать эту проблему с другими разработчиками и менеджерами ,

Попробуйте оценить стоимость хранения компилятора и стоимость использования другого:

  1. Что текущий компилятор приносит вам, что никто другой не может?
  2. Код вашего продукта легко компилируется с использованием другого компилятора? Почему нет ?

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

CoreDump
источник
Am I paranoid...: «Сделайте ваши интерфейсы простыми в использовании, правильными и трудными в использовании». Я попробовал этот конкретный принцип, когда кто-то сообщил, что один из моих статических методов был ошибочно использован. Произошедшая ошибка казалась несвязанной, и инженеру потребовалось несколько часов, чтобы найти источник. Эта «ошибка интерфейса» соответствует назначению ссылки на интерфейс другой. Так что да, я хочу избежать такой ошибки. Кроме того, в C ++ философия заключается в том, чтобы поймать как можно больше во время компиляции, и язык дает нам эту силу, поэтому мы идем с этим.
paercebal
Best solution: Я согласен. , , Better solutionЭто потрясающий ответ. Я буду работать над этим ... Теперь о Pure virtual classes: Что это? C ++ абстрактный интерфейс? (класс без состояния и только чисто виртуальные методы?). Как этот «чистый виртуальный класс» защитил меня от нарезки? (Чистые виртуальные методы делают инстанцирование некомпилируемым, но копирование-назначение будет, а перемещение-перемещение тоже будет IIRC).
paercebal
About the compilerМы согласны, но наши компиляторы не входят в сферу моей ответственности (не то, чтобы это мешало мне выдумывать комментарии ... :-p ...). Я не буду раскрывать детали (я бы хотел), но это связано с внутренними причинами (например, наборы тестов) и внешними причинами (например, связь клиента с нашими библиотеками). В конце концов, изменение версии компилятора (или даже его исправление) НЕ является тривиальной операцией. Не говоря уже о замене одного испорченного компилятора на недавний gcc.
paercebal
@paercebal спасибо за ваши комментарии; про чистые виртуальные классы, вы правы, это не решает все ваши ограничения (я уберу эту часть). Я понимаю часть «ошибки интерфейса» и то, как полезно отлавливать ошибки во время компиляции: но вы спросили, являетесь ли вы параноиком, и я думаю, что рациональный подход заключается в том, чтобы сбалансировать вашу потребность в статических проверках с вероятностью возникновения ошибки. Удачи с компилятором :)
coredump
1
Я не фанат макросов, особенно потому, что рекомендации ориентированы (также) на мужчин младшего возраста. Слишком часто я видел людей, которым давали такие «удобные» инструменты, чтобы применять их вслепую и никогда не понимать, что на самом деле происходит. Они приходят к выводу, что то, что делает макрос, должно быть самым сложным, потому что их начальник думал, что им будет слишком сложно сделать это самим. И поскольку макрос существует только в вашей компании, они не могут даже выполнить его поиск в Интернете, тогда как для документированного руководства, какие функции-члены нужно объявить и почему, они могли бы.
5gon12eder
2

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

Лучший из известных мне способов избежать этих проблем в C ++ - это использовать стирание типов . Это техника, в которой вы скрываете полиморфный интерфейс времени выполнения за обычным интерфейсом класса. Этот нормальный интерфейс класса имеет семантику значений и заботится обо всем полиморфном «беспорядке» за экранами. std::functionявляется ярким примером стирания типа.

Для лучшего объяснения того, почему предоставление наследования вашим пользователям плохо и как стирание типов может помочь исправить это, смотрите эти презентации Шона Родителя:

Наследование - это базовый класс зла (короткая версия)

Семантика значений и полиморфизм на основе понятий (длинная версия; легче следовать, но звук не очень хорош)

D Drmmr
источник
0

Ты не параноик. Мое первое профессиональное задание как программиста на C ++ привело к нарезке и сбоям. Я знаю о других. Есть не много хороших решений для этого.

Учитывая ваши ограничения компилятора, вариант 2 является лучшим. Вместо того, чтобы делать макрос, который ваши новые программисты будут считать странным и загадочным, я бы предложил сценарий или инструмент для автоматической генерации кода. Если ваши новые сотрудники будут использовать IDE, вы сможете создать инструмент «Новый интерфейс MYCOMPANY», который запросит имя интерфейса, и создать структуру, которую вы ищете.

Если ваши программисты используют командную строку, используйте любой доступный вам язык сценариев для создания сценария NewMyCompanyInterface для генерации кода.

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

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

Бен
источник