什么是好的软件设计呢?软件设计的金科玉律:复用
面向对象的设计到底有没有什么原则呢?
变化是复用最大的天敌!面向对象的最大优势就在于:抵御变化。
重新认识面向对象:
1.理解隔离变化
从宏观层面来看,面向对象的构建方式更能适应软件的变化,能将变化所带来的影响新奇迹世界最小
2..各司其职
从微观层面来看,面向对象的方式更强调各个类的“责任”
由于需求变化导致的校报增类型不应该影响原来类型的实现——是所谓各负其责
3.对象是什么
从语文实现层面来看,对象封装国代码和数据
从规格层面来讲,对象是一系列可被使用的公共接口
从概念层面来讲,对象是某种拥有责任的抽象
一、开闭原则(The Open-Closed Principle ,OCP)
对扩展开放,对更改封闭
类模块应该是可扩展,但是不可修改
一个软件实体应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。
软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类。
继续拿《设计模式简介》中的Mainform的例子来讲解吧。
如果要增加一个圆形呢?怎么修改代码呢?
第一种写法,先增加一个Circle类,接着在MainForm类添加一个成员变量,这个成员变量是存放Circel的vector,还要在OnMouseUp()和OnPaint()的这两个成员方法做修改,这样就违反开闭原则。
此外,该设计逻辑复杂,总的来说是一个僵化的、脆弱的、具有很高的牢固性的设计。
在很多面向对象编程语言中都提供了接口、抽象类等机制,可以通过它们定义系统的抽象层,再通过具体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。
在本实例中,由于在MainForm类的OnPaint()方法中针对每一个形状类编程,因此增加新的形状类不得不修改源代码。可以通过抽象化的方式对系统进行重构,使之增加新的形状类时无须修改源代码,满足开闭原则。
用开闭原则重构该设计如下图:
(1) 增加一个形状积累类Shape,将各种具体形状类作为其子类;
(2)对于MainForml来说,它管理的容器也发生了改变,只需要管理Shape的指针,而不再具体管理某一个具体的形状,实现了一次向上抽象的管理。同时onPaint的方法来说,也不在直接管理绘制的问题,而是调用了Shape中的虚函数Draw,通过多态调用来实现了MainForm来调用了自己绘制自己的方法。
此时,在该设计中,新增一个图形,只需要实现继承Shape,实现Draw的接口,满足对扩展开放;也不需要修改Mairform的onPaint()方法,对修改关闭。
二、 里氏替换原则(Liskov Substitution Principle ,LSP)
所有引用基类的地方必须能透明地使用其子类的对象
void MainForm::OnPaint(const PaintEventArgs& e){
//针对所有形状
for (int i = 0; i < shapeVector.size(); i++){
shapeVector[i]->Draw(e.Graphics); //多态调用,各负其责
}
//...
Form::OnPaint(e);
}
在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常。
在Mainform类的OnPaint()方法中,shapeVecotr存放的是基类Shape的指针,引用了基类Shape,shapeVecotr可以存放Shape的不同派生类实例对象,调用同一接口Draw,这里不会产生任何错误异常。
例如有两个类,一个类为BaseClass,另一个是SubClass类,并且SubClass类是BaseClass类的子类,那么一个方法如果可以接受一个BaseClass类型的基类对象base的话,如:method1(base),那么它必然可以接受一个BaseClass类型的子类对象sub,method1(sub)能够正常运行。反过来的代换不成立,如一个方法method2接受BaseClass类型的子类对象sub为参数:method2(sub),那么一般而言不可以有method2(base),除非是重载方法。
在使用里氏代换原则时需要注意如下几个问题:
(1)子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏代换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法。
(2) 我们在运用里氏代换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏代换原则是开闭原则的具体实现手段之一。
里氏代换原则是实现开闭原则的重要方式之一。在传递参数时使用基类对象,除此以外,在定义成员变量、定义局部变量、确定方法返回类型时都可使用里氏代换原则。针对基类编程,在程序运行时再确定具体子类。
三、 依赖倒置原则(Dependency Inversion Principle ,DIP)
高层模块不应该依赖于低层模块,二者都应该依赖于抽象
抽象不应该依赖于细节,细节应该依赖于抽象
针对接口编程,不要针对实现编程。
面向对象程序设计相对于面向过程(结构化)程序设计而言,依赖关系被倒置了。因为传统的结构化程序设计中,高层模块总是依赖于低层模块。
问题的提出:
Robert C. Martin氏在原文中给出了“Bad Design”的定义:
系统很难改变,因为每个改变都会影响其他很多部分。
当你对某地方做一修改,系统的看似无关的其他部分都不工作了。
系统很难被另外一个应用重用,因为很难将要重用的部分从系统中分离开来。
导致“Bad Design”的很大原因是“高层模块”过分依赖“低层模块”。
一个良好的设计应该是系统的每一部分都是可替换的。如果“高层模块”过分依赖“低层模块”,一方面一旦“低层模块”需要替换或者修改,“高层模块”将受到影响;另一方面,高层模块很难可以重用。
问题的解决:
为了解决上述问题,Robert C. Martin氏提出了OO设计的Dependency Inversion Principle (DIP) 原则。
DIP给出了一个解决方案:在高层模块与低层模块之间,引入一个抽象接口层。
抽象接口是对低层模块的抽象,低层模块继承或实现该抽象接口。
这样,高层模块不直接依赖低层模块,而是依赖抽象接口层。抽象接口也不依赖低层模块的实现细节,而是低层模块依赖(继承或实现)抽象接口。
类与类之间都通过抽象接口层来建立关系。
在《设计模式简介》中的Mainform的例子中,第一种写法,Marinform类的OnPaint()方法针对具体类来操作,因此在增加新的形状时,都不得不修改CustomerDAO的源代码。
我们可以通过引入抽象类Shape来解决该问题。在引入抽象类Shape之后,Maiform针对抽象类Shape编程,而将具体类实例对象存储在shapeVector中,符合依赖倒置原则。
根据里氏代换原则,虽然shapeVector存储的是Shape类型的指针,但具体类实例对象是可以存储在该Vecotr的,遍历shapeVector,调用了Shape中的虚函数Draw,通过多态调用来真正的具体实例对象的方法,程序不会出现任何问题。
如果需要增加新的形状类,只要将新的具体类实例对象加入到shapeVector即可,其实MainForm的OnMouseUp()方法可以用工厂模式,MainForm的代码没作多少修改,满足开闭原则。
在Mainform例子中,使用了开闭原则、里氏代换原则和依赖倒转原则。在大多数情况下,这三个设计原则会同时出现,开闭原则是目标,里氏代换原则是基础,依赖倒转原则是手段,它们相辅相成,相互补充,目标一致,只是分析问题时所站角度不同而已。
四、 迪米特原则(最少知道原则)(Law of Demeter ,LoD)
一个对象应当对其他对象有尽可能少的了解,不和陌生人说话。
对于面向OOD来说,又被解释为下面两种方式:
1)一个软件实体应当尽可能少地与其他实体发生相互作用。
2)每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
迪米特法则通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。
迪米特法则还有一个更简单的定义:只与直接的朋友通信。
首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。
代码举例:通过老师要求班长告知班级人数为例,讲解迪米特法则。先来看一下违反迪米特法则的设计,代码如下:
public class Student {
private Integer id;
private String name;
public Student(Integer id, String name)
{
this.id = id;
this.name = name;
}
}
public class Teacher {
public void call(Monitor monitor) {
List<Student> sts = new ArrayList<>();
for (int i = 0; i < 10; i++) {
sts.add(new Student(i + 1, "name" + i));
}
monitor.getSize(sts);
}
}
public class Monitor {
public void getSize(List list) {
System.out.println("班级人数:" + list.size());
}
}
现在这个设计的主要问题出在 Teacher 中,根据迪米特法则,只与直接的朋友发生通信,而 Student 类并不是 Teacher 类的直接朋友(以局部变量出现的耦合不属于直接朋友),从逻辑上讲 Teacher 只与 Monitor 耦合就行了,与 Student 并没有任何联系,这样设计显然是增加了不必要的耦合。按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合。修改后的代码如下:
public class Student {
private Integer id;
private String name;
public Student(Integer id, String name) {
this.id = id;
this.name = name;
}
}
public class Teacher {
public void call(Monitor monitor) {
monitor.getSize();
}
}
public class Monitor {
public void getSize() {
List<Student> sts = new ArrayList<>();
for (int i = 0; i < 10; i++) {
sts.add(new Student(i + 1, "name" + i));
}
System.out.println("班级人数" + sts.size());
}
}
将Student 从 Teacher 抽掉,也就达到了 Student 和 Teacher 的解耦,从而符合了迪米特原则。
迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,例如本例中,老师(Teacher)就是通过班长(Monitor)这个“中介”来与 学生(Student)发生联系的。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。
使用迪米特原则时要考虑的
朋友间也是有距离的
一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为private等。
**注意:**迪米特原则要求类“羞涩”一点,尽量不要对外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、protected等访问权限。
是自己的就是自己的
如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,就放置在本类中。
五、单一职责原则
一个类应该仅有一个引起它变化的原因.
变化的方向隐含着类的责任
换句话说,如果一个类需要改变,改变它的理由永远只有一个。如果存在多个改变它的理由,就需要重新设计该类。
单一职责原则原则的核心含意是:只能让一个类/接口/方法有且仅有一个职责。
假如说,类 A 负责两个不同的职责,T1 和 T2,当由于职责 T1 需求发生改变而需要修改类 A 时,有可能会导致原本运行正常的职责 T2 功能发生改变或出现异常。为什么会出现这种问题呢?代码耦合度太高,实现复杂,简单一句话就是:不够单一。那么现在提出解决方案:分别建立两个类 A 和 B ,使 A 完成职责 T1 功能,B 完成职责 T2 功能,这样在修改 T1 时就不会影响 T2 了,反之亦然。
为什么一个类不能有多于一个以上的职责?
如果一个类具有一个以上的职责,那么就会有多个不同的原因引起该类变化,而这种变化将影响到该类不同职责的使用者(不同用户):
一方面,如果一个职责使用了外部类库,则使用另外一个职责的用户却也不得不包含这个未被使用的外部类库。
另一方面,某个用户由于某个原因需要修改其中一个职责,另外一个职责的用户也将受到影响,他将不得不重新编译和配置。这违反了设计的开闭原则,也不是我们所期望的。
说到单一职责原则,很多人都会不屑一顾。因为它太简单了。稍有经验的程序员即使从来没有读过设计模式、从来没有听说过单一职责原则,在设计软件时也会自觉的遵守这一重要原则,因为这是常识。在软件编程中,谁也不希望因为修改了一个功能导致其他的功能发生故障。而避免出现这一问题的方法便是遵循单一职责原则。虽然单一职责原则如此简单,并且被认为是常识,但是即便是经验丰富的程序员写出的程序,也会有违背这一原则的代码存在。为什么会出现这种现象呢?因为有职责扩散。所谓职责扩散,就是因为某种原因,职责 T 被分化为粒度更细的职责 T1 和 T2
结论:只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则。
六、 接口分隔原则(Interface Segregation Principle ,ISP)
不应该强迫客户程序依赖它们不用的方法
接口应该小而完备
换句话说,使用多个专门的接口比使用单一的总接口总要好。
它包含了2层意思:
接口的设计原则:接口的设计应该遵循最小接口原则,不要把用户不使用的方法塞进同一个接口里。如果一个接口的方法没有被使用到,则说明该接口过胖,应该将其分割成几个功能专一的接口。
接口的依赖(继承)原则:如果一个接口a继承另一个接口b,则接口a相当于继承了接口b的方法,那么继承了接口b后的接口a也应该遵循上述原则:不应该包含用户不使用的方法。 反之,则说明接口a被b给污染了,应该重新设计它们的关系。
如果用户被迫依赖他们不使用的接口,当接口发生改变时,他们也不得不跟着改变。换而言之,一个用户依赖了未使用但被其他用户使用的接口,当其他用户修改该接口时,依赖该接口的所有用户都将受到影响。这显然违反了开闭原则,也不是我们所期望的。
代码示例:
public interface IAnimal {
void eat();
void talk();
void fly();
}
public class BirdAnimal implements IAnimal {
@Override
public void eat() {
System.out.println("鸟吃虫子");
}
@Override
public void talk() { //并不是所有的鸟都会说话
}
@Override
public void fly() { //并不是所有的鸟都会飞
}
}
public class DogAnimal implements IAnimal {
@Override
public void eat() {
System.out.println("狗狗吃饭");
}
@Override
public void talk() { //狗不会说话
}
@Override
public void fly() { //狗不会飞
}
}
通过上面的代码发现:狗实现动物接口,必须实现三个接口,根据常识我们得知,第二个和第三个接口不一定会有实际意义,换句话说也就是这个方法有可能一直不会被调用。但是就是这样我们还必须实现这个方法,尽管方法体可以为空,但是这就违反了接口隔离的定义。我们知道 由于Java类支持实现多个接口,可以很容易的让类具有多种接口的特征,同时每个类可以选择性地只实现目标接口,基于此特点我们可以对功能进一步的细化,编写一个或多个接口,代码如下:
public interface IEat {
void eat();
}
public interface IFly {
void fly();
}
public interface ITalk {
void talk();
}
public class DogAnimal implements IEat {
@Override
public void eat() {
System.out.println("狗狗吃饭");
}
}public class ParrotAnimal implements IEat, IFly, ITalk {
@Override
public void eat() {
System.out.println("鹦鹉吃东西");
}
@Override
public void fly() {
System.out.println("鹦鹉吃飞翔");
}
@Override
public void talk() {
System.out.println("鹦鹉说话");
}
}
说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。
其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。
其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建
接口隔离原则一定要适度使用,接口设计的过大或过小都不好,过分的细粒度可能造成接口数量庞大不易于管理
总而言之,接口分隔原则指导我们:
一个类对一个类的依赖应该建立在最小的接口上
建立单一接口,不要建立庞大臃肿的接口
尽量细化接口,接口中的方法尽量少
七、 组合/聚合复用原则(Composite/Aggregate Reuse Principle ,CARP)CARP)
尽量使用组合/聚合,不要使用类继承
即在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新对象通过向这些对象的委派达到复用已有功能的目的。就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的。
组合和聚合都是关联的特殊种类。
聚合表示整体和部分的关系,表示“拥有”。组合则是一种更强的“拥有”,部分和整体的生命周期一样。
组合的新的对象完全支配其组成部分,包括它们的创建和湮灭等。一个组合关系的成分对象是不能与另一个组合关系共享的。
组合是值的聚合(Aggregation by Value),而一般说的聚合是引用的聚合(Aggregation by Reference)。
在面向对象设计中,有两种基本的办法可以实现复用:第一种是通过组合/聚合,第二种就是通过继承。
什么时候才应该使用继承
只有当以下的条件全部被满足时,才应当使用继承关系:
1)派生类是基类的一个特殊种类,而不是基类的一个角色,也就是区分"Has-A"和"Is-A"。只有"Is-A"关系才符合继承关系,"Has-A"关系应当用聚合来描述。
2)永远不会出现需要将派生类换成另外一个类的派生类的情况。如果不能肯定将来是否会变成另外一个派生类的话,就不要使用继承。
3)派生类具有扩展基类的责任,而不是具有置换掉(override)或注销掉(Nullify)基类的责任。如果一个派生类需要大量的置换掉基类的行为,那么这个类就不应该是这个基类的派生类。
4)只有在分类学角度上有意义时,才可以使用继承。
总的来说:
如果语义上存在着明确的"Is-A"关系,并且这种关系是稳定的、不变的,则考虑使用继承;如果没有"Is-A"关系,或者这种关系是可变的,使用组合。另外一个就是只有两个类满足里氏替换原则的时候,才可能是"Is-A" 关系。也就是说,如果两个类是"Has-A"关系,但是设计成了继承,那么肯定违反里氏替换原则。
错误的使用继承而不是组合/聚合的一个常见原因是错误的把"Has-A"当成了"Is-A" 。"Is-A"代表一个类是另外一个类的一种;"Has-A"代表一个类是另外一个类的一个角色,而不是另外一个类的特殊种类。
看一个例子:
如果我们把“人”当成一个类,然后把“雇员”,“经理”,“学生”当成是“人”的派生类。这个的错误在于把 “角色” 的等级结构和 “人” 的等级结构混淆了。“经理”,“雇员”,“学生”是一个人的角色,一个人可以同时拥有上述角色。如果按继承来设计,那么如果一个人是雇员的话,就不可能是学生,这显然不合理。
正确的设计是有个抽象类 “角色”,“人”可以拥有多个“角色”(聚合),“雇员”,“经理”,“学生”是“角色”的派生类。
组合/聚合的优缺点:类之间的耦合比较低,一个类的变化对其他类造成的影响比较少,缺点:类的数量增多实现起来比较麻烦
继承的优点:由于很多方法父类已经实现,子类的实现会相对比较简单,缺点:将父类暴露给了子类,一定程度上破坏了封装性,父类的改变对子类影响比较大
---------------------
作者:X-Programer
来源:CSDN
原文:https://blog.csdn.net/q5707802/article/details/91355587
版权声明:本文为博主原创文章,转载请附上博文链接!