继承和派生
继承的前提:分类
聚集(aggregation)
对于那些有明显层次之分的类,在构造后代类对象的时候,不是在其中包含一个前驱类对象,而是在前驱类对象的基础上,在其后面直接添加新的特征。这使得后代对象完全将前驱对象融合在自己内部,而且,每一个后代的前面部分就是一个完整的上一代前驱的对象
在C++和其它面向对象的语言中,聚集的实现过程被称为“继承(inheritance)”和“派生(derivation)”
继承的优点
使程序员可以不必重头开始编写下层类,而只是在上层类的基础上进行修改和扩展。这使得类的可重用性和可扩展性得到充分的体现。
继承和派生的概念
1.基本概念:基类和派生类
在最简单的情况下,如果说,一个类felid继承自类carnivore,那么通常将类carnivore称为“基类(base class) ”,或者“父类(parent class) ”;类felid称为carnivore类的“派生类(derived class) ”,或者“子类(child class) ”。
class 派生类名:access 基类类名//accsee为访问控制
{
…
};
继承基类的所有成员
派生类实际上包含了它所有基类中除了构造函数、析构函数和赋值运算符之外的所有成员(思考:why?)
2.改造基类成员
方式一 通过不同的派生方式改造基类成员
方式二 就是在派生类中声明与基类成员同名的成员来覆盖基类成员
3.增加新的成员
根据派生类的实际特征,增加不同于基类的成员
从编码的角度,派生类从基类中以较低的代价换来了较大的灵活性。一旦产生了可靠的基类,只需要调试派生类中所做的修改即可。派生类从基类继承属性时,可使派生类对其进行限制,也可以改变或隐藏。
eg:
class carnivore
{
};
class felid : public carnivore
{
public:
bool slitPupil;
};
class tiger : public felid
{
public:
void roar() { std::cout << name << " roars" << std::endl; }
//other members
};
在上述的三代继承中,几个类构成了继承的类等级。其中,felid是tiger的“直接基类”,carnivore是tiger的“间接基类”,或者称为“祖先类”
这便就是类等级。
如果要在派生类的成员函数中使用祖先类的成员,可以用如下语法:
祖先类名::祖先类成员
但这样做是不能访问基类的私有成员的。(如果一定要访问呢?——用protected)
4.访问控制
正如在上面的代码中示意的那样,C++中,派生类的一般语法形式为
class 派生类名 : <access> 基类类名
{
成员定义
};
访问控制(access control),可以是以下三者之一:
public、private、protected
基类的访问控制描述符和类中的访问控制描述符(段描述符)用的是相同的关键字,区别:
• 段描述符用于控制类的成员在类外的可访问性,例如私有成员在类外是不可访问的;
• 而继承中的访问控制描述符则描述了基类的成员将被放在派生类的哪个段中,例如私有继承将基类的所有成员放在了派生类的私有段中
5.基类的protected成员
基类的某些私有成员必须对派生类是可见的,或者可访问的,而在派生类外却又是不可见的。
解决方案:定义成protected成员
特点:无论在基类还是在派生类外都无法被访问到,但在派生类中却可以被直接访问。
基类的protected成员是这样一类成员:它们无论在基类还是在派生类外都无法被访问到,但在派生类中却可以被直接访问。
class carnivore
{
protected:
std::string name;
//other members
};
6.访问声明
如果考虑Rectangle是私有派生的情况,那么其代码可能如下:
如此一来,felid的所有成员都将成为tiger的私有成员。这带来了一个问题:原来felid的公有成员通过tiger的对象中无法访问。
为了让被私有化的成员在派生类外可见,那么可以使用访问声明来恢复其公有属性。
class tiger : private felid
{
public:
using felid::prey;
};
注意:这里仅有名字而没有任何类型规格说明。
访问声明只能恢复成员原有的访问属性,而不能提升或降低它们的可访问性。
但也有如下定义:
class A
{
private: int f(int);
public: int f();
};
class B : private A
{
public: A::f;
};
这个访问声明回复的是哪个版本?
编译器无法判定,因此是个错误。
思:如何解决?
7.基类静态成员的派生
class carnivore
{
public:
//other members
static int counter;
};
现在的问题是,它派生类将以什么样的方式继承其基类的静态成员呢?
C++规定,在整个继承树中,所有后代都和基类共享唯一的静态成员。换句话说,就是无论存在多少个基类和/或派生类对象,只要其中一个改变了静态成员的值,那么这个改变将反映到其它所有的对象中。这严格遵循了静态成员是属于类而非对象的原则。
正是由于这个原因,对静态成员的访问都采用如下限定方式:
基类名::静态成员名
这种访问方式受到该静态成员访问控制的约束。
8.开闭原则
当一个基类被设计出来,那么它对修改是封闭的,只对扩展开放。这就是OOD原则中的“开闭原则(OCP, Open-Close Principle)”。
基于开闭原则,不能因为派生类需要某些功能就去修改基类的源码。派生类只能通过继承的方法去扩展基类
如果在设计继承关系时,发现一个派生类需要大量修改基类的成员,或者抵消基类的责任,那么这种继承关系就不应该成立。
基类与派生类的关系
1.基类对象的初始化
在派生过程,派生类首先继承基类的成员,然后再增加派生类具有自己特性的成员。那么可以设想,派生类对象创建的时候,在调用其构造函数之前,一定会先调用其基类构造函数来构造基类子对象部分。
在 C++ 中,派生类构造函数的声明为:
派生类构造函数(参数列表):基类(参数列表),成员(参数列表),…
{ … }
在派生类的构造函数执行过程中,遵循**先父辈(基类),再客人(对象成员),后自己(派生类)**的顺序。如果基类使用缺省构造函数或不带参数的构造函数,那么派生类构造函数声明中“:”后面的“基类(参数列表)”一项可以省去,但是派生类构造函数执行时仍然隐含地调用基类的构造函数
可不可以将child的构造函数定义成如下形式呢?
答案是否定的。因为i不是child的基类,也不是其直接成员,所以上述语句在编译时会出现错误报告。因此,要初始化基类子对象(的成员),必须将这项任务交给基类的构造函数去完成。
2.派生类对象和基类对象的相互转换
前面提到过,派生类对象中包含一个基类子对象。因此,在这个基础上,派生类对象可以和基类对象之间进行规定的转换。
-
派生类对象和基类对象间的直接赋值
设有如下对象定义:
felid f;
carnivore c;
那么赋值语句
c = f;
是合法的。
这种赋值将派生类对象中属于基类的部分赋给了指定的基类对象,而只属于派生类对象的部分被舍弃了。这种现象称为**“切片(slicing)”**
如果将两个对象位置互换,那么赋值就是非法的:
f = c; //非法(思:why?——空间内存)
-
引用作用于派生类和基类对象
设有如下定义:
felid f;
carnivore &cr = f;
此时,引用cr的初始化是合法的,且cr成为了f的别名。
注意:派生类对象赋给基类的引用不会引起派生类对象到基类对象的转换。
同一个对象在f和cr“眼中”,内存布局是不一样的。通过名字cr看不到这部分内存。
反过来,例如:
carnivore c;
felid &fr = c;
这样的初始化是非法的。
如果一定要这么做,那么只能这样:
felid &fr = dynamic_cast<felid&>©;
这要求felid类必须是个多态类。
-
指针作用于派生类和基类对象
设有如下定义:
felid f;
carnivore *p = &f;
与引用的情况类似,以上对p的初始化是合法的。此时,指针p只“看到”了属于基类子对象的部分,而其余部分在被它忽略。
基类指针直接赋值给派生类指针是非法的:
carnivore c;
felid *q = &c; //非法
如果一定要这么做,那么也可以使用类型强制转换运算符:
q = dynamic_cast<felid *>(&c);
总的来说,派生类对象可以直接赋值给其基类对象或者基类引用;派生类对象的指针(地址)可以直接赋值给其基类指针。这种现象称为**“up-casting”,不必使用任何的强制类型转换。这是一种非常重要的机制,面向对象技术的核心概念之一“多态”就是依赖于这个机制。
而反过来,我们称之为“down-casting”,是有条件的,并且应当使用dynamic_cast**运算符完成转换,以保证类型的安全。
3. 派生类中重新定义基类的成员
- 派生类中重新定义基类的数据成员
class carnivore
{
protected:
string name;
};
class felid : public carnivore
{
protected:
string name;
public:
string who() const { return name; }
};
派生类中就拥有两个同名的name成员。
问题是,这里访问的name成员属于派生类自己还是基类?
这涉及到名字查找(name lookup)问题。每一个类都定义了一个作用域,即使是派生类也不例外。名字查找机制首先在类自己的作用域中查找指定的名字,如果找到,那么肯定是使用这个名字;如果没有找到,那么就会到该类的外围基类中查找,如此类推,直到找到,或者失败(这会产生一个编译时错误)。简而言之,就是派生类的数据成员将会屏蔽基类中的同名数据成员。
屏蔽并不意味着基类的成员就被删除了,它们还是可以被访问到的,只不过需要使用名字限定。例如,我们可以将who()改成如下形式:
string felid::who()
{
return "base name:" + carnivore::name + ", my name:" + name;
}
基类成员的访问属性描述符仍然会发挥作用:私有的基类成员在其派生类中是不可访问的。
-
派生类中重载基类的成员函数
与重新定义数据成员一样,派生类中可以重新定义基类的成员函数。显然,这是典型意义上的函数重载。
class carnivore
{
public:
string who() const { return name; }
};
class felid : public carnivore
{
public:
string who() const { return carnivore::name + “::”+name; }
};
who()在派生类中被原型一致地重载。
函数重载规则:
• 在相同的作用域中(例如同一个类中),重载的函数原型必须不同;
• 在不同的作用域中(例如不同的类中),重载的函数原型可以相同。
无论如何,派生类的函数名将屏蔽基类中重名的函数,即使它们的原型不一致。
4. 派生类继承基类重载的运算符函数
作为基类的特殊成员,重载的运算符函数也能被派生类继承,除了赋值运算符。这种限制是可以理解的,因为派生类的内部结构与基类不同,而基类的赋值操作不可能覆盖到派生类中新增的成员。
基类与派生类在类型上的相容性为继承运算符函数提供了基础。
一个派生类对象内部嵌入了一个基类子对象
这样一来,派生类就拥有了基类的功能。这是类之间合作关系的一种体现。而这种合作关系是一种“is a”的关系。
“has a”或“is part of” 的关系
这是一种属于复合的合作关系,但并不是严格意义上的继承关系
例如:轿车有四个轮胎
判断类A和类B之间是何关系可以应用如下两个断言,二者只能有一个成立,或者都不成立:
A是一种B(或反过来)
A包含B(或反过来)
何时使用继承
1. 类/对象之间的关系
在设计类的过程中,判断类A和类B之间是否构成继承关系应用如下断言:
B是一种A
如果上述断言成立,那么类A和B之间的继承关系成立,并且A是B的基类。
2. 组合/聚集复用原则
组合和聚合都是对象建模中关联(Association)关系的一种.聚合表示整体与部分的关系,表示“含有”
原则描述:
• 分类是在分类学上有意义的
• 分类不是按照角色进行的
• 类之间的关系是Is-a
• 永远不会出现需要将派生类的对象换成另一类对象的情况
• 派生类具有扩展基类的责任,而不是具有修改或抵消基类的责任
如果以上条件不能同时满足,那么首先考虑类之间应该使用组合/聚集。这就是OOD原则中的另一条原则:组合/聚集复用原则(CARP,Composition/Aggregation Reuse Principle)。
在继承发生时,派生类虽然接受了基类的所有成员,但有些基类成员在派生类中是不能被继承的。这些基类的成员是:
• 构造函数
• 析构函数
• 重载的operator=
此外,基类授权的友元关系也不能被派生类继承。
提问:为什么上述机制不能被继承?
继承性是对象之间合作的另一种方式(另两种方式是友元类和对象作成员),派生类继承了基类,一个派生类对象除了可以包含基类对象,这一点和对象作成员类似,派生类还可以继承基类中的成员, 派生类对象可以在类外直接使用继承的基类公有成员
继承的意义
利用面向对象方法,一个操作的特殊实现的变化将仅仅影响实现所应用的类。加一个新类型在许多情况下不影响其它类。再者,分布性是关键:类管理自己的事务,不干涉其他内政,各人自扫门前雪,这是获得分布式结构的要求。同时,虚函数提供了多种操作的界面一致性。
这样,类、继承、重载、虚函数的动态匹配和泛型在一起,提供了对可重用性和可扩充性需要卓越的表达能力。