洛译小筑

别来无恙,我的老友…
随笔 - 45, 文章 - 0, 评论 - 172, 引用 - 0
数据加载中……

[ECPP读书笔记 条目39] 审慎使用私有继承

条目32中我们讨论过:C++将公共继承处理为“A是一个B”关系。比如我们给定一个Student类继承自Person类的层次结构,那么编译器则会在特定的时刻将Student对象隐式的转变为Person对象,以便使特定的函数得以成功调用,这一点C++本身已经为我们考虑周全了。这里我们不妨继续花一点时间研究一下,在上述示例中如果使用私有继承代替公共继承会发生什么:

class Person { ... };

class Student: private Person { ... };

                                  // 现在是私有继承

void eat(const Person& p);        // 每个人都能吃东西

 

void study(const Student& s);     // 只有学生会学习

 

Person p;                         // pPerson对象

Student s;                        // sStudent对象

 

eat(p);                           // 正确,pPerson对象

 

eat(s);                           // 错误!Student对象

                                  // 不是Person对象

很明显,私有继承并不呈现“A是一个B”的关系。那么私有继承表示的是什么关系呢?

“哇。。。”你说,“在我们讨论私有继承的含义之前,让我们先了解一下它的行为,私有继承拥有怎样的行为呢?”好的,正如上文的代码中我们看到的,私有继承的第一条守则是:如果类之间的层次关系是私有继承的话,那么编译器一般不会将派生类对象(比如Student)直接转换为一个基类对象(比如Person)。这一点是与公共继承背道而驰的。这也就说明了为什么对s对象调用eat函数时会发生错误。第二条守则是:派生类中继承自私有基类的成员也将成为私有成员,即使他们在基类中用publicprotected修饰也是如此。

行为就介绍到这。下面我们来讨论含义。私有继承意味着“A以B的形式实现”。如果有人编写了一个D类私有继承自B类,那么他这样做的真实目的应该是想借用B类中某些功能或特征,而不是B和D之间在概念层面的什么关系。综上,我们说私有继承是一个纯粹的实现域的技术。(也就是为什么在派生类中继承自私有基类的一切都是私有的:这一切都是具体实现的细节)私有继承(条目34中引入的概念)意味着只有实现应该被继承下来,而接口则应该忽略掉。如果D私有继承自B,那么这就意味着D对象以B对象的形式实现,而且再没有其他任何意义。私有继承只存在于软件实现的过程中,而在软件设计过程中永远不会涉及。

私有继承意味着“A以B的形式实现”的关系,这一事实显得有些令人困惑,因为条目38中指出,组合可以做同样的事情。那么在这两者中要怎样做出权衡取舍呢?答案很简单:尽量使用组合,在不得已时才使用私有继承。那么什么时候才是“不得已”的情形?主要是设计中出现了受保护的成员和/或虚函数的情形,这里还存在一种边缘情形,当存在存储空间问题的情形下,我们还需要考虑更多。这个问题我们稍后再讨论,毕竟它仅仅存在于极端条件下。

现在我们来考虑一个应用程序,其中包含Widget对象。我们的设计要求我们对Widget的使用方式有一个更全面的了解。比如说,我们不仅仅需要了解诸如“Widget的成员函数多久会被调用”这类问题,还需要知道“调用的频率随时间的推移有何变化”。对于拥有不同运行阶段的程序而言,在各阶段都需要有相应的行为配置与之配套。比如说,对于一个编译器而言,解释阶段所运行的函数,与优化和代码生成阶段的函数是大相径庭的。

我们决定修改Widget类以便跟踪每个成员函数被调用的次数。在运行时,我们将不时检查这一信息,这一工作可能与监视每个Widget对象的值,以及其他一切我们认为有用的数据同时进行。为了达到这一目的,我们需要设置一个某种类型的计时器,以便让我们掌握收集统计信息的时机。

复用现有代码肯定比重写新的代码更好,因此我们“翻箱倒柜”寻找出了最适合的类,下边是代码:

class Timer {

public:

  explicit Timer(int tickFrequency);

   virtual void onTick() const;   // 表针跳一下,自动调用一回

  ...

};

这正是我们需要的。对于Timer对象,我们可以依照需要任意设定表针跳动的频率,而且对于每一次跳动,该对象都会自动调用一个虚函数。我们可以对这一虚函数进行重定义,以使它具备监视所有Widget对象当前状态的功能。堪称完美!

为了让Widget重定义Timer重的虚函数,Widget必须继承自Timer。但是公共继承在此时就不合时宜了。我们不能说“Widget是一个Timer”。由于onTick不是Widget概念层面接口的一部分,因此Widget的客户不应该拥有调用onTick的权限。如果我们让客户能够调用这类函数的话,那么他们很容易就会用错Widget的接口。这显然违背了条目18目中的建议:“让接口更易使用,而不易被误用”。公共继承在这里并不是一个可选方案。

于是我们使用私有继承:

class Widget: private Timer {

private:

  virtual void onTick() const;    // 监视Widget对象的使用数据等等

  ...

};

借助于私有继承的美妙特性,Timer类的公共函数onTickWidget中变成了私有的,同时我们确保onTickWidget中被重定义。再次强调,将onTick置于公共接口下将会使客户误认为这些函数可以调用,这将会违背条目18目。

这是一个不错的设计方案,但是这里使用私有继承并不是必须的,这样做并没有实际的意义。如果我们使用组合来代替也没有问题。我们可以在Widget类的内部声明一个私有的嵌套类,并由这个嵌套类公共继承Timer,在此嵌套类中重定义onTick,然后在Widget类中放置一个改嵌套类的对象。以下代码是这一方法的概要:

class Widget {

private:

  class WidgetTimer: public Timer {

  public:

    virtual void onTick() const;

    ...

  };

  WidgetTimer timer;

  ...

 

};


这一设计比仅使用私有继承的版本更加复杂,这是因为此设计中包含了公共继承和组合两种技术,并且引入了一个新的类(WidgetTimer)。坦白说,我介绍这一方案的主要目的实际上是要告诉大家设计不要局限于某种单一的方案,刻意训练自己对于同一个问题举一反三,会令你受益非浅(参见条目35)。最后,我还是要举出两个理由来说明:公共继承加组合的方案要优于私有继承。

首先,你可能期望将Widget设计为可派生的类,但是同时你也希望在派生类中阻止对onTick的重定义。如果Widget继承自Timer,那么即便你使用私有继承,你的期望也无法完全达成。(请回忆条目35:尽管派生类中无法调用基类中的虚函数,派生类中也可以对虚函数做出重定义。)但是,如果由WidgetTimer继承自Timer,并将其放置在Widget中作为私有嵌套类,这样Widget的派生类便失去了对WidgetTimer的访问权限,于是Widget的派生类则无法继承WidgetTimer,也无法重定义WidgetTimer内的虚函数。如果你曾经使用Java或C#编程,你会发现这两种语言中没有防止派生类重定义虚函数(Java中的final方法,C#中的sealed方法)的功能,现在你已经了解如何在C++中等效的实现这一行为。

其次,你可能期望使Widget的编译依赖程度最小化。如果Widget继承自Timer,那么在编译Widget类时,Timer必须有可用的定义,因此定义Widget类的文件可能需要添加 #include ”Timer.h” 指令。另外,如果将WidgetTimer移出Widget Widget中仅包含一个指向WidgetTimer的指针,Widget可以通过WidgetTimer类的简单声明来调用这一指针。这样不需要任何 #include 指令就可以使用Timer。对于大型系统来说,这样的剥离工作可能是非常重要的。(对于最小化编译依赖议题,请参见条目31)

上文曾强调过,私有继承主要应用于以下情形:一个“准派生类”需要访问“准基类”中受保护的部分,或者需要重定义一个或多个虚函数,但是两个类之间在概念层面的关系是“A以B的形式实现”,而不是“A是一个B”。然而,我们也说过,当涉及到空间优化问题时,这里存在一种边缘情形,我们更倾向于使用虚拟继承,而不是组合。

此边缘情形中的“边缘”实际上是十分“锋利”的,也就是说它发生的几率很小:只有在你使用一个不存在数据的类时才会遇到。这样的类中,没有非静态数据成员,没有虚函数(因为虚函数的存在会使每一个对象中添加一个vptr,参见条目7),没有虚拟基类(因为虚拟基类会带来一个size的开销,参见条目40)。在概念层面,这些空类的对象不应该使用任何空间,因为对每个对象而言,是没有任何数据需要存储的。然而,C++强制要求这些独立的对象不得不占据任何空间,是有其技术上的原因的。比如你写下了下面的代码:

class Empty {};                    // 由于没有任何数据,

                                   // 因此对象也应不占任何内存

class HoldsAnInt {                 // 应仅占一个int的空间

private:

  int x;

  Empty e;                         // 不应消耗任何内存

};

你将发现:sizeof(HoldsAnInt) > sizeof(int)Empty数据成员也需要内存。对于大多数编译器而言,sizeof(Empty)的值是1,这是因为C++中禁止存在零空间的独立对象,这一“禁令”一般是静默地通过在“空”对象中添加一个char成员来达成的。然而,C++对内存对齐的要求会使得编译器在诸如HoldsAnInt这样的类中做适当的填充,因此HoldsAnInt对象中很可能不仅添加了一个char的空间,这些对象可能被扩容到能容纳另一个int。(我所测试的所有编译器都恰好是这样的情形)

但是你可能注意到刚才的讨论我很小心的使用了一个字眼——“独立”,这类对象不能为零空间。对于拥有派生类对象的基类就没有强制约束了,这是因为这类对象不是独立的。如果Empty不是包含于HoldsAnInt中,而是被其继承:

class HoldsAnInt: private Empty {

private:

  int x;

};

你一定会发现,sizeof(HoldsAnInt) == sizeof(int)。这一情形被称为“空基类优化(empty base optimization,简称EBO)”,我所测试的所有编译器均支持这一特性。如果你是一个类库开发人员,你的客户更关心内存空间问题,那么EBO则很值得你去了解。同时还有一件事,EBO一般只在单一层次环境中可行。一般情况下,C++对象的排列守则要求EBO不能适用于继承自多个基类的派生类。

在实际环境中,尽管“空”类中不包含任何非静态数据成员,但实际上它们并不真是空的。这些空类中通常会包含typedef、枚举类型成员、静态数据成员,或非虚函数。STL中包含很多技术层面的空类,这些类包含了诸多有用的成员(通常是typedef),包括unary_functionbinary_function这些基类,一般一些用户自定义的函数对象可以继承它们。感谢EBO的广泛应用,这样的继承操作一般不会为派生类带来额外的空间开销。

让我们重温基础的部分。大多数类不是空的,因此仅在极少数情况下,EBO可以作为私有继承的合理理由。另外,大多数继承结构呈现出“A是一个B”关系,其应由公共继承司职,而不是私有继承。组合和私有继承都意味着“A以B的形式实现”关系,但是组合更易于理解,因此你应该尽可能的使用它。

当你正在处理的两个类没有呈现“A是一个B”关系,并且其中一个类需要访问另一个类中的受保护的成员,或者需要重定义其中一个或若干个虚函数的情况下,私有继承最有可能成为一个合理的设计策略。即使在这种情况下,我们看到配合使用公共继承和组合的方法可以得到等效的行为,只是在设计上略显复杂。当你需要描述这样的两个类之间的关系时,使用私有继承要三思而后行。这意味着在你考虑过其他所有的可行方案后,并且确定没有比它更合适的,才选择使用。

时刻牢记

私有继承意味着“A以B的形式实现”。通常它的优先级要低于组合,但是当派生类需要访问基类中受保护的成员,或者需要重定义派生的虚函数时,私有继承还是有其存在的合理性的。

与组合不同,私有继承可以启用“空基类优化”特性。对于类库开发人员而言,私有继承对于降低对象尺寸来说至关重要。

posted on 2012-10-12 23:39 ★ROY★ 阅读(1966) 评论(0)  编辑 收藏 引用 所属分类: Effective C++


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理