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


116

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

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

c++  oop 

Ответы:


60

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

Помимо базового использования только частного наследования, показанного в C ++ FAQ (ссылки в других комментариях), вы можете использовать комбинацию частного и виртуального наследования, чтобы запечатать класс (в терминологии .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ключевое слово contextual именно для этой цели:

class Sealed final // ...

Это отличная техника. Я напишу об этом запись в блоге.

1
Вопрос: если бы мы не использовали виртуальное наследование, то FailsToDerive компилировался. Верный?

4
+1. @Sasha: Правильно, необходимо виртуальное наследование, поскольку наиболее производный класс всегда вызывает конструкторы всех виртуально унаследованных классов напрямую, чего нельзя сказать о простом наследовании.
j_random_hacker

5
Это можно сделать универсальным без создания специального ClassSealer для каждого класса, который вы хотите запечатать! Проверьте это: class ClassSealer {protected: ClassSealer () {}}; вот и все.

+1 Ираимбиланжа, очень круто! Кстати, я видел ваш предыдущий комментарий (теперь удаленный) об использовании CRTP: я думаю, что это действительно должно сработать, просто сложно получить правильный синтаксис для друзей по шаблону. Но в любом случае ваше не-шаблонное решение гораздо
круче

138

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

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

Типичный пример - частное извлечение из контейнера 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...  
};
  • При реализации шаблона адаптера частное наследование от адаптируемого класса избавляет от необходимости пересылать во вложенный экземпляр.
  • Реализовать частный интерфейс. Это часто встречается с паттерном наблюдателя. Обычно мой класс Observer, скажем, MyClass, подписывается на некоторый Subject. Затем только MyClass должен выполнить преобразование MyClass -> Observer. Остальной части системы не нужно об этом знать, поэтому указывается частное наследование.

4
@Krsna: На самом деле, я так не думаю. Причина здесь только одна: помимо последней, лень, которую было бы сложнее обойти.
Matthieu M.

11
Не то чтобы лень (если вы не в хорошем смысле). Это позволяет без дополнительной работы создавать новые перегрузки функций, которые были представлены. Если в C ++ 1x добавят 3 новых перегрузки push_back, MyVectorполучит их бесплатно.
Дэвид Стоун

@DavidStone, ты не можешь сделать это с помощью шаблонного метода?
Julien__

5
@Julien__: Да, вы можете писать, template<typename... Args> constexpr decltype(auto) f(Args && ... args) noexcept(noexcept(std::declval<Base &>().f(std::forward<Args>(args)...)) and std::is_nothrow_move_constructible<decltype(std::declval<Base &>().f(std::forward<Args>(args)...))>) { return m_base.f(std::forward<Args>(args)...); }или вы можете писать, используя Base::f;. Если вы хотите больше функциональности и гибкости , что частное наследование и usingутверждение дает вам, у вас есть этот монстр для каждой функции (и не забывайте о том constи volatileперегрузках!).
Дэвид Стоун

2
Я говорю о большей части функциональности, потому что вы все еще вызываете один дополнительный конструктор перемещения, которого нет в версии оператора using. В общем, можно было бы ожидать, что это будет оптимизировано, но теоретически функция может возвращать неподвижный тип по значению. Шаблон функции пересылки также имеет дополнительный экземпляр шаблона и глубину constexpr. Это может привести к выходу вашей программы за пределы возможностей реализации.
Дэвид Стоун

31

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


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

2
его основное использование - уменьшить потребление пространства там, где это действительно важно, например, в строковых классах, контролируемых политикой, или в сжатых парах. на самом деле boost :: compressed_pair использует защищенное наследование.
Йоханнес Шауб - лит,

jalf: Эй, я этого не осознавал. Я думал, что закрытое наследование в основном использовалось как уловка, когда вам нужен доступ к защищенным членам класса. Интересно, почему пустой объект занимает какое-то место при использовании композиции. Наверное, для универсальной адресации ...

3
Также удобно сделать класс не копируемым - просто наследовать в частном порядке от пустого класса, который не копируется. Теперь вам не нужно выполнять напряженную работу по объявлению, но не определению частного конструктора копии и оператора присваивания. Об этом говорит и Мейерс.
Майкл Берр,

Я не понимал, что этот вопрос на самом деле касается частного наследования, а не защищенного наследования. да, я думаю, для него есть довольно много приложений. не могу придумать много примеров защищенного наследования: / похоже, что это редко бывает полезно.
Йоханнес Шауб - лит,

23

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

Например:

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


1
Действительно, частное наследование - это частный IS-A.
curiousguy

18

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

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

В случае сомнений вам следует предпочесть композицию частному наследованию.


4

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

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

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

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

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

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

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

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

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


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

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

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

@Noos: Да, я думаю, что ваше утверждение «Вильма - это интерфейс» немного двусмысленно, поскольку большинство людей восприняло бы это как означающее, что Вильма - это интерфейс, который Фред намеревается предоставить миру , а не контракт только с Вильмой.
j_random_hacker

@j_ Вот почему я считаю интерфейс плохой репутацией. Термин «интерфейс» не обязательно должен означать для мира, как можно было бы подумать, это скорее гарантия функциональности. На самом деле, у меня возникли споры по поводу термина «интерфейс» в моем классе «Разработка программ». Но мы используем то, что нам дают ...
bias

2

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

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). Нет необходимости иметь дополнительный элемент данных, занимающий место.


Нет необходимости в прямом объявлении, BigClassесть ли в этом примере? Мне это интересно, но это кричит мне в лицо.
Томас Эдинг

2

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

Проблема для решения

Предположим, вам предоставлен следующий 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-иш подход

Конечно, мы могли бы выбрать такой стиль реализации 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 .


1

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

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


1

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

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


0

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

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

Квадрат "является" прямоугольником (математически), но квадрат не является прямоугольником (поведенчески). Следовательно, вместо «is-a» мы предпочитаем говорить «работает как a» (или, если вы предпочитаете, «usable-as-a»), чтобы описание было менее подвержено недоразумениям.


0

Класс содержит инвариант. Инвариант устанавливается конструктором. Однако во многих ситуациях полезно иметь представление о состоянии представления объекта (которое вы можете передать по сети или сохранить в файл - 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 не влияет на сумму); тем не менее, когда кеша нет, его стоит поискать.

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


0

Если вам нужен 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которого поведение undefined.

Решение предлагается в ответе Бена на вышеупомянутый вопрос , просто наследовать сначала из буфера потока, затем из потока, а затем инициализировать поток с помощью 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 }
    {}
};

-1

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

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

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

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


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

9
" вы в основном нарушаете инкапсуляцию " Вы можете привести пример?
curiousguy

1
данные в одном классе и поведение в другом звучит как увеличение гибкости, поскольку может быть более одного класса поведения и клиентов, и они выбирают, какой из них им нужен, чтобы удовлетворить то, что они хотят,
Макар
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.