有时候一味地追求"降低代码依赖"反而会使系统更加复杂,我们必须在"降低代码依赖"和"增加系统设计复杂性"之间找到一个平衡点,而不应该去盲目追求"六人定理"那种设计境界。
在计算机科技发展历史中,编程的方式一直都是趋向于简单化、人性化,"面向对象编程"正是历史发展某一阶段的产物,它的出现不仅是为了提高软件开发 的效率,还符合人们对代码世界和真实世界的统一认识观。当说到"面向对象",出现在我们脑海中的词无非是:类,抽闲,封装,继承以及多态,本节将从对象基 础、对象扩展以及对象行为三个方面对"面向对象"做出解释。
和现实世界一样,无论从微观上还是宏观上看,这个世界均是由许许多多的单个独立物体组成,小到人、器官、细胞,大到国家、星球、宇宙, 每个独立单元都有自己的属性和行为。仿照现实世界,我们将代码中有关联性的数据与操作合并起来形成一个整体,之后在代码中数据和操作均是以一个整体出现, 这个过程称为"封装"。封装是面向对象的基础,有了封装,才会有整体的概念。
如上图12-1所示,图中左边部分为封装之前,数据和操作数据的方法没有相互对应关系,方法可以访问到任何一个数据,每个数据没有访问限制,显得杂 乱无章;图中右边部分为封装之后,数据与之关联的方法形成了一个整体单元,我们称为"对象",对象中的方法操作同一对象的数据,数据之间有了"保护"边 界。外界可以通过对象暴露在外的接口访问对象,比如给它发送消息。
通常情况下,用于保存对象数据的有字段和属性,字段一般设为私有访问权限,只准对象内部的方法访问,而属性一般设为公开访问权限,供外界访问。方法就是对象的表现行为,分为私有访问权限和公开访问权限两类,前者只准对象内部访问,而后者允许外界访问。
上面代码Code 12-1将学生这个人群定义成了一个Student类(NO.1处),它包含三个字段:分别为保存姓名的_name、保存年龄的_age以及保存爱好的 _hobby字段,这三个字段都是私有访问权限,为了方便外界访问内部的数据,又分别定义了三个属性:分别为访问姓名的Name,注意该属性是只读的,因 为正常情况下姓名不能再被外界改变;访问年龄的Age,注意当给年龄赋值小于等于0时,代码自动将其设置为1;访问爱好的Hobby,外界可以通过该属性 对_hobby字段进行完全访问。同时Student类包含两个方法,一个公开的SyaHello()方法和一个受保护的 GetSayHelloWords()方法,前者负责输出对象自己的"介绍信息",后者负责格式化"介绍信息"的字符串。Student类图见图 12-2:
注意类与对象的区别,如果说对象是代码世界对现实世界中各种事物的一一映射,那么类就是这些映射的模板,通过模板创建具体的映射实例:
我们可以看到代码Code 12-1中的Student类既包含私有成员也包含公开成员,私有成员对外界不可见,外界如需访问对象,只能调用给出的公开方法。这样做的目的就是将外界 不必要了解的信息隐藏起来,对外只提供简单的、易懂的、稳定的公开接口即可方便外界对该类型的使用,同时也避免了外界对对象内部数据不必要的修改和访问所 造成的异常。
封装是面向对象的第一步,有了封装,才会有类、对象,再才能谈继承、多态等。经过前人丰富的实践和总结,对封装有以下准则,我们在平时实际开发中应该尽量遵循这些准则:
1)一个类型应该尽可能少地暴露自己的内部信息,将细节的部分隐藏起来,只对外公开必要的稳定的接口;同理,一个类型应该尽可能少地了解其它类型, 这就是常说的"迪米特法则(Law of Demeter)",迪米特法则又被称作"最小知识原则",它强调一个类型应该尽可能少地知道其它类型的内部实现,它是降低代码依赖的一个重要指导思想, 详见本章后续介绍;
2)理论上,一个类型的内部代码可以任意改变,而不应该影响对外公开的接口。这就要求我们将"善变"的部分隐藏到类型内部,对外公开的一定是相对稳定的;
3)封装并不单指代码层面上,如类型中的字段、属性以及方法等,更多的时候,我们可以将其应用到系统结构层面上,一个模块乃至系统,也应该只对外提供稳定的、易用的接口,而将具体实现细节隐藏在系统内部。
封装不仅能够方便对代码对数据的统一管理,它还有以下意义:
1)封装隐藏了类型的具体实现细节,保证了代码安全性和稳定性;
2)封装对外界只提供稳定的、易用的接口,外部使用者不需要过多地了解代码实现原理也不需要掌握复杂难懂的调用逻辑,就能够很好地使用类型;
3)封装保证了代码模块化,提高了代码复用率并确保了系统功能的分离。
封装强调代码合并,封装的结果就是创建一个个独立的包装件:类。那么我们有没有其它的方法去创建新的包装件呢?
在现实生活中,一种物体往往衍生自另外一种物体,所谓衍生,是指衍生体在具备被衍生体的属性基础上,还具备其它额外的特性,被衍生体往往更抽象,而 衍生体则更具体,如大学衍生自学校,因为大学具备学校的特点,但大学又比学校具体,人衍生自生物,因为人具备生物的特点,但人又比生物具体。
图12-4 学校衍生图
如上图12-4,学校相对来讲最抽象,大学、高中以及小学均可以衍生自学校,进一步来看,大学其实也比较抽象,因为大学还可以有具体的本科、专科, 因此本科和专科可以衍生自大学,当然,抽象和具体的概念是相对的,如果你觉得本科还不够具体,那么它可以再衍生出来一本、二本以及三本。
在代码世界中,也存在"衍生"这一说,从一个较抽象的类型衍生出一个较具体的类型,我们称"后者派生自前者",如果A类型派生自B类型,那么称这个过程为"继承",A称之为"派生类",B则称之为"基类"。
注:派生类又被形象地称为"子类",基类又被形象地称为"父类"。
在代码12-1中的Student类基础上,如果我们需要创建一个大学生(College_Student)的类型,那么我们完全可以从Student类派生出一个新的大学生类,因为大学生具备学生的特点,但又比学生更具体:
如上代码Code 12-2所示,College_Student类继承Student类(NO.1处),College_Student类具备Student类的属性,比 如Name、Age以及Hobby,同时College_Student类还增加了额外的专业(Major)属性,通过在派生类中重写 GetSyaHelloWords()方法,我们重新格式化"个人信息"字符串,让其包含"专业"的信息(NO.3处),最后,调用 College_Student中从基类继承下来的SayHello()方法,便可以轻松输出自己的个人信息。
我们看到,派生类通过继承获得了基类的全部信息,之外,派生类还可以增加新的内容(如College_Student类中新增的Major属性), 基类到派生类是一个抽象到具体的过程,因此,我们在设计类型的时候,经常将通用部分提取出来,形成一个基类,以后所有与基类有种族关系的类型均可以继承该 基类,以基类为基础,增加自己特有的属性。
图12-5 College_Student类继承图
有的时候,一种类型只用于其它类型派生,从来不需要创建它的某个具体对象实例,这样的类高度抽象化,我们称这种类为"抽象类",抽象类不负责创建具 体的对象实例,它包含了派生类型的共同成分。除了通过继承某个类型来创建新的类型,.NET中还提供另外一种类似的创建新类型的方式:接口实现。接口定义 了一组方法,所有实现了该接口的类型必须实现接口中所有的方法:
如上代码Code 12-3所示,People和Dog类型均实现了IWalkable接口,那么它们必须都实现IWalkable接口中的Walk()方法,见下图12-6:
继承包括两种方式,一种为"类继承",一种为"接口继承",它们的作用类似,都是在现有类型基础上创建出新的类型,但是它们也有区别:
1)类继承强调了族群关系,而接口继承强调通用功能。类继承中的基类和派生类属于祖宗和子孙的关系,而接口继承中的接口和实现了接口的类型并没有这种关系。
2)类继承强调"我是(Is-A)"的关系,派生类"是"基类(注意这里的"是"代表派生类具备基类的特性),而接口继承强调"我能做(Can-Do)"的关系,实现了接口的类型具有接口中规定的行为能力(因此接口在命名时均以"able"作为后缀)。
3)类继承中,基类虽然较抽象,但是它可以有具体的实现,比如方法、属性的实现,而接口继承中,接口不允许有任何的具体实现。
继承是面向对象编程中创建类型的一种方式,在封装的基础上,它能够减少工作量、提高代码复用率的同时,快速地创建出具有相似性的类型。在使用继承时,请遵循以下准则:
1)严格遵守"里氏替换原则",即基类出现的地方,派生类一定可以出现,因此,不要盲目地去使用继承,如果两个类没有衍生的关系,那么就不应该有继 承关系。如果让猫(Cat)类派生自狗(Dog)类,那么很容易就可以看到,狗类出现的地方,猫类不一定可以代替它出现,因为它两根本就没有抽象和具体的 层次关系。
2)由于派生类会继承基类的全部内容,所以要严格控制好类型的继承层次,不然派生类的体积会越来越大。另外,基类的修改必然会影响到派生类,继承层次太多不易管理,继承是增加耦合的最重要因素。
3)继承强调类型之间的通性,而非特性。因此我们一般将类型都具有的部分提取出来,形成一个基类(抽象类)或者接口。
"多态"一词来源于生物学,本意是指地球上的所有生物体现出形态和状态的多样性。在面向对象编程中多态是指:同一操作作用于不同类的实例,将产生不同的执行结果,即不同类的对象收到相同的消息时,得到不同的结果。
多态强调面向对象编程中,对象的多种表现行为,见下代码Code 12-4:
如上代码Code 12-4所示,分别定义了三个类:Student(NO.1处)、College_Student(NO.2处)、 Senior_HighSchool_Student(NO.3处),后面两个类继承自Student类,并重写了SayHello()方法。在客户端代 码中,对于同一行代码"student.IntroduceMyself();"而言,三次调用(NO.4、NO.5以及NO.6处),屏幕输出的结果却 不相同:
如上图12-7所示,三次调用同一个方法,不同对象有不同的表现行为,我们称之为"对象的多态性"。从代码Code 12-4中可以看出,之所以出现同样的调用会产生不同的表现行为,是因为给基类引用student赋值了不同的派生类对象,并且派生类中重写了 SayHello()虚方法。
对象的多态性是以"继承"为前提的,而继承又分为"类继承"和"接口继承"两类,那么多态性也有两种形式:
类继承式多态需要虚方法的参与,正如代码Code 12-4中那样,派生类在必要时,必须重写基类的虚方法,最后使用基类引用调用各种派生类对象的方法,达到多种表现行为的效果:
接口继承式多态不需要虚方法的参与,在代码Code 12-3的基础上编写如下代码:
如上代码Code 12-5所示,对于同一行代码"iw.Walk();"的两次调用(NO.1和NO.2处),有不同的表现行为:
在面向对象编程中,多态的前提是继承,而继承的前提是封装,三者缺一不可。多态也是是降低代码依赖的有力保障,详见本章后续有关内容。