难免的尴尬:代码依赖
在浩瀚的代码世界中,有着无数的对象,跟人和人之间有社交关系一样,对象跟对象之间也避免不了接触,所谓接触,就是指一个对象要使用到另外对象的属性、方法等成员。现实生活中一个人的社交关系复杂可能并不是什么不好的事情,然而对于代码中的对象而言,复杂的"社交关系"往往是不提倡的,因为对象之间的关联性越大,意味着代码改动一处,影响的范围就会越大,而这完全不利于系统重构和后期维护。所以在现代软件开发过程中,我们应该遵循"尽量降低代码依赖"的原则,所谓尽量,就已经说明代码依赖不可避免。
有时候一味地追求"降低代码依赖"反而会使系统更加复杂,我们必须在"降低代码依赖"和"增加系统设计复杂性"之间找到一个平衡点,而不应该去盲目追求"六人定理"那种设计境界。
注:"六人定理"指:任何两个人之间的关系带,基本确定在六个人左右。两个陌生人之间,可以通过六个人来建立联系,此为六人定律,也称作六人法则。
12.1 从面向对象开始
在计算机科技发展历史中,编程的方式一直都是趋向于简单化、人性化,"面向对象编程"正是历史发展某一阶段的产物,它的出现不仅是为了提高软件开发的效率,还符合人们对代码世界和真实世界的统一认识观。当说到"面向对象",出现在我们脑海中的词无非是:类,抽闲,封装,继承以及多态,本节将从对象基础、对象扩展以及对象行为三个方面对"面向对象"做出解释。
注:面向对象中的"面向"二字意指:在代码世界中,我们应该将任何东西都看做成一个封闭的单元,这个单元就是"对象"。对象不仅仅可以代表一个可以看得见摸得着的物体,它还可以代表一个抽象过程,从理论上讲,任何具体的、抽象的事物都可以定义成一个对象。
12.1.1 对象基础:封装
和现实世界一样,无论从微观上还是宏观上看,这个世界均是由许许多多的单个独立物体组成,小到人、器官、细胞,大到国家、星球、宇宙, 每个独立单元都有自己的属性和行为。仿照现实世界,我们将代码中有关联性的数据与操作合并起来形成一个整体,之后在代码中数据和操作均是以一个整体出现,这个过程称为"封装"。封装是面向对象的基础,有了封装,才会有整体的概念。
图12-1 封装前后
如上图12-1所示,图中左边部分为封装之前,数据和操作数据的方法没有相互对应关系,方法可以访问到任何一个数据,每个数据没有访问限制,显得杂乱无章;图中右边部分为封装之后,数据与之关联的方法形成了一个整体单元,我们称为"对象",对象中的方法操作同一对象的数据,数据之间有了"保护"边界。外界可以通过对象暴露在外的接口访问对象,比如给它发送消息。
通常情况下,用于保存对象数据的有字段和属性,字段一般设为私有访问权限,只准对象内部的方法访问,而属性一般设为公开访问权限,供外界访问。方法就是对象的表现行为,分为私有访问权限和公开访问权限两类,前者只准对象内部访问,而后者允许外界访问。
1 //Code 12-1 2 class Student //NO.1 3 { 4 private string _name; //NO.2 5 private int _age; 6 private string _hobby; 7 public string Name //NO.3 8 { 9 get 10 { 11 return _name; 12 } 13 } 14 public int Age 15 { 16 get 17 { 18 return _age; 19 } 20 set 21 { 22 if(value<=0) 23 { 24 value=1; 25 } 26 _age = value; 27 } 28 } 29 public string Hobby 30 { 31 get 32 { 33 return _hobby; 34 } 35 set 36 { 37 _hobby = value; 38 } 39 } 40 public Student(string name,int age,string hobby) 41 { 42 _name = name; 43 _age = age; 44 _hobby = hobby; 45 } 46 public void SayHello() //NO.4 47 { 48 Console.WriteLine(GetSayHelloWords()); 49 } 50 protected virtual string GetSayHelloWords() //NO.5 51 { 52 string s = ""; 53 s += "hello,my name is " + _name + ",\r\n", 54 s += "I am "+_age + "years old," + "\r\n"; 55 s += "I like "+_hobby + ",thanks\r\n"; 56 return s; 57 } 58 }上面代码Code 12-1将学生这个人群定义成了一个Student类(NO.1处),它包含三个字段:分别为保存姓名的_name、保存年龄的_age以及保存爱好的_hobby字段,这三个字段都是私有访问权限,为了方便外界访问内部的数据,又分别定义了三个属性:分别为访问姓名的Name,注意该属性是只读的,因为正常情况下姓名不能再被外界改变;访问年龄的Age,注意当给年龄赋值小于等于0时,代码自动将其设置为1;访问爱好的Hobby,外界可以通过该属性对_hobby字段进行完全访问。同时Student类包含两个方法,一个公开的SyaHello()方法和一个受保护的GetSayHelloWords()方法,前者负责输出对象自己的"介绍信息",后者负责格式化"介绍信息"的字符串。Student类图见图12-2:
图12-2 Student类图
注:上文中将类的成员访问权限只分为两个部分,一个对外界可见,包括public;另一种对外界不可见,包括private、protected等。
注意类与对象的区别,如果说对象是代码世界对现实世界中各种事物的一一映射,那么类就是这些映射的模板,通过模板创建具体的映射实例:
图12-3 对象实例化
我们可以看到代码Code 12-1中的Student类既包含私有成员也包含公开成员,私有成员对外界不可见,外界如需访问对象,只能调用给出的公开方法。这样做的目的就是将外界不必要了解的信息隐藏起来,对外只提供简单的、易懂的、稳定的公开接口即可方便外界对该类型的使用,同时也避免了外界对对象内部数据不必要的修改和访问所造成的异常。
封装的准则:
封装是面向对象的第一步,有了封装,才会有类、对象,再才能谈继承、多态等。经过前人丰富的实践和总结,对封装有以下准则,我们在平时实际开发中应该尽量遵循这些准则:
1)一个类型应该尽可能少地暴露自己的内部信息,将细节的部分隐藏起来,只对外公开必要的稳定的接口;同理,一个类型应该尽可能少地了解其它类型,这就是常说的"迪米特法则(Law of Demeter)",迪米特法则又被称作"最小知识原则",它强调一个类型应该尽可能少地知道其它类型的内部实现,它是降低代码依赖的一个重要指导思想,详见本章后续介绍;
2)理论上,一个类型的内部代码可以任意改变,而不应该影响对外公开的接口。这就要求我们将"善变"的部分隐藏到类型内部,对外公开的一定是相对稳定的;
3)封装并不单指代码层面上,如类型中的字段、属性以及方法等,更多的时候,我们可以将其应用到系统结构层面上,一个模块乃至系统,也应该只对外提供稳定的、易用的接口,而将具体实现细节隐藏在系统内部。
封装的意义:
封装不仅能够方便对代码对数据的统一管理,它还有以下意义:
1)封装隐藏了类型的具体实现细节,保证了代码安全性和稳定性;
2)封装对外界只提供稳定的、易用的接口,外部使用者不需要过多地了解代码实现原理也不需要掌握复杂难懂的调用逻辑,就能够很好地使用类型;
3)封装保证了代码模块化,提高了代码复用率并确保了系统功能的分离。
12.1.2 对象扩展:继承
封装强调代码合并,封装的结果就是创建一个个独立的包装件:类。那么我们有没有其它的方法去创建新的包装件呢?
在现实生活中,一种物体往往衍生自另外一种物体,所谓衍生,是指衍生体在具备被衍生体的属性基础上,还具备其它额外的特性,被衍生体往往更抽象,而衍生体则更具体,如大学衍生自学校,因为大学具备学校的特点,但大学又比学校具体,人衍生自生物,因为人具备生物的特点,但人又比生物具体。
图12-4 学校衍生图
如上图12-4,学校相对来讲最抽象,大学、高中以及小学均可以衍生自学校,进一步来看,大学其实也比较抽象,因为大学还可以有具体的本科、专科,因此本科和专科可以衍生自大学,当然,抽象和具体的概念是相对的,如果你觉得本科还不够具体,那么它可以再衍生出来一本、二本以及三本。
在代码世界中,也存在"衍生"这一说,从一个较抽象的类型衍生出一个较具体的类型,我们称"后者派生自前者",如果A类型派生自B类型,那么称这个过程为"继承",A称之为"派生类",B则称之为"基类"。
注:派生类又被形象地称为"子类",基类又被形象地称为"父类"。
在代码12-1中的Student类基础上,如果我们需要创建一个大学生(College_Student)的类型,那么我们完全可以从Student类派生出一个新的大学生类,因为大学生具备学生的特点,但又比学生更具体:
1 //Code 12-2 2 class College_Student:Student //NO.1 3 { 4 private string _major; 5 public string Major 6 { 7 get 8 { 9 return _major; 10 } 11 set 12 { 13 _major = value; 14 } 15 } 16 public College_Student(string name,int age,string hobby,string major) :base(name,age,hobby) //NO.2 17 { 18 _major = major; 19 } 20 protected override string GetSayHelloWords() //NO.3 21 { 22 string s = ""; 23 s += "hello,my name is " + Name + ",\r\n", 24 s += "I am "+ Age + "years old, and my major is " + _major + ",\r\n"; 25 s += "I like "+ Hobby + ", thanks\r\n"; 26 return s; 27 } 28 }如上代码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中还提供另外一种类似的创建新类型的方式:接口实现。接口定义了一组方法,所有实现了该接口的类型必须实现接口中所有的方法:
1 //Code 12-3 2 interface IWalkable 3 { 4 void Walk(); 5 } 6 class People:IWalkable 7 { 8 //… 9 public void Walk() 10 { 11 Console.WriteLine("walk quickly"); 12 } 13 } 14 class Dog:IWalkable 15 { 16 //…