文章目录
- 原则一:单一职责原则(Single Responsibility Principle SRP)
- 原则二:里氏替换原则(Liskov Substitution Principle LSP)
- 何时使用里氏替换原则
- 原则三:依赖倒置原则(Dependence Inversion Principle)
- 为什么需要依赖倒置:
- 总结
- 原则四:接口隔离原则
- 原则五:迪米特法则(Law of Demeter, LoD)
- 原则六:开闭原则
- 开闭原则的重要性
- 为什么难以坚持使用设计原则
- 学习成本高
- 短期效率错觉
- 过度设计担忧
- 总结
原则一:单一职责原则(Single Responsibility Principle SRP)
定义:应该有且仅有一个原因引起类的变更
好处:
1.类的复杂性降低,实现什么职都有清晰明确的定义;
2.可读性高,负责性降低,当然可读性就提高了;
3.可维护性提高,可读性提高,自然就更容易维护了;
4.变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性。维护性都有非常大的帮助。
难点:职责的划分。
注意:单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计的是否优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。
对于接口,再设计的时候一定要做到单一,但是对于实现类就需要多方面的考虑了。生搬硬套单一职责原则会引起类的剧增,给维护带来非常多的麻烦,而且过分细分类的职责也会人为的增加系统的复杂性。本来可以一个类实现的行为硬要拆成两个类,然后再使用聚合或者组合的方式耦合在一起,人为制造了系统的复杂性。
注意:单一职责适用于接口、类,同时也适用于方法,即一个方法尽可能只做一件事。
建议:接口一定做到单一职责,类的设计尽量做到只有一个原因引起变化。
例子:在基于角色的访问控制中,用户管理、修改用户信息、增加机构、增加角色等,我们将这些写到一个接口。
原则二:里氏替换原则(Liskov Substitution Principle LSP)
定义:父类的对象可以被子类的对象替换,而程序的行为不会发生变化。也就是说,如果一个类型A是另一个类型B的子类型,那么在任何使用B的地方都可以使用A,而不会引起错误或异常。
继承机制的优点:
- 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
- 提高代码的重用性;
- 子类可以形似父类,但是又异于父类;
- 提高代码的可扩展性,例如开源框架扩展接口都是通过继承父类来完成了;
- 提高产品或者项目的开放性。
继承机制的缺点: - 继承是侵入性的,只要有继承,就必须拥有父类的属性和方法;
- 降低代码的灵活性。子类必须拥有父类的方法和属性,让子类自由的世界中多了很多约束;
- 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的情况下,这种修改可能带来非常糟糕的结果–大量代码需要重构。
何时使用里氏替换原则
- 当需要编写基类或抽象类时:应该尽可能地遵循里氏替换原则,以保证后续的子类能够正确地继承和使用基类的接口或者抽象类的方法。
- 当需要对已有的代码进行重构时:我们可以通过遵循里氏替换原则,使得代码更加易于理解、扩展和维护。通过将某些动态绑定的行为转化为静态绑定的行为,可以降低代码的复杂度并增强其可控性。
- 当需要进行单元测试或集成测试时:我们可以使用子类对象来替换父类对象,以确保测试结果的准确性。如果使用子类对象无法替换相应的父类对象,则表示可能存在设计上的问题,需要进一步优化。
- 当需要扩展系统的功能时:我们应该尽可能地遵循里氏替换原则,以确保新的组件能够与现有的组件正常协作。通过使用基类或抽象类来定义接口,可以使得组件之间的耦合度更低。
里氏替换原则为良好的继承定义了一个规范,定义包含了四层含义:
1 子类必须完全实现父类的方法
假设:在上述基础上增加本地服务商品,用户去店铺或上门的本地生活,类图如下 :
但是很可惜不需要减库存,需要将服务品与带库存的隔离开,类图如下:
注意:如果子类不能完整的实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议父子类断开继承关系,采用依赖、聚类、组合等关系代替继承。
注意:在类中调用其他类时务必要使用父类或者接口,如果不能使用父类或者接口,则说明类的设计已经违背LSP原则。
2 子类可以有自己的个性
在上述类图的基础上,给商品子类增加自己特有的方法和属性,类图如下:
3 覆盖或者实现父类的方法时输入参数可以被放大,注意子类的参数
父类
public class Product { public Collection doSomething(HashMap map){ System.out.println("父类被执行........"); return map.values(); }
}子类
public class CardProduct extends Product{ public Collection doSomething(Map map){ System.out.println("子类被执行........."); return map.values(); }
}
public class Client { public static void main(String[] args) { Product f=new Product(); HashMap map=new HashMap(); f.doSomething(map); CardProduct s=new CardProduct(); HashMap map=new HashMap(); s.doSomething(map); }
}
输出:
父类被执行........
父类被执行........
里氏替换前后运行结果一致,父类中方法的参数为HashMap类型,子类的参数是Map类型,即子类的参数范围扩大,子类代替父类传递到调用者,子类的方法永远不会执行。反之,如果父类的参数类型比子类的参数类型范围大,则父类存在的地方,子类未必能够存在,因为一旦将子类作为参数传入,调用者就有可能进入子类的方法范畴
因此,子类方法中的前置条件必须与超类中被重载的方法的前置条件更加宽松或相同。
4 覆写或实现父类的方法时输出的结果可能被缩小。
假设父类的某个方法返回类型T,子类的相同方法(重载或者覆写)的返回值为S,那么历史替换原则要求S必须小于等于T。如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这是重中之重,子类覆写父类的方法,天经地义。如果是重载,则要求方法的输入类型或者数量不同,在里氏替换原则要求下,就是子类的输入参数大于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的。
原则三:依赖倒置原则(Dependence Inversion Principle)
含义:
(1) 高层模块不应该依赖低层模块,两者都应该依赖其抽象;
(2) 抽象不应该依赖细节;
(3) 细节应该依赖抽象。
理解:高层模块和低层模块好理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子模块就是低层模块,原子模块再组装就是高层模块。在Java中抽象就是指接口或者抽象类,二者都不能直接被实例化;细节就是类的实现,实现接口或者继承抽象类而产生的类就是细节,可以被直接实例化,即可以加上关键字new产生一个对象。
依赖倒置原则在Java中的表现:
(1) 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过借口或者抽象产生的;
(2) 接口或抽象不依赖于实现类;
(3) 实现类依赖接口或者抽象类。
依赖倒置反命题:不使用依赖倒置也可以减少类间耦合,提高系统稳定性,降低并行开发风险,提高代码的可读性可可维护性。
为什么需要依赖倒置:
设计是否具备稳定性,只要适当的“松松土”,观察“设计的蓝图”是否可以茁壮的成长就可以得出结论,稳定性较高的设计,在周围环境频繁变换的时候,依然可以做到“我自岿然不动”。
接着证明“减少并行开发的风险”,并行开发最大的风险就是风险扩散,本来只是一段程序的错误或者异常,逐步波及一个功能,一个模块,最后毁了整个项目。例如,一个团队各人负责不同的模块,甲负责汽车类,乙负责司机类,在甲没有完成的情况下,乙是不能完全的编写代码的。在这种不使用依赖倒置原则的环境,所有的开发都是“单线程”的,甲做完乙做,乙做完丙做…
以上足以证明如果不使用依赖倒置原则就会加重类间耦合,降低系统稳定性,增加并行开发的风险,降低代码的可读性和可维护性。
//实体商品类:
public class PhysicalProduct { public void run(){ System.out.println("实体商品发布......."); }
}//店铺类:
public class Shop { public void publish(PhysicalProduct product){ product.run(); }
}//场景类:
public class Client { public static void main(String[] args) { Shop zhangSan=new Shop(); PhysicalProduct product=new PhysicalProduct(); zhangSan.publish(product); }
}//运行结果
//实体商品发布.......
可以发现,上述代码成功运行起来,但是当张三不仅需要发布实体商品进行有机,还需要发布卡虚拟商品时,上述代码不能很好的实现,店铺类和商品类之间是紧耦合关系,其导致的结果就是系统的可维护性大大降低。
使用依赖倒置的例子:
//店铺接口
public interface IShop { public void publish(IProduct product);
}//店铺类
public class Shop implements IShop { public void publish(IProduct product){ product.run(); }
}
//商品接口
public interface IProduct { public void run();
}//实物类
public class PhysicalProduct implements IProduct{ public void run(){ System.out.println("实物商品发布成功......."); }
}//虚拟卡商品
public class CardProduct implements IProduct { public void run(){ System.out.println("虚拟卡商品发布成功....."); }
}//场景一
public class Client { public static void main(String[] args) { IShop zhangSan=new Shop(); IProduct product1=new PhysicalProduct(); zhangSan.publish(product1); IProduct cardProduct=new CardProduct(); zhangSan.publish(cardProduct);}
}
//实物商品发布成功.......
//虚拟卡商品发布成功.....
注意:Java中只要定义变量就必然有类型,一个变量可以有两个类型:表面类型和实际类型,表面类型是在定义的时候赋予的类型,实际类型是对象的类型,如zhangSan表面类型是IShop,实际类型是Shop。
总结
依赖倒置的本质是通过抽象(接口或者抽象类)使各个领域或模块的实现彼此独立,互不影响,实现模块间的松耦合。
1.每个类尽量有接口或抽象类,或者抽象类和接口两者都具备(基本要求);
2.变量的表面类型尽量是接口或者抽象类;
3.任何类都不应该从具体类派生,一般具体项目中只要不超过两层的继承都是可以忍受的;
4.尽量不要覆写基类的方法,如果基类是一个抽象类,而这个方法已经实现,子类尽量不要覆写,类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响;
5.结合里氏替换原则使用,接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化。
原则四:接口隔离原则
两种接口:
- 实例接口,在Java中通过new关键字产生的一个实例,它是对一个类型的事物的描述;
- 类接口,在Java中使用interface关键字定义的接口。
两种隔离: - 客户端不应该依赖它不需要的接口
- 类间的依赖关系应该建立在最小的接口上
总结:建立单一接口,不要建立臃肿庞大的接口。接口尽量细化,同时接口中的方法尽量少。
接口隔离与单一职责的区别:接口隔离原则与单一职责原则的审视角度不同,单一职责原则要求的是类和接口的职责单一,注重的是职责,这是业务逻辑上的划分,而接口隔离原则要求接口的方法尽量少。
美女接口
public interface IPettyGirl { public void goodLooking(); public void niceFigure(); public void greatTemperament();
}美女类
public class PettyGirl implements IPettyGirl{ private String name; public PettyGirl(String _name){ this.name=_name; } public void goodLooking(){ System.out.println(this.name+"--脸蛋漂亮"); } public void greatTemperament(){ System.out.println(this.name+"--气质好"); } public void niceFigure(){ System.out.println(this.name+"--身材好"); }
}
抽象星探类
public abstract class AbstractSeacher { protected IPettyGirl pettyGirl; public AbstractSeacher(IPettyGirl _pettyGirl){ this.pettyGirl=_pettyGirl; } public abstract void show();
}星探类
public class Seacher extends AbstractSeacher{ public Seacher(IPettyGirl _pettyGirl){ super(_pettyGirl); } public void show(){ System.out.println("信息如下"); super.pettyGirl.goodLooking(); super.pettyGirl.niceFigure(); super.pettyGirl.greatTemperament(); }
}
场景类
public class Client { public static void main(String[] args) { // TODO Auto-generated method stubIPettyGirl yanZi=new PettyGirl("燕子"); AbstractSeacher seacher=new Seacher(yanZi); seacher.show(); }
}//结果
信息如下
燕子--脸蛋漂亮
燕子--身材好
燕子--气质好
人们审美的观点在不断变化,可以发现当审美条件发生变化时,IPettyGirl的设计是有缺陷的,过于庞大,容纳了一些可变因素,根据接口隔离原则,星探AbstractSeacher应该依赖于具有部分特质的女孩子,而我们却将这些特质全部封装了起来,放到了一个接口中,造成封装过渡。
改进:将IPettyGirl拆分为两个接口
两种美女接口
public interface IGoodBodyGirl { public void goodLooking(); public void niceFigure();
}
public interface IGreatTemperamentGirl { public void greatTemperamentGirl();
}美女类
public class PettyGirl implements IGoodBodyGirl,IGreatTemperamentGirl{ private String name; public PettyGirl(String _name){ this.name=_name; } publicvoid goodLooking(){ System.out.println(this.name+"--脸蛋漂亮"); } publicvoid greatTemperament(){ System.out.println(this.name+"--气质好"); } publicvoid niceFigure(){ System.out.println(this.name+"--身材好"); }
}
接口隔离原则的4层含义:
- 接口要尽量最小,这是接口隔离原则的核心定义,但是“小”是有限度的,不能违反单一职责原则;
- 接口要高内聚,提高接口、类、模块的处理能力,减少对外的交互,具体就是要求在接口中尽量少用public方法,接口是对外承诺的,承诺越少对系统的开发越有利,变更的风险越小,同时也有利于降低成本;
- 定制服务,即单独为一个个体提供优良的服务,我们在系统设计时也需要考虑对系统之间或模块之间的接口采用定制服务,只提供访问者需要的方法;
- 接口设计是有限度的,接口粒度越小,系统越灵活,但是灵活的同时也带来了结构的复杂化,开发难度增加,可维护性降低。
原则五:迪米特法则(Law of Demeter, LoD)
迪米特法则(Law of Demeter)也成最少知识原则(Least Knowledge Principle):一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,只知道其public方法,其余一概不关心。
总结:迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才会提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也给维护带来了难度。在使用时要做到反复权衡,既要做到结构清晰,又做到高内聚低耦合。
原则六:开闭原则
开闭原则定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭
理解:软件实体应该对扩展开放,对修改关闭,其含义是说一个软件实体应该是通过扩展来实现变化,而不是通过修改已有的代码来实现变化。
软件实体包括:
- 项目或软件产品中按照一定的逻辑规则划分的模块;
- 抽象和类;
- 方法。
书籍接口
public interface IBook { public String getName(); publicint getPrice(); public String getAuthor();
}
小说类
public class NovelBook implements IBook{ private String name; privateint price; private String author; public NovelBook(String _name,int _price,String _author){ this.name=_name; this.price=_price; this.author=_author; } public String getAuthor(){ returnthis.author; } public String getName(){ returnthis.name; } publicint getPrice(){ returnthis.price; }
}书店类
public class BookStore { private final static ArrayList<IBook> bookList=new ArrayList<IBook>(); static{ bookList.add(new NovelBook("天龙八部",3200,"金庸")); bookList.add(new NovelBook("巴黎圣母院",5600,"y")); bookList.add(new NovelBook("悲惨世界",3500,"雨果")); bookList.add(new NovelBook("金瓶梅",4300,"兰陵笑笑生")); } publicstaticvoid main(String[] args) { System.out.println("卖书记录如下"); for(IBook book:bookList){ System.out.println("书籍名称:"+book.getName()+"\t书籍作者:"+ book.getAuthor()+"\t书籍价格:"+formatter.format(book.getPrice()/100.0)+"元"); } }
}
上述代码正常运行,但是当书籍需要打折出售时,书籍类需要作出变化,一般有如下三种方法解决此类问题:
- 修改接口,在IBook上增加一个getOffPrice()方法,专门进行打折处理。但是这样处理,NovelBook和BookStore都需要修改;
- 修改实现类,直接在NovelBook中getPrice()方法中实现打折处理,但是这样仍是有缺陷的;
- 通过扩展类实现变化,增加一个子类offNovelBook,覆写getPrice()方法,修改少,风险小。
打折小说类
public class offNovelBook extends NovelBook{ public offNovelBook(String _name,int _price,String _author){ super(_name,_price,_author); } public int getPrice(){ int selfPrice=super.getPrice(); int offPrice=0; if(selfPrice>4000){ offPrice=selfPrice*90/100; }else{ offPrice=selfPrice*80/100; } return offPrice; }
}书店类
public class BookStore { private final static ArrayList<IBook> bookList=new ArrayList<IBook>(); static{ bookList.add(new offNovelBook("天龙八部",3200,"金庸")); bookList.add(new offNovelBook("巴黎圣母院",5600,"y")); bookList.add(new offNovelBook("悲惨世界",3500,"雨果")); bookList.add(new offNovelBook("金瓶梅",4300,"兰陵笑笑生")); } public static void main(String[] args) { System.out.println("卖书记录如下"); for(IBook book:bookList){ System.out.println("书籍名称:"+book.getName()+"\t书籍作者:"+ book.getAuthor()+"\t书籍价格:"+formatter.format(book.getPrice()/100.0)+"元"); } }
}
开闭原则的重要性
开闭原则非常著名,只要是面向对象编程,不管是什么编程语言,开发时都会提及开闭原则。另外,开闭原则是最基础的一个原则,前五个原则都是开闭原则的具体形态,也就是说前五个原则就是指导设计的工具和方法,而开闭原则才是精神领袖。
- 开闭原则方便测试
- 开闭原则可以提高复用性
- 开闭原则可以提高可维护性
- 面向对象开发的要求
为什么难以坚持使用设计原则
学习成本高
设计原则涉及到抽象的概念和复杂的逻辑关系。对于新手程序员来说,理解这些原则本身就需要花费大量的时间和精力。例如,依赖倒置原则要求程序员改变固有的思维方式,从具体的实现细节中抽象出接口,这对于编程经验较少的人来说是有难度的。
短期效率错觉
在项目初期或者小型项目中,不遵循设计原则可能看起来效率更高。因为直接编写代码实现功能会更快,不需要花费时间去考虑设计模式和原则。例如,简单地将所有功能都写在一个类中,在功能简单且不考虑后续扩展和维护的情况下,似乎能够快速完成任务。
过度设计担忧
有些程序员担心过度应用设计原则会导致过度设计。他们认为如果项目需求简单,而过度使用复杂的设计模式和原则,会使代码变得臃肿和难以理解。例如,在一个简单的命令行工具项目中,如果强行应用多种设计模式,可能会使原本简单的代码变得复杂,增加不必要的代码量和维护成本。
总结
单一职责原则:一个类应该只有一个引起它变化的原因。也就是说,一个类只负责一项职责。
开闭原则:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。
里氏替换原则:所有引用基类(父类)的地方必须能透明地使用其子类的对象。
依赖倒置原则:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
接口隔离原则:客户端不应该被迫依赖于它不使用的接口;一个类对另一个类的依赖应该建立在最小的接口上。
迪米特法则:一个对象应该对其他对象有最少的了解。也叫最少知识原则。