一、设计原则意义
为了设计出一个好的软件系统。我们必须遵照一定的规则。
衡量软件设计质量的首要标准是该设计是否能满足软件的功能需求。除了功能需求以外,还有很多衡量软件设计质量的标准,包括可读性、可复用性、可扩展性、可维护性等。
1、一般一个好的软件具有以下特点:
可读性:软件的设计文档是否轻易被其他程序员理解。可读性差的设计会给大型软件的开发和维护过程带来严重的危害。
可复用性:软件系统的架构、类、组件等单元能否很容易被本项目的其它部分或者其它项目复用。
可扩展性:软件面对需求变化时,功能或性能扩展的难易程度。
可维护性:软件维护(主要是指软件错误的修改、遗漏功能的添加等)的难易程度。
2、内聚度和耦合度标准:
内聚度:
表示一个应用程序的单个单元所负责的任务数量和多样性。内聚与单个类或者单个方法单元相关。好的软件设计应该做到高内聚。
理想状态下,一个代码单元应该负责一个内聚的任务,也就是说一个任务可以看作是一个逻辑单元。一个方法应该实现一个逻辑操作,一个类应该代表一种类型的实体。
内聚原则背后的主要原因是重用:如果一个方法或一个类只负责一个定义明确的事情,那么在不同的上下文环境中,它就能更好地被再次使用。
遵循该规则的另一个优点是,当一个应用程序的某些方面需要做出改变时,我们能够在相同单元中找到所有相关的部分。
如果一个系统单元只负责一件事情,就说明这个系统单元有很高的内聚度;如果一个系统单元负责了很多不相关的事情,则说明这个系统单元是内聚度很低。内聚度很高的系统单元通常很容易理解,很容易被复用、扩展和维护。
如果一个方法可以用简单的“动词+名词”的形式来命名(例如,loadFile()、getName()),或者如果一个类可以用准确的名词来命名(例如,Employee、Student),那么这样的类或者方法就是内聚度较高的系统单元;反之,如果类或者方法的名字必须包含“和”、“或”等字样才能准确反映其功能特性的话,这些类或方法的内聚度就一定不高。
耦合度:
耦合度表示类之间关系的紧密程度。耦合度决定了变更一个应用程序的容易程度。在紧密耦合的类结构中,更改一个类会导致其它的类也随之需要做出修改。显然,这是我们在类设计时应该避免的,因为微小的修改会迅速波动影响到整个应用程序。此外,找到需要修改的所有的地方是必须的,实际上就使得修改变得困难并且耗费时间。而在松散耦合的系统中,我们可以更改一个类,不需要修改其它类,而应用程序仍然能够正常工作。
概括起来,较低的耦合度和较高的内聚度,也即我们常说的“高内聚、低耦合”是所有优秀软件的共同特征。
如果一个软件的内聚度和耦合度都符合要求,它也就自然具备了比较好的复用性、可扩展性和可维护性。
二、7大设计原则
1、单一职责原则
单一职责原则(Single Responsibility Principle,SRP)是指:所有的对象都应该有单一的职责,它提供的所有的服务也都仅围绕着这个职责。换句话说就是:一个类而言,应该仅有一个引起它变化的原因,永远不要让一个类存在多个改变的理由。
类的职责是由该类的对象在系统中的角色所决定的。
一个类如果有多个职责,也有多个改变它的理由。反之,如果你能想到一个类存在多个使其改变的原因,那么这个类就存在多个职责。
2、开闭原则
开闭原则(Open-Close Principle,简称OCP)是指一个软件实体(类、模块、方法等)应该对扩展开放,对修改关闭。
遵循开闭原则设计出来的模块具有两个基本特征:
对于扩展是开放的(Open for extension):模块的行为可以扩展,当应用的需求改变时,可以对模块进行扩展,以满足新的需求。
对于更改是封闭的(Closed for modification):对模块行为扩展时,不必改动模块的源代码或二进制代码。
这两个特征看起来是相互矛盾的。扩展模块的行为通常需要修改该模块的源代码,而不允许修改的模块通常被认为是具有固定的行为。
实现开闭原则的关键在于抽象化。在Java中,抽象化的具体实现就是使用抽象类或接口。
2.1使用抽象类
在设计类时,对于拥有共同功能的相似类进行抽象化处理,将公用的功能部分放到抽象类中,而将不同的行为封装在子类中。这样,在需要对系统进行功能扩展时,只需要依据抽象类实现新的子类即可。在扩展子类时,不仅可以拥有抽象类的共有属性和共有方法,还可以拥有自定义的属性和方法。
2.2接口
与抽象类不同,接口只定义实现类应该实现的接口方法,而不实现公有的功能。在现在大多数的软件开发中,都会为实现类定义接口,这样在扩展子类时必须实现该接口。如果要改换原有的实现,只需要改换一个实现类即可。
3、里氏替换原则(The Liskov Substitution Principle,LSP)
在一个软件系统中,子类应该能够完全替换任何父类能够出现的地方,并且经过替换后,不会让调用父类的客户程序从行为上有任何改变。
里氏替换原则实现了开闭原则中的对扩展开放。实现开闭原则的关键步骤是抽象化,父类与子类之间的继承关系就是一种抽象化的体现。因此,里氏替换原则是实现抽象化的一种规范。违反里氏替换原则意味着违反了开闭原则,反之未必。里氏替换原则是使代码符合开闭原则的一个重要保证。
一般来说,只要有可能,就不要从具体类继承。在一个由继承关系形成的等级结构中,树叶节点都应当是具体类,树枝节点都应该是抽象类或者接口。
里氏替换原则是使代码符合开闭原则的一个重要的保证,同时,它体现了:
3.1类的继承原则
里氏替换原则常用来检查两个类是否为继承关系。在符合里氏替换原则的继承关系中,使用父类代码的地方,用子类代码替换后,能够正确的执行动作处理。换句话说,如果子类替换了父类后,不能够正确执行动作,那么他们的继承关系就是不正确的,应该重新设计它们之间的关系。
3.2动作正确性保证
里氏替换原则对子类进行了约束,所以在为已存在的类进行扩展,来创建一个新的子类时,符合里氏替换原则的扩展不会给已有的系统引入新的错误。
3.3对类的继承关系的定义
面向对象的设计关注的是对象的行为,它是使用“行为”来对对象进行分类的,只有行为一致的对象才能抽象出一个类来。
我们说类的继承关系就是一种“is-a”关系,实际上指的是行为上的“is-a”关系,可以把它描述为“表现为,act
as”。
正方形在设置长度和宽度这两个行为上,与长方形显然是不同的。长方形的行为:设置长方形的长度的时候,它的宽度保持不变,设置宽度的时候,长度保持不变。正方形的行为:设置正方形的长度的时候,宽度随之改变;设置宽度的时候,长度随之改变。所以,如果我们把这种行为加到父类长方形的时候,就导致了正方形无法继承这种行为。我们“强行”把正方形从长方形继承过来,就造成无法达到预期的结果。
3.4设计要依赖于用户需求和具体环境。
继承关系要求子类要具有基类全部的行为。这里的行为是指落在需求范围内的行为。
这里我们以另一个理解里氏替换原则的经典例子“鸵鸟非鸟”来做示例。生物学中对于鸟类的定义是“恒温动物,卵生,全身披有羽毛,身体呈流线形,有角质的喙,眼在头的两侧。前肢退化成翼,后肢有鳞状外皮,有四趾”。从生物学角度来看,鸵鸟肯定是一种鸟,是一种继承关系。但是根据上一个“正方形非长方形”的例子,鸵鸟和鸟之间的继承关系又可能不成立。那么,鸵鸟和鸟之间到底是不是继承关系如何判断呢?这需要根据用户需求来判断。
现在鸟类有四个对外的行为,其中两个行为分别落在A和B系统需求中,如下图所示。
A需求期望鸟类提供与飞翔有关的行为,即使鸵鸟跟普通的鸟在外观上就是100%的相像,但在A需求范围内,鸵鸟在飞翔这一点上跟其它普通的鸟是不一致的,它没有这个能力,所以,鸵鸟类无法从鸟类派生,鸵鸟不是鸟。
B需求期望鸟类提供与羽毛有关的行为,那么鸵鸟在这一点上跟其它普通的鸟一致的。虽然它不会飞,但是这一点不在B需求范围内,所以,它具备了鸟类全部的行为特征,鸵鸟类就能够从鸟类派生,鸵鸟就是鸟。
所有子类的行为功能必须和使用者对其父类的期望保持一致,如果子类达不到这一点,那么必然违反里氏替换原则。
4、依赖倒转原则
依赖倒转原则(Dependency Inversion Principle,简称DIP)是指将两个模块之间的依赖关系倒置为依赖抽象类或接口。具体有两层含义:
4.1高层模块不应该依赖于低层模块,二者都应该依赖于抽象
4.2 抽象不应该依赖于细节,细节应该依赖于抽象
具体耦合关系:发生在两个具体的(可实例化的)类之间,经由一个类对另一个具体类的直接引用造成。
抽象耦合关系:发生在一个具体类和一个抽象类(或接口)之间,使两个必须发生关系的类之间存有最大的灵活性。
面向接口编程:
在高层模块和低层模块之间定义一个抽象接口,高层模块调用抽象接口定义的方法,低层模块实现该接口。
依赖倒转原则的本质就是要求将类之间的关系建立在抽象接口的基础上的。通过上面的方式,将错误的依赖关系倒转过来,使具体实现类依赖于抽象类和接口。这就是依赖倒转原则中“倒转”的由来。
以抽象方式耦合是依赖倒转原则的关键。
5、组合/聚合复用原则
组合/聚合复用原则(Composite/Aggregation Reuse Principle,CARP)是指要尽量使用组合/聚合而非继承来达到复用目的。另一种解释是在一个新的对象中使用一些已有的对象,使之成为新对象的一部分;新的对象通过向这些对象委托功能达到复用这些对象的目的。
5.1组合/聚合复用
我们知道组合/聚合都是关联关系的特殊种类,二者都是体现整体与部分的关系,也就是两个类之间的是“has-a”关系,它表示某一个角色具有某一项责任。由于组合/聚合都可以将已有的对象加入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,从而实现对象复用。
使用组合/聚合实现复用有如下好处:
5.1.1新对象存取成分对象的唯一方法是通过成分对象的接口。
5.1.2这种复用是黑箱复用,因为成分对象的内部细节是新对象所看不见的。
5.1.3这种复用所需的依赖较少。
5.1.4每一个新的类可以将焦点集中在一个任务上。
5.1.5这种复用可以在运行时间内动态进行,作为整体的新对象可以动态地引用与部分对象类型相同的对象。也就是说,组合/聚合是动态行为,即运行时行为。可以通过使用组合/聚合的方式在设计上获得更高的灵活性。
当然,这种复用也有缺点。其中最主要的缺点就是系统中会有较多的对象需要管理。
一般来说,如果一个角色得到了更多的责任,就可以使用组合/聚合关系将新的责任委派到合适的对象上。
5.2继承复用
继承是面向对象语言特有的复用工具。由于使用继承关系时,新的实现较为容易,因父类的大部分功能可以通过继承的关系自动进入子类;同时,修改和扩展继承而来的实现较为容易。于是,在面向对象设计理论的早期,程序设计师十分热衷于继承,好像继承就是最好的复用手段,于是继承也成为了最容易被滥用的复用工具。然而,继承有多个缺点:
5.2.1继承复用破坏封装,因为继承将父类的实现细节暴露给子类。由于父类的内部细节常常是对于子类透明的,所以这种复用是透明的复用,又称“白箱”复用。
5.2.2如果父类发生改变,那么子类的实现也不得不发生改变。
5.2.3从父类继承而来的实现是静态的,也就是编译时行为,不可能在运行时间内发生改变,没有足够的灵活性。
正是因为继承有上述缺点,所以应首先使用组合/聚合,其次才考虑继承,达到复用的目的。并且在使用继承时,要严格遵循里氏替换原则。
要正确的选择组合/聚合和继承,必须透彻的理解里氏替换原则和Coad法则。里氏替换原则前面学习过,Coad法则由Peter
Coad提出,总结了一些什么时候使用继承作为复用工具的条件。
5.3Coad条件全部被满足时,才应当使用继承关系
5.3.1子类是父类的一个特殊种类,而不是父类的一个角色,也就是区分“has-a”和“is-a”。只有“is-a”关系才符合继承关系,“has-a”关系应当用组合/聚合来描述。
5.3.2永远不会出现需要将子类换成另外一个类的子类的情况。如果不能肯定将来是否会变成另外一个子类的话,就不要使用继承。
5.3.3子类具有扩展父类的责任,而不是具有置换(重写)或注销掉父类的责任。如果一个子类需要大量的置换掉父类的行为,那么这个类就不应该是这个父类的子类。
5.3.4只有在分类学角度上有意义时,才可以使用继承。不要从工具类继承。
6、接口隔离原则
接口隔离原则(Interface Segregation Principle,简称ISP)是指客户不应该依赖它们用不到的方法,只给每个客户它所需要的接口。换句话说,就是不能强迫用户去依赖那些他们不使用的接口。
接口隔离原则实际上包含了两层意思:
6.1接口的设计原则
接口的设计应该遵循最小接口原则,不要把用户不使用的方法塞进同一个接口里。如果一个接口的方法没有被使用到,则说明该接口过胖,应该将其分割成几个功能专一的接口,使用多个专门的接口比使用单一的总接口要好。
6.2接口的继承原则
如果一个接口A继承另一个接口B,则接口A相当于继承了接口B的方法,那么继承了接口B后的接口A也应该遵循上述原则:不应该包含用户不使用的方法。反之,则说明接口A被B给污染了,应该重新设计它们的关系。
6.3通过多重继承分离接口
多重继承可以有两个方式,第一种方式是同时实现两个接口,属于多重接口继承;第二种方式是实现一个接口,同时继承一个具体类,实际上也是一种多重继承。
6.4通过委托分离接口
使用聚合和组合来将部分实现交给已知类实现。自己在实现部分接口。
7、迪米特法则
迪米特法则(Law of Demeter,简称LOD),又称为“最少知识原则”,它的定义为:一个软件实体应当尽可能少的与其他实体发生相互作用。这样,当一个模块修改时,就会尽量少的影响其他的模块,扩展会相对容易。迪米特法则是对软件实体之间通信的限制,它对软件实体之间通信的宽度和深度做出了要求。迪米特的其它表述方式为:
7.1只与你直接的朋友们通信。
7.2不要跟“陌生人”说话。
7.3每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
7.4做为“朋友”的条件为:
7.4.1当前对象本身(this);
7.4.2被当做当前对象的方法的参数传入进来的对象;
7.4.3当前对象的方法所创建或者实例化的任何对象;
7.4.4当前对象的任何组件(被当前对象的实例变量引用的任何对象)。
|