Когда я должен использовать частное наследование C++?

В отличие от защищенного наследования, частное наследование C++ нашло свое отражение в основной разработке C++. Тем не менее, я до сих пор не нашел хорошего использования для этого.

Когда вы, ребята, используете это?

13 ответов

Решение

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

Помимо базового использования только частного наследования, показанного в FAQ C++ (связанного в комментариях других), вы можете использовать комбинацию частного и виртуального наследования, чтобы запечатать класс (в терминологии.NET) или сделать класс окончательным (в терминологии Java), Это не обычное использование, но в любом случае мне было интересно:

class ClassSealer {
private:
   friend class Sealed;
   ClassSealer() {}
};
class Sealed : private virtual ClassSealer
{ 
   // ...
};
class FailsToDerive : public Sealed
{
   // Cannot be instantiated
};

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

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


РЕДАКТИРОВАТЬ

В комментариях было упомянуто, что это нельзя сделать общим в то время, используя CRTP. Стандарт C++11 устраняет это ограничение, предоставляя другой синтаксис для подстановки аргументов шаблона:

template <typename T>
class Seal {
   friend T;          // not: friend class T!!!
   Seal() {}
};
class Sealed : private virtual Seal<Sealed> // ...

Конечно, все это спорный вопрос, так как C++11 предоставляет final контекстное ключевое слово именно для этой цели:

class Sealed final // ...

Я пользуюсь этим все время. Несколько примеров из головы:

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

Типичным примером является частное получение из контейнера STL:

class MyVector : private vector<int>
{
public:
    // Using declarations expose the few functions my clients need 
    // without a load of forwarding functions. 
    using vector<int>::push_back;
    // etc...  
};
  • При реализации шаблона адаптера частное наследование от класса Adapted избавляет от необходимости пересылки во вложенный экземпляр.
  • Для реализации частного интерфейса. Это часто встречается с паттерном Observer. Как правило, мой класс Observer, скажем, MyClass, подписывается каким-либо субъектом. Тогда только MyClass должен выполнить преобразование MyClass -> Observer. Остальной системе не нужно знать об этом, поэтому указывается частное наследование.

Каноническое использование частного наследования - это отношение "реализовано в терминах" (спасибо Скотту Мейерсу "Effective C++" за эту формулировку). Другими словами, внешний интерфейс унаследованного класса не имеет (видимой) связи с унаследованным классом, но использует его для реализации своих функций.

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

Например:

class FooInterface
{
public:
    virtual void DoSomething() = 0;
};

class FooUser
{
public:
    bool RegisterFooInterface(FooInterface* aInterface);
};

class FooImplementer : private FooInterface
{
public:
    explicit FooImplementer(FooUser& aUser)
    {
        aUser.RegisterFooInterface(this);
    }
private:
    virtual void DoSomething() { ... }
};

Поэтому класс FooUser может вызывать закрытые методы FooImplementer через интерфейс FooInterface, в то время как другие внешние классы не могут. Это отличный шаблон для обработки определенных обратных вызовов, которые определены как интерфейсы.

Я думаю, что критический раздел из C++ FAQ Lite:

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

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

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

[отредактировано в примере]

Возьмите пример, связанный с выше. Говоря это

[...] класс Вильма должен вызывать функции-члены из вашего нового класса, Фред.

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

частное наследство не зло; это просто дороже в обслуживании, так как увеличивает вероятность того, что кто-то изменит что-то, что нарушит ваш код.

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

[отредактировано в другом примере]

На этой странице кратко обсуждаются частные интерфейсы (с другой стороны).

Если вам нужен std::ostreamс небольшими изменениями (как в этом вопросе) вам может потребоваться

  1. Создать класс MyStreambuf который происходит от std::streambuf и внести изменения там
  2. Создать класс MyOStream который происходит от std::ostream который также инициализирует и управляет экземпляром MyStreambuf и передает указатель на этот экземпляр конструктору std::ostream

Первая идея - добавить MyStream экземпляр в качестве члена данных для MyOStream учебный класс:

class MyOStream : public std::ostream
{
public:
    MyOStream()
        : std::basic_ostream{ &m_buf }
        , m_buf{}
    {}

private:
    MyStreambuf m_buf;
};

Но базовые классы создаются перед любыми членами данных, поэтому вы передаете указатель на еще не построенный std::streambuf экземпляр для std::ostream что является неопределенным поведением.

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

class MyOStream : public MyStreamBuf, public std::ostream
{
public:
    MyOStream()
        : MyStreamBuf{}
        , basic_ostream{ this }
    {}
};

Однако полученный класс можно также использовать как std::streambufэкземпляр, который обычно нежелателен. Переход на частное наследование решает эту проблему:

class MyOStream : private MyStreamBuf, public std::ostream
{
public:
    MyOStream()
        : MyStreamBuf{}
        , basic_ostream{ this }
    {}
};

Я нашел хорошее приложение для частного наследования, хотя оно имеет ограниченное использование.

Проблему решить

Предположим, вы получили следующий C API:

#ifdef __cplusplus
extern "C" {
#endif

    typedef struct
    {
        /* raw owning pointer, it's C after all */
        char const * name;

        /* more variables that need resources
         * ...
         */
    } Widget;

    Widget const * loadWidget();

    void freeWidget(Widget const * widget);

#ifdef __cplusplus
} // end of extern "C"
#endif

Теперь ваша задача - реализовать этот API с помощью C++.

С-подход

Конечно, мы могли бы выбрать стиль реализации C-ish следующим образом:

Widget const * loadWidget()
{
    auto result = std::make_unique<Widget>();
    result->name = strdup("The Widget name");
    // More similar assignments here
    return result.release();
}

void freeWidget(Widget const * const widget)
{
    free(result->name);
    // More similar manual freeing of resources
    delete widget;
}

Но есть несколько недостатков:

  • Ручное управление ресурсами (например, памятью)
  • Это легко настроить struct неправильно
  • Легко забыть освободить ресурсы при освобождении struct
  • Это C-ish

C++ подход

Нам разрешено использовать C++, так почему бы не использовать все его возможности?

Представляем автоматизированное управление ресурсами

Вышеуказанные проблемы в основном все связаны с ручным управлением ресурсами. Решение, которое приходит на ум, заключается в наследовании от Widget и добавить экземпляр управления ресурсами в производный класс WidgetImpl для каждой переменной:

class WidgetImpl : public Widget
{
public:
    // Added bonus, Widget's members get default initialized
    WidgetImpl()
        : Widget()
    {}

    void setName(std::string newName)
    {
        m_nameResource = std::move(newName);
        name = m_nameResource.c_str();
    }

    // More similar setters to follow

private:
    std::string m_nameResource;
};

Это упрощает реализацию до следующего:

Widget const * loadWidget()
{
    auto result = std::make_unique<WidgetImpl>();
    result->setName("The Widget name");
    // More similar setters here
    return result.release();
}

void freeWidget(Widget const * const widget)
{
    // No virtual destructor in the base class, thus static_cast must be used
    delete static_cast<WidgetImpl const *>(widget);
}

Таким образом, мы исправили все вышеперечисленные проблемы. Но клиент все еще может забыть о сеттерах WidgetImpl и назначить Widget Участники напрямую.

Частное наследство выходит на сцену

Инкапсулировать Widget Члены мы используем частное наследство. К сожалению, теперь нам нужны две дополнительные функции для приведения между двумя классами:

class WidgetImpl : private Widget
{
public:
    WidgetImpl()
        : Widget()
    {}

    void setName(std::string newName)
    {
        m_nameResource = std::move(newName);
        name = m_nameResource.c_str();
    }

    // More similar setters to follow

    Widget const * toWidget() const
    {
        return static_cast<Widget const *>(this);
    }

    static void deleteWidget(Widget const * const widget)
    {
        delete static_cast<WidgetImpl const *>(widget);
    }

private:
    std::string m_nameResource;
};

Это делает следующие адаптации необходимыми:

Widget const * loadWidget()
{
    auto widgetImpl = std::make_unique<WidgetImpl>();
    widgetImpl->setName("The Widget name");
    // More similar setters here
    auto const result = widgetImpl->toWidget();
    widgetImpl.release();
    return result;
}

void freeWidget(Widget const * const widget)
{
    WidgetImpl::deleteWidget(widget);
}

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

Фрагменты кода формируют пример компиляции на Coliru.

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

class BigClass;

struct SomeCollection
{
    iterator begin();
    iterator end();
};

class BigClass : private SomeCollection
{
    friend struct SomeCollection;
    SomeCollection &GetThings() { return *this; }
};

Затем, если SomeCollection требуется доступ к BigClass, он может static_cast<BigClass *>(this), Нет необходимости иметь дополнительный элемент данных, занимающий место.

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

Пример из "Стандартов кодирования C++ Андрея Александреску, Херба Саттера":- Учтите, что два класса Square и Rectangle имеют виртуальные функции для установки их высоты и ширины. Тогда Square не сможет правильно наследовать от Rectangle, потому что код, который использует модифицируемый Rectangle, будет предполагать, что SetWidth не изменяет высоту (независимо от того, явно ли Rectangle документирует этот контракт или нет), тогда как Square::SetWidth не может сохранить этот контракт и свой собственный инвариант прямоугольности в в то же время. Но Rectangle не может правильно наследовать от Square, если клиенты Square предполагают, например, что площадь квадрата равна его ширине в квадрате, или если они полагаются на какое-то другое свойство, которое не сохраняется для прямоугольников.

Квадратный прямоугольник "is-a" (математически), но Квадрат не является прямоугольником (поведенчески). Следовательно, вместо "is-a" мы предпочитаем говорить "works-like-a" (или, если вы предпочитаете "useable-as-a"), чтобы сделать описание менее подверженным недопониманию.

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

Но вы правы, у него не так много примеров из реального мира.

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

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

Класс содержит инвариант. Инвариант устанавливается конструктором. Однако во многих ситуациях полезно иметь представление о состоянии представления объекта (которое вы можете передать по сети или сохранить в файл - DTO, если хотите). REST лучше всего делать с точки зрения AggregateType. Это особенно верно, если вы правы. Рассматривать:

struct QuadraticEquationState {
   const double a;
   const double b;
   const double c;

   // named ctors so aggregate construction is available,
   // which is the default usage pattern
   // add your favourite ctors - throwing, try, cps
   static QuadraticEquationState read(std::istream& is);
   static std::optional<QuadraticEquationState> try_read(std::istream& is);

   template<typename Then, typename Else>
   static std::common_type<
             decltype(std::declval<Then>()(std::declval<QuadraticEquationState>()),
             decltype(std::declval<Else>()())>::type // this is just then(qes) or els(qes)
   if_read(std::istream& is, Then then, Else els);
};

// this works with QuadraticEquation as well by default
std::ostream& operator<<(std::ostream& os, const QuadraticEquationState& qes);

// no operator>> as we're const correct.
// we _might_ (not necessarily want) operator>> for optional<qes>
std::istream& operator>>(std::istream& is, std::optional<QuadraticEquationState>);

struct QuadraticEquationCache {
   mutable std::optional<double> determinant_cache;
   mutable std::optional<double> x1_cache;
   mutable std::optional<double> x2_cache;
   mutable std::optional<double> sum_of_x12_cache;
};

class QuadraticEquation : public QuadraticEquationState, // private if base is non-const
                          private QuadraticEquationCache {
public:
   QuadraticEquation(QuadraticEquationState); // in general, might throw
   QuadraticEquation(const double a, const double b, const double c);
   QuadraticEquation(const std::string& str);
   QuadraticEquation(const ExpressionTree& str); // might throw
}

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

Частное наследование может почти всегда моделироваться членом (сохраняя ссылку на базу при необходимости). Просто не всегда стоит так моделировать; иногда наследование является наиболее эффективным представлением.

Тот факт, что C++ имеет функцию, не означает, что она полезна или ее следует использовать.

Я бы сказал, что вы не должны использовать его вообще.

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

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

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