首页 > 编程笔记 > C++笔记 阅读:34

深度剖析C++类的继承特性(非常详细)

类作为 C++ 与面向对象程序设计相结合的产物,是面向对象程序设计在 C++ 中的具体体现。类的设计从成员构成到类之间的继承关系,再到虚函数,都体现了面向对象程序设计的封装、继承和多态这三大特征。

那么,继承的具体含义到底是什么呢?

用基类和派生类实现继承

在现实世界中,我们发现老师和学生这两类不同的对象有一些相同的属性和行为,例如这两类对象都有姓名、年龄、性别,并且都能走路、说话、吃饭等。这些相同的属性和行为是因为这些特征都是人类共有的。老师和学生都是人类的子类,因此都具有人类共同的属性和行为。

像这种子类和父类拥有相同属性和行为的现象非常普遍。例如,小汽车和卡车是汽车类的子类,它们都具有汽车的共有属性(如发动机、轮胎)和行为(如行驶);电视机和电冰箱是家用电器的子类,它们都具有家用电器的共有属性(如用电)和行为(如开关操作)。

在 C++ 中,我们用类(class)来表示某一类的对象。既然父类和子类的对象有相同的属性和行为,那么在父类和子类中重复定义这些相同的成员变量和成员函数,显然是不必要的。为了描述现实世界中这种父类和子类之间的关系,C++ 提供了继承的机制。

在 C++ 中,父类也称为基类,从基类继承产生的类被称为派生类或子类。继承允许我们在保持父类原有属性的基础上进行更具体的说明或者扩展,从而形成新的类,即子类。

例如,可以说“老师是会上课的人”,说明老师这个类可以从人这个父类继承而来。对于那些表现人类共有属性和行为的成员,老师类无须重新定义而直接从人类继承而来即可,然后在老师这个子类中添加老师特有的描述上课行为的函数即可。

通过继承与扩展,我们就得到了一个既有人类的共有属性和行为,又有老师特有属性和行为的老师类。

所谓继承,就是获得从父辈传下来的“财富”。在现实世界中,这种财富可能是金银珠宝,也可能是淳淳家风。而在 C++ 世界中,这种财富就是父类的成员变量和成员函数。

通过继承,子类可以轻松拥有父类的成员。更重要的是,通过继承,子类可以对父类的成员进行进一步的细化或扩充,以满足新的需求,形成与父类不完全相同的新类。因此,当我们复用旧有的类来创建新类时,只需要从旧有的类继承,然后修改或者扩充需要的成员即可。

有了继承机制,C++ 不仅能够提高开发效率,还能够应对不断变化的需求,因此继承机制成为缓解“软件危机”的有力武器。

下面来看一个实际的例子。在现实世界中,有这样一棵“继承树”,如下图所示。


图 1 现实世界的继承关系

从这棵“继承树”中可以看到,老师和学生都继承自人类,因此老师和学生都具有了人类的属性和行为。而小学生、中学生和大学生继承自学生这个类,于是他们不但具有人的属性和行为,还具有学生的属性和行为。

通过继承,派生类不用重复设计和实现基类已有的属性和行为,直接通过继承即可拥有基类(或父类)的属性和行为,从而最大限度地实现设计和代码的复用。

在 C++ 中,派生类的声明方式如下:
class 派生类名 : 继承方式 基类名1, 继承方式 基类名2, ...
{
    // 派生类新增加的属性和行为
};
其中,派生类名是我们要定义的新类的名字,而基类名是已经定义的类的名字。一个类可以同时继承自多个基类,如果只有一个基类,这种情况被称为单继承;如果有多个基类,则被称为多继承,此时,派生类可以同时获得多个基类的特征(属性和行为),就如同我们身上既有父亲的特征,同时也有母亲的特征一样。

但是,需要注意的是,多继承可能会引发成员的二义性问题,因为两个基类可能拥有同名的成员。如果两个同名成员都继承到派生类中,派生类中就会出现两个同名的成员,这会导致在派生类中通过成员名称访问时,不知道到底访问的是哪一个基类的成员,导致程序产生了二义性。

因此,多继承只应在极少数情况下使用,大多数情况下使用的是单继承。

C++类继承的3种方式

与类成员的访问控制类似,继承方式也有公有继承(public)、受保护继承(protected)和私有继承(private)三种。不同的继承方式决定了派生类如何访问从基类继承下来的成员,反映了派生类和基类之间的关系。

1) 公有继承(public)

用 public 声明的继承被称为公有继承或接口继承,它表示派生类是基类的一个子类,基类中的公有和受保护成员连同其访问级别直接继承给派生类,不做任何改变。

也就是说,基类中的公有(public)成员在派生类中同样是公有成员,基类中的受保护(protected)成员在派生类中也同样是受保护成员。

公有继承反映了派生类和基类之间的一种“is a”关系继承(即接口继承)。例如,老师是一个人,所以 Teacher 类应该以 public 方式继承自 Human 类。

公有继承所反映的这种父类和子类的关系在现实世界中非常普遍,从生物进化到组织体系,都可以用公有继承来表达,所以它是 C++ 中最为常见的一种继承方式。

2) 私有继承(private)

用 private 声明的继承被称为私有继承或实现继承,它把基类的公有和受保护成员都变成自己的私有(private)成员。这样,派生类不再支持基类的公有接口,而只是重用基类的实现。

私有继承反映的是一种“用……实现”的关系。如果 A 类私有继承自 B 类,仅仅是因为 A 类需要用到 B 类的某些已有代码,但又不想扩展 A 类的接口,并不表示 A 类和 B 类之间有概念上的关系。从这个意义上讲,私有继承纯粹是一种实现技术,对设计而言并不具有概念上的意义。

3) 受保护继承(protected)

用 protected 声明的继承被称为受保护继承,它把基类的公有和受保护成员变成自己的受保护成员,以此来保护基类的所有公有接口不再被外界访问,只能由派生类及其子类访问。

因此,当我们需要继承某个基类的成员,并让这些成员可以继续遗传给下一代派生类,同时又不希望这个基类的公有成员暴露出来时,就可以采用受保护继承方式。

在了解了派生类的声明方式后,我们可以用具体的代码来描述图 1 所示的这棵继承树所表达的继承关系。
// 定义基类 Human
class Human
{
    // 人类共有的行为,可以被外界访问
    // 访问级别设置为 public 级别
public:
    void Walk();     // 走路
    void Talk();     // 说话
    // 人类共有的属性
    // 因为需要继承给派生类,同时又要防止外界访问
    // 所以将其访问级别设置为 protected(受保护)
protected:
    string m_strName;    // 姓名
    int m_nAge;          // 年龄
    bool m_bMale;        // 性别
private:                // 没有私有成员
};

// Teacher 类与 Human 类是 is a 的关系
// 所以 Teacher 类采用公有(public)继承方式继承自 Human 类
class Teacher : public Human
{
    // 在子类中添加老师特有的行为
public:
    void PrepareLesson(); // 备课
    void GiveLesson();    // 上课
    void ReviewHomework();// 批改作业
    // 在子类中添加老师特有的属性
protected:
    int m_nDuty;         // 职务
private:
};

// 学生同样是人类,用 public 方式继承自 Human 类
class Student : public Human
{
    // 在子类中添加学生特有的行为
public:
    void AttendClass();  // 上课
    void DoHomework();   // 做家庭作业
    // 在子类中添加学生特有的属性
protected:
    int m_nScore;        // 考试成绩
private:
};

// 小学生是学生,以 public 方式继承自 Student 类
class Pupil : public Student
{
    // 在子类中添加小学生特有的行为
public:
    void PlayGame();     // 玩游戏
    void WatchTV();      // 看电视
    // 对“做作业”的行为重新定义
    void DoHomework();
protected:
private:
};
在这段代码中,首先声明了 Human 这个基类,它定义了 Human 类应当具有的共有属性(姓名、年龄、性别)和行为(走路、说话)。因为 Teacher 类是 Human 类的一种,所以我们以 Human 类为基类,以公有(public)继承方式定义 Teacher 类这个派生类。

通过继承,Teacher 类不仅直接具备了 Human 类中的公有和受保护成员,同时还根据需要添加了 Teacher 类特有的属性(职务)和行为(备课、上课)。这样,Teacher 类在继承和扩展了 Human 类的基础上,成为“会备课、上课的人类”。
// 创建(或声明)一个 Teacher 对象
Teacher MrChen;
// 老师走进教室
// 我们在 Teacher 类中并没有定义 Walk() 成员函数
// 是从基类 Human 中继承的成员函数
MrChen.Walk();
// 老师开始上课
// 这里调用的是 Teacher 类自己定义的成员函数
MrChen.GiveLesson();
同理,我们通过公有继承自 Human 类,同时增加了学生特有的属性(m_nScore)和行为(AttendClass() 和 DoHomework ()),以此定义了Student类。又根据需要,以同样的方式从 Student 类继承得到了派生类 Pupil 来表示小学生。通过继承方式,我们可以把整棵“继承树”完整清晰地表达出来。

仔细体会可以发现,整个继承过程就是类的不断派生、不断传承自父类的属性和行为,同时扩展自己特有属性和行为。这就像现实世界中的物种进化,子代吸收和保留部分父代的能力,同时根据环境的变化,对父代的能力进行改进,并增加一些新的能力,从而形成新的物种。继承就是这种进化过程在程序世界中的体现。因此,类的“进化”也遵循类似的规则。

C++类继承的好处

1) 保留基类的属性和行为

继承的主要目的是复用基类的设计和实现,保留基类的属性和行为。对于派生类而言,通过继承可以避免一切从零开始,可以以基类现有的设计为基础派生出所需的新类。

在前文的例子中,派生类 Teacher 继承自 Human 基类,轻松拥有了 Human 类所有的公有和受保护成员,就像站在巨人的肩膀上,Teacher 类只需编写很少的代码,就拥有了从基类继承而来的姓名、年龄等属性,以及走路、说话等行为,实现了设计和代码的复用。

2) 改进基类的属性和行为

继承不仅仅是简单地继承基类的属性和行为,还包括对其进行改进和扩展。派生类是在基类的基础上进行升级。

例如,Student 类定义了表示“做作业”这个行为的 DoHomework() 成员函数,派生类 Pupil 继承自 Student 类,也就拥有了这个成员函数。

然而,“小学生”做作业的方式是比较特殊的,基类定义的 DoHomework() 函数无法满足它的需求。因此,派生类 Pupil 只好重新定义了 DoHomework() 成员函数,根据自己的实际情况对该成员函数进一步具体化和改写,以适应新的需求。

这样,虽然基类和派生类都拥有 DoHomework() 成员函数,但派生类中的这个成员函数是经过改写的,更有针对性。

3) 添加新的属性和行为

在类的继承中,派生类除可以改进基类的属性和行为外,还可以添加新的属性和行为。例如,Teacher 类从 Human 类派生而来,它保留了基类的属性和行为,还根据需要添加了基类所没有的新属性(职务)和行为(备课、上课),使它区别于 Human 类,完成了从 Human 类到 Teacher 类的“进化”——派生类。

正确使用C++继承特性

既然继承可以带来如此多的好处,是否意味着我们应该在所有适用的地方都使用继承,越多越好呢?

当然不是。虽然继承非常有用,但过度使用会导致设计上的问题,尤其是初学者容易滥用继承,结果可能设计出一些“四不像”的怪物。为了避免这种情况,我们应当遵循一些使用继承的原则和规则:

1) 只有相关性强的两个类才能使用继承

如果两个类 A 和 B 毫不相关,则不应为了使 B 拥有更多功能而让 B 继承 A。也就是说,不可以为了让“人”具有“飞行”的行为,而让“人”从“鸟”派生,因为这样派生而来的将不再是“人”,而是“鸟人”。

因此应该遵循“多一事不如少一事”的原则,避免不必要的复杂性。

2) 不要把组合当成继承

如果类 B 有必要使用类 A 提供的服务,应考虑以下两种情况:
如果 A 不能从 B 继承,A 是 B的“一部分”且 B 需要使用 A 提供的服务,那又该怎么办呢?

在这种情况下,可以将 A 的对象作为 B 的一个成员变量,用 A 和其他对象共同组合成 B。这样,在 B 中就可以访问 A 的对象,自然就可以获得 A 提供的服务。

例如,一台计算机需要键盘的输入服务和显示器的输出服务,而键盘和显示器都是计算机的一部分,但计算机不能从键盘和显示器派生出来,我们可以将键盘和显示器的对象作为计算机的成员变量,这样计算机就可以获得它们提供的服务:
// 键盘
class Keyboard
{
public:
    // 接收用户键盘输入
    void Input()
    {
        cout << "键盘输入" << endl;
    }
};

// 显示器
class Monitor
{
public:
    // 显示画面
    void Display()
    {
        cout << "显示器输出" << endl;
    }
};

// 计算机
class Computer
{
public:
    // 用键盘和显示器组合成一台计算机
    Computer(Keyboard* pKeyboard, Monitor* pMonitor)
    {
        m_pKeyboard = pKeyboard;
        m_pMonitor = pMonitor;
    }
    // 计算机的行为
    // 它的具体动作都交由其各个组成部分来完成
    // 键盘负责用户输入
    void Input()
    {
        m_pKeyboard->Input();
    }
    // 显示器负责显示画面
    void Display()
    {
        m_pMonitor->Display();
    }
    // 计算机的各个组成部分
private:
    Keyboard* m_pKeyboard = nullptr; // 键盘
    Monitor* m_pMonitor = nullptr;   // 显示器
    // 其他组成部件对象
};

int main()
{
    // 先创建键盘和显示器对象
    Keyboard keyboard;
    Monitor monitor;
    // 用键盘和显示器对象组合成计算机
    Computer com(&keyboard, &monitor);
    // 计算机的输入和输出,实际上最终是交由键盘和显示器来完成
    com.Input();
    com.Display();
    return 0;
}
在上面的代码中,Computer(计算机)类由 Keyboard 和 Monitor 这两个类的对象组成(在实际应用中可能还有更多的组成部分)。它的所有功能不是自己实现的,而是通过将功能转交给各个组成对象来实现的,它仅提供一个统一的对外接口。这种把几个类的对象结合在一起构成新类的方式被称为“组合”。

虽然计算机类没有从键盘类和显示器类继承而来,但通过组合这种方式,它同样获得了键盘和显示器提供的服务,实现了输入和输出的功能。

在组合中,通常使用对象指针作为类成员变量来组合各个对象。这是因为计算机是一个可以插拔的系统,键盘和显示器都是可以更换的。键盘可以在这台计算机上使用,也可以在其他计算机上使用,计算机和键盘的生命周期是不同的,是各自独立的。因此,使用对象指针作为成员变量使得两个对象可以独立创建、组合或拆分,灵活地适应不同的使用场景。

然而,如果整体和部分之间的关系密不可分,且它们具有相同的生命周期,例如一个人和组成这个人的胳膊、大腿等组成部分,这时就可以直接采用对象作为成员变量。

例如:
// 胳膊
class Arm
{
public:
    // 胳膊提供的服务,拥抱
    void Hug()
    {
        cout << "用手拥抱" << endl;
    }
};

// 脚
class Leg
{
public:
    // 脚提供的服务,走路
    void Walk()
    {
        cout << "用脚走路" << endl;
    }
};

// 身体
class Body
{
public:
    // 身体提供的服务,都交由组成身体的各个部分来完成
    void Hug()
    {
        arm.Hug();
    }
    void Walk()
    {
        leg.Walk();
    }
private:
    // 组成身体的各个部分,因为它们与 Body 有着共同的生命周期
    // 所以这里使用对象作为类的成员变量
    Arm arm;
    Leg leg;
};

int main()
{
    // 在创建 Body 对象时,也创建了组成它的 Arm 和 Leg 对象
    Body body;
    // 使用 Body 提供的服务,这些服务最终由组成 Body 的 Arm 和 Leg 来完成
    body.Hug();
    body.Walk();
    // 在 Body 对象销毁时,组成它的 Arm 和 Leg 对象同时也被销毁
    return 0;
}

相关文章