第6章
当C++爱上面向对象
很多第一次进入C++世界的人都会问:C++中的那两个加号到底是什么意思啊?
C++是由C语言发展而来的,它比C语言多出的两个加号,实际上是C语言的自增操作符,表示C++语言是在C语言的基础上添加了新的内容而发展形成的。如果其中一个加号代表C++在C语言的基础上增加了模板、异常处理等现代程序设计语言的新特性的话,那么另外一个加号则代表C++在C语言的基础上增加了对面向对象程序设计思想的支持。正是这两个加号所代表的新增内容,让C++在C语言的根基之上,完成了从传统到现代,从面向过程到面向对象的进化,让C++与C语言有了本质的区别。
而在C++所新增的这两个内容中,最核心的是对面向对象程序设计思想的支持,正是它的加入,才完成了从C语言到C++的华丽蜕变。那么,到底什么是面向对象?C++为什么要添加对它的支持?C++又是如何对面向对象进行支持的?别着急,且听我一一道来。
6.1 从结构化设计到面向对象程序设计
面向对象程序设计的雏形早在1960年的Simula语言中就出现过。当时程序设计领域正面临一种危机:面对越来越复杂的软硬件系统,传统的以C语言为代表的面向过程程序设计思想已越来越无法满足现实的需要——面向过程的设计无法很好地描述整个系统,同时设计结果也让人难以理解,因而给软件的实现以及后期的维护带来了巨大的挑战,项目越大越难以实现,越到项目后期越难以实现,人们正陷入一场前所未有的软件危机中。为了化解这场软件危机,人们开始寻找能够消灭“软件危机”这头怪兽的“银弹(silver bullet)”。面向对象程序设计思想正是在这种背景下应运而生,它通过强调设计与实现的可扩展性和可重复性,成功地化解了这场“软件危机”。至此以后,面向对象的程序设计思想开始在业界大行其道,逐渐成为主流。而从C语言向C++的进化刚好就发生在这个时期,自然而然地,C++也就选择了支持面向对象程序设计思想。
《人月神话》与银弹
《人月神话》是软件领域里一本具有深远影响的著作。它诞生于软件危机的背景之下,而正是这本书提出了“银弹”的概念。
在西方的神话传说中,只有被银弹击中心脏,才可以杀死怪兽。而在这本书中,作者把那些规模越来越大的、管理与维护越来越困难的软件开发项目比作传说中无法控制的怪兽,并希望有一项技术能够像银弹杀死怪兽那样,彻底地解决这场软件危机。
其实,面向对象程序设计思想并不是完全意义上的银弹,它不可能解决所有大型软件项目所遇到的问题,但是它提出了一种描述软件的更加自然的方式,在一定程度上解决了这场软件危机。
6.1.1 “自顶向下,逐步求精”的面向过程程序设计
要想了解新的面对对象思想有什么优点,最简单直接的方式就是先看看旧的面向过程思想有什么缺点。回顾前面章节中曾经学过的例子,我们在解决问题时总是按照这样的流程:先提出问题;然后分析问题的处理流程;接着根据流程需要把一个大问题划分为几个小问题;如果细分后的小问题仍然比较复杂,则进一步细分,直到小问题可以简单解决为止;实现每个子模块,解决每个小问题;最后通过主函数按照业务流程次序调用这些子模块,最终解决整个大问题,如图6-1所示。像这样从问题出发,自顶向下、逐步求精的开发思想我们称为“面向过程程序设计思想”,它描述的主要是解决问题的“过程”。
图6-1 面向过程程序设计的流程
面向过程程序设计思想诞生于20世纪60年代,鼎盛于20世纪80年代,是当时最为流行的程序设计思想。它的流行有其内在原因,跟当时其他程序设计思想相比,它有着明显的优势。
1. 程序仅由三种基本结构组成
正如第4章中所介绍的程序流程控制结构一样,面向过程程序设计思想限定程序只有顺序、选择和循环这三种基本控制结构。任何程序逻辑,无论是简单的还是复杂的,都可以用这三种基本的控制结构经过不同的组合或嵌套来实现。这就使得程序的结构相对比较简单,易于实现和维护。
2. 分而治之,各个击破
人们在解决复杂问题时,总是采用“分而治之”的策略,把大问题分解为多个小问题后,再“各个击破”并最终让大问题得到解决。面向过程程序设计思想也采取这种“分而治之”的策略,把较大的程序按照业务逻辑划分为多个子模块,然后分工逐个完成这些子模块,最后再按照业务流程把它们组织起来,最终使得整个问题得到解决。按照一定的原则,把大问题细分为小问题“各个击破”,符合人们思考问题的一般规律,其设计结果更易于理解,同时这种方法也更易于人们掌握。通过分解问题,降低了问题的复杂度,使得程序易于实现和维护。另外,部分分解后的小问题(子模块)可以重复使用,从而避免了重复开发。而多个子模块也可由多人分工协作完成,提高了开发效率。
3. 自顶向下,逐步求精
面向过程程序设计思想倡导的方法是“自顶向下,逐步求精”。所谓“自顶向下,逐步求精”,就是先从宏观角度考虑,按照功能或者业务逻辑划分程序的子模块,定义程序的整体结构,然后再对各个子模块逐步细化,最终分解到程序语句为止。这种方法使得程序员能够全面考虑问题,使程序的逻辑关系清晰明了。它让整个开发过程从原来的考虑“怎么做”变成考虑“先做什么,再做什么”,流程也更加清晰。
随着时代的发展,软件开发项目也越来越复杂。虽然面向过程程序设计思想有诸多优点,但在利用它解决复杂问题的时候,其缺点也逐渐暴露出来:在面向过程程序设计中,数据和操作是相互分离的,这就导致如果数据的结构发生变化,相应的操作函数就不得不重新改写;如果遇到需求变化或者新的需求,还可能涉及模块的重新划分,这就要修改大量原先写好的功能模块。面向过程程序设计中数据和操作相互分离的特点,使得一些模块跟具体的应用环境结合紧密,旧有的程序模块很难在新的程序中得到复用。这些面向过程程序设计思想的固有缺点使得它越来越无法适应大型的软件项目的开发,这真是“成也面向过程,败也面向过程”。于是,人们开始寻找一种新的程序设计思想。正是在这种情况下,一些新的程序设计思想开始不断涌现并逐渐取代面向过程程序设计思想,而面向对象程序设计思想就是其中的“带头大哥”。
6.1.2 万般皆对象:面向对象程序设计
面向对象程序设计(Object Oriented Programming, OOP)是对面向过程程序设计的继承和发展,它不仅汲取了后者的精华,而且以一种更接近人类思维的方式来分析和解决问题:程序是对现实世界的抽象和描述,现实世界的基本单元是物体,与之对应的,程序中的基本单元就是对象。
面向对象思想认为:现实世界是由很多彼此相关并互通信息的实体——对象组成的。大到一个星球、一个国家,小到一个人、一个分子,无论是有生命的,还是没有生命的,都可以看成是一个对象。通过分析这些对象,发现每个对象都由两部分组成:描述对象状态或属性的数据(变量)和描述对象行为或功能的方法(函数)。与面向过程将数据和对数据进行操作的函数相分开不同的是,面向对象将数据和操作数据的函数紧密结合,共同构成对象来更加准确地描述现实世界。这可以说是面向过程与面向对象两者最本质的区别。
跟现实世界相对应的,在面向对象中,我们用某个对象来代表现实世界中的某个实体,每个对象都有自己的属性和行为,而整个程序则由一系列相互作用的对象构成,对象之间通过互相操作来完成复杂的业务逻辑。比如在一个班中,有一位陈老师和50名学生,那么我们就可以用一个老师对象和50个学生对象来抽象和描述这一个班级。对这51个对象而言,有些属性是它们所共有的,比如姓名、年龄等,每个对象都有;而有部分属性则是某类对象特有的,比如老师对象有职务这个属性,而学生对象则没有。另外,老师和学生这两种对象还有各自不同的行为;比如老师对象有备课、上课、批改作业的行为;而学生对象则有听课、完成作业等行为。老师对象和学生对象各自负责自己的行为和职责,同时又相互发生联系,比如老师上课的动作需要以学生作为动作对象。通过对象之间的相互作用,整个班级就可以正常运作。整个面向对象分析设计的结果,跟我们的现实世界非常接近,自然也就更容易理解和实现了。老师对象如图6-2所示。
图6-2 用面向对象思想将老师抽象成对象
知道更多:面向对象编程的重要性在哪?
这一点,也许可以从面向对象的诞生说起。
在面向对象出生之前,有一个叫做面向过程的人,它将整个待解决的问题,抽象为描述事物的数据以及描述对数据进行处理的函数,或者说数据处理过程。当问题规模比较小,需求变化不大的时候,面向过程工作得很好。
可是(任何事物都怕“可是”二字),当问题的规模越来越大越来越复杂,需求变化越来越快的时候,面向过程就显得力不从心了。想象一下,当我们根据需求变化修改了某个结构体,就不得不修改与之相关的所有过程函数,而一个过程函数的修改,往往又会涉及到其他数据结构,在系统规模较小的时候,这还比较容易解决,可是当系统规模越来越大,特别是涉及到多人协作开发的时候,这简直就是一场噩梦。这就是那场著名的软件危机。
为了解决这场软件危机,面向对象应运而生了(有问题的出现,必然就有解决问题的方法的出现,英雄人物大都是这样诞生的)。
我们知道,面向对象的三板斧分别是封装、继承和多态:它用封装将问题中的数据和对数据进行处理的函数结合在了一起,形成了一个整体的类的概念,这样更加符合人的思维习惯,更利于理解,自然在理解和抽象一些复杂系统的时候也更加容易;它用继承来应对系统的扩展,在原有系统的基础上,只要简单继承,就可以完成系统的扩展,而无需重起炉灶;它用多态来应对需求的变化,统一的借口,却可以有不同的实现。
可以说,面向对象思想用它的三板斧,在一定程度上解决了软件危机,这就是它重要性的根本体现。
6.1.3 面向对象的三座基石:封装、继承与多态
我们知道,面向对象是为了解决面向过程所无法解决的“软件危机”而诞生的,那么,它又是如何解决“软件危机”的呢?封装、继承与多态是面向对象思想的三座基石,正是它们的共同作用,才使得“软件危机”得到了一定程度的解决,如图6-3所示。
程序是用来抽象和描述现实世界的。那么先来看看在现实世界中我们又是如何描述周围的事物的。我们总是从数据和操作两个方面来描述某个事物:这个事物是什么和这个事物能做什么。比如我们要描述一位老师,我们会说:他身高178厘米,体重72公斤,年龄32岁,同时他能给学生上课,能批改作业。这样,一个活生生的老师形象就会在我们头脑中建立起来。
在传统的面向过程思想中,程序中的数据和操作是相互分离的。也就是说,在描述一个事物的时候,事物是什么(数据)和事物能做什么(操作)是相互分离的。但在面向对象思想中,我们通过封装机制将数据和相应的操作捆绑到了一起,以形成一个完整的、具有属性(数据)和行为(操作)的数据类型。在C++中,我们把这种数据类型称为类(class),而用这种数据类型所定义的变量,就被称为对象(object)。这样就使得程序中的数据和对这些数据的操作结合在了一起,更加符合人们描述某个事物的思维习惯,因此也更加容易理解和实现。简单来说,对象就是封装了数据和操作这些数据的动作的逻辑实体,也是现实世界中事物在程序中的反映,如图6-4所示。
图6-4 将属性和行为封装成对象
封装机制还带来了另外一个好处,那就是对数据的保护。在面向过程思想中,因为数据和操作是相互分离的,某些操作有可能错误地修改了不属于它的数据,从而造成程序错误。而在面向对象思想中,数据和操作被捆绑在一起成了对象,而数据可以是某个对象所私有的,只能由与它捆绑在一起的操作访问,这样就避免了数据被其他操作意外地访问。这就如同钱包里的钱是我们的私有财产,只有我们自己可以访问,别人是不可以访问的。当然,小偷除外。
2. 继承
在创造某个新事物的时候,我们总是希望可以在某个旧事物的基础之上开始,毫无疑问这样会提高效率。可是对于面向过程的C语言而言,这一点却很难做到。在C语言中,如果已经写好了一个“上课”的函数,而要想再写一个“上数学课”的函数,很多时候我们都不得不另起炉灶全部重新开始。如果我们每次都另起炉灶,那样开发效率就太低了。显然,这无法满足大型的复杂系统的开发需要。
正是为了解决这个问题,面向对象思想提出了继承的机制。继承是可以让某个类型获得另一个类型的属性(成员变量)和行为(成员函数)的简单方法。继承就如同现实世界中的进化一样,继承得到的子类型,既可以拥有父类型的属性和行为,又可以新增加子类型所特有的属性和行为。比如我们已经用封装机制将姓名属性和说话行为封装成了“人”这个类,再此基础之上,我们可以很容易地通过继承“人”这个类,同时添加职务属性和上课行为而得到一个新的“老师”类。而这个新的“老师”类,不仅拥有它的父类“人”的姓名属性和说话行为,同时还拥有它自己的职务属性和上课行为。如果需要,我们还可以在“老师”类的基础之上继承得到“数学老师”、“语文老师”类等等。在这个过程中,我们直接复用了父类已有的属性和行为,这就避免了面向过程的另起炉灶重新开始,很大程度地提高了开发效率。如图6-5所示。
图6-5 继承
3. 多态
“见领导阿谀奉承,见下属飞扬跋扈”,是说一个人两面三刀,不是什么好人。可在C++世界中,这种在不同情况下做不同事情的现象,却被冠以一个冠冕堂皇的名字——多态,成为面向对象思想的一个重要特性。
多态是继承的直接结果。由于继承,在同一个继承体系中的多个类型的对象往往拥有相同的行为能干同样的事情,但是因为类型的不同,这些行为往往又需要有不同的实现方式。比如,“大学老师”和“小学老师”都是从“老师”这个父类继承而来的,它们两者都同样从“老师”父类中继承得到了“上课”这个行为,然而两者“上课”的具体方式又是不同的:“小学老师”是拿着课本上课,而“大学老师”是拿着鼠标上课。多态就是让一个对象在做某件事(调用某个接口函数)时,该对象能够搞清楚到底怎么完成(采用何种实现)这件事。还是上面的例子,有了多态机制,同样是对“上课”函数的调用,如果这个对象是“小学老师”类型的,就使用“小学老师”类的实现,而如果这个对象是“大学老师”类型的,就使用“大学老师”类的实现。这就是C++世界中的“见领导阿谀奉承,见下属飞扬跋扈”。如图6-6所示。
图6-6 多态
最佳实践:多态与重载的区别
多态跟之前我们学习的函数重载相比,两者都是根据不同的情况而决定调用函数的不同实现。但是,无论是内在机制还是外在形式,两者却有着很大的区别。
首先,在内在机制上,两者发生的时间不同。重载是一个编译时概念,它发生在程序的编译时期,编译器根据代码中调用函数的实际参数的个数和类型,来决定调用这个重载函数的某个具体实现。而多态是一个运行时概念,它发生在程序的运行时期,程序会根据调用这个函数的真实对象类型,来决定调用这个函数在某个特定类中的实现。
其次,在外在形式上,两者的层次关系不同。对于函数重载,同名的重载函数都在同一个作用域内,要么同为全剧函数,要么同为某个局部作用域的局部函数。而多态则是伴随着继承而生的,它只能发生在某个继承体系的不同层次的类之间。
多态机制使得不同类型的不同内部实现可以拥有相同的函数声明,共享相同的外部接口。这意味着虽然针对不同对象的具体操作不同,但通过一个公共的父类,它们(成员函数)能够以相同的方式被调用。简单来说,多态机制允许通过相同的接口引发一组相关但不相同的动作。通过这种方式,保持了代码的一致性减少了代码的复杂度。相同的函数调用形式,但在某种特定的情况下应该做出怎样的动作由编译器决定,而不需要程序员手工进行干预,为程序员省了很多事。
纵观面向对象思想的三大特征,它们是紧密相关、不可分割的。通过封装,我们将现实世界中的数据和对数据进行操作的动作捆绑到一起成了类,然后再通过类定义对象,很好地实现了对现实世界事物的抽象和描述;通过继承,可以在旧类型的基础上快速派生得到新的类型,很好地实现了设计和代码的复用;同时,多态机制保证了在继承的同时,还有机会对已有行为进行重新定义,满足了不断出现的新需求的需要。
正是因为面向对象思想的封装、继承和多态这三大特性,使得面向对象思想在程序设计中有着不可替代的优势。
(1) 容易设计和实现。
面向对象思想强调从客观存在的事物(对象)出发来认识问题和分析解决问题,因为这种方式更加符合我们认识事物的规律,所以大大降低了问题的理解难度。面向对象思想所运用的封装、继承与多态等基本原则,符合人类日常的思维习惯,使得采用面向对象思想设计的程序结构清晰、更容易设计和实现。
(2) 复用设计和代码,开发效率和系统质量都得到提高。
面向对象思想的继承和多态,强调了程序设计和代码的重用,这在设计新系统的时候,可以最大限度地重用已有的、经过大量实践检验的设计和代码,使系统能够满足新的业务需求并具有较高的质量。同时,因为可以复用以前的设计和代码,大大提高了开发效率。
(3) 容易扩展。
开发大型系统的时候,最担心的就是需求的变更以及对系统进行扩展。利用面向对象思想继承、封装和多态的特性,可以设计出“高内聚、低耦合”的系统结构,可让系统更灵活、更易扩展,从而轻松应对系统的扩展需求,降低维护成本。
最佳实践:高内聚,低耦合
高内聚,低耦合是软件工程中的一个概念,通常用以判断一个软件设计的好坏。所谓的高内聚,是指一个软件模块是由相关性很强的代码组成,只负责某项单一任务,也就是常说的“单一责任原则”。而低耦合指的是在一个完整的系统中,模块与模块之间,尽可能地保持相互独立。换句话说,也就是让每个模块尽可能的独立完成某个特定的子功能。模块与模块之间的接口,尽量的少而简单。
高内聚低耦合的系统具有更好的重用性、可维护性和扩展性,可以更高效的完成系统的开发、维护和扩展,持续地支持业务的发展。因而,它可以作为判断一个软件设计好坏的标准,自然也是我们软件设计的目标。
面向对象程序设计思想在软件开发中的这些优势,使其成为当前最为流行的程序设计思想之一,是每个进入C++世界的程序员都需要理解和掌握的。它就像程序设计中的《易筋经》般博大精深,而这里所介绍的只是面向对象思想最基础的入门知识,要完全领会和灵活运用面向对象思想,还需要在实践中不断学习和总结。在理解概念的同时,更要着重体会如何利用面向对象思想来分析问题设计程序,只有这样才能增加软件设计和开发的功力,成为真正的高手。
(我还会回来的)