你好,C++(36)人参再好,也不能当饭吃!6.3 类是如何面向对象的

6.3  类是如何面向对象的

类做为C++与面向对象思想结合的产物,做为面向对象思想在C++中的载体,它的身上流淌着面向对象的血液。从类成员的构成到类之间的继承关系再到虚函数,处处都体现着面向对象封装、继承和多态的三大特征。函数

6.3.1  用类机制实现封装

考虑这样一个现实问题,学校中有多个老师,每一个老师的名字、年龄等属性都各不相同,但这些老师都会备课上课,具备相同的行为。那么,咱们如何在程序中表现这些老师呢?老师的具体个体虽多,但他们都属于同一类事物——老师。在C++中,咱们用类的概念来描述某一类事物,而抽象正是这个过程的第一道工序。抽象通常分为属性抽象和行为抽象两种。前者寻找一类事物所共有的属性,好比老师们都有年龄、姓名等描述其状态的数据,而后用变量将它们表达出来,好比用m_nAge变量表达年龄,用m_strName变量表达姓名;然后者则寻找这类事物所共有的行为,好比老师都会备课、上课等,而后用函数将它们表达出来,好比用PrepareLesson()函数表达老师的备课行为,用GiveLesson()函数表达其上课行为。从这里也能够看出,整个抽象过程,是一个从具体(各个老师)到通常(变量和函数)的过程。spa

若是说抽象是将同类事物的共有属性和行为提取出来并将其用变量和函数表达出来,那么封装机制则是将它们捆绑在一块儿造成一个完整的类。在C++语言中,咱们可使用6.2节中介绍的类(class)概念来封装分析获得的变量和函数,使其成为类的成员,从而表现这类事物的属性和行为。好比老师这类事物就能够封装为:设计

// 用Teacher类封装老师的属性和行为
class Teacher
{
// 构造函数
public:
    // 根据名字构造老师对象
    Teacher(string strName) 
    {
            m_strName = strName;
    };

// 用成员函数描述老师的行为
public:
    void PrepareLesson();      // 备课
    void GiveLesson();          // 上课
    void ReviewHomework();     // 批改做业
    // 其它成员函数…
    // 用成员变量描述老师的属性
protected:
    string    m_strName;        // 姓名
    int        m_nAge;            // 年龄
    bool       m_bMale;           // 性别
    int        m_nDuty;           // 职务
private:
};

经过封装,能够将老师这类事物所共有的属性和行为紧密结合在Teacher类中,造成一个可重用的数据类型。从现实的老师到Teacher类,是一个从具体到抽象的过程,如今有了抽象的Teacher类,就能够用它来定义某个对象,进而用这个对象来描述某位具体的老师,这又是一个从抽象到具体的过程。例如:3d

// 定义Teacher类对象描述学校中的某位陈老师
Teacher MrChen("ChenLiangqiao");
// 学校中的某位王老师
Teacher MrWang("WangGang");

虽然MrChen和MrWang这两个对象都是Teacher类的对象,可是由于它们的属性不一样,因此能够描述现实世界中的两位不一样的老师。指针

经过类的封装,还能够很好地实现对事物的属性和行为的隐藏。由于访问控制的限制,外界是没法直接访问类的隐藏信息的,对于类当中的一些敏感数据,咱们能够将其设置为保护或私有类型,这样就能够防止其被意外修改,实现对数据的隐藏。另一方面,封装好的类经过特定的外部接口(公有的成员函数)向外提供服务。在这个过程当中,外界看到的只是服务接口的名字和须要的参数,而并不知道类内部这些接口究竟是如何具体实现的。这就很好地对外界隐藏了接口的具体实现细节,而仅仅把外界最关心的服务接口直接提供给它。经过这种方式,类实现了对行为的隐藏,如图6-10所示。code

 

图6-10  抽象与封装对象

抽象与封装,用来将现实世界的事物转变成C++世界中的各个类,也就是用程序语言来描述现实世界。面向过程思想也有抽象这个过程,只是它的抽象仅针对现实世界中的过程,而面向对象思想的抽象不只包括事物的数据,同时还包括事物的行为,更进一步地,面向对象利用封装将数据和行为有机地结合在一块儿而造成类,从而更加真实地反映现实世界。抽象与封装,完成了从现实世界中的具体事物到C++世界中类的过程,是将现实世界程序化的第一步,也是最重要的一步。blog

6.3.2  用基类和派生类实现继承

在理解了类机制是如何实现面向对象思想的封装特性以后,继续分析上面的例子。在现实世界中,咱们发现老师和学生这两类不一样的事物有一些相同的属性和行为,好比都有姓名、年龄、性别,都能走路、说话、吃饭等。为何不一样的事物会有相同的属性和行为呢?这是由于这些特征都是人类所共有的,老师和学生都是人类的一个子类别,因此都具备这些人类共同的属性和行为。像这种子类别和父类别拥有相同属性和行为的现象很是广泛。好比小汽车、卡车是汽车的某个子类别,它们都具备汽车的共有属性(发动机)和行为(行驶);电视机、电冰箱是家用电器的某个子类别,它们都具备家用电器的共有属性(用电)和行为(开启)。继承

在C++中,咱们用类来表示某一类别的事物。既然父子两个类别的事物可能有相同的属性和行为,这也就意味着父类和子类当中应该有大量相同的成员变量和成员函数。那么,对于这些相同的成员,是否须要在父子两个类中都定义一次呢?显然不是。为了描述现实世界中的这种父类别和子类别之间的关系,C++提供了继承的机制。咱们把表示父类别的类称为基类或者父类,而把从基类继承产生的表示子类别的类称为派生类或子类。继承容许咱们在保持父类原有特性的基础上进行更加具体的说明或者扩展,从而造成新的子类。例如,能够说“老师是会上课的人”,那么就可让老师这个子类从人这个父类继承,对于那些表现人类共有属性和行为的成员,老师类无需再次定义而直接从人类遗传得到,而后在老师子类中再添加上老师特有的表示上课行为的函数,经过继承与发展,咱们就得到了一个既有人类的共有属性和行为,又有老师特有行为的老师类。接口

所谓继承,就是得到从父辈传下来的财富。在现实世界中,这个财富多是金银珠宝,也多是淳淳家风,而在C++世界中,这个财富就是父类的成员变量和成员函数。经过继承,子类能够轻松拥有父类的成员。而更重要的是,经过继承能够对父类的成员进行进一步的细化或者扩充来知足新的需求造成新的类。这样,当复用旧有的类造成新类时,只须要从旧有的类继承,而后修改或者扩充须要的成员便可。有了继承机制,C++不只可以提升开发效率,同时也能够应对不断变化的需求,所以它也就成为了消灭“软件危机”的有力武器。

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

 

图6-11  现实世界的继承关系

从这棵“继承树”中能够看到,老师和学生都继承自人类,这样,老师和学生就具备了人类的属性和行为,而小学生、中学生、大学生继承自学生这个类,他们不但具备人的属性和行为,同时还具备学生的属性和行为。经过继承,派生类不用再去重复设计和实现基类已有的属性和行为,只要直接经过继承就拥有了基类的属性和行为,从而实现设计和代码最大限度上的复用。

在C++中,派生类的声明方式以下:

class 派生类名 : 继承方式 基类名1, 继承方式 基类名2…

{

    // 派生类新增长的属性和行为…

};

其中,派生类名就是咱们要定义的新类的名字,而基类名是已经定义的类的名字。一个类能够同时继承多个类,若是只有一个基类,这种状况称为单继承,若是有多个基类,则称为多继承,这时派生类能够同时获得多个基类的特征,就如同咱们身上既有父亲的特征,同时也有母亲的特征同样。可是,咱们须要注意的是,多继承可能会带来成员的二义性,由于两个基类可能拥有同名的成员,若是都遗传到派生类中,则派生类中会出现两个同名的成员,这样在派生类中经过成员名访问来自基类的成员时,就不知道到底访问的是哪个基类的成员,从而致使程序的二义性。因此,多继承只在极少数必要的时候才使用,更多时候咱们使用的是单继承。

跟类成员的访问控制相似,继承方式也有public、protected和private三种。不一样的继承方式决定了派生类如何访问从基类继承下来的成员,反映的是派生类和基类之间的关系:

(1) public。

public继承被称为类型继承,它表示派生类是基类的一个子类型,而基类中的公有和保护类型成员连同其访问级别直接遗传给派生类,不作任何改变。在基类中的public成员在派生类中也一样是public成员,在基类中的protected成员在派生类中也是protected成员。public继承反映了派生类和基类之间的一种“is-a”的关系,也就是父类别和子类别的关系。例如,老师是一我的(Teacher is-a Human),因此Teacher类应该以public方式继承自Human类。 public所反映的这种父类别和子类别的关系在现实世界中很是广泛,大到生物进化,小到组织体系,均可以用public继承来表达,因此它也是C++中最为常见的一种继承方式。

(2) private。

private继承被称为实现继承,它把基类的公有和保护类型成员都变成本身的私有(private)成员,这样,派生类将再也不支持基类的公有接口,它只但愿能够重用基类的实现而已。private继承所反映的是一种“用…实现”的关系,若是A类private继承自B类,仅仅是由于A类当中须要用到B类的某些已经存在的代码但又不想增长A类的接口,并不表示A类和B类之间有什么概念上的关系。从这个意义上讲,private继承纯粹是一种实现技术,对设计而言毫无心义。

(3) protected。

protected继承把基类的公有和保护类型成员变成本身的protected类型成员,以此来保护基类的全部公有接口再也不被外界访问,只能由自身及自身的派生类访问。因此,当咱们须要继承某个基类的成员并让这些成员能够继续遗传给下一代派生类,而同时又不但愿这个基类的公有成员暴露出来的时候,就能够采用protected继承方式。

在了解了派生类的声明方式后,就能够用具体的代码来描述上面这棵继承树所表达的继承关系了。

// 定义基类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();            // 看电视
public:
    // 对“作做业”的行为从新定义
    void DoHomework();
protected:
private:
};

在这段代码中,首先声明了人(Human)这个基类,它定义了人这类事物应当具备的共有属性(姓名、年龄、性别)和行为(走路、说话)。由于老师是人的一种,是人这个类的具体化,因此咱们以Human为基类,以public继承的方式定义Teacher这个派生类。经过继承,Teacher类不只直接具备了Human类中公有和保护类型的成员,同时还根据须要添加了Teacher类本身所特有的属性(职务)和行为(备课、上课),这样就完成了对Human类的继承和扩展,获得的Teacher类是一个“会备课、上课的人类”。

// 定义一个Teacher对象
Teacher MrChen;
// 老师走进教室
// 咱们在Teacher类中并无定义Walk()成员函数,
// 这里是经过继承从基类Human中获得的成员函数
MrChen.Walk();
// 老师开始上课
// 这里调用的是Teacher本身定义的成员函数
MrChen.GiveLesson();

同理,咱们还经过public继承Human类,同时增长了学生特有的属性(m_nScore)和行为(AttendClass()和DoHomwork()),定义了Student类。进而,又根据须要,以一样的方式从Student类继承获得了更加具体的Pupil类来表示小学生。经过继承,咱们能够把整棵“继承树”完整清晰地表达出来。

仔细体会就会发现,整个继承的过程就是类的不断具体化、不断传承基类的属性和行为,同时发展本身特有属性和行为的过程。现实世界中的物种进化,经过子代吸取和保留部分父代的能力,同时根据环境的变化,对父代的能力作一些改进并增长一些新的能力来造成新的物种。继承,就是现实世界中这种进化过程在程序世界中的体现。因此,类的进化也遵循着与之相似的规则:

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

继承最大的目的就是复用基类的设计和实现,保留基类的属性和行为。对于派生类而言,不用本身白手起家,一切从零开始,只要经过继承就直接成了拥有基类丰富属性和行为的“富二代”。在上面的例子中,派生类Teacher经过继承Human基类,轻松拥有了Human类的全部公有和保护类型成员,这就像站在巨人的肩膀上,Teacher类只用不多的代码就拥有了基类遗传下来的姓名、年龄等属性和走路、说话等行为,实现了设计和代码的复用。

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

既然是进化,派生类就要有优于基类的地方,这些地方就表如今派生类对基类成员的修改。例如,Student类有表示“作做业”这个行为的DoHomework()成员函数,派生类Pupil原本直接继承Student类也就一样拥有了这个成员函数,可是,“小学生”作做业的方式是比较特殊的,基类定义的DoHomework()函数没法知足它的需求。因此派生类Pupil只好从新定义了DoHomework()成员函数,从而根据本身的实际状况对它作进一步的具体化,对它进行改写以适应新的需求。这样,基类和派生类都拥有DoHomework()成员函数,但派生类中的这个函数是通过改写后的更具体的更有针对性的,是对基类的一种改进。

(3) 添加新的属性和行为。

若是进化仅仅是对原有事物的改进,那么是远远不够的。进化还须要一些“革命性”的内容才能产生新的事物。因此在类的继承当中,派生类除了能够改进基类的属性和行为以外,更重要的是添加一些“革命性”的新属性和行为使其成为一个新的类。例如,Teacher类从Human类派生,它保留了基类的属性和行为,同时还根据须要添加了基类所没有的新属性(职务)和行为(备课、上课),正是这些新添加的属性和行为,使它从本质上区别于Human类,完成了从Human到Teacher的进化。

很显然,继承既很好地解决了设计和代码复用的问题——派生类继承保留了基类的属性和行为,同时又提供了一种扩展的方式来轻松应对新的需求——派生类能够改变基类的行为同时根据须要添加新的属性和行为,而这正是面向对象思想的魅力所在。

既然继承能够带来这么多好处,不用费吹灰之力就能够复用之前的设计和代码,那么是否是能够在可以使用继承的地方就都使用继承,并且越多越好呢?

固然不是。人参再好,也不能当饭吃。正是由于继承太有用,带来了不少好处,因此每每会被初学者滥用,最后致使设计出一些“四不像”的怪物出来。在这里,咱们要给继承的使用定几条规矩:

(1) 拥有相关性的两个类才能发生继承。

若是两个类(A和B)绝不相关,则不能够为了使B的功能更多而让B继承A。也就是说,不能够为了让“人”具备“飞行”的行为,而让“人”从“鸟”派生,那获得的就再也不是“人”,而是“鸟人”了。不要以为类的功能越多越好,在这里,要奉行“多一事不如少一事”的原则。

(2) 不要把组合当成继承。

若是类B有必要使用类A提供的服务,则要分两种状况考虑:

1)   B是A的“一种”。若在逻辑上B是A的“一种”(a kind of),则容许B继承A。例如,老师(Teacher)是人(Human)的一种,是对人的特殊化具体化,那么Teacher就能够继承自Human。

2)   A是B的“一部分”。若在逻辑上A是B的“一部分”(a part of),虽然二者也有相关性,但不容许B继承A。例如,键盘、显示器是电脑的一部分。

若是B不能继承A,但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;
}

在上面的代码中,电脑这个类由Keybord和 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;
}
相关文章
相关标签/搜索