Определение интерфейсов (абстрактных классов без элементов) в C++

Под интерфейсом (терминология C#) я подразумеваю абстрактный класс без элементов данных. Таким образом, такой класс указывает только контракт (набор методов), который должны реализовать подклассы. Мой вопрос: как правильно реализовать такой класс в современном С++?

Основные рекомендации C++ [1] поощряют использование абстрактных классов без элементов данных в качестве интерфейсов [I.25 и C.121]. Обычно интерфейсы должны полностью состоять из общедоступных чисто виртуальных функций и деструктора по умолчанию/пустого виртуального деструктора [из C.121]. Следовательно, я предполагаю, что его следует объявить с ключевым словом struct, так как в любом случае он содержит только общедоступные члены.

Чтобы разрешить использование и удаление объектов подкласса с помощью указателей на абстрактный класс, абстрактному классу необходим общедоступный виртуальный деструктор по умолчанию [C.127]. «Полиморфный класс должен подавлять копирование» [C.67] путем удаления операций копирования (оператор присваивания копирования, конструктор копирования) для предотвращения нарезки. Я предполагаю, что это также распространяется на конструктор перемещения и оператор присваивания перемещения, поскольку их также можно использовать для нарезки. Для фактического клонирования абстрактный класс может определить виртуальный метод clone. (Не совсем понятно, как это сделать. С помощью умных указателей или owner<T*> из библиотеки поддержки рекомендаций. Метод с использованием owner<T> мне непонятен, так как примеры не должны компилироваться: производная функция все равно ничего override не делает!?) .

В C.129 в примере используются интерфейсы только с виртуальным наследованием. Если я правильно понимаю, нет никакой разницы, производны ли интерфейсы (может быть, лучше: «реализованы»?) с помощью class Impl : public Interface {...}; или class Impl : public virtual Interface {...};, так как в них нет данных, которые можно было бы продублировать. Проблема алмаза (и связанные с ней проблемы) не существует для интерфейсов (что, я думаю, является причиной того, что такие языки, как C#, не допускают/не требуют множественного наследования для классов). Здесь виртуальное наследование сделано просто для ясности? Это хорошая практика?

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

Еще одна вещь, которая меня смущает: Очевидное противоречие: «Абстрактному классу обычно не нужен конструктор» [C.126]. Однако, если реализовать правило пяти, удалив все операции копирования (в соответствии с [C.67]), у класса больше не будет конструктора по умолчанию. Следовательно, подклассы никогда не могут быть созданы (поскольку конструкторы подклассов вызывают конструкторы базового класса), и поэтому абстрактный базовый класс всегда должен объявлять конструктор по умолчанию?! Я что-то неправильно понимаю?

Ниже приведен пример. Согласны ли вы с таким способом определения и использования абстрактного класса без членов (интерфейса)?

// C++17
/// An interface describing a source of random bits. 
// The type `BitVector` could be something like std::vector<bool>.
#include <memory>

struct RandomSource { // `struct` is used for interfaces throughout core guidelines (e.g. C.122)
    virtual BitVector get_random_bits(std::size_t num_bits) = 0; // interface is just one method

    // rule of 5 (or 6?):
    RandomSource() = default; // needed to instantiate sub-classes !?
    virtual ~RandomSource() = default; // Needed to delete polymorphic objects (C.127)

    // Copy operations deleted to avoid slicing. (C.67)
    RandomSource(const RandomSource &) = delete;

    RandomSource &operator=(const RandomSource &) = delete;

    RandomSource(RandomSource &&) = delete;

    RandomSource &operator=(RandomSource &&) = delete;

    // To implement copying, would need to implement a virtual clone method:
    // Either return a smart pointer to base class in all cases:
    virtual std::unique_ptr<RandomSource> clone() = 0;
    // or use `owner`, an alias for raw pointer from the Guidelines Support Library (GSL):
    // virtual owner<RandomSource*> clone() = 0;
    // Since GSL is not in the standard library, I wouldn't use it right now.
};

// Example use (class implementing the interface)
class PRNG : public virtual RandomSource { // virtual inheritance just for clarity?
    // ...
    BitVector get_random_bits(std::size_t num_bits) override;

    // may the subclass ever define copy operations? I guess no.

    // implemented clone method:
    // owner<PRNG*> clone() override; // for the alternative owner method...
    // Problem: multiple identical methods if several interfaces are inherited,
    // each of which requires a `clone` method? 
    //Maybe the std. library should provide an interface 
    // (e.g. `Clonable`) to unify this requirement?
    std::unique_ptr<RandomSource> clone() override;
    // 
    // ... private data members, more methods, etc...
};
  [1]: https://github.com/isocpp/CppCoreGuidelines, commit 2c95a33fefae87c2222f7ce49923e7841faca482

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

463035818_is_not_a_number 23.12.2020 16:53

вы можете попробовать получить обзор здесь: codereview.stackexchange.com. Хотя я слышал, что они довольно строгие, поэтому обязательно прочитайте их правила, чтобы ваш вопрос был там по теме.

463035818_is_not_a_number 23.12.2020 16:55

Спасибо за комментарии! Если вы спросите меня, тот (весьма прискорбный) факт, что C++ допускает около 10 правильных и около 100 правильных, но глубоко неверных способов решения любой проблемы, не вызывает основных вопросов вроде «как вы определяете абстрактный класс?» основанный на мнении.

Adomas Baliuka 23.12.2020 17:46

вы могли бы спросить: «Это фундаментально сломано?» что не о мнениях, но вы спросили мнений. Иногда дело просто в формулировках...

463035818_is_not_a_number 23.12.2020 18:21
Do you agree основано на мнении. Может быть, вам следует вместо этого разместить сообщение на codereview.stackexchange.com? Если вы обнаружили какие-то противоречия в каком-то руководстве, напишите автору и помогите ему его разъяснить. Там, где речь идет о дизайне и архитектуре, простых ответов не бывает, ответы в основном приходят из опыта. Выберите дизайн, который лучше всего подходит для конкретной проблемы, которую вы решаете. Via smart pointers or owner<T*> Реализуйте все возможные способы и посмотрите, какой вам больше подходит. Рекомендации не строгие, они лишь показывают вам возможный путь. Этот вопрос настолько широк - так много вопросов.
KamilCuk 04.01.2021 12:19

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

Rudolf Lovrenčić 27.01.2023 14:26
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
6
1 278
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Вы задаете много вопросов, но я попробую.

Под интерфейсом (терминология C#) я подразумеваю абстрактный класс без элементов данных.

Ничего особенно похожего на интерфейс C# не существует. Наиболее близок абстрактный базовый класс C++, но есть и отличия (например, вам нужно будет определить тело для виртуального деструктора).

Таким образом, такой класс указывает только контракт (набор методов), который должны реализовать подклассы. Мой вопрос: как правильно реализовать такой класс в современном С++?

Как виртуальный базовый класс.

Пример:

class OutputSink
{
public:
    
    ~OutputSink() = 0;

    // contract:
    virtual void put(std::vector<std::byte> const& bytes) = 0;
};

OutputSink::~OutputSink() = default;

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

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

«Полиморфный класс должен подавлять копирование»

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

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

virtual std::unique_ptr<OutputSink> OutputSink::clone() = 0;

Если это по какой-то причине не работает, используйте другую подпись (например, верните shared_ptr). owner<T> — полезная абстракция, но ее следует использовать только в крайних случаях (когда у вас есть кодовая база, которая навязывает вам использование необработанных указателей).

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

Не пытайтесь представить идеальный интерфейс C# в C++. C++ более гибок, и вам редко потребуется добавлять один к одному реализацию концепции C# в C++.

Например, в базовые классы на C++ я иногда добавляю общедоступные невиртуальные реализации функций с виртуальными реализациями:

class OutputSink
{
public:
     void put(const ObjWithHeaderAndData& o) // non-virtual
     {
          put(o.header());
          put(o.data());
     }

protected:
     virtual void put(ObjectHeader const& h) = 0; // specialize in implementations
     virtual void put(ObjectData const& d) = 0; // specialize in implementations
};

таким образом, абстрактный базовый класс всегда должен объявлять конструктор по умолчанию?! Я что-то неправильно понимаю?

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

Обновлено: (обращаясь к комментарию)

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

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

Большое спасибо за ваш ответ! Вы ответили на большинство моих вопросов. Ради завершения (и потомства), возможно, вы можете отредактировать свой ответ, чтобы ответить на вопрос о «виртуальном наследовании»? Кроме того, что касается вопроса о конструкторе по умолчанию, как только вы объявляете виртуальный деструктор, вы должны объявить некоторый конструктор, чтобы класс можно было использовать любым способом, верно? Я не полностью удовлетворен фразой «делай все, что нужно, чтобы компилировать». Код, который компилируется, все еще может быть сломан (например, отсутствие объявления конструктора в моем случае проявляется как проблема только после фактического создания экземпляра подкласса).

Adomas Baliuka 04.01.2021 19:40

Спасибо за редактирование. Можете ли вы еще прокомментировать вопрос о виртуальном наследовании? Например, «Абстрактный класс всегда должен быть получен с использованием public virtual».

Adomas Baliuka 05.01.2021 09:00

«Например, в базовые классы на C++ я иногда добавляю общедоступные невиртуальные реализации функций с [защищенными] виртуальными реализациями». 1) Кажется, это означает, что это невозможно сделать на С#, но это легко сделать с помощью абстрактных классов. 2) Это просто шаблон проектирования; он называется шаблоном метода шаблона и на самом деле не имеет ничего общего с вопросом об интерфейсах. Возможно, эту деталь следует убрать из ответа.

Alexander Guyer 05.01.2021 18:27

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

utnapistim 06.01.2021 17:15

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

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

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

Имеем следующую ситуацию:

struct A {
    int someData;

    A(): someData(0) {}
};

struct B : public A {
    virtual void modifyData() = 0;
};

struct C : public A {
    virtual void alsoModifyData() = 0;
};

struct D : public B, public C {
    virtual void modifyData() { someData += 10; }
    virtual void alsoModifyData() { someData -= 10; }
};

Когда modifyData и alsoModifyData вызываются для экземпляра D, они не будут изменять одну и ту же переменную, как можно было бы ожидать, из-за того, что компилятор создаст две копии someData для классов B и C.

Чтобы противостоять этой проблеме, была введена концепция виртуального наследования. Это означает, что компилятор не просто рекурсивно создаст производный класс из членов суперклассов, но вместо этого проверит, происходят ли виртуальные суперклассы от общего предка. Точно так же в Java есть концепция интерфейса, которому не разрешено владеть данными, только функциями.

Но интерфейсы могут строго наследоваться от других интерфейсов, исключая для начала проблему алмаза. В этом, конечно же, Java отличается от C++. Эти «интерфейсы» С++ по-прежнему могут наследоваться от классов, владеющих данными, тогда как в java это невозможно.

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

Я надеюсь, что этот ответ был (хотя и более концептуальным) полезным для вас!

Другие вопросы по теме

Как использовать @abstractmethod для создания абстрактного интерфейса, который определяет структуру аргументов своего конструктора?
Создание 2D-массива дочерних элементов
Ошибка TS1243: модификатор «асинхронный» нельзя использовать с модификатором «абстрактный»
Можно ли переопределить определение вложенного класса абстрактного родителя в наследующем классе?
Имеет ли GCC11 регрессию, в которой он неправильно принимает указатель на массив абстрактного класса
Вызвать суперметод суперкласса из дочернего класса
Контейнер с уникальным указателем на абстрактный класс
С++ 23 ошибки std::views::zip при просмотре ссылок на абстрактный базовый класс
Должен ли интерфейс C++ (абстрактный класс только с чистыми виртуальными функциями) удалять конструкторы присваивания копирования/перемещения
Передайте расширенный класс абстрактного класса в качестве параметра во флаттере