读书笔记 effective c++ Item 39 明智而谨慎的使用private继承

时间:2022-12-08 00:52:07

1. private 继承介绍

Item 32表明C++把public继承当作”is-a”关系来对待。考虑一个继承体系,一个类Student public 继承自类Person,如果一个函数的成功调用需要从Student到Person的隐式转换,这时候“is-a”关系就出现了。对于一部分实例,使用private继承来代替public继承也是有价值的事情:

 class Person { ... };
class Student: private Person { ... }; // inheritance is now private void eat(const Person& p); // anyone can eat void study(const Student& s); // only students study Person p; // p is a Person Student s; // s is a Student eat(p); // fine, p is a Person eat(s); // error! a Student isn’t a Person

很清楚的是,private继承并不意味着“is-a”关系。那它意味着什么呢?

在我们看到结果之前,先让我们看一下private继承的行为。Private继承的第一条规则也是刚才从实际中看到的:public继承相反,如果类之间的继承关系是privte继承,编译器不会将派生类对象(Student)转换成为基类对象(Person)。这也是为什么为对象s调用eat会失败。第二条规则是即使在基类中的成员是protected或者public的,从此基类中private继承而来的成员会变成派生类中的private成员

这就是private继承所表现出来的行为。我们也从中看到了结论:private继承意味着“is-implemented-in-terms-of”。如果你让类D private继承自类B,你这么做是因为你想利用类B中的一些让你感兴趣的性质,而不是因为在类型B和类型D之前有任何概念上的关系。因此,private继承纯粹是实现上的技术。(这也是为什么你从private基类中继承而来的任何东西在你的类中都变为了private的:所有的都只是实现上的细节。)使用Item34中引入的术语,private继承意味着只是继承了实现;而接口应该被忽略掉。如果类D private继承自类B,就意味着D对象的实现依赖于类B对象,没有别的意思了。Private继承在软件实现层名才有意义,在软件设计层面是没有意义的

2. 如何在private继承和组合之间做出选择

Private继承意味着“is-implemented-in-terms-of”的事实会让你感觉有一些不安,因为Item 38中指出组合(composition)也同样意味着“is-implemented-in-terms-of”。怎么在它们之间做出选择?答案是简单的:在任何可能的时候使用组合(composition),在必须使用private继承的时候才去使用它。什么时候必须使用?主要是当protected成员或者(和)虚函数被牵扯进来的时候,也有处在边界的情况,因为空间原因而不能使用private继承。我们过后再去担心它,毕竟它是处在边界的情况。

2.1  一个不能简单的使用public继承的例子

假设我们正在一个涉及到Widgets类的应用上工作,我们想更好的理解Widgets是如何被使用的。例如,我们不但想知道Widget成员函数的调用有多频繁,我们同样想知道函数调用随时间变化的频率变化情况。程序在不同的执行阶段会有不同的行为轮廓。举个例子,编译器对函数的使用会大大的不同于优化和代码生成时对函数的使用。

我们决定修改Widget类来对每个成员函数的调用次数进行追踪。在运行时,我们会定期来检查这项信息,可能也会伴随着检查每个Widget对象值和其它一些我们认为有用的数据。为了达到这个目的,我们会创建一个定时器于是我们可以知道什么时候去收集这些统计信息。

我们更乐意去重用代码而不是实现新代码,我们翻阅了工具集,很高兴的找到了如下的类:

 class Timer {
public:
explicit Timer(int tickFrequency); virtual void onTick() const; // automatically called for each tick ... };

这正是我们要找的。我们可以为这个Timer对象配置任意的tick频率,在每个tick发生的时候,它会调用一个虚函数。我们可以重定义这个虚函数来检查Widget世界的当前状态。非常完美!

为了让Widget在Timer中重定义一个虚函数,Widget必须继承自Timer。但public继承是不合适的。因为Widget不是一个Timer。Widget客户不应该在一个Widget对象上调用onTick,因为onTick不是Widget的接口。并且允许这样的函数调用会使得客户很容易出现对Widget接口的误用,这很明显的违反了Item 18的忠告:使接口容易被正确使用不容易被误用。Public继承在这里不是有效选择。

2.2 使用private继承

所以我们在这里使用private继承:

 class Widget: private Timer {
private: virtual void onTick() const; // look at Widget usage data, etc. ... }

凭借private继承的力量,Timer的public onTick函数在Widget中变为了private,我们将其放在private关键字下并对其进行了重新声明。

2.3 使用组合(compostion)以及两个优点

这是个很好的设计,但如果private继承不是必须的,它就没有任何价值。如果我们决定使用组合(compostion)来替代private继承。我们可以在Widget内部声明一个内嵌类,此类public继承Timer,在Timer中重新定义onTick,然后在Widget中声明一个此类型的对象。下面是这个方法的实现:

读书笔记 effective c++ Item 39 明智而谨慎的使用private继承

这个设计比private继承更加复杂,因为它同时涉及到(public)继承和组合(composition),同时引入了一个新类(WidgetTimer)。我用这个例子是提醒你如果有多种方法来处理一个设计问题,训练自己考虑多种方法是值得的(Item 35)。然而,我能想出两个原因来证明使用public继承加组合比private继承更好。

第一,  你可能想使用Widget作为其他类的基类,但是你可能想阻止派生类重新定义onTick。如果Widget继承自Timer,这是不可能的,即使继承是private继承。(回忆一下Item 35,即使虚函数是private的,派生类还是可能重新定义它)但是如果WidgetTImer在Widget中是private的,并且继承自Timer,Widget的派生类就没有对WidgetTimer的访问权,也就不能继承它或者重新定义它的虚函数。如果你使用java或者C#,并且发现C++没有阻止派生类重定义虚函数的能力(Java使用final methods,C#使用sealed),现在你有方法在C++中对此行为进行模拟了。

第二,  你可能想最小化Widget的编译依赖性。如果Widget继承自Timer,当Widget被编译的时候必须能够得到Timer的定义,所以定义Widget的文件必须#include Timer.h。从另外一个角度讲,如果将WidgetTimer移出Widget并且Widget只包含一个指向WidgetTimer的指针,在Widget中对WidgetTimer进行简单的声明就可以了,不需要#include与Timer相关的任何头文件。对于大型系统来说,这样的解耦是很重要的。(编译依赖的详细介绍看Item 31

2.4 使用private继承比组合更加合理的例子

早些时候我指出来在派生类想要访问基类的protected部分或者想去重定义基类的虚函数的时候private继承才是有用的,但是类之间的关系是”is-implemented-in-terms-of”而不是“is-a”。然而,我同时指出有一种涉及到空间优化的边缘情况可以促使你更加喜欢private继承而不是composition(组合)。

这种边缘情况确实靠边缘:它只应用在没有数据的类中。这种类没有非静态数据成员;没有虚函数(因为虚函数的存在会为每个对象添加一个vptr指针,见Item 7);没有虚基类(因为这样的基类同样会引入间接费用,见Item40)。从概念上来说,这样的空类对象应该不使用空间,因为对象中没有数据需要保存。然而由于技术的原因,C++使得独立对象必须占用空间,所以如果你写下下面的代码:

 class Empty {}; // has no data, so objects should
// use no memory class HoldsAnInt { // should need only space for an int private:
int x; Empty e; // should require no memory };

你会发现sizeof(HoldsAnInt)>sizeof(int):一个Empty数据成员也会占用空间。对于大多数编译器来说,sizeof(Empty)为1,因为C++法则处理大小为0的独立对象时会默认向” empty ”对象中插入一个char。然而,内存对齐的需求(见Item 50)可能导致编译器向HoldsASnInt这样的类中添加填充物,所以HoldsAnInt对象不会只多出来一个char的大小,实际上会增加足够的空间来容纳第二个int。(在我测试过的所有编译器中,上面描述的填充也确实发生了。)

但是可能你注意到了我非常小心的说明是“独立”(freestanding)对象占用的空间必须不能为0。这个限制不能被应用在派生类对象的基类部分中,因为他们不是“独立“的。如果你继承自Empty类而不是包含一个Empty类型的对象,

 class HoldsAnInt: private Empty {
private:
int x;
};

你就会发现sizeof(HoldsAnInt)==sizeof(int)。这被称作EBO(empty base optimization),并且我测试过的编译器都通过了这个测试。如果你是一个库开发人员,如果其客户对空间十分关心,那么了解一下EBO是很值得的。并且你需要知道EBO一般只在单继承下才是可行的。管理C++对象布局的规则通常意味着EBO不能被应用在有多个基类的派生类中。

事实上,“empty“类不是真的空。虽然它们永远不会拥有非静态数据成员,它们通常会包含typedefs,enums,静态数据成员或者非虚函数。STL在技术上有很多包含有用成员(通常为typedefs)的空类,包括基类unary_function和binary_function,用户定义的函数对象会继承这些类。多亏了EBO的广泛使用,使得这些继承很少会增加派生类的大小。

2.5 结论

让我们回到基本议题。因为大多数类不是空的,所以EBO不是使用private继承的合法理由。进一步来说,大多数继承对应着”is-a”,这也是public继承的工作而不是private继承。组合和private继承都意味着“is-implemented-in-terms-of“,但是组合更容易理解,所以你应该在任何可能的情况下使用它。

当你处理两个类时,它们不是“is-a“的关系,一个类要么需要访问另外一个类的protected成员要么需要重新定义一个或多个它的虚函数,这时候private继承在大多数情况下会是合法的设计策略。即使在这种情况中,我们看到public继承和包含(containment)的混合使用通常情况下能够产生我们需要的行为,虽然增加了设计复杂性。明智而谨慎的使用private继承就意味着,在你考虑过所有的替代方法之后,在你的软件中它是表示两个类关系的最好方法,在这种情况下才去使用它。

3. 总结

  • Private继承意味着“is-implemented-in-terms-of “。它通常比组合的使用要低一个层次,但是当派生类需要访问protected基类成员或者需要重新定义继承而来的虚函数时使用Private继承有意义的。
  • 不像组合,private继承能够使用空基类优化。这对努力减少对象大小的库开发者来说很重要。