6.3 类是如何面向对象的
类作为C++与面向对象思想结合的产物,作为面向对象思想在C++中的载体,它的身上流淌着面向对象的血液。从类成员的构成到类之间的继承关系再到虚函数,到处都体现着面向对象封装、继承和多态的三大特征。
6.3.1 用类机制实现封装
考虑这样一个现实问题,学校中有多个老师,每个老师的名字、年龄等属性都各不相同,但这些老师都会备课上课,具有相同的行为。那么,我们如何在程序中表现这些老师呢?老师的具体个体虽多,但他们都属于同一类事物——老师。在C++中,我们用类的概念来描述某一类事物,而抽象正是这个过程的第一道工序。抽象一般分为属性抽象和行为抽象两种。前者寻找一类事物所共有的属性,比如老师们都有年龄、姓名等描述其状态的数据,然后用变量将它们表达出来,比如用m_nAge变量表达年龄,用m_strName变量表达姓名;而后者则寻找这类事物所共有的行为,比如老师都会备课、上课等,然后用函数将它们表达出来,比如用PrepareLesson()函数表达老师的备课行为,用GiveLesson()函数表达其上课行为。从这里也可以看出,整个抽象过程,是一个从具体(各个老师)到一般(变量和函数)的过程。
如果说抽象是将同类事物的共有属性和行为提取出来并将其用变量和函数表达出来,那么封装机制则是将它们捆绑在一起形成一个完整的类。在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类,就可以用它来定义某个对象,进而用这个对象来描述某位具体的老师,这又是一个从抽象到具体的过程。例如:
// 定义Teacher类对象描述学校中的某位陈老师 Teacher MrChen("ChenLiangqiao"); // 学校中的某位王老师 Teacher MrWang("WangGang");
虽然MrChen和MrWang这两个对象都是Teacher类的对象,但是因为它们的属性不同,所以可以描述现实世界中的两位不同的老师。
通过类的封装,还可以很好地实现对事物的属性和行为的隐藏。因为访问控制的限制,外界是无法直接访问类的隐藏信息的,对于类当中的一些敏感数据,我们可以将其设置为保护或私有类型,这样就可以防止其被意外修改,实现对数据的隐藏。另外一方面,封装好的类通过特定的外部接口(公有的成员函数)向外提供服务。在这个过程中,外界看到的只是服务接口的名字和需要的参数,而并不知道类内部这些接口到底是如何具体实现的。这就很好地对外界隐藏了接口的具体实现细节,而仅仅把外界最关心的服务接口直接提供给它。通过这种方式,类实现了对行为的隐藏,如图6-10所示。
图6-10 抽象与封装
抽象与封装,用来将现实世界的事物转变成C++世界中的各个类,也就是用程序语言来描述现实世界。面向过程思想也有抽象这个过程,只是它的抽象仅针对现实世界中的过程,而面向对象思想的抽象不仅包括事物的数据,同时还包括事物的行为,更进一步地,面向对象利用封装将数据和行为有机地结合在一起而形成类,从而更加真实地反映现实世界。抽象与封装,完成了从现实世界中的具体事物到C++世界中类的过程,是将现实世界程序化的第一步,也是最重要的一步。
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; }