首页 > 代码库 > [JAVA设计模式]第一部分:接口、抽象类、设计原则
[JAVA设计模式]第一部分:接口、抽象类、设计原则
接口(纯粹的抽象类)
什么是接口
接口与类的区别
为什么要使用接口
抽象类
什么是抽象类
抽象类的用途
什么时候才应当使用继承复用
接口与抽象类的区别
联合使用接口和抽象类
设计原则
单一职责原则(SRP)——内聚性
定义
示例:用户管理
示例:Modem
结论
“开-闭”原则(OCP)—— 抽象应对一切的变化
定义
与其他原则之间的关系
为什么要使用开闭原则
示例:书店打折
怎样做到“开-闭”原则
里氏代换原则(LSP)
定义
Java语言对象里氏代换的支持
Java语言对里氏代换支持的局限
从代码重构的角度理解
依赖倒转原则(DIP)
定义
什么是“倒转”
“倒转”另类理解
面向对象设计的标志
三种耦合关系
示例:张三开奔驰,就可以开宝马
组合/聚合复用原则(CARP)
组合/聚合区别
组合/聚合复用与继承复用的区别
与里氏代换原则区别
类库中的反例
迪米特法则(LOD)
狭义的迪米特法则
迪米特法则与模式
广义的迪米特法则
接口隔离原则(ISP)
定义
与单一职责原则的不同
接口(纯粹的抽象类)
什么是接口
一个Java接口(interface)是一些方法特征的集合。一个接口只有方法的特征,而没有方法的实现,因此这些方法在不同的地方被实现时,可以具有完全不同的行为。接口还可以定义public的常量。
接口其实是一种纯粹的抽象类。
常谈到的接口可以分为二种不同的含义:第一种是指Java语言中的接口,这是一种Java语言中存在的结构,具有特定的语法和结构,并使用关键字interface来定义;另一种仅仅是指一个类所具有的方法的特征集合,是一种逻辑上的抽象。前者叫做“Java接口”,后者就叫做“接口”。
一个方法的特征仅包括方法的名字,参数的个数、类型、顺序(实质上就是参数列表),而不包括方法的返回类型与所抛出的异常。
重载(也叫过载)时只与方法特征有关,但重写(也叫覆写)是会进一步检查两个方法的返回类型和抛出的异常是否相同。
接口与类的区别
最重要的区别是,接口仅仅描述方法与特征,而不给出方法的实现;而类不仅给出方法的特征,而且给出方法的实现。因此,接口把方法的特征与实现分离,这种分离,体现在接口常常代表一个角色,它包装与该角色相关的操作与属性,而实现这个接口的类便是扮演这个角色的演员。一个角色可以由不同的演员来演(即可以由不同类来实现),而不同的演员之间除了扮演一个共同的角色之外,并不要求有任何其他的共同之处。
为什么要使用接口
1、 实现多继承
2、 提高系统的灵活性、可扩展性、可插入性、可复用性、可维护性、可测试性
抽象类
什么是抽象类
抽象类仅提供一个类的部分实现。抽象类可以有实例变量,构造函数,可以同时有抽象方法和具体方法。
一个抽象类不会有实例。模板方法模式就是抽象类和子类的一种应用。
抽象类的用途
抽象类通常代表一个抽象概念,它是一个继承的出发点。
1、 抽象类是用来继承的
要求继承都是从抽象类开始,而所有的具体子类都不应该被继承。
在一个以继承关系形成的等级结构里面,树叶节点均应当是具体类,而树枝节点均应当是抽象类(或是Java接口)。
2、 抽象类应当拥有尽可能多的共同代码
在一个从抽象类到多个具体类的继承关系中,共同的代码应尽量移动到抽象类中。在一个继承等级结构中,共同的代码应当尽量向等级结构的上方移动。
一个对象从超类继承而来的代码,在不使用时不会造成对象资源的浪费。这样把共同的代码尽量抽取到顶级抽象类中,可以保证最大限度的复用。
3、 抽象类应当拥有尽可能少的数据
与代码的移方向相反的是,数据的移动方向是从抽象类到具体类,也即从继承的等级结构的高端向等级结构的低端移动。
一个对象的数据不论是否使用都会占用资源,因此数据应当尽量话到具体类或者等级结构的低端。
4、 尽量不要重写基类的方法
如果基类是一个抽象类,并且这个方法已经实现了,子类尽里不要重写。要时刻记住:抽象类是用来继承的,而不是用来重写的。
什么时候才应当使用继承复用
继承代表“一般/特殊化”关系,其中基类代表一般,派生类代表特殊,派生类将基类特殊化或扩展化。只有满足以下全部条件时才使用继承关系:
l 子类是超类的一个特殊种类,而不是超类的一个角色,也就是要区分“Has-A”与“Is-A”两种关系的不同。“Has-A”应该使用聚合关系来描述,而只有“Is-A”才符合继承关系。
l 永远不会出现需要将子类变成另一个类的子类的情况,如果不确定一个类在将来会不会成为另一个类的子类时,请不要继承当前这个类。
l 子类有扩展超类的责任,而不是具有置换掉(Override)或注销掉超类的责任。如果子类需要大量地转换超类的行为,那么这个子类就不应该成为超类的子类。
l 只有在分类学角度上有意义时,才可以使用继承。
接口与抽象类的区别
1) 从继承角度方面来看,一个类只能继承一个抽象类,但可以实现多个接口。
2) 从类的修改角度方面来看:在于抽象类可以提供某些方法的部分实现,而接口则不可以,这也大概是抽象类的唯一的优点。
如果向一个抽象类加入一个新的具体方法,那么所有的子类一下子就都得到了这个新的具体方法,而Java接口做不到这一点,如果向一个Java接口加入一个新方法的话,所有实现这个接口的类就不能通过编译了,这显然是Java接口的一个缺点,这也从另一方面说明了抽象类在后继版本中演变的过程会比接口要容易得多。
3) 从代码重构的角度上讲,将一个单独的Java具体类重构成一个实现Java接口是很容易的,只需要声明一个Java接口,并将重要的方法添加到接口声明中,然后在具体定义语句后面加上一个合适的implements关键字即可;而为一个具体类添加一个Java抽象作为抽象类型却不是那么容易,因为这个具体类可能已经有了一个超类。这样一来,这样新定义的抽象类只好继续向上移动,变成这个超类的超类,这样,最后这个新定义的抽象类必定处于整个类型等级结构的最上端,从而使等级结构中的所有成员都会受到影响。
4) Java接口是定义混合类型的理想工具。所谓混合类型就是在一个类的主类型之外的次要类型。一个混合类型表明一个类不仅仅具有某个主类型的行为,而且具有其他次要行为。比如HashMap就具有多种类型,它的主类型是Map,表示它是一种映射。它还实现了Cloneable,表示该类可以安全的被克隆。另外它还实现了Serializable接口,表示这个类可以被序列化。
5) 接口是多功能组合的重要手段。将一个庞大的接口按照单一职责原则拆分成多个小的接口后,实现者可以根据需求将某些接口组合起来形成一个个不同功能的类,这些创建出来的类是符合接口隔离原则的,所以也不会造成接口污染。如果你采用抽象类,将会引起类爆炸风险,因为每种功能组合你都得要提供一个抽象类,而接口则不需要,我们只需要提供这些职责单一的接口,然后由用户自己去自由地组合。
联合使用接口和抽象类
一般来说,要想在公开的接口增加方法,而不破坏实现这个接口的所有现有类,这是不可能的,除非一开始就让某个类实现接口的时候,也继承抽象类。
由于抽象类具有提供缺省实现的优点,而接口具有其他所有的优点,所以联合使用就是一个很好的选择。如果一个具体类直接实现这个接口的话,它就必须自行实现所有的接口;相反,如果它继承自抽象类的话,它可以省去一些不必要的方法,因为它可以从抽象类中自动得到这些方法的缺省实现。如果需要向接口加入一个新的方法的话,那么只要同时向这个抽象类加入这个方法的一个具体实现就可以了,因为所有继承自这个抽象类的子类都会从这个抽象类得到这个具体方法。这其实就是一种缺省适配器模式。在API中也是用了这种缺省适配模式,比如Collection与AbstractCollection、List与AbstractList、Set与AbstractSet、Map与AbstractMap。再来看看HashSet的类图,从类图我们可以看出它联合使用了接口和抽象类,ArrayList、HashMap也一样:
在SSH框架中我们也会这么做。
设计原则
单一职责原则(SRP)——内聚性
定义
单一职责原则的定义:就一个类页言,应该仅有一个引起它变化的原因(There should never be more than one reason for a class to change)。
单一职责原则要求一个接口或类只有一个原因引起变化,也就是一个接口或类只有一个职责,它就是负责一件事情。
内聚性:一个模块的组成元素之间的功能相关性。
示例:用户管理
用户的属性与用户的行为没有分开,应该把用户的信息抽取成一个BO(Bussiness Object,业务对象),把行为抽取成一个Biz(Business Logic,业务逻辑),下面采用单一职责原则来分成两个职责单一的接口:
上面图中UserInfo其实还是实现了两个接口,又把两个职责整合在一个类中了,这又不是有两个原因会引起修改啊,但要注意的是我们是面向接口编程,我们对外公布的是接口而不是实现类,只要接口不变,对系统是没有影响的,实现类是可以随时更换。
在实际的使用用中,我们更倾向于使用两个不同的接口与类或接口:一个是IUserBo,一个是IUserBiz:
单一职责原则不仅仅只适合于类,还适合于方法。
示例:Modem
我们把职责定义为“变化的原因”。如果你能够想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责。有时,我们很难注意到这一点。我们习惯于以组的形式(如一个流程,拨号、挂断、发送数据、接收数据)去考虑职责。如Modem接口,大多数人会认为这个接口看起来非常合理。该接口所声明的4个函数确实是调制解调器所具有的功能(不一定具有这样的功能就应该把它们设计在一个接口中,而是分离接口后要所需要再组合必要的接口)。
interface Modem{
public void dial(Strng pno);//拨号
public void hangup();//挂机
public void send(char c);//发送数据
public void recv();//接收数据
}
然而,该接口中却显示出两个职责,第一个是连接管理(dial、hangup),第二个是数据通信(send、recv)。这两个职责应该被分开吗?这依赖于应用程序变化的方式,如果应用程序的变化会影响连接函数的签名,那么这个设计就具有僵化性,因为调用send和recv类必须要重新编译、部署,在这种情况下,我们应该分开。
另一方而,如果应用程序的变化方式总是导致这两个职责同时变化,那么就不必分离它们,实际上,分离它们就会具有不必要的复杂性。
清注意,上面图中我把两个职责都耦合进Modem Implementation类中。这不是所希望的,但是或许是必要的。常常会有一些和硬件或者操作系统的细节有关的原因,迫使我们把不愿耦合在一起的东西耦合在了—起。然而,对于应用的其余部分来说,通过分离它们的接口,我们已经解耦了概念。
我们可以把Modem Implementation类看作是一个杂凑物,或者一个瑕疵。然而,请注意所有的依赖关系都和它无关。谁也不需要依赖于它。除了main外,谁也不需要知道它的存在。因为我们是针对接口在编程。
结论
SRP是所有原则中层简单之一的原则,也是最难正确运用的之—。我们会自然地把职责结合在一起。软件设计真正要做的许多内容,就是发现职责并把那些职责相互分离。事实上,我们将要论述的其余原则都会以这样或那样的方式回到这个问题上。
“开-闭”原则(OCP)—— 抽象应对一切的变化
经典力学的基石是牛顿三大定律(静止或匀速运动、加速运动、作用力与反作用力),而面向对象设计的第一块基石,便是“开-闭”原则。
通过扩展的方式来修改现有类。
只要是面向对象编程,不管是什么语言,在开发进都会提及开闭原则。
定义
Open-Closed Principle:Software entities should be open for extension, but closed for modification.(一个软件实体应当对扩展开放,对修改关闭。)
这个原则说的是,在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展,换而言之,应当可以在不必修改现有源码的情况下改变这个模块的行为。
如果程序中的一处改动就会产生连锁反应,导致—系列相关模块的改动,那么设计就具有僵化性的臭味。如果正确地应用OCP,那么以后再进行同样的改动时,就只需要添加新的代码,而不必改动已经正常运行的代码。
遵循开闭原则设计出的模块具有两个主要的特征。它们是:
1、“对扩展是开放的”。这意味着模块的行为是可以扩展的,当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为。换句话说,我们可以改变模块的能。
2、“对于更改是封闭的”。对模块行为进行扩展时,不必改动模式的源代码或者二进制代码。模块的二进制可执行版本,无论是可链接的库、DLL或者Java的..jar文件,都无需改动。
与其他原则之间的关系
开闭原则是最基础的一个原则,其他原则都是开闭原则的具体做法,也就是说,其他原则是指导设计的工具和方法,而开闭原则才是其精神领袖。开闭原则是目标,而其他原则是达到这个目标的手段。要遵循开闭原则,则需要使用其他原则来实施。
“开-闭”原则与其他原则的是目标与手段的关系,“开-闭”是目标,而达到这一目标的手段是遵循其他原则。
为什么要使用开闭原则
开闭原则提高了系统的可维护性(可扩展性-增、灵活性/可修改性-修、可插入性-替、可测试性)、可复用性。
示例:书店打折
public interface IBook {//书籍接口
//书籍有名称
public String getName();
//书籍有售价
public int getPrice();
//书籍有作者
public String getAuthor();
}
public class NovelBook implements IBook {//小说
//书籍名称
private String name;
//书籍的价格
private int price;
//书籍的作者
private String author;
//通过构造函数传递书籍数据
public NovelBook(String _name,int _price,String _author){
this.name = _name;
this.price = _price;
this.author = _author;
}
//获得作者是谁
public String getAuthor() {
return this.author;
}
//书籍叫什么名字
public String getName() {
return this.name;
}
//获得书籍的价格
public int getPrice() {
return this.price;
}
}
public class BookStore {//场景
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
//静态模块初始化,项目中一般是从持久层初始化产生
static{
bookList.add(new NovelBook("天龙八部",3200,"金庸"));
bookList.add(new NovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new NovelBook("悲惨世界",3500,"雨果"));
bookList.add(new NovelBook("金、瓶、梅",4300,"兰陵笑笑生"));
}
//模拟书店买书
public static void main(String[] args) {
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("------------书店买出去的书籍记录如下:---------------------");
for(IBook book:bookList){
System.out.println("书籍名称:" + book.getName()+"\t书籍作者:" + book.getAuthor()+ "\t书籍价格:" + formatter.format(book.getPrice()/100.0)+"元");
}
}
}
现要求所有40元以上的书籍9折销售,其他的8折销售。我们该如何应付这样的一个需求变化呢?有以下三种可以选择:
1、 修改接口。在IBook上新增加一个方法getOffPrice(),专门用于进行打折处理。但这样修改的后果就是NovelBook要修改,BookStore中的main方法也要修改,同时IBook作为接口应该是稳定的,接口的修改将是大范围的修改,所以,该方案否决。
2、 修改实现类。虽然没有直接修改接口,不需要修改其他子类,也不需要修改BookStore中的main方法,看起来是一个不错的方案。但是,getPrice是一个已开放了的方法,你不知道会有多少人调用这个方法,直接修改它会引起所有调用该方法的类都所到影响,比如有的调用者就是需要书的原价怎么?如果真允许这样修改,所有调用该方法都需要重新测试,所以还是否决。
3、 通过扩展实现变化。增加一个子类OffNovelBook,重写getPrice方法,这只需要修改高层次的模块(这里也就是static静态模块区)换成新的OffNovelBook的对象即可。不会对其他地方造成影响,这是好的方案。
OffNovelBook类继承了NovelBook,并重写了getPrice方法,不用修改现有实现。
public class OffNovelBook extends NovelBook {//打折小说
public OffNovelBook(String _name,int _price,String _author){
super(_name,_price,_author);
}
//覆写销售价格
@Override
public int getPrice(){
//原价
int selfPrice = super.getPrice();
int offPrice=0;
if(selfPrice>4000){ //原价大于40元,则打9折
offPrice = selfPrice * 90 /100;
}else{
offPrice = selfPrice * 80 /100;
}
return offPrice;
}
}
public class BookStore {
…
static{//只需修改这里
bookList.add(new OffNovelBook("天龙八部",3200,"金庸"));
…
}
//模拟书店买书
…
}
注,开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,低层模块的修改,必然会引起高层模块进行修改(上面是需求范围内的修改,完全没有影响其他模块),我们要做的就是尽量减少这种修改所带来的影响。
开闭原则对测试的影响
这是以打折以前对NovelBook进行测试的测试代码:
public class NovelBookTest extends TestCase {//NovelBook的测试类
private String name = "平凡的世界";
private int price = 6000;
private String author = "路遥";
private IBook novelBook = new NovelBook(name,price,author);
//测试getPrice方法
public void testGetPrice() {
//原价销售,判断输入和输出的值是否相等进行断言
super.assertEquals(this.price, this.novelBook.getPrice());
}
}
加入打折小说后类OffNovelBook后,我们只需要为它单独提供一个测试类就可以了,而不需要修改已经的测试代码:
public class OffNovelBookTest extends TestCase {{//打折小说的测试类
private IBook below40NovelBook = new OffNovelBook("平凡的世界",3000,"路遥");
private IBook above40NovelBook = new OffNovelBook("平凡的世界",6000,"路遥");
//测试低于40元的数据是否是打8折
public void testGetPriceBelow40() {
super.assertEquals(2400, this.below40NovelBook.getPrice());
}
//测试大于40的书籍是否是打9折
public void testGetPriceAbove40(){
super.assertEquals(5400, this.above40NovelBook.getPrice());
}
}
怎样做到“开-闭”原则
怎样在能在不改动模块源代码的情况下去更改它的行为呢?怎样才能在无需对象模块进行改动的情况下就改变它的功能呢?
l 抽象化是关键
首先,抽象层是稳定的,抽象层预见了所有的可能扩展,在任何扩展情况下都不会改变。然后,在程序中都要求是针对接口或抽象类进行编程,而不是实现,这样就约束了你不能在实现类中添加抽象类中没有的方法(即使你添加了,抽象层也没有调用)。如果做到了这两点,那么系统的抽象层与现有的实现不需要修改,从而满足了“开-闭”原则的第二条:对修改关闭;
从抽象层导出一个或多个新的具体类就可以改变系统的行为,因此系统的设计对扩展是开放的,从而满足了“开-闭”原则的第一条:对扩展开放。
l 对可变性的封装(还是抽象?)
找出系统中可能会变化或不稳定的点,将这些点抽取出来,封装到一个接口或抽象类中,创建稳定的抽象层。在封装的过程我们要注意两点:第一,将相同的变化封装到一个接口或抽象类中(这不就是单一职责原则吗?),第二,将不同的变化封装到不同的接口或抽象类中,而不应该将它混合到一起。类图的继承结构一般都不要超过两层,不然就意味着将两种不同的可变性混合在一起了。
“对可变性的封装原则”实际上是设计模式的主题。换而言之,所有的设计模式都是对不同的可变性的封装,从而使系统在不同的角度上达到“开-闭”原则的要求。
里氏代换原则(LSP)
从“开-闭”原则中可以看出面向对象设计的关键是抽象,从抽象化到具体化需要使用继承关系,而是否满足继承关系则需要使用里氏代换原则(Liskov Substation principle)来验证。
定义
所有基类(泛指接口与抽象类、还有具体类也可)出现的地方,子类都可以出现。
严格表达是:如果对每一个类型的T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有的对象o1都代换成02时,程序P的行为没有变化,那么类型T2是类型T1的子类型。换言这,一个软件实体如果使用的是一个基类的话,那么一定适用于子类,而且它根本不能察觉出基类对象和子类对象的区别。
Java语言对象里氏代换的支持
在编译期间,Java语言编译器会检查一个程序是否符合里氏代换原则。当然这纯粹是语法上的检查,不会去检查业务逻辑是否违反这个法则。在语言上Java作了这样的检查:子类必须包括全部的基类接口,而且实现时接口访问权限只能放宽,否则编译器会出错。从里氏代换角度来看这个问题,就不难得出答案了,因为客户端完全有可能调用超类的公开方法,如果以子类代替,如果子类这个方法是私有,客户端就不能调用了,它们又是父子关系,又不能代换,显然这是违反里氏代换原则的,Java编译器就不会让这样的程序过关。
在平时我们的参数、变量、及返回类型一般都定义成了 或抽象类,这时其实就已经用到了这个原则了。
Java语言对里氏代换支持的局限
Java编译器不能检查一个系统的业务逻辑与实现是否满足里氏代换法则。一个其名的例子,就是“正方形类型是滞是长方形的子类”的问题。所以说Java只在语法上为我们把了第一道关,如果我们将两个互不相干的类构建成父子关系,在编译期间可以通过,但在业务逻辑上就会出现问题,而业务逻辑上的检查这道关,就只能由我们自己去把握了,这也是里氏代换最难以把握的一点,后面我们会针对此点进行讲解。
从代码重构的角度理解
两种重构方式
里氏代换原则讲的是基类与子类的关系,只有当这种关系(这里主要是针对业务逻辑上的关系)存在时,里氏代换关系才存在,反之则不存在。如果有两个具体类A和B之间的关系违反了里氏代换原则,则可以要所具体情况可以在下面的两种重构方案中选择一种进行重构即可解决:
1、 创建一个新的抽象类C,作为两个具体类的超类,将A和B的共同行为移动到C中,从而解决A和B行为不完全一致的问题,如:
2、 从B到A的继承关系改写为委派关系,如下图:
长方形和正方形问题
正方形是否是长方形(类似的,圆是否是椭圆),先看看它们的类:
public class Rectangle {// 长方形
private long width;
private long height;
public void setWidth(long width) {
this.width = width;
}
public long getWidth() {
return this.width;
}
public void setHeight(long height) {
this.height = height;
}
public long getHeight() {
return this.height;
}
}
public class Square {//正方形
private long side;
public void setSide(long side) {
this.side = side;
}
public long getSide() {
return side;
}
}
因为这个正方形类不是长方形类的子类(而且也不可能成为长方形类的子类,为什么请看后面),因此,Rectangle类与Square类之间不存在里氏代换关系。
正方形不可以作为长方形的子类
为什么不可以,我们从反面着手,先假设可以。正方形重新设计如下:
public class Square extends Rectangle {//正方形是一个长方形
private long side;
public void setWidth(long width) {
setSide(width);
}
public long getWidth() {
return getSide();
}
public void setHeight(long height) {
setSide(height);
}
public long getHeight() {
return getSide();
}
public long getSide() {
return side;
}
public void setSide(long side) {
this.side = side;
}
}
public class SmartTest {//测试
public void resize(Rectangle r) {
while (r.getHeight() <= r.getWidth()) {//正方形会出问题
r.setWidth(r.getWidth() + 1);
}
}
}
这样,只要width或height被赋值,那 么width和height会同时被赋值,从而使长形的和宽总是相等。这看上去正方形确实是一种长方形,但是如果将Square传进SmartTest的resize时,应用到这里将会出问题,正方形运行时会出现计算溢出的问题。原因就是正方形没有高与宽之分,一旦需求要区分高与宽时,就会出现这种设计上的错误,很显示从这个需求点出发就会发现它们根本不是父子关系。换言这,里氏代换原则被破坏了,因此Square不应当做为Rectangle的子类。
再次重构
public interface Quadrangle{//形状,只抽取出两读的方法
public long getWidth();
public long getHeight();
}
public class Rectangle implements Quadrangle {//长方形
private long width;
private long height;
public void setWidth(long width) {
this.width = width;
}
public long getWidth(){
return this.width;
}
public void setHeight(long height){
this.height = height;
}
public long getHeight(){
return this.height;
}
}
public class Square implements Quadrangle {//正方形
private long side;
public void setSide(long side){
this.side = side;
}
public long getSide(){
return side;
}
public long getWidth(){
return getSide();
}
public long getHeight(){
return getSide();
}
}
这样SmartTest就不可能破坏了。那么破坏里氏代换的问题在这里是怎样避免的呢?关键在于基类Quadrangle没有赋值方法,因此Quadrangle不可能用于SmartTest,而只能是Rectangle,因此里氏代换不可能被破坏。
忠告
如果子类不能完整地实现父类的业务(如上面需要完成调整长方形的宽到长时,正方形就难以完成),或者父类的某些方法在子类中已经发生“畸变”,则建议断开父了关系,采用依赖、关联或将两者抽取出共同的接口来代替原来的继承。
依赖倒转原则(DIP)
定义
Dependency inversion principle:
1、高层模块不应该依赖底层模块,两者都应该依赖于抽象层。
2、抽象(接口或抽象类)不应该依赖于细节(实现)。
3、(实现)细节应该依赖于抽象(接口或抽象类)。
最精简的定义就是:针对接口编程,不要针对实现编程。
针对接口编程的意思就是说,应当使用接口和抽象类进行变量的类型声明、参数的类型声明、方法的返回类型声明,以及数据类型转换等;不要针对实现编程的意思是说,不应当使用具体类进行变量的类型声明、参数的类型声明、方法的返回类型声明,以及数据类型转换等。要保证这一点,一个具体类应当只实现接口和抽象类中声明过的方法,而不应当给出多余的方法。
抽象是稳定的,细节是变化的,所以我们应该依赖于抽象。
什么是“倒转”
什么是“倒转”,要想理解它,我们看看正置吧。依赖正置就是类间的依赖是实实在在的实现类间的依赖,也就是面向实现编程,这也是正常人的思维方式,我要开奔驰就依赖奔驰,要使用苹果笔记本就使用苹果笔记本。而编写程序需要的是对现实世界的事物进行抽象,抽象的结果就是有了抽象类和接口,比如宝马与奔驰抽象成小汽车,苹果笔记本与IBM笔记本抽象成笔记本,然后我们的程序就依赖于这些抽象,这代替了人们传统思维中的具体事物间的依赖,“倒转”就是从这里产生的。用图来表示就是这样的:
“倒转”另类理解
在以往我们开发时,为了使常用代码可以复用,一般都会把常用代码写成函数库,这样我们在做新项目时,去调用这些封装好的底层函数就可以了。比如项目大多数要访问数据库,所以我们就把访问数据库的代码写成了函数,封装成方法,每次做新项目时就去调用这些函数。这也就叫做高层模式依赖底层模式。这样问题就可能会出现,有时客户希望使用不同的数据库或存储方式(如Hibernate,jdbc),这时就出现麻烦了。我们希望再次复用这些高层模块,但高层模式都与低层的访问数据库绑定在一起了,没办法复用这些高层模块,这是非常糟糕的。而如果不管高层还是低层模块,它们都依赖于抽象,具体一点就是接口或抽象类,只要接口是稳定的,那么实现的修改不会影响到其他的模块,这就使得无论高层模块还是底层模块都可以很容易地被复用。
这里再举个易懂的例子:电脑的主板与插在上面的内存、CPU、硬盘、显卡、网卡等,主板就像是这里的底层模块,而内存、CPU、硬盘、显卡、网卡等就是高层模块,它们都是以插槽连接的,不论坏了主板还是内存、CPU、硬盘、显卡、网卡,都没有任何关系,把坏了部件换下来就可以了,只在各个厂商生产的部件都遵循标准插槽。所以这里的插槽就像Java里的接口或抽象类一样。
面向对象设计的标志
依赖倒转其实可以说是面向对象设计的标志,用哪种语言来编写程序不重要,如果编写时考虑的都是如何针对抽象编程而不是针对细节编程,即程序中所有的依赖关系都是终止于抽象类或者是接口,那就是面向对象的设计,反之那就是过程化的设计了。
三种耦合关系
依赖即耦合。依赖倒转原则指类与类之间是通过接口或抽象类来耦合。
1、 零耦合:两个类没有耦合关系
2、 具体耦合:具体耦合发生在两个具体的类之间
3、 抽象耦合:抽象耦合关系发生在一个具体和一个抽象类(或者是Java接口)之间
示例:张三开奔驰,就可以开宝马
public class Driver {//司机
public void drive(Benz benz){
benz.run();
}
}
public class Benz {//奔驰
public void run(){
System.out.println("奔驰汽车开始运行...");
}
}
public class Client {//场景
public static void main(String[] args) {
Driver zhangSan = new Driver();
Benz benz = new Benz();
//张三开奔驰车
zhangSan.drive(benz);
}
}
上面的程序好好的,运行时没有问题。但将来某天张三要换一辆宝马怎么办?除非重写Driver的drive方法。由于司机过度依赖于实现(具体就是某个品牌的汽车),这就导致了后期需求变化后难以修改的问题。
现在我们将奔驰与宝马抽象成汽车,当然司机也最好抽象一把也应对可能的变化:
public interface IDriver {//司机接口
//是司机就应该会驾驶汽车
public void drive(ICar car);
}
public interface ICar {//汽车接口
//是汽车就应该能跑
public void run();
}
public class Driver implements IDriver{//司机
//司机的主要职责就是驾驶汽车
public void drive(ICar car){
car.run();
}
}
public class Benz implements ICar{//奔驰
//汽车肯定会跑
public void run(){
System.out.println("奔驰汽车开始运行...");
}
}
现在张三可以开宝马了:
public class Client {
public static void main(String[] args) {
IDriver zhangSan = new Driver();
//ICar benz = new Benz();
ICar bmw = new BMW();
//张三开宝马
zhangSan.drive(bmw);
}
}
组合/聚合复用原则(CARP)
组合/聚合复用原则(Composition/Aggregation Reuse Principle)经常又叫合成复用原则(Composition Reuse Principle 或 CRP)。综是在一个新的对象里使用已有的对象,使之成为新对象的一部分,新的对象通过向这些对象的委派达到复用已有功能的目的。
该原则另一个简短的表述:尽量使用组合/聚合,不要使用继承。
组合/聚合区别
聚合表示一种弱的“拥有”关系,体现的是A对象可以包含B对象,但B对象不是A对象的一部分;
合成则是一种强的“拥有”关系,体现了严格的部分和整体的关系,部分和整体的生命周期一样,一般一个合成的多重性不能超过1,换言这,一个合成关系中的成分对象是不能与另一个合成关系共享的。一个成分对象在同一个时间内只能属于一个合成关系。
组合/聚合复用与继承复用的区别
有两种复用的方式:组合/聚合复用或继承复用。
组合/聚合复用方式可以在运行期内动态的改变,具有很好的灵活性。
继承是在编译时就发生了的,所以无法在运行时改变,没有足够的灵活性。
由于子类会继承父类所有非私有的东西(好比爱她就接受她的一切),如果继承下来的实现不适合解决新的问题,则子类必须重写,这样最终还是限制了复用。
继承会破坏封装特性,因为继承将超类的实现细节暴露给了子类。
如果超类的实现发生改变时,那么子类的实现也会跟着发生改变。
与里氏代换原则区别
“Is-A”是严格的分类学意义上的定义,意思是一个类是另一个类的“一种”。而“Has-A”则不同,它表示某个角色拥有某一项责任。
里氏代换原则表述的是“Is-A”关系,而组合/聚合复用表述的是“Has-A”关系。
类库中的反例
类库有几个明显违反组合/聚合复用原则的例子,其中著名的就是Stack和Properties,一个Stack不是一个Vector,所以Stack应该设计成Vector的子类。同样,Properties与不是一个Hashtable。
由于Properties是Hashtable的子类,因此,客户端可以直接使用超类的行为。但是客户端完全可以通过Hashtable提供的行为加入任意类型的健和值,绕过Properties的接口,并导致Properties的内部矛盾和崩溃。这样看来,Properties其实仅仅是有一些Hashtable的属性罢了,换言这,这是一个“Has-A”的关系,而不是一个“Is-A”。
迪米特法则(LOD)
迪米特法则(Law of Demeter)也称最少知识原则(Least Knowledge Principle,LKP),就是说,一个对象应该对象其他对象有最少的了解。
各种不同的表述:
1、 只与直接的朋友通信。
2、 不要跟“陌生人”说话。
3、 每个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
4、 我的知识(实现细节)你知道得越少越好。
狭义的迪米特法则
狭义的迪米特则要求一个对象仅仅与其朋友发生相互作用。
如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果其中的一个类需要调用另一个类的某个方法的话,可以通过第三者转换。
朋友类的定义
1、 当前对象本身(this)
2、 方法的输入输出参数中的类
3、 成员变量的直接引用对象
4、 集合成员中的元素类
5、 当前对象所创建的对象
不满足迪米特法则的系统
Someone与Friend是朋友,而Friend与Stranger是朋友,从类图也可以清楚的看到这一点:
public class Someone {
//Friend出现在参数中,是朋友
public void operation1(Friend friend){
//Stranger出现在方法,但不是自己创建的,所以为陌生人
Stranger stranger = friend.provide();
stranger.operation3();
}
}
public class Friend {
//Stranger为成员,所以是朋友
private Stranger stranger = new Stranger();
public void operation2(){}
public Stranger provide(){
return stranger;
}
}
显然,Someone的方法operation1()不满足迪米特法则。为什么呢?因为这个方法引用了Stranger对象,而Stranger对象不是Someone的朋友。
使用迪米特法则进行重构
public class Someone {
public void operation1(Friend friend){
//不与陌生人说话,通过朋友转换
friend.forward();
}
}
public class Friend {
private Stranger stranger = new Stranger();
public void operation2(){}
public void forward(){
stranger.operation3();//调用朋友的方法
}
}
这样一来,使得系统内部的耦合度降低,在系统的某个类需要修改时,仅仅会直接影响到这个类的“朋友”们,而不会直接影响到其余的类。
狭义的迪米特法则的缺点
会在系统里产生大量的小方法,散落在系统的各个角落,这些方法仅仅是转发的调用,因此与系统的业务逻辑无关。
与依赖倒转原则互补使用
为了克服狭义的迪米特法则的缺点,可以使用依赖倒转原则将陌生人抽象出来,使它与抽象类发生耦合:
“某人”现在与一个抽象角色建立了朋友关系,这新的好处是“朋友”可以随时将具体“陌生人”换掉。只要新的具体“陌生人”具有相同的抽象类型,那么“某人”就无法区分他们,这就允许“随行人”的具体要实现可以独立于“某人”而变化了。
这样个人认为只是从某种意义上降低耦合,并没有完全消除陌生人。但这样又有一个好外就是减少了大量与业务逻辑无关的小的跳转方法
迪米特法则与模式
门面模式、调停模式都用到了迪米特法则
广义的迪米特法则
广义的迪米特法则是指对信息的隐藏,即封装。在设计时需要注意以下几点:
1、 在类的划分上,应当创建有弱耦合的类。类之间的耦合越弱,就越有利于复用。
2、 在类的结构设计上,每个类都以是降低访问权限,不要公开自己的属性,而是提供访问方法。
3、 在类的设计上,只要有可能,一个类应当设计成不变类。
4、 在对其他类的引用,一个对象对其对象的引用应当降到最低。
广义的迪米特法则在类设计上的体现
1、 优先考虑将一个类设置成不变类。
2、 尽量降低一个类的访问权限。默认的包访问权限类只能从当前类库中访问,我们可以自由地删除一个包私有的类,受到影响的客户端必定都在这个库内部,但public类一旦发布,则不删除。
3、 谨慎使用Serializable。滥用会导致版本不兼容,另外会引起安全漏洞。
4、 尽量降低成员的访问权限。尽量将成员设计成private的或package-private。将一个方法从private或package-private改成protected或public,意味着它的访问权限有了巨大的变化。一旦一个方法被设置成为protected,这个方法就可以被位于另一个库(客户端所在的库)中的子类访问;如果设置成public,那么就会被所有的类访问。这对于提供商来说,需要承诺不能改变这个方法的特征。
5、 取代C Struct。不要声明像C中的Struct的类:
public calss Point{
public int x;
public int y;
}
这是一个退化了的类,因为这个类没有提供数据的封装,这种设计是错误的,因为一旦某天数据结构发生变化,它将不能修改,没有演化的空间了,而最好的做法是将它们设计成private的后再提供相应的访问方法。
广义的迪米特法则在代码层次上的实现
限制局部变量的有效范围。在需要一个变量的时候才声明它,可以有效地限制局部变量的有效范围。
迪米特法则对类的耦合提出了明确的要求,其包含以下几点含义:
1、 只和朋友交流,不要跟陌生人说话(减少对其他类的耦合,不是什么人都能做你的朋友)
实例:老师叫体育委员清(亲)一下全班女生。
public class Teacher {//老师
//老师给体育委员发布命令:清一下女生
public void commond(GroupLeader groupLeader){// 这里GroupLeader是唯一的朋友
//List是陌生人,但属于调用类库,这是编程最基本的要求
List<Girl> listGirls = new ArrayList();//Girl是陌生人,不应该与她交流
//初始化女生
for(int i=0;i<20;i++){
listGirls.add(new Girl());
}
//上面的细节完全不用关注
//告诉体育委员开始执行清查任务
groupLeader.countGirls(listGirls);
}
}
public class GroupLeader {//体育委员
//有清查女生的工作
public void countGirls(List<Girl> listGirls){
System.out.println("女生数量是:"+listGirls.size());
}
}
public class Girl {//女生}
public class Client {//场景
public static void main(String[] args) {
Teacher teacher= new Teacher();
//老师发布命令
teacher.commond(new GroupLeader());
}
}
下面去掉Teacher对Girl的依赖关系:
源码修改如下:
public class Teacher {//老师
//老师对学生发布命令,清一下女生
public void commond(GroupLeader groupLeader){
//告诉体育委员开始执行清查任务
groupLeader.countGirls();
}
}
public class GroupLeader {//体育委员
private List<Girl> listGirls;
//传递全班的女生进来
public GroupLeader(List<Girl> _listGirls){
this.listGirls = _listGirls;
}
//有清查女生的工作
public void countGirls(){
System.out.println("女生数量是:"+this.listGirls.size());
}
}
public class Client {//场景类
public static void main(String[] args) {
//产生一个女生群体
List<Girl> listGirls = new ArrayList<Girl>();
//初始化女生
for(int i=0;i<20;i++){
listGirls.add(new Girl());
}
Teacher teacher= new Teacher();
//老师发布命令
teacher.commond(new GroupLeader(listGirls));
}
}
类与类之间的关系是建立在类之间的,而不是在方法中,因此一个方法尽量不引入非本类朋友的类,当然,JDK API提供的类除外。
2、 朋友之间要保持一定的距离(减少与朋友类的过度耦合)
实例:软件安装向导。
//按照步骤执行的业务逻辑类
public class Wizard {
private Random rand = new Random(System.currentTimeMillis());
//第一步
public int first(){
System.out.println("执行第一个方法...");
return rand.nextInt(100);
}
//第二步
public int second(){
System.out.println("执行第二个方法...");
return rand.nextInt(100);
}
//第三个方法
public int third(){
System.out.println("执行第三个方法...");
return rand.nextInt(100);
}
}
//业务组装类,负责调用各个步骤
public class InstallSoftware {
public void installWizard(Wizard wizard){
int first = wizard.first(); //调用第一步
//根据first返回的结果,看是否需要执行second
if(first>50){
int second = wizard.second();//如果第一步成员,进行第二步安装
if(second>50){
int third = wizard.third();//如果第一步成员,进行第三步安装
}
}
}
}
分析:Wizard把太多的方法暴露给InstallSoftware类,两者的朋友关系太近了,耦合关系变得非常紧。如果要将Wizard类中的first方法返回值的类型则int改为boolean,就需要修改InstallSoftware类,从而把修改变更的风险扩散开了。下面我们对它进行重构,只提供了个public方法installWizard,由这个方法实现安装步骤调用的细节,InstallSoftware只需直接调用Wizard的installWizard方法即可,重构后的类图:
一个类公开的public属性或方法越多,修改时涉及的面积也就越大,变更引起的风险扩散也就越大。因此我们在定义方法与变量时,尽量首先定义为privte类型,如果不行再开始一级级向上开放。
尽量不要对象外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、package-private、protected访问权限。这样提高类的内部高内聚性,同时降低了类之间的耦合性。
3、 是自己的就是自己的
在实际的应用会经常出现这样的一个方法:放在本类也可,放在其他类中也没有错,那怎么决定呢?原则:如果一个方法入在本类中后,并没有增加与其他类间的依赖关系,也对本类没产生负面影响,就放在本类中吧。
最佳实践:
迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性抽提高,同时降低了可维护性。所以用进一定要衡量,既做到让结构清晰,又要做到高内聚低耦合。
如果一个类跳转两次以上才能访问到另一个类,就需要想办法进行重构了,为什么是两次以上呢?因为一个系统的成功不仅仅是一个标准或是原则就能决定的,有非常多的外在因素决定,跳转次数越多,系统越复杂,所以只要跳转不超过两次都是可以忍受的,这需要具体问题具体分析。
不遵守原则是不对的,严格执行就是“过犹不及”。
显然,遵守接口隔离原则与迪类特法则,会使一个软件系统在功能扩展的过程当中,不会将修改的压力传递到其他的对象。
接口隔离原则(ISP)
接口隔离原则讲的是:使用多个专门的接口比使用一个总的接口要好。换言这,从一个客户类角度来看,一个类对另外一个类的依赖性应当是建立的在最小的接口之上。
定义
Interface Segregation principle:应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口,即提供职责单一的接口。
1、 客户端不应该被强迫去依赖于它们不需要的接口。
2、 类间的依赖关系应该建立在最小的接口上。
第一点讲的是,客户端需要什么接口我们就提供什么接口,把不需要的接口剔除掉,那就需要对接口进行细化,保证其纯洁性。第二点其实与第一点表达的主旨是一样的,只是一个事物的两种不同描述。
与单一职责原则的不同
上面两点可以概括为一句话:建立专门的接口,不要建立臃肿庞大的接口,或更进一步讲:接口尽量细化,同时接口中的方法尽量少。这与单一职责原则不是相同吗?错,接口隔离原则与单一职责原则的审视角度是不相同的,单一职责要求的是类和接口的职责单一,即功能单一,注重的是“功能”,它是从功能上的划分;而接口隔离原则是从“服务”的角度来看的,它要求的是“服务”专一,是从服务的角度来划分的。
假如现在有很多个个方法,包括m1、m2、p1、p2等等,它们都是完成同一功能职责,所以Service接口从职责的角度来看是允许的。但是Client1现在只需要或只允许使用m1、p2两个方法,Client2只需要或只允许使用m2、p2两个方法,Client3只需要或只允许使用m1、p1、p2,那么我们就不应该将Service接口提供给客户端,而需要创建专门的接口来为它们提供专一的服务如下图如示:
上图中如果Service提给不同的客户端时,由于提供的扣口庞大臃肿,会造成接口污染。
接口是一个系统里的业务表现,只有深入了解业务逻辑,最好的接口自然会出自你的手。
根据经验和常识决定接口的粒度大小,接口粒度太小,导致接口数据剧增,太大,灵活性降低,无法提供定制服务。
[JAVA设计模式]第一部分:接口、抽象类、设计原则