何时应该使用C++私有继承?

132

与受保护的继承不同,C++私有继承已经被引入到主流C++开发中。然而,我仍然没有找到一个好的用途。

你们什么时候使用它呢?

14个回答

160

我经常使用它。以下是我的几个举例:

  • 当我想要暴露一些但不是所有基类接口时。公共继承将是一个谎言,因为 Liskov 可替代性 被破坏了,而组合则意味着要编写一堆转发函数。
  • 当我想从一个没有虚析构函数的具体类派生时。公共继承会引导客户端通过指向基类的指针进行删除,从而调用未定义行为。

一个典型的示例是私有地从 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...  
};
  • 在实现适配器模式时,私有继承自被适配类可以避免转发到封闭实例。
  • 实现私有接口。这在观察者模式中经常出现。通常情况下,我的观察者类(比如MyClass)会向某个主题订阅自己。然后,只有MyClass需要进行MyClass->Observer的转换。其余系统不需要知道它,因此建议使用私有继承。

5
@Krsna:实际上,我不这么认为。这里只有一个原因:懒惰,除了最后一个问题,这可能会更加棘手解决。 - Matthieu M.
14
懒惰并不是指消极懒散(除非你是以好的方式表达)。这样可以创建新的函数重载,而无需额外工作。如果在C++1x中向"push_back"添加了3个新的函数重载,"MyVector"将自动获得它们。 - David Stone
8
@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语句提供的大部分功能和灵活性,那么对于每个函数,你都需要编写这个怪物(不要忘记constvolatile的重载!)。 - David Stone
2
我说大部分的功能,因为你仍然会调用一个在using语句版本中不存在的额外移动构造函数。通常,你期望它被优化掉,但是这个函数理论上可能会通过值返回一个不可移动的类型。转发函数模板还有一个额外的模板实例化和constexpr深度。这可能会导致你的程序运行到实现限制。 - David Stone
2
@LanYi 你做不到。这就是私有继承的意义所在。 - Yongwei Wu
显示剩余4条评论

63
答案采纳后的注意事项:这并不是一个完整的答案。如果你对这个问题感兴趣,可以阅读其他答案,比如这里(从概念上)和这里(理论和实践都有)。这只是一种可以通过私有继承实现的花哨技巧。虽然它很花哨,但它并不是这个问题的答案。

除了C++ FAQ中显示的仅使用私有继承的基本用法之外,您还可以使用私有和虚拟继承的组合来在.NET术语中“封装”一个类或在Java术语中使一个类“final”。这不是常见的用法,但我觉得它很有趣:

class ClassSealer {
private:
   friend class Sealed;
   ClassSealer() {}
};
class Sealed : private virtual ClassSealer
{ 
   // ...
};
class FailsToDerive : public Sealed
{
   // Cannot be instantiated
};
Sealed可以被实例化。它继承自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 // ...

这是个很棒的技巧。我会写一篇博客来介绍它。 - Sasha
1
问题:如果我们没有使用虚继承,那么FailsToDerive会编译通过。对吗? - Sasha
4
+1. @Sasha:正确,需要使用虚拟继承,因为最派生的类总是直接调用所有虚拟继承类的构造函数,这在普通继承中不是这种情况。 - j_random_hacker
5
可以通过通用方式来实现,而不必为每个想要封装的类都创建一个自定义的ClassSealer!看看这个:class ClassSealer { protected: ClassSealer() {} }; 就是这样。 - Iraimbilanja
1
@Iraimbilanja:如果你只是将构造函数设置为protected,那么最终派生的对象也可以从ClassSealer(多重继承)派生,并且它可以调用构造函数。这将破坏封印。不过我没有检查过。 - David Rodríguez - dribeas
显示剩余5条评论

34

私有继承的典型用法是“基于实现”关系(感谢Scott Meyers在其著作“Effective C++”中提出的这个术语)。换句话说,继承类的外部接口与被继承类没有(可见的)联系,但它在内部使用被继承类来实现其功能。


9
这里可能值得提一下为什么在这种情况下使用它:这样可以执行空基类优化,如果该类是成员而不是基类,则无法执行该优化。 - jalf
2
其主要用途是在真正需要节省空间的地方进行空间压缩,例如在受策略控制的字符串类或压缩对中。实际上,boost::compressed_pair 使用了保护继承。 - Johannes Schaub - litb
jalf:嘿,我没意识到这一点。我以为非公有继承主要是在需要访问类的受保护成员时使用的一个技巧。不过,当使用组合时,为什么一个空对象会占用任何空间呢?可能是为了通用可寻址性... - Iraimbilanja
3
为了使一个类不可复制,可以使用一个私有继承自一个不可复制的空类。这样就不必费力声明但不定义私有复制构造函数和赋值运算符了。Meyers 也讨论了这个问题。 - Michael Burr
我没有意识到这个问题实际上是关于私有继承而不是受保护的继承。是的,我猜有相当多的应用程序可以使用它。但是我想不出太多关于受保护的继承的例子 :/ 看起来那只有很少用处。 - Johannes Schaub - litb

25

私有继承的一个有用用途是当您拥有实现接口的类并将其注册到某些其他对象时。您将该接口设置为私有,以便该类本身必须注册,并且只有注册的特定对象可以使用这些函数。

例如:

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类可以通过FooInterface接口调用FooImplementer的私有方法,而其他外部类则不能。这是处理特定回调的优秀模式,这些回调被定义为接口。

1
私有继承确实是私有的IS-A。 - curiousguy
@Daemin,为什么没有人将FooInterface标记为抽象的?接口通常都是抽象的..不是吗。 - Sourav Kannantha B
@SouravKannanthaB 我不知道你的意思。C++中没有抽象关键字。 - Dominik Grabiec
是的...对不起..我刚从Java过来。我很早就接触过C++了。我只记得C++有抽象类,忘记了没有抽象关键字。谢谢。 - Sourav Kannantha B
@Daemin 抱歉晚了问问题。我尝试了以下代码:FooUser ff; FooInterface *i = new FooImplementer(ff); 但是出现了编译错误 - error: ‘FooInterface’ is an inaccessible base of ‘FooImplementer’. 我不明白这是什么意思。你能帮忙解决吗? - awakened
你不能将 FooImplementer 赋值给 FooInterface,因为它使用了私有继承。你的 FooImplementer 类需要有一个静态工厂函数来创建 FooImplementer,但是通过 FooInteface 指针或返回 FooInterface* 的成员函数返回它。可以使用 return this; - Dominik Grabiec

19

我认为来自C++ FAQ Lite的关键部分是:

私有继承的一个合法、长期的用途是当你想要构建一个类Fred,它使用类Wilma中的代码,并且来自类Wilma的代码需要调用你的新类Fred的成员函数。在这种情况下,Fred调用Wilma中的非虚函数,而Wilma则调用(通常是纯虚函数)自身中的函数,这些函数被Fred覆盖。使用组合将使此过程更加困难。

如果不确定,您应该优先选择组合而不是私有继承。


4

我发现对于我要继承的接口(即抽象类),它非常有用,因为我不希望其他代码触及接口(只能触及继承类)。

[在示例中进行编辑]

以上面链接的示例为例。说

[...]类Wilma需要从您的新类Fred调用成员函数。

这意味着Wilma需要调用某些成员函数,或者更确切地说,它是在说Wilma是一个接口。因此,正如示例中提到的那样

私有继承并不邪恶;它只是更昂贵的维护方式,因为它增加了某个人会更改某些将破坏您代码的东西的概率。

对需要满足我们接口要求的程序员达到期望效果或破坏代码进行了评论。而且,由于fredCallsWilma()只保护朋友和派生类才能触及,即仅继承类可以触及的继承接口(抽象类)(和朋友)。

[在另一个示例中进行编辑]

这个页面简要讨论了私有接口(从另一个角度)。


听起来并不实用...你能发一个例子吗? - Iraimbilanja
我想我明白你的意思了...一个典型的使用情况可能是Wilma是某种实用类,需要调用Fred中的虚函数,但其他类不需要知道Fred是基于Wilma实现的。对吗? - j_random_hacker
是的。我应该指出,据我所知,“接口”这个术语在Java中更常用。当我第一次听到它时,我认为它本可以有一个更好的名称。因为,在这个例子中,我们有一个接口,但没有任何人以我们通常想到的方式与之交互。 - bias
@Noos:是的,我认为你的说法“Wilma是一个接口”有点模糊不清,因为大多数人会认为这意味着Wilma是Fred打算提供给世界的一个接口,而不是只有与Wilma的合同。 - j_random_hacker
@j_,这就是为什么我认为接口是一个不好的名称。接口这个术语,并不需要向“世界”所想象的那样具有广泛意义,它只是功能性的保证。事实上,在我的程序设计课程中,我对接口这个术语有争议。但是,我们必须使用我们所给定的名称... - bias
补充说明:我说接口并不意味着对于外界,因为接口包括服务和属性,但这就像在我的课堂上一样变得哲学化(也许是词汇学), - bias

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

现在你的工作是使用C++实现这个API。

C风格方法

当然,我们可以选择一种类似于C的实现风格,例如:
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;
}

但是存在一些缺点:
  • 手动资源(例如内存)管理
  • 很容易设置错误的结构
  • 释放结构时很容易忘记释放资源
  • 有点类似于 C 语言

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上编译示例

2
如果您需要对std::ostream进行一些小的更改(例如这个问题),则需要:
  1. 创建一个从std::streambuf派生的类MyStreambuf,并在其中实现更改
  2. 创建一个从std::ostream派生的类MyOStream,它还初始化和管理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实例,这是未定义的行为。

解决方案在Ben的答案中提出,只需先从流缓冲区继承,然后从流继承,然后使用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 }
    {}
};

看起来这种行为至少在MSVC编译器中是有问题的。它允许我在派生类中覆盖(并且特别使用override关键字标记)通过继承变成私有的函数。当遇到多个私有继承的相同声明的函数时,编译器也会混淆 - 明显的方式是选择可用的公共成员,但它仍然选择私有成员,并抱怨它们是不可访问的,即使它有公共成员。在这种情况下,私有继承没有任何意义。 - Mr D
抱歉,我不明白你的评论如何与我的回答相关。我从来没有说过派生类不能覆盖私有虚函数,这在法律上一直是完全合法的。(请参见模板模式或C++ FAQ中的23.4)可能(私有)继承不是解决“你”的情况的方案。 - Brandlingo

2
有时候,在需要在其他接口中暴露更小的接口(例如集合)时,我会使用私有继承。这种情况下,集合实现需要访问公开类的状态,类似于Java中的内部类。请注意保留HTML标签。
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的前向声明,是吗?我觉得这很有趣,但它让我感觉像是一个hackish。 - Thomas Eding

2
私有继承应用于关系不是“is a”的情况,但新类可以“基于现有类实现”,或者新类“像”现有类一样工作。
来自Andrei Alexandrescu、Herb Sutter的《C++编程规范》中的示例: 考虑两个类Square和Rectangle,每个类都有用于设置其高度和宽度的虚函数。那么Square不能正确地从Rectangle继承,因为使用可修改的Rectangle的代码将假定SetWidth不会改变高度(无论Rectangle是否明确记录该合同),而Square :: SetWidth无法同时保持该合同和其自身的正方形不变式。但是如果客户端假定Square的面积是其宽度的平方,或者如果他们依赖于某些不适用于Rectangles的属性,则Rectangle也不能正确地从Square继承。
一个正方形在数学上“是一个”矩形,但在行为上不是一个矩形。因此,我们更喜欢说“像……一样工作”(或者,如果您愿意,“可用作……”)来使描述不易误解。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接