这篇博文主要讲的是在Java面向对象设计中,我们应该遵循的六大原则。只有掌握了这几大原则,我们才能更好的理解设计模式。
Java面向对象设计的六大原则如下:
单一职责原则——SRP
开闭原则——OCP
里式替换原则——LSP
依赖倒置原则——DIP
接口隔离原则——ISP
迪米特原则——LOD
下面我们会通过具体的实例逐个讲解这几大原则。
单一职责原则定义接口或类和职责的关系是一一对应的,就一个类而言,应该有且仅有一个引起它变化的原因。也就是说一个类应该只负责一件事情。单一职责原则在日常开发中是经常用到的,以一个类举例,假如目前有2种动物,猫和狗,虽然它们之间是有相似的地方,但是我们在定义类的时候也会把它们区分成2个类;以接口举例,尽管List和Map都是用来存放数据的容器,但是它们的数据结构是不一样的,包含的方法也有区别,所以它们也被区分为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装配时,打电话模块采用新的零件就可以了。这就体现了单一职责的好处,它对于现有类的修改造成的影响有了约束。
通过上面的实例,我们可以对单一职责原则有一个更清晰的理解。其实在项目开发中,如果针对每个接口都提供一个实现类会导致类的数量很庞大,使用起来很不方便,所以,在上面实例的基础上,我们可以整合一些功能。
在下面的例子中,我们的接口依旧单一职责,但是接听和拨打电话的功能往往是不可分的,他们基本上是同时存在的。所以我们可以提供一个同时继承两个接口的实现类。代码如下:
public class CallAndMessageDevice implements CallInterface,MessageInterface{
@Override
public void sendMessage() {
System.out.println("我即会发短信");
}
@Override
public void call() {
System.out.println("我又会打电话");
}
}
开闭原则定义:一个软件实体如类,模块和函数应该对扩展开放,对修改关闭。
在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。开闭原则要求我们,当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
对变化的封装包含两层含义:
下面我们举例说明什么是开闭原则。以书店销售书籍为例,其类图如下:
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类,我们修改的代码都是在上层模块,没有修改底层模块,代码改变量少,可以有效的防止风险的扩散。
通常情况下,我们可以把需求变化归纳为两种类型:
逻辑变化
只变化了一个逻辑,而不涉及其他模块。比如一个算法是a*b*c,现在需要修改为a+b+c,可以通过直接修改原有类中的方法的方式来完成,前提条件是所有依赖或关联类都按照相同的逻辑处理。
子模块变化
一个模块变化,会对其它的模块产生影响。特别是一个底层的模块变化必然引起其上层模块的变化,因此可以通过扩展实现该变化。
事实上,开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
里式替换原则严格的定义:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
里式替换原则通俗的定义:所有引用基类(父类)的地方必须能透明地使用其子类的对象。
里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常。反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。例如:我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。
所以说,里氏替换原则其实就是为“良好的继承”制定一些规范。
既然 里氏替换原则 是解决继承带来的问题,那么我们先总结一下使用继承的优缺点。
上面我们总结了继承的优缺点,也提出了 里氏替换原则 是为“良好的继承”制定一些规范,那么具体有哪些规范呢?
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,那么就会报错。如下:
下面我们举例分析 里式替换原则 的用法。需求如下:
在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大原则(依赖倒置原则、接口隔离原则、迪米特原则)将在后续博文中推出。
如有任何疑问,可关注公众号留言,工程师将尽快回答您的问题。公众号二维码: