设计基本原则
最基本的设计原则是对 面向对象思维的深化和具体。
基本设计原则的综合使用是产生高质量软件设计的基石。
设计的基本原则
接口隔离原则(Interface Segregation Principle, ISP)
开放封闭原则(Open-Closed Principle, OCP)
Liskov替换原则(Liskov Substitution Principle, LSP)
单一职责原则(Single Responsibility Principle, SRP)
合成/聚合复用原则(Composite/Aggregate Reuse Principle, CARP)
扩展的基础越具体,扩展的难度也越大,具体类的变化无常势必造成扩展类的不稳定
依赖倒置原则使细节和具体实现都依赖于抽象,抽象的稳定性决定了系统的稳定性
一个基础稳定的系统要比一个基础不稳定的系统在整体上要更“稳定”一些
应尽量使用“接口继承” ,而非“实现继承”。接口关注对象的概貌,将对象中“不变”的信息抽象出来,不涉及细节,因此是“稳定”的。
通过接口只将需要的操作“暴露”给客户类,而将不需要的操作隐藏起来。接口在这里充当类的视图。
一个对象可以很容易地被(实现了相同接口的)另一个对象所替换,这样对象间的连接不必硬绑定(hard wire)到一个具体类的对象上,因此增加了灵活性。
这是一种松散的耦合,同时增加了重用的可能性。
这条原则是面向对象思想的最高境界,即设计者应给出对于需求变化进行扩展的模块,而永远不需要改写已经实现的内部代码或逻辑。
两个基本的特点:
模块的行为可以被扩展,以需要满足新的需求。
模块的源代码是不允许进行改动的。
OCP是相对的,没有绝对符合OCP的设计,而且一个软件系统的所有模块不可能都满足OCP,要做的是尽量最小化不满足OCP的模块数量。
可是,但需求发生了变化,如在计算零件价格和的时候还需要考虑市场的价格波动情况,则可以修改代码如图中的注释部分,但这将导致该设计不符合OCP。
但这一属性放在什么地方?显然它不是类part的自然属性,因为这一属性是与价格策略相关的,为此引入类PricePolicy,与类Part呈关联关系,这样做的好处是零件对象与价格策略对象的对应是动态的,可以在程序运行时(runtime)动态改变。
职责是引起“变化”的原因:当一个类中有两个以上的变化方向,会产生过多的变化点。
拆分后设计并未结束,两个拆分的抽象类肯定不是也不应该独立存在,下一步的设计?
单一职责原则要求的条件是比较苛刻的,一个类真的要做到只能有一个功能而一点儿其它功能也不能具有?答案同样是否定的。
多个功能在一个类中是可以同时存在的,但这里有个前提:是否能够成为变化的方向。
如果成为单独的变化方向,则应该按照SRP进行类职责的拆分,否则可以保留功能共存(装饰模式) 。
有两个不同的应用程序均使用了 Rectangle 类。一个应用为计算几何程序,它使用了 Rectangle 中的数学几何模型,但不会在显示屏幕上绘制矩形。另一个应用是一个图形界面程序(GUI),它可能会做一些计算几何方面的工作,但主要功能是在屏幕上绘制矩形。
1 public class Rectangle 2 { 3 public int Height { get; set; } 4 public int Width { get; set; } 5 6 public double Area() 7 { 8 return Width * Height; 9 } 10 11 public void Draw(Form form) 12 { 13 SolidBrush brush = new SolidBrush(Color.Red); 14 Graphics formGraphics = form.CreateGraphics(); 15 formGraphics.FillRectangle(brush, 16 new System.Drawing.Rectangle( 17 new Point(0, 0), new Size(Width, Height))); 18 } 19 }
这个设计侵犯了 SRP 原则。Rectangle 类包含了两个职责。第一个职责是提供矩形几何计算的数学模型,第二个职责是在 GUI 上渲染矩形。
对 SRP 原则的侵犯会导致诸多难以解决的问题:
首先,我们必须在计算几何应用中包含对 GUI 库的引用。这导致应用程序无谓的消耗了链接时间、编译时间、内存空间和存储空间等。
再者,如果因为某些原因对 GraphicalApplication 的一个更改导致 Rectangle 类也相应做了更改,这将强制我们对 ComputationalGeometryApplication 进行重新编译、重新测试和重新部署等。如果我们忘了做这些事情,那么应用程序可能以无法预期的方式而崩溃。
一个较好的设计是将这两个职责完全地隔离到不同的类当中,如下图所示。这个设计将 Rectangle 中关于几何计算的职责移到了 GeometricRectangle 类中,而 Rectangle 类中仅保留矩形渲染职责。
1 public class GeometricRectangle 2 { 3 public int Height { get; set; } 4 public int Width { get; set; } 5 6 public double Area() 7 { 8 return Width * Height; 9 } 10 } 11 12 public class Rectangle 13 { 14 public void Draw(Form form, GeometricRectangle geometric) 15 { 16 SolidBrush brush = new SolidBrush(Color.Red); 17 Graphics formGraphics = form.CreateGraphics(); 18 formGraphics.FillRectangle(brush, 19 new System.Drawing.Rectangle( 20 new Point(0, 0), 21 new Size(geometric.Width, geometric.Height))); 22 } 23 }
然后,如果我们再对 Rectangle 中渲染职责进行更改时将不会再影响到 ComputationalGeometryApplication 了。
1 public class ComputationalGeometryApplication 2 { 3 public double CalculateArea(GeometricRectangle geometric) 4 { 5 double area = geometric.Area(); 6 return area; 7 } 8 } 9 10 public class GraphicalApplication 11 { 12 public Form form { get; set; } 13 14 public void DrawOnScreen(Rectangle rectangleDraw, GeometricRectangle rectangleShape) 15 { 16 rectangleDraw.Draw(form, rectangleShape); 17 } 18 }
那么,职责(Responsibility)到底是什么?
在单一职责原则(SRP:Single Responsibility Principle)的概念中,我们将职责(Responsibility)定义为 "一个变化的原因(a reason for change)"。如果你能想出多于一种动机来更改一个类,则这个类就包含多于一个职责。
委托重用与继承重用是两种本质上不同的重用方式,委托重用追求的是对象间的独立性即低耦合,而继承重用追求的是对象间应能尽可能的高内聚。
合成/聚合复用原则指的是应尽量使用合成/聚合形式的委托重用,尽量不使用继承重用
是合成/聚合复用原则?
合成/聚合复用原则是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分;新的对象通过向这些对象的委派达到复用已有功能的目的。
简述为:要尽量使用合成/聚合,尽量不要使用继承。
二、合成和聚合的区别;依赖和关联
合成(Composition)和聚合(Aggregation)都是关联(Association)的特殊种类。用C语言来讲,合成是值的聚合(Aggregation by Value),聚合是则是引用的聚合(Aggregation by Reference)。
(1)聚合用来表示“拥有”关系或者整体与部分的关系。代表部分的对象有可能会被多个代表整体的对象所共享,而且不一定会随着某个代表整体的对象被销毁或破坏而被销毁或破坏,部分的生命周期可以超越整体。例如,班级和学生,当班级删除后,学生还能存在,学生可以被培训机构引用。
聚合关系UML类图
class Student {
}
class Classes{
privateStudent student;
publicClasses(Student student){
this.student=student;
}
}
(2)合成用来表示一种强得多的“拥有”关系。在一个合成关系里,部分和整体的生命周期是一样的。一个合成的新对象完全拥有对其组成部分的支配权,包括它们的创建和湮灭等。使用程序语言的术语来说,合成而成的新对象对组成部分的内存分配、内存释放有绝对的责任。
一个合成关系中的成分对象是不能与另一个合成关系共享的。一个成分对象在同一个时间内只能属于一个合成关系。如果一个合成关系湮灭了,那么所有的成分对象要么自己湮灭所有的成分对象(这种情况较为普遍)要么就得将这一责任交给别人(较为罕见)。
例如,一个人由头、四肢和各种器官组成,人与这些具有相同的生命周期,人死了,这些器官也就挂了。房子和房间的关系,当房子没了,房间也不可能独立存在。
合成关系UML类图
class Room{
public Room createRoom(){
System.out.println(“创建房间”);
returnnew Room();
}
}
class House{
private Room room;
public House(){
room=new Room();
}
public void createHouse(){
room.createRoom();
}
}
(3)依赖和关联
依赖(Dependency)
依赖是类与类之间的连接,表示一个类依赖于另外一个类的定义。依赖关系仅仅描述了类与类之间的一种使用与被使用的关系,在Java中体现为局部变量、方法的参数或者是对静态方法的调用。
依赖关系UML类图
static class Boat{
public static void row(){
System.out.println("开动");
}
}
class Person{
public void crossRiver(Boatboat){
boat.row();
}
public void fishing(){
Boat boat =new Boat() ;
boat.row();
}
public void patrol(){
Boat.row() ;
}
}
关联(Association)
关联是类与类之间的连结。关联关系使一个类知道另外一个类的属性和方法。关联可以是双向的,也可以是单向的。体现在Java中,关联关系是通过成员变量来实现的。
一般关联关系UML类图
class Computer{
public void develop(){
System.out.println("Develop ");
}
}
class Person{
private Computer computer ;
public Person(Computer computer){
this.computer = computer ;
}
public void work(){
computer.develop() ;
System.out.println("work");
}
}
三、为什么使用合成/聚合复用,而不使用继承复用?
在面向对象的设计里,有两种基本的方法可以在不同的环境中复用已有的设计和实现,即通过合成/聚合复用和通过继承复用。两者的特点和区别,优点和缺点如下。
1、合成/聚合复用
由于合成或聚合可以将已有对象纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能。这样做的好处有
(1) 新对象存取成分对象的唯一方法是通过成分对象的接口。
(2) 这种复用是黑箱复用,因为成分对象的内部细节是新对象看不见的。
(3) 这种复用支持包装。
(4) 这种复用所需的依赖较少。
(5) 每一个新的类可以将焦点集中到一个任务上。
(6) 这种复用可以再运行时间内动态进行,新对象可以动态地引用与成分对象类型相同的对象。
一般而言,如果一个角色得到了更多的责任,那么可以使用合成/聚合关系将新的责任委派到合适的对象。当然,这种复用也有缺点。最主要的缺点就是通过这种复用建造的系统会有较多的对象需要管理。
2、继承复用
继承复用通过扩展一个已有对象的实现来得到新的功能,基类明显的捕获共同的属性和方法,而子类通过增加新的属性和方法来扩展超类的实现。继承是类型的复用。
继承复用的优点。
(1) 新的实现较为容易,因为超类的大部分功能可以通过继承关系自动进入子类。
(2) 修改或扩展继承而来的实现较为容易。
继承复用的缺点。
(1) 继承复用破坏包装,因为继承将超类的实现细节暴露给了子类。因为超类的内部细节常常对子类是透明的,因此这种复用是透明的复用,又叫“白箱”复用。
(2) 如果超类的实现改变了,那么子类的实现也不得不发生改变。因此,当一个基类发生了改变时,这种改变会传导到一级又一级的子类,使得设计师不得不相应的改变这些子类,以适应超类的变化。
(3) 从超类继承而来的实现是静态的,不可能在运行时间内发生变化,因此没有足够的灵活性。
由于继承复用有以上的缺点,所有尽量使用合成/聚合而不是继承来达到对实现的复用,是非常重要的设计原则。
四、从代码重构的角度理解
一般来说,对于违反里氏代换原则的设计进行重构时,可以采取两种方法:一是加入一个抽象超类;二是将继承关系改写为合成/聚合关系。
要正确的使用继承关系,必须透彻的理解里氏代换原则和Coad条件。
区分“Has-A”和“Is -A”
“Is-A”是严格的分类学意义上的定义,意思是一个类是另以个类的“一种”。而“Has-A”表示某一个角色具有某一项责任。
导致错误的使用继承而不是合成/聚合的一个常见原因是错误的把“Has-A”当做“Is-A”。“Is-A”代表一个类是另一个类的一种;“Has-A”代表一个类是另一个类的一个角色,而不是另一个类的一个特殊种类。这是Coad条件的第一条。
下面类图中描述的例子。“人”被继承到“学生”、“经理”和“雇员”等子类。而实际上,学生”、“经理”和“雇员”分别描述一种角色,而“人”可以同时有几种不同的角色。比如,一个人既然是“经理”,就必然是“雇员”;而“人”可能同时还参加MBA课程,从而也是一个“学生”。使用继承来实现角色,则只能使每一个“人”具有Is-A角色,而且继承是静态的,这会使得一个“人”在成为“雇员”身份后,就永远为“雇员”,不能成为“学生”和“经理”,而这显然是不合理的。
这一错误的设计源自于把“角色”的等级结构和“人”的等级结构混淆起来,把“Has-A”角色误解为“Is -A”角色。因此要纠正这种错误,关键是区分“人”与“角色”的区别。下图所示的的设计就正确的做到了这一点。
从上图可以看出,每一个“人”都可以有一个以上的“角色”,所有一个“人”可以同时是“雇员”,又是“经理”,甚至同时又是“学生”。而且由于“人”与“角色”的耦合是通过合成的,因此,角色可以有动态的变化。一个“人”可以开始是“雇员”,然后晋升为“经理”,然后又由于他参加了MBA课程,又称为了“学生“。
当一个类是另一个类的角色时,不应当使用继承描述这种关系。
与里氏代换原则联合使用
里氏代换原则是继承复用的基石。如果在任何可以使用B类型的地方都可以使用S类型,那么S类型才可以称为B类型的子类型(SubType),而B类型才能称为S类型的基类型(BaseType)。
换言之,只有当每一个S在任何情况下都是一种B的时候,才可以将S设计成B的子类。如果两个类的关系是“Has-A”关系而不是“Is -A”,这两个类一定违反里氏代换原则。
只有两个类满足里氏代换原则,才有可能是“Is -A”关系。