这篇博文主要讲的是在Java面向对象设计中,我们应该遵循的六大原则。只有掌握了这几大原则,我们才能更好的理解设计模式。
Java面向对象设计的六大原则如下:
-
单一职责原则——SRP
-
开闭原则——OCP
-
里式替换原则——LSP
-
依赖倒置原则——DIP
-
接口隔离原则——ISP
-
迪米特原则——LOD
下面我们会通过具体的实例逐个讲解这几大原则。
一、单一职责原则(SRP)
单一职责原则定义接口或类和职责的关系是一一对应的,就一个类而言,应该有且仅有一个引起它变化的原因。也就是说一个类应该只负责一件事情。单一职责原则在日常开发中是经常用到的,以一个类举例,假如目前有2种动物,猫和狗,虽然它们之间是有相似的地方,但是我们在定义类的时候也会把它们区分成2个类;以接口举例,尽管List和Map都是用来存放数据的容器,但是它们的数据结构是不一样的,包含的方法也有区别,所以它们也被区分为2个接口。
1.1 遵循单一职责原则的好处
- 降低类或接口的复杂度。每个类或接口都只实现单一的职责,定义明确清晰,内部变量、方法等也容易理解;
- 代码可读性高。在类的定义和声明清晰的基础上,自然会带来较高的代码可读性;
- 代码可维护性高。代码可读性强,更容易理解,自然方便维护;类的职责单一,类之间耦合度低,所以更容易修改;
- 拓展性更好。有新的职责需要拓展,只需要新增成员或实现对应的接口即可。
1.2 实例分析
因为面向对象的编程是推崇面向接口编程的,所以我们对外暴露的方法也最好是以接口的形式定义,再由具体的类进行实现。下面就基于一个简单的场景来进行程序设计,满足单一职责原则。
我们知道苹果厂商早期生产Iphone4,是可以打电话、发短信的;后来发布了Iphone5,即可以打电话、发短信又可以支持指纹解锁;同时它还有一个产品是Ipad,可以指纹解锁,但是不能打电话、发短信。基于这个场景,我们来进行程序设计。
我们先定义几个接口,里面分别包含打电话、发短信、指纹解锁等方法,代码如下:
public interface CallInterface {
public void call();
}
public interface MessageInterface {
public void sendMessage();
}
public interface FingerPrintInterface {
public void fingerPrint();
}
可以看到,这几个接口在定义的时候就根据功能划分设定了单一职责。然后我们再定义几个类,实现上面的几个接口,同样设定单一职责:
public class CallDevice implements CallInterface {
@Override
public void call() {
System.out.println("我能打电话");
}
}
public class MessageDevice implements MessageInterface {
@Override
public void sendMessage() {
System.out.println("我能发短信");
}
}
public class FingerPrintDevice implements FingerPrintInterface {
@Override
public void fingerPrint() {
System.out.println("我能指纹解锁");
}
}
CallDevice、MessageDevice、FingerPrintDevice相当于支持各种功能的零件,现在零件有了,我们需要生产设备了。我们首先生产Iphone4,它能打电话和发短信。打电话和发短信的零件已经有了,我们只需要装配上就可以。代码如下:
//在生产这台手机时我们就已经明确知道了它的功能(打电话和发短信)
public class Iphone4 implements CallInterface, MessageInterface {
/**
* 打电话的零件
*/
private CallDevice callDevice = new CallDevice();
/**
* 发短信的零件
*/
private MessageDevice messageDevice = new MessageDevice();
@Override
public void sendMessage() {
messageDevice.sendMessage();
}
@Override
public void call() {
callDevice.call();
}
public static void main(String[] args) {
Iphone4 iphone4 = new Iphone4();
iphone4.call();
iphone4.sendMessage();
}
}
输出结果:
这样,一台Iphone4就生产出来了。同理,Iphone5、Ipad也可以简单装配出来。假如Iphone4出新款了,打电话逻辑和之前不一样了,那么只需要再定义一个零件类,它采用 “新技术” 实现CallInterface接口中的call方法,这样在Iphone4装配时,打电话模块采用新的零件就可以了。这就体现了单一职责的好处,它对于现有类的修改造成的影响有了约束。
1.3 扩展
通过上面的实例,我们可以对单一职责原则有一个更清晰的理解。其实在项目开发中,如果针对每个接口都提供一个实现类会导致类的数量很庞大,使用起来很不方便,所以,在上面实例的基础上,我们可以整合一些功能。
在下面的例子中,我们的接口依旧单一职责,但是接听和拨打电话的功能往往是不可分的,他们基本上是同时存在的。所以我们可以提供一个同时继承两个接口的实现类。代码如下:
public class CallAndMessageDevice implements CallInterface,MessageInterface{
@Override
public void sendMessage() {
System.out.println("我即会发短信");
}
@Override
public void call() {
System.out.println("我又会打电话");
}
}
1.4 注意点
- 对于单一职责原则,接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化;
- 单一职责原则的难点在于职责的划分,在不同情景和生产环境下我们对职责的细化是不同的。
二、开闭原则(OCP)
开闭原则定义:一个软件实体如类,模块和函数应该对扩展开放,对修改关闭。
在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。开闭原则要求我们,当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
2.1 遵循开闭原则的优势
- 通过扩展已有的软件系统,可以提供新的行为,以满足对软件的新需求,使变化中的软件系统有一定的适应性和灵活性;
- 已有的软件模块,特别是最重要的抽象层模块不能再修改,这就使变化中的软件系统有一定的稳定性和延续性;
- 这样的系统同时满足了可复用性与可维护性。
2.2 如何遵循开闭原则
- 抽象约束
- 通过接口或者抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;
- 参数类型、引用对象尽量使用接口或者抽象类,而不是实现类;
- 抽象层尽量保持稳定,一旦确定即不允许修改。
- 元数据(metadata)控制模块行为
- 元数据就是用来描述环境和数据的数据,通俗地说就是配置参数,参数可以从文件中获得,也可以从数据库中获得。
- Spring容器就是一个典型的元数据控制模块行为的例子,其中达到极致的就是控制反转(Inversion of Control)
- 制定项目章程
- 在团队中,建立项目章程是非常重要的,因为章程中指定了所有人员都必须遵守的约定,对项目来说,约定优于配置。
- 封装变化
对变化的封装包含两层含义:
- 将相同的变化封装到一个接口或者抽象类中;
- 将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。
2.3 实例分析
下面我们举例说明什么是开闭原则。以书店销售书籍为例,其类图如下:
IBook接口代码:
package com.moi.csdn;
public interface IBook {
public String getName();
public int getPrice();
public String getAuthor();
}
NovelBook小说书籍类:
package com.moi.csdn;
public class NovelBook implements IBook{
protected String name;
protected int price;
protected String author;
public NovelBook(String name,int price,String author){
this.name = name;
this.price = price;
this.author = author;
}
@Override
public String getName(){
return this.name;
}
@Override
public int getPrice(){
return this.price;
}
@Override
public String getAuthor() {
return this.author;
}
}
Test类:
package com.moi.csdn;
public class Test {
public static void main(String[] args) {
IBook novel = new NovelBook("笑傲江湖",100,"金庸");
System.out.println("书籍名字:"+novel.getName()+"\n书籍作者:"+novel.getAuthor()+"\n书籍价格:"+novel.getPrice());
}
}
输出结果:
以上是针对初期需求实现的代码,功能方面是没有问题的。但是目前有一个新需求,在书籍销售的过程中,我们经常因为各种原因,要打折来销售书籍,这是一个需求上的变化。我们应该如何设计并修改我们的代码呢?
我们有下面三个方法来应对新需求:
-
修改接口
在IBook接口中,增加一个方法getOffPrice(),专门用于进行打折处理,所有的实现类实现此方法。但是这样的一个修改方式,实现类NovelBook要修改,同时IBook接口应该是稳定且可靠,不应该经常发生改变,否则接口作为契约的作用就失去了。因此,此方案否定。 -
修改实现类
修改NovelBook类的方法,直接在getPrice()方法中实现打折处理。此方法是有问题的,因为我们在对应新需求的时候也要保证老需求功能的实现。当然我们也可以再增加getOffPrice()方法,这也是可以实现新需求,但是这里面就有两个读取价格的方法,因此,该方案也不是一个最优方案。 -
通过扩展实现类来解决
我们可以增加一个子类OffNovelBook,重写父类NovelBook的getPrice()方法。此方法修改少,对现有的代码没有影响,风险小,满足开闭原则。
修改后的类图:
代码:
package com.moi.csdn;
public class OffNovelBook extends NovelBook{
public OffNovelBook(String name,int price,String author){
super(name,price,author);
}
// 重写价格方法,当价格大于40,就打8析,其他价格就打9析
public int getPrice(){
if(this.price > 40){
return (int) (this.price * 0.8);
}else{
return (int) (this.price * 0.9);
}
}
}
上面针对打折销售的功能开发完成了,我们只是增加了一个OffNovelBook类,我们修改的代码都是在上层模块,没有修改底层模块,代码改变量少,可以有效的防止风险的扩散。
2.4 总结扩展
通常情况下,我们可以把需求变化归纳为两种类型:
-
逻辑变化
只变化了一个逻辑,而不涉及其他模块。比如一个算法是a*b*c,现在需要修改为a+b+c,可以通过直接修改原有类中的方法的方式来完成,前提条件是所有依赖或关联类都按照相同的逻辑处理。 -
子模块变化
一个模块变化,会对其它的模块产生影响。特别是一个底层的模块变化必然引起其上层模块的变化,因此可以通过扩展实现该变化。
事实上,开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
三、里式替换原则(LSP)
里式替换原则严格的定义:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
里式替换原则通俗的定义:所有引用基类(父类)的地方必须能透明地使用其子类的对象。
里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常。反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。例如:我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。
所以说,里氏替换原则其实就是为“良好的继承”制定一些规范。
3.1 继承的优缺点
既然 里氏替换原则 是解决继承带来的问题,那么我们先总结一下使用继承的优缺点。
3.1.1 继承的优点
- 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
- 提高代码的复用性;
- 子类可以形似父类,但又异于父类;
- 提高代码的可扩展性;
- 提高产品或项目的开放性。
3.1.2 继承的缺点
- 继承是侵入性的,只要继承就必须拥有父类的所有属性和方法;
- 降低代码的灵活性,子类必须拥有父类的属性和方法,让子类增加了约束;
- 增强了耦合性,当父类的常量、变量和方法被修改时,必须考虑子类的修改。
3.2 良好的继承规范
上面我们总结了继承的优缺点,也提出了 里氏替换原则 是为“良好的继承”制定一些规范,那么具体有哪些规范呢?
1、子类要完全实现父类的抽象方法,但尽量不要覆盖父类的非抽象方法。
这一点很容易理解,如果子类覆盖了父类的非抽象方法,当使用子类代替父类时,程序行为可能会有所改变。
举例:我们知道,计算器可以用来计算加减乘除。现在我们定义一个计算器类,里面实现非抽象的计算方法。
package com.moi.csdn; public class Calculator { public int calculate(int num1, int num2) { return num1 + num2; } }
然后我们再定义一个子类,继承Calculator类,它里面也实现计算方法,覆盖父类的方法。
package com.moi.csdn; public class MiniCalculator extends Calculator{ public int calculate(int num1, int num2) { return num1 - num2; } }
可以看到,父类和子类都有calculate方法,父类是加法实现,子类是减法实现。我们再写一个Test类:
package com.moi.csdn; public class Test { public static void main(String[] args) { Calculator calculator = new Calculator(); System.out.println(calculator.calculate(10, 5)); MiniCalculator calculator2 = new MiniCalculator(); System.out.println(calculator2.calculate(10, 5)); } }
输出结果如下:
可以看到,由于子类MiniCalculator重写了父类Calculator的calculate方法,当子类MiniCalculator代替父类Calculator时,导致计算结果有误,也就是程序的行为发生了变化。所以对父类的非抽象方法,尽量不要覆盖重写。
2、子类中可以增加自己特有的方法。
子类一般都会有自己特有的属性或方法,这个无须赘述。
3、当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数要更抽象化。
举例说明,首先我们定义一个父类,Father类:
package com.moi.csdn; import java.util.HashMap; public class Father { public void doSomething(HashMap map){ System.out.println("父类被执行"); } }
可以看到,Father类中doSomething方法中接收的是HashMap。下面定义子类Son:
package com.moi.csdn; import java.util.Map; public class Son extends Father{ public void doSomething(Map map){ System.out.println("子类被执行"); } }
最后是Test类:
package com.moi.csdn; import java.util.HashMap; import java.util.Map; public class Test { public static void main(String[] args) { Father father = new Father(); HashMap map = new HashMap(); father.doSomething(map); Son son = new Son(); Map map2 = null; son.doSomething(map2); } }
执行结果如下:
可以看到,父类和子类分别执行。在这个例子中,子类方法的形参要比父类方法的形参更加抽象化。反之,则调用的都是父类方法,那么子类的方法重写就失去了意义。具体代码就不贴出来了,读者可以自行编码验证。
4、当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更具体化。
这个规范是必须要遵守的,否则会报编译错误。在上面的例子中我们给Father类doSomething方法加一个HashMap返回值,如果我们在Son类doSomething方法中返回Map,那么就会报错。如下:
3.3 实例分析
下面我们举例分析 里式替换原则 的用法。需求如下:
在Sunny软件公司开发的CRM系统中,客户(Customer)可以分为VIP客户(VIPCustomer)和普通客户(CommonCustomer)两类,系统需要提供一个发送Email的功能,无论是普通客户还是VIP客户,发送邮件的过程都是相同的,而且在本系统中还将增加新类型的客户。为了让系统具有更好的扩展性,同时减少代码重复,我们需要使用里氏代换原则进行设计开发。
在本实例中,可以定义一个抽象客户类Customer,同时将CommonCustomer和VIPCustomer类作为其子类,邮件发送类EmailSender类针对抽象客户类Customer编程,根据里氏代换原则,能够接受基类对象的地方必然能够接受子类对象,因此将EmailSender中的send()方法的参数类型改为Customer,如果需要增加新类型的客户,只需将其作为Customer类的子类即可。结构设计图如下:
开始编码。首先我们定义抽象类Customer:
package com.moi.csdn; public abstract class Customer { public abstract String getName(); }
getName()方法用于区分发送Email的对象。
然后分别定义CommonCustomer和VIPCustomer继承Customer,并分别实现getName()方法:
package com.moi.csdn; public class CommonCustomer extends Customer{ @Override public String getName() { return "普通客户"; } }
package com.moi.csdn; public class VIPCustomer extends Customer{ @Override public String getName() { return "VIP客户"; } }
再定义EmailSender类,声明send方法,接收Customer参数用于发送邮件:
package com.moi.csdn; public class EmailSender { public void send(Customer c){ System.out.println("发送给:"+c.getName()); } }
最后是Test类及运行结果:
package com.moi.csdn; public class Test { public static void main(String[] args) { EmailSender sender = new EmailSender(); sender.send(new CommonCustomer()); sender.send(new VIPCustomer()); } }
后续如果需要增加新类型的客户,只需将其作为Customer类的子类即可。
3.4 总结扩展
里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
在使用里氏代换原则时需要注意如下几个问题:
- 子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏代换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法;
- 我们在运用里氏代换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类对象替换父类对象,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏代换原则是开闭原则的具体实现手段之一;
- Java语言中,在编译阶段,Java编译器会检查一个程序是否符合里氏代换原则,这是一个与实现无关的、纯语法意义上的检查,但Java编译器的检查是有局限的。
由于篇幅有限,本文先总结到这里,另外3大原则(依赖倒置原则、接口隔离原则、迪米特原则)将在后续博文中推出。
如有任何疑问,可关注公众号留言,工程师将尽快回答您的问题。公众号二维码: