开闭原则
开闭原则指的是一个软件实体如类、模块、和函数应该对扩展开放、对修改关闭。所谓的开闭,指的是对扩展和修改两个行为的一个原则。强调的是用抽象构建框架,用实现扩展细节。可以提高软件系统的可复用性和可维护性。它指导我们如何建立灵活稳定的系统。
实现开闭原则的核心思想就是面向抽象编程。
举例:弹性工作时间就是一个例子,它对每天工作8小时是关闭的、对几点来几点走是开放的。
下面以课程为例,首先建立一个课程接口ICourse:
public interface ICourse {
Integer getId();
String getName();
Double getPrice();
}
现在来建立一个Java类:
public class JavaCourse implements ICourse{
private Integer id;
private String name;
private Double price;
public JavaCourse(Integer id, String name, Double price) {
this.id = id;
this.name = name;
this.price = price;
}
@Override
public Integer getId() {
return this.id;
}
@Override
public String getName() {
return this.name;
}
@Override
public Double getPrice() {
return this.price;
}
}
这时,我们要给Java课程做一个价格优惠的活动,此时,如果我们修改JavaCourse 里的getPrice()则有可能会影响其他地方的调用。那么要如何在不修改源代码的同时实现价格优惠的功能呢?现在,再写一个处理优惠的类,JavaDiscountCourse类。
public class JavaDiscountCourse extends JavaCourse{
public JavaDiscountCourse(Integer id, String name, Double price) {
super(id, name, price);
}
public Double getOrigenPrice() {
return super.getPrice();
}
public Double getPrice() {
return super.getPrice() * 0.16;
}
}
依赖倒置原则
是指在设计代码结构时,高层模块不应该依赖底层模块,二者都应该依赖其抽象。抽象不应该依赖细节,细节应该依赖抽象。
通过依赖倒置,可以减少类与类之间的耦合性,提高系统稳定性,提高代码的可读性和可维护性,降低修改程序做造成的风险。
以课程为例,建立一个Tom类:
public class Tom {
public void studyJava() {
System.out.println("Tom正在学习Java");
}
public void studyAI() {
System.out.println("Tom正在学习AI");
}
}
新建一个调用类:
public class DependenceInversionTest {
public static void main(String[] args) {
Tom tom = new Tom();
tom.studyAI();
tom.studyJava();
}
}
倘若现在Tom还想学习大数据,这个时候,我们就需要从底层到高层(调用层)依次进行修改。如此一来,系统发布以后是非常不稳定的,在修改代码的同时也会带来未知的风险。接下来,我们优化代码,创建一个课程的抽象接口:
public interface ICourse {
void study();
}
新建一个JavaCourse类:
public class JavaCourse implements ICourse{
@Override
public void study() {
System.out.println("Tom 正在学习Java");
}
}
新建一个AICourse类:
public class AICourse implements ICourse{
@Override
public void study() {
System.out.println("Tom正在学习AI");
}
}
修改Tom类:
public class Tom {
public void study(ICourse course) {
course.study();
}
}
来看调用:
public static void main(String[] args) {
Tom tom = new Tom();
tom.study(new AICourse());
tom.study(new JavaCourse());
}
现在,不论Tom想要学习任何新课程,我们只需要新建一个类,通过传参的方式告诉Tom,不需要修改底层代码,就可以实现。这也叫依赖注入。注入的方式还有构造器方式和Setter方式。
- 构造器方式注入:
修改Tom类:
public class Tom {
private ICourse iCourse;
public Tom(ICourse iCourse) {
this.iCourse = iCourse;
}
public void study() {
iCourse.study();
}
}
修改调用类:
public static void main(String[] args) {
Tom tom = new Tom(new JavaCourse());
tom.study();
Tom tom2 = new Tom(new AICourse());
tom2.study();
}
可以看出:构造器的注入方式需要每次都创建一个实例,如果Tom是全局单例的话,我们就只能使用Setter的方式注入了
2. Setter方式注入:
修改Tom类:
public class Tom {
private ICourse iCourse;
public void setICourse(ICourse iCourse) {
this.iCourse = iCourse;
}
public void study() {
iCourse.study();
}
}
修改调用类:
public static void main(String[] args) {
Tom tom = new Tom();
tom.setICourse(new JavaCourse());
tom.study();
tom.setICourse(new AICourse());
tom.study();
}
以抽象为基准要比以细节为基准搭建起来的架构要稳定得多,因此拿到需求后,要面向接口编程,先顶层再细节来设计代码结构。
单一职责原则
指的是不要存在多于一个导致类变更的原因。
假设我们有一个Class负责两个职责,一旦发生需求变更,修改其中一个职责的逻辑代码,有可能会导致另一个职责的功能发生故障。这样一来,这个Class存在2个导致类变更的原因。如此,我们就要给两个职责分别用两个Class来实现,进行解耦。后期需求变更维护互不影响。
这样的设计,可以降低类的复杂度,提高类的可读性,提高系统的可维护性,降低变更引起的风险。
总的来说就是,一个Class\Interface\Method只负责一项职责。
- Class层面的单一职责设计
还是以课程为例:
public class ICourse {
public void study(String courseName) {
if("直播".equals(courseName)) {
System.out.println(courseName+"不能快进");
}else {
System.out.println(courseName+"随意");
}
}
}
代码调用:
public static void main(String[] args) {
ICourse iCourse = new ICourse();
iCourse.study("直播");
iCourse.study("重播");
}
从上面来看,ICourse 承担了两种处理逻辑。如果我们现在要对课程进行加密,两种课程的加密逻辑是不一样的,这样的话就肯定要改代码,而修改代码就会相互影响造成不可控的风险。因此,对职责进行分离,分别创建两个类LiveCourse和ReplayCourse。
public class LiveCourse {
public void study(String courseName) {
System.out.println(courseName + "不能快进");
}
}
public class ReplayCourse {
public void study(String courseName) {
System.out.println(courseName + "随意");
}
}
修改调用类:
public static void main(String[] args) {
LiveCourse liveCourse = new LiveCourse();
liveCourse.study("直播");
ReplayCourse replayCourse = new ReplayCourse ();
replayCourse .study("重播");
}
- Interface 层面的单一职责设计
业务继续发展,课程需要加权限,未交费的学员只可以获取课程基本信息,已经交费的学员可以获取视频信息。那么对于控制课程层面上至少有两个职责。我们可以把展示职责和管理职责分离开来,都实现同一个抽象依赖。我们可以设计一个顶层接口,创建ICourse接口。
public interface ICourse {
//获得基本信息
String getCourseName();
//获得视频流
byte[] getCourseVideo();
//学习课程
void studyCourse();
//退款
void refundCourse();
}
我们可以把这个接口拆成两个接口,创建一个接口ICourseInfo和ICourseManager:
ICourseInfo接口:
public interface ICourseInfo {
String getCourseName();
byte[] getCourseVideo();
}
ICourseManager接口:
public interface ICourseManager {
void studyCourse();
void refundCourse();
}
- 方法层面的单一职责设计
通常我们会将一个方法写成如下所示:
private void modifyUserInfo(String userName,String address) {
userName = "Tom";
address = "Changsha";
}
显然,上面的modifyUserInfo方法承担了多个职责,既可以修改userName,又可以修改address,甚至更多,明显不符合单一职责原则。因此,我们将该方法拆分成两个:
private void modifyUserName(String userName){
userName = "Tom";
}
private void modifyAddress(String address){
address = "Changsha";
}
我们在编写代码的过程中,不可控因素太多,很多都不符合职责单一原则。但是在编写的过程中,还是要尽量让接口和方法保持单一职责,这样对项目后期的维护是会有很大的帮助的。
接口隔离原则
是指使用多个专门的接口,而不是单一的总接口。客户端不应该依赖它不需要的接口。该原则指导我们在设计接口时应该注意以下几点:
- 一个类对类的依赖应该建立在最小的接口之上。
- 建立单一接口,不要建立庞大臃肿的接口。
- 尽量细化接口,接口中的方法尽量少(不是越少越好,一定要适度)。
接口隔离原则符合我们所说的高内聚低耦合的设计思想,使得类具有很好的可读性、可扩展性、可维护性。在设计接口的时候,要多花时间思考业务模型,以及能对将来可能出现变更的地方做出预判。所以,对于抽象,对业务模型的理解是非常重要的。
下面以动物行为的抽象为例:
public interface Animal {
void eat();
void fly();
void swim();
}
Bird类实现:
public class Bird implements Animal{
@Override
public void eat() {
}
@Override
public void fly() {
}
@Override
public void swim() {
}
}
Dog类实现:
public class Dog implements Animal{
@Override
public void eat() {
}
@Override
public void fly() {
}
@Override
public void swim() {
}
}
在上述的实现中,对于Bird 来说,swim()方法是永远也用不上的,同样,对于Dog来说,fly()方法也是永远用不上的。这时候,我们就需要为不同的动物设计不同的接口。分别设计 IEatAnimal,IFlyAnimal 和ISwimAnimal接口,来看代码:
IEatAnimal接口:
public interface IEatAnimal {
void eat();
}
IFlyAnimal 接口:
public interface IFlyAnimal {
void fly();
}
ISwimAnimal接口:
public interface ISwimAnimal{
void swim();
}
Dog只实现IEatAnimal和ISwimAnimal接口:
public class Dog implements IEatAnimal,ISwimAnimal{
@Override
public void swim() {
// TODO Auto-generated method stub
}
@Override
public void eat() {
// TODO Auto-generated method stub
}
}
迪米特法则
是指一个对象应该对其他对象保持最少的了解,又叫最少知道原则。尽量降低类与类之间的耦合。迪米特原则主要强调只和朋友交流,不与陌生人说话。出现在成员变量、方法的输入参数、输出参数中的类都可以称之为朋友,而出现在方法体内部的类不属于朋友类。
业务场景:
现在,Boss需要统计全部已上线的课程数量,这时候,Boss只需要找到TeamLeader去统计,TeamLeader将统计好的结果返回给Boss即可。
以代码为例:
Course类:
public class Course {
}
TeamLeader类:
public class TeamLeader {
public void checkNumberOfCourses(List<Course> courseList) {
System.out.println("目前已发布的课程数量是:"+courseList.size());
}
}
Boss类:
public class Boss {
public void commandCheckNumber(TeamLeader teamLeader) {
List<Course> courseList = new ArrayList<>();
//模拟 Boss 一页一页往下翻页,TeamLeader 实时统计
for(int i=0;i<20;i++) {
courseList.add(new Course());
}
teamLeader.checkNumberOfCourses(courseList);
}
}
测试代码:
public static void main(String[] args) {
Boss boss = new Boss();
TeamLeader teamLeader = new TeamLeader();
boss.commandCheckNumber(teamLeader);
}
根据迪米特原则,Boss只需要和TeamLeader交流。它和Course 并不是朋友,不需要和它直接交流,TeamLeader统计需要用到Course 。
现在将其改造成符合迪米特法则的代码:
TeamLeader类:
public class TeamLeader {
public void checkNumberOfCourses() {
List<Course> courseList = new ArrayList<>();
//模拟 Boss 一页一页往下翻页,TeamLeader 实时统计
for(int i=0;i<20;i++) {
courseList.add(new Course());
}
System.out.println("目前已发布的课程数量是:"+courseList.size());
}
}
Boss类:
public class Boss {
public void commandCheckNumber(TeamLeader teamLeader) {
teamLeader.checkNumberOfCourses();
}
}
测试代码:
public class LawOfDemeterTest {
public static void main(String[] args) {
Boss boss = new Boss();
TeamLeader teamLeader = new TeamLeader();
boss.commandCheckNumber(teamLeader);
}
}
现在再看,Boss和Course 就没有关联了,符合迪米特法则。
合成复用原则
是指尽量使用对象组合\聚合,而不是继承关系达到软件复用的目的。可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少。
继承我们叫白箱复用,相当于把所有的实现细节暴露给子类。组合\聚合也叫黑箱复用,对类以外的对象是无法获取到细节的。
以数据库操作为例:
public class DBConnection {
public String getDBConnection() {
return "Mysql数据库连接";
}
}
新建一个ProductDao类:
public class ProductDao {
private DBConnection dBConnection;
public void setDBConnection(DBConnection dBConnection) {
this.dBConnection = dBConnection;
}
public void addProduct() {
String cocc = dBConnection.getDBConnection();
System.out.println("使用"+cocc+"生成产品");
}
}
测试代码:
public static void main(String[] args) {
ProductDao productDao = new ProductDao();
productDao.setDBConnection(new DBConnection());
productDao.addProduct();
}
这是一种非常典型的合成复用原则应用场景。但是,目前的设计来说,DBConnection 不是抽象,不便于系统扩展。目前的系统仅支持Mysql,但是如果我们也想要让其支持Oracle怎么办呢?可以在DBConnection 类中增加对Oracle支持的方法,但是如此一来,就违背了开闭原则。其实,无需修改ProductDao ,我们只需将DBConnection修改为abstract即可。
修改后的DBConnection:
public abstract class DBConnection {
public abstract String getDBConnection();
}
然后,将MySQL的逻辑抽离:
public class MysqlDBConnection extends DBConnection{
@Override
public String getDBConnection() {
return "MySQL 数据库连接";
}
}
再创建Oracle支持的逻辑:
public class OracleDBConnection extends DBConnection{
@Override
public String getDBConnection() {
return "Oracle数据库连接";
}
}
具体选择交给应用层来选择
测试代码:
public static void main(String[] args) {
ProductDao productDao = new ProductDao();
productDao.setDBConnection(new OracleDBConnection());
productDao.addProduct();
}
里氏替换原则
是指如果对每一个类型为T1的对象O1,都有类型为T2的对象O2,使得以T1定义的所有程序P在所有的对象O1都被替换成O2时,程序P的行为没有发生变化,那么类型T2是T1的子类型。
即:一个软件实体如果适用于父类的话,那一定适用于子类,所有使用父类的地方必须能透明地使用其子类的对象,子类对象能够替换其父类对象,而程序逻辑不变。
引申含义:子类可以扩展父类的功能,但不能改变父类的原有功能。
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的输出/返回值)要比父类更严格或相等。
回过头来看上面的开闭原则,我们在获取折后的时候重写了父类的getPrice()方法,增加了一个获取原来价格的方法getOriginPrice(),这样就违背了里氏替换原则。在此,修改一下,不应该覆盖getPrice()方法,增getDiscountPrice
()方法:
public class JavaDiscountCourse extends JavaCourse{
public JavaDiscountCourse(Integer id, String name, Double price) {
super(id, name, price);
}
public Double getDiscountPrice() {
return super.getPrice() * 0.16;
}
}
使用里氏替换原则有以下优点:
1、约束继承泛滥,开闭原则的一种体现。
2、加强程序的健壮性,同时变更时也可以做到非常好的兼容性,提高程序的维护性、扩
展性。降低需求变更时引入的风险。
现在来描述一个经典的业务场景,用正方形、矩形和四边形的关系说明里氏替换原则。
正方形是一个特殊的长方形,因此创建一个长方形父类Rectangle类:
public class Rectangle {
private long height;
private long width;
public long getHeight() {
return height;
}
public void setHeight(long height) {
this.height = height;
}
public long getWidth() {
return width;
}
public void setWidth(long width) {
this.width = width;
}
}
然后创建一个正方形继承长方形:
public class Square extends Rectangle{
private long length;
public long getLength() {
return length;
}
public void setLength(long length) {
this.length = length;
}
@Override
public long getWidth() {
return getLength();
}
@Override
public long getHeight() {
return getLength();
}
@Override
public void setHeight(long height) {
setLength(height);
}
@Override
public void setWidth(long width) {
setLength(width);
}
}
测试代码:
public class LiskovSubstitutionTest {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle();
rectangle.setWidth(20);
rectangle.setHeight(10);
resize(rectangle);
}
public static void resize(Rectangle rectangle){
while (rectangle.getWidth() >= rectangle.getHeight()){
rectangle.setHeight(rectangle.getHeight() + 1);
System.out.println("width:"+rectangle.getWidth() + ",height:"+rectangle.getHeight());
}
System.out.println("resize 方法结束" + "\nwidth:"+rectangle.getWidth() + ",height:"+rectangle.getHeight());
}
}
运行结果:
width:20,height:11
width:20,height:12
width:20,height:13
width:20,height:14
width:20,height:15
width:20,height:16
width:20,height:17
width:20,height:18
width:20,height:19
width:20,height:20
width:20,height:21
resize 方法结束
width:20,height:21
发现高比宽还大了,在长方形中是一种非常正常的情况。现在我们再来看下面的代码,
把长方形Rectangle替换成它的子类正方形Square,修改测试代码:
public class LiskovSubstitutionTest {
public static void main(String[] args) {
Square square = new Square();
square.setLength(10);
resize(square);
}
public static void resize(Rectangle rectangle){
while (rectangle.getWidth() >= rectangle.getHeight()){
rectangle.setHeight(rectangle.getHeight() + 1);
System.out.println("width:"+rectangle.getWidth() + ",height:"+rectangle.getHeight());
}
System.out.println("resize 方法结束" + "\nwidth:"+rectangle.getWidth() + ",height:"+rectangle.getHeight());
}
}
这时候我们运行的时候就出现了死循环,违背了里氏替换原则,将父类替换为子类后,程序运行结果没有达到预期。因此,我们的代码设计是存在一定风险的。里氏替换原则只存在父类与子类之间,约束继承泛滥。我们再来创建一个基于长方形与正方形共同的抽象四边形Quadrangle接口:
问题由来:有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。
解决方案:当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。
对于这样不符合里氏替换原则原则的关系,我们在代码重构的时候一般采用下面的方法。
创建一个基于长方形与正方形共同的抽象四边形Quadrangle接口:
public interface Quadrangle {
long getWidth();
long getHeight();
}
修改长方形Rectangle类:
public class Rectangle implements Quadrangle{
private long height;
private long width;
@Override
public long getWidth() {
return width;
}
@Override
public long getHeight() {
return height;
}
public void setHeight(long height) {
this.height = height;
}
public void setWidth(long width) {
this.width = width;
}
}
修改正方形Square类:
public class Square implements Quadrangle{
private long length;
public long getLength() {
return length;
}
@Override
public long getWidth() {
return length;
}
@Override
public long getHeight() {
return length;
}
}
此时,如果我们把resize()方法的参数换成四边形Quadrangle类,方法内部就会报错。因为正方形Square已经没有了setWidth()和setHeight()方法了。因此,为了约束继承泛滥,resize()的方法参数只能用Rectangle长方形。
但是我们会发现在自己编程中常常会违反里氏替换原则,程序照样跑的好好的。所以大家都会产生这样的疑问,假如我非要不遵循里氏替换原则会有什么后果?来看一个例子。
public class A{
public int func1(int a, int b){
return a-b;
}
}
public class B extends A{
public int func1(int a, int b){
return a+b;
}
public int func2(int a, int b){
return func1(a,b)+100;
}
}
public class Client{
public static void main(String[] args){
B b = new B();
System.out.println("100-50="+b.func1(100, 50));
System.out.println("100-80="+b.func1(100, 80));
System.out.println("100+20+100="+b.func2(100, 20));
}
}
输入结果:
100-50=150
100-80=180
100+20+100=220
我们发现原本运行正常的相减功能发生了错误。原因就是类B在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类B重写后的方法,造成原本运行正常的功能出现了错误。在本例中,引用基类A完成的功能,换成子类B之后,发生了异常。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。