写点什么

面向对象五大基本原则

用户头像
彭阿三
关注
发布于: 2020 年 06 月 11 日
面向对象五大基本原则

可能我们大家有的知道这五个原则,可能有的人杜没有听说过,当你重构一个系统的时候,就会深有感触,遵循五个原则设计的系统能够更加的优雅



单一职责原则(Single-Resposibility Principle)



其核心思想为:一个类,最好只做一件事,只有一个引起它的变化。单一职责原则可以看做是低耦合、高内聚在面向对象原则上的引申,将职责定义为引起变化的原因,以提高内聚性来减少引起变化的原因。职责过多,可能引起它变化的原因就越多,这将导致职责依赖,相互之间就产生影响,从而大大损伤其内聚性和耦合度。通常意义下的单一职责,就是指只有一种单一功能,不要为类实现过多的功能点,以保证实体只有一个引起它变化的原因。

专注,是一个人优良的品质;同样的,单一也是一个类的优良设计。交杂不清的职责将使得代码看起来特别别扭牵一发而动全身,有失美感和必然导致丑陋的系统错误风险。



demo

demo能够帮助我们更好的理解单一职责原则

通常我们可以有各种各样的方法实现一些方法,下面是一个简单的例子

public class Order {
int price;
int count;
public Order(int price, int count) {
this.price = price;
this.count = count;
}
//8折
public int eightyPercent(int price, int count) {
return (int) (price * count * 0.8);
}
//7折
public int seventyPercent(int price, int count) {
return (int) (price * count * 0.7);
}
}

但这种写法却没有很好的按照单一职责的原则去构建,在一个订单类中实现各种各样的操作,假如我们要加上其他的操作就要在原来的类上改动,添加新的方法,但如果我们按照单一职责原则设计,一个类专注做一件事,对上一个类进行拆分,例如:



1.添加SeventyPercentOrder类

public class SeventyPercentOrder extends Order {
public SeventyPercentOrder(int price, int count) {
super(price, count);
}
//7折
public int seventyPercent(int price, int count) {
return (int) (price * count * 0.7);
}
}

2.添加EightyPercentOrder类

public EightyPercentOrder(int price, int count) {
super(price, count);
}
//8折
public int eightyPercent(int price, int count) {
return (int) (price * count * 0.8);
}

这样的话,我们遵循单一职责原则,如果有新的方法可以创建新的类实现对应的方法,而不需要在原来 的类上进行修改。



开放封闭原则(Open-Closed principle)

其核心思想是:软件实体应该是可扩展的,而不可修改的。也就是,对扩展开放,对修改封闭的。开放封闭原则主要体现在两个方面1、对扩展开放,意味着有新的需求或变化时,可以对现有代码进行扩展,以适应新的情况。2、对修改封闭,意味着类一旦设计完成,就可以独立完成其工作,而不要对其进行任何尝试的修改。

实现开开放封闭原则的核心思想就是对抽象编程,而不对具体编程,因为抽象相对稳定。让类依赖于固定的抽象,所以修改就是封闭的;而通过面向对象的继承和多态机制,又可以实现对抽象类的继承,通过覆写其方法来改变固有行为,实现新的拓展方法,所以就是开放的。

“需求总是变化”没有不变的软件,所以就需要用封闭开放原则来封闭变化满足需求,同时还能保持软件内部的封装体系稳定,不被需求的变化影响。



开发封闭原则示例:

1.类图:



2.代码实现:

/*
**商品接口
**
**/
public interface Goods {
int getPrice();
String getName();
}



/**
*
* 具体商品(牛奶)
*/
public class Milk implements Goods {
//商品的名称
private String name;
//商品的价格
private int price;
public Milk (String name,int price){
this.name = name;
this.price = price;
}
@Override
public int getPrice() {
return this.price;
}
@Override
public String getName() {
return this.name;
}
}



/**
* 具体商品(水)
*/
public class Water implements Goods {
//商品名称
private String name;
//商品价格
private int price;
public Water (String name,int price){
this.name = name;
this.price = price;
}
@Override
public int getPrice() {
return this.price;
}
@Override
public String getName() {
return this.name;
}
}



/**
* 商店各种物品
*/
public class Shop {
private final static ArrayList<Goods> goodList=new ArrayList<>();
static {
goodList.add(new Milk("特伦舒",100));
goodList.add(new Milk("金典",80));
goodList.add(new Water("康师傅",5));
}
//模拟书店买书
public static void main(String[] args) {
System.out.println("-=-=-=-=-=-=-=-=商店卖出物品记录-=-=-=--=-=-=-=-");
for (Goods good:goodList){
System.out.println("物品名称:"+good.getName()+"\t物品价格:"+good.getPrice()+"元");
}
}
}
运行结果:
-=-=-=-=-=-=-=-=商店卖出物品记录-=-=-=--=-=-=-=-
物品名称:特伦舒 物品价格:100
物品名称:金典 物品价格:80
物品名称:康师傅 物品价格:5

如果商店进行促销活动金典牛奶进行打8折活动,如果解决这个问题呢?

1.第一个办法

修改接口。在Goods上新增加一个方法getOffPrice(),专门进行打折,所有实现类实现这个方法。

但是这样修改的后果就是实现类Milk要修改,Shop中的main方法也修改,同时Goods作为接口应该是稳定且可靠的,不应该经常发生变化,否则接口做为契约的作用就失去了效能,其他不想打折的牛奶商品也会因为实现了商品的接口必须打折,因此该方案被否定。



2.第二个方法

修改实现类。修改Milk 类中的方法,直接在getPrice()中实现打折处理,这个应该是大家在项目中经常使用的就是这样办法,通过class文件替换的方式可以完成部分业务(或是缺陷修复)变化,该方法在项目有明确的章程(团队内约束)或优良的架构设计时,是一个非常优秀的方法。

但是该方法还是有缺陷的,例如采购物品人员也是要看价格的,由于该方法已经实现了打折处理价格,因此采购人员看到的也是打折后的价格,这就产生了信息的蒙蔽效果,导致信息不对称而出现决策失误的情况。该方案也不是一个最优的方案。



3.第三个方法

最优方案,通过扩展实现变化。增加一个子类 OffMilk,重写getPrice方法,高层次的模块(也就是static静态模块区)通过OffMilk类产生新的对象,完成对业务变化开发任务。好办法,风险也小。



代码实现:

/**
* 牛奶打折类
*/
public class OffMilk extends Milk {
public OffMilk(String name, int price) {
super(name, price);
}
@Override
public int getPrice() {
//原价
int prePrice = super.getPrice();
//牛奶名称
String preName = super.getName();
int offPrice = prePrice;
if ("金典".equals(preName)){
offPrice = offPrice * 80/100;
}
return offPrice;
}
}



/**
* 商店各种物品
*/
public class Shop {
private final static ArrayList<Goods> goodList=new ArrayList<>();
static {
goodList.add(new OffMilk("特伦舒",100));
goodList.add(new OffMilk("金典",80));
goodList.add(new Water("康师傅",5));
}
//模拟书店买书
public static void main(String[] args) {
System.out.println("-=-=-=-=-=-=-=-=商店卖出物品记录-=-=-=--=-=-=-=-");
for (Goods good:goodList){
System.out.println("物品名称:"+good.getName()+"\t物品价格:"+good.getPrice()+"元");
}
}
}
物品名称:特伦舒 物品价格:100
物品名称:金典 物品价格:64
物品名称:康师傅 物品价格:5



归纳变化:

逻辑变化。只变化一个逻辑,而不涉及到其他模块。比如原有的一个方法是A+B*C,现在要求A-B*C,可能通过修改原有类中的方法方式来完成,前提条件是所有依赖或关联类都按此相同逻辑处理。不然修改原有的类中的方法会影响其他依赖或关联的类。如果我们进行拓展创建新的类重写方法就不会影响原有的业务。



4.扩展接口再扩展实现

商店引进了一种特殊的商品糖果,该商品有一个特殊的属性味道

代码实现:

/**
*糖果味道接口
*/
public interface CandyGoods extends Goods {
//糖果味道
String getFlavour();
}



/**
* 糖果类商品
*/
public class Candy implements CandyGoods {
//商品的名称
private String name;
//商品的价格
private int price;
//味道
private String flavour;
public Candy (String name,int price,String flavour){
this.name = name;
this.price = price;
this.flavour = flavour;
}
@Override
public String getFlavour() {
return this.flavour;
}
@Override
public int getPrice() {
return this.price;
}
@Override
public String getName() {
return this.name;
}
}



/**
* 商店各种物品
*/
public class Shop {
private final static ArrayList<Goods> goodList=new ArrayList<>();
static {
goodList.add(new OffMilk("特伦舒",100));
goodList.add(new OffMilk("金典",80));
goodList.add(new Water("康师傅",5));
goodList.add(new Candy("苹果糖",5,"苹果味"));
goodList.add(new Candy("香蕉糖 ",6,"香蕉味 "));
}
//模拟书店买书
public static void main(String[] args) {
System.out.println("-=-=-=-=-=-=-=-=商店卖出物品记录-=-=-=--=-=-=-=-");
for (Goods good:goodList){
System.out.println("物品名称:"+good.getName()+"\t物品价格:"+good.getPrice()+"元");
}
}
}
-=-=-=-=-=-=-=-=商店卖出物品记录-=-=-=--=-=-=-=-
物品名称:特伦舒 物品价格:100
物品名称:金典 物品价格:64
物品名称:康师傅 物品价格:5
物品名称:苹果糖 物品价格:5
物品名称:香蕉糖 物品价格:6






里氏替换原则(Liskov-Substituion Principle)



其核心思想是:子类必须能够替换其基类。这一思想体现为对继承机制的约束规范,只有子类能够替换基类时,才能保证系统在运行期内识别子类,这是保证继承复用的基础。在父类和子类的具体行为中,必须严格把握继承层次中的关系和特征,将基类替换为子类,程序的行为不会发生任何变化。同时,这一约束反过来则是不成立的,子类可以替换基类,但是基类不一定能替换子类。

Liskov替换原则,主要着眼于对抽象和多态建立在继承的基础上,因此只有遵循了Liskov替换原则,才能保证继承复用是可靠地。实现的方法是面向接口编程:将公共部分抽象为基类接口或抽象类,通过Extract Abstract Class,在子类中通过覆写父类的方法实现新的方式支持同样的职责。

Liskov替换原则是关于继承机制的设计原则,违反了Liskov替换原则就必然导致违反开放封闭原则。

Liskov替换原则能够保证系统具有良好的拓展性,同时实现基于多态的抽象机制,能够减少代码冗余,避免运行期的类型判别。



1.问题描述

一个功能T有类A来完成,后来由于需求的变化,该功能T被分为了T1和T2两部分,这两部分的功能分别有类A的子类:类B和类C来完成。如果功能T1发生了变化,修改类B的同事,有可能引起T2的功能产生故障。



2.产生原因

在继承关系中,基类的存在是为整个继承的结构设定一系列的规定和约束,让整个结构都按照这个规定和约束来。例如说用一个基类来描述鸟类,根据我们对鸟类的一贯认知,会在基类中通过约定有羽毛属性,有飞翔行为的是鸟类。这样在实现乌鸦或者喜鹊的时候,它都有基类中规定的属性和行为约束,但是突然有一天boss过来说把孔雀也要加进来,因为孔雀也属于鸟类。此时我们在继承了鸟类这个基类的时候,把飞翔的行为都改了。此时乌鸦或者喜鹊就都如孔雀一般不会飞翔了。



3.解决办法

当使用继承的时候,使用里氏替换原则。当使用继承的时候,尽量不覆盖或重写父类的方法。当扩展父类方法的时候,保证不影响父类功能的前提下扩展。



4.实例

/**
* 鸟类
*/
public class Bird {
public void test(){
System.out.println("我是会飞的鸟");
}
}

来了一个不会飞的孔雀

/**
* 孔雀
*/
public class Peacock extends Bird {
@Override
public void test() {
System.out.println("我是不会飞的鸟");
}
}



根据里氏替换原则:任何出现基类的地方,都可以用子类替换。那么此刻就尴尬了,如果用里氏替换原则将它替换,那么所有的鸟都不会飞了。这显然是不合理的,但是这种问题在实际应用中确实很常见的。



Liskov于1987年提出了一个关于继承的原则“Inheritance should ensure that any property proved about supertype objects also holds for subtype objects.”——“继承必须确保超类所拥有的性质在子类中仍然成立。”也就是说,当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有is-A关系。



如果我们对Peacock 进行扩展重新写一个方法test1(),就可以完美实现里氏替换原则。

代码如下:

/**
*
*/
public class Peacock extends Bird {
public void test1(){
System.out.println("我是不会飞的鸟");
}
}

如果用里氏替换原则来判断一个类的框架是否合理的话,继承和多态是不是就没用了?答案显然是否定的。就上面的鸟的这个例子来看,会飞的鸟和不会飞的鸟显然不应该是继承关系,而是并行的关系。在处理这种情况的时候,我们只需要定义一个共同的基类,创建一个纯虚函数来实现。那么假如我们非要用到继承来实现一个框架的时候怎么办呢?此时就要遵守里氏替换原则的四层含义:



  • 子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法。

  • 子类中可以增加自己特有的方法。

  • 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。

  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

总结起来就是:子类实现父类的抽象方法优先,但是不能覆盖父类的抽象方法。但是当子类必须要实现父类的方法的时候,那么就要遵守里氏替换原则中的第三条和第四条。




接口隔离原则(Interface-Segregation Principle)

其核心思想是:使用多个小的专门的接口,而不要使用一个大的总接口。

具体而言,接口隔离原则体现在:接口应该是内聚的,应该避免“胖”接口。一个类对另外一个类的依赖应该建立在最小的接口上,不要强迫依赖不用的方法,这是一种接口污染。

接口有效地将细节和抽象隔离,体现了对抽象编程的一切好处,接口隔离强调接口的单一性。而胖接口存在明显的弊端,会导致实现的类型必须完全实现接口的所有方法、属性等;而某些时候,实现类型并非需要所有的接口定义,在设计上这是“浪费”,而且在实施上这会带来潜在的问题,对胖接口的修改将导致一连串的客户端程序需要修改,有时候这是一种灾难。在这种情况下,将胖接口分解为多个特点的定制化方法,使得客户端仅仅依赖于它们的实际调用的方法,从而解除了客户端不会依赖于它们不用的方法。



分离的手段主要有以下两种:1、委托分离,通过增加一个新的类型来委托客户的请求,隔离客户和接口的直接依赖,但是会增加系统的开销。2、多重继承分离,通过接口多继承来实现客户的需求,这种方式是较好的。



1.问题描述

当一个提供接口的类中对于它的子类来说不是最小的接口,那么它的子类在实现该类的时候就必须要实现一些自己不需要的功能。如此一来,整个系统就会变得臃肿难以维护。



2.问题由来

当类A通过接口I来依赖B,类C也通过接口I来依赖D,那么对于类A和类C来说,如果接口I不是最小接口,那么类B和类D就必须要实现他们不需要的方法。



3.解决问题

遵守接口隔离原则,将“胖大”接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口类来建立依赖关系。这样他们依赖的类就不需要实现他们不需要的方法。



4.实例

场景:某高三学生需要进行一次模拟测试,由于文理科教学内容之别,那么他们考试内容也有区别。假如现在考试的内容有语文,数学,地理,物理这些科目。 作为文科生,他们只考语文,数学,地理;而作为理科生,他们要考语文,数学,物理。用Java来实现这个功能



没有实现接口隔离的代码

/**
* 考试科目接口类
*/
public interface IExam {
void chinese();
void math();
void physics();
void geograp();
}



/**
* 文科考试类
*/
public class ArtsExam implements IExam {
@Override
public void chinese() {
System.out.println("语文");
}
@Override
public void math() {
System.out.println("数学");
}
@Override
public void physics() {
}
@Override
public void geograp() {
System.out.println("地理");
}
}



/**
*理科考试类
*
**/
public class PhyExam implements IExam{
@Override
public void chinese() {
System.out.println("语文");
}
@Override
public void math() {
System.out.println("数学");
}
@Override
public void physics() {
System.out.println("物理");
}
@Override
public void geograp() {
}
}

如此实现的话显然是有问题的,为什么一个类里面会出现空方法这么尴尬的东西。如果现在文理科目不止这四科,增加了生物,历史等等,那是不是出现的空方法就更多了。这时候就需要使用接口隔离原则,让类之间的依赖建立在最小接口的原则上。



用接口隔离原则实现



/**
* 考试接口基类
*/
public interface IExam {
void chinese();
void math();
}



/**
* 文科接口类
*/
public interface IArtExam extends IExam {
void geograp();
}



/**
*理科接口类
*/
public interface IPhyExam extends IExam {
void physics();
}



/**
* 文科考试类
*/
public class ArtsExam implements IArtExam {
@Override
public void chinese() {
System.out.println("语文");
}
@Override
public void math() {
System.out.println("数学");
}
@Override
public void geograp() {
System.out.println("地理");
}
}



/**
*理科考试类
* **/
public class PhyExam implements IPhyExam {
@Override
public void chinese() {
System.out.println("语文");
}
@Override
public void math() {
System.out.println("数学");
}
@Override
public void physics() {
System.out.println("物理");
}
}

但是在使用接口隔离原则的时候,还是需要根据情况来控制接口的粒度,接口太小会引起系统中接口泛滥,不利于维护;太大则有违背了接口隔离规则,容易出现“胖大”接口。所以一般接口中只为被依赖的类提供定制的方法即可,不要让客户去实现他们不需要的方法。




依赖倒置原则(Dependecy-Inversion Principle)

其核心思想是:依赖于抽象。具体而言就是高层模块不依赖于底层模块,二者都同依赖于抽象;抽象不依赖于具体,具体依赖于抽象。

我们知道,依赖一定会存在于类与类、模块与模块之间。当两个模块之间存在紧密的耦合关系时,最好的方法就是分离接口和实现:在依赖之间定义一个抽象的接口使得高层模块调用接口,而底层模块实现接口的定义,以此来有效控制耦合关系,达到依赖于抽象的设计目标。

抽象的稳定性决定了系统的稳定性,因为抽象是不变的,依赖于抽象是面向对象设计的精髓,也是依赖倒置原则的核心。

依赖于抽象是一个通用的原则,而某些时候依赖于细节则是在所难免的,必须权衡在抽象和具体之间的取舍,方法不是一层不变的。依赖于抽象,就是对接口编程,不要对实现编程

简单来说

A.高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。

B.抽象不应该依赖于具体实现,具体实现应该依赖于抽象。



1.为什么要遵循依赖倒置原则

 

很多时候我们更改一个需求,发现更改一处地方需要更改多个文件,看见很多的报错我们自己都觉得烦,我们很清醒的意识到这是因为严重的耦合导致的,所以自然要想办法解决这个问题



2.依赖倒置有什么好处

简单来说,解决耦合。一般情况下抽象的变化概率很小,让用户程序依赖于抽象,实现的细节也依赖于抽象。即使实现细节不断变动,只要抽象不变,客户程序就不需要变化。这大大降低了客户程序与实现细节的耦合度。



3.例子

公司是奇瑞和江淮公司的金牌合作伙伴,现要求开发一套自动驾驶系统,只要汽车上安装该系统就可以实现无人驾驶,该系统可以在奇瑞和江淮车上使用,只要这两个品牌的汽车使用该系统就能实现自动驾驶。



4.常规写法



既然是两种不同的汽车,那我们分别定义出来,一个QQ一个JAC,代码如下:



/**
* @Classname QQ
* @Description TODO
* @Date 2020/6/11 6:18 PM
* @Author by lixin
*/
public class QQ {
public void run(){
System.out.println("奇瑞汽车启动");
}
public void stop(){
System.out.println("奇瑞汽车停止");
}
}
/**
* @Classname QQ
* @Description TODO
* @Date 2020/6/11 6:18 PM
* @Author by lixin
*/
public class QQ {
public void run(){
System.out.println("奇瑞汽车启动");
}
public void stop(){
System.out.println("奇瑞汽车停止");
}
}
/**
* @Classname AutoCar
* @Description TODO
* @Date 2020/6/11 6:19 PM
* @Author by lixin
*/
public class AutoSys {
private String mType;
private QQ qq;
private JAC jac;
public AutoCar(String mtype){
this.mType=mtype;
qq=new QQ();
jac=new JAC();
}
public void AutoRun(){
if ("qq".equals(mType)){
qq.run();
}else{
jac.run();
}
}
public void AutoStop(){
if ("qq".equals(mType)){
qq.stop();
}else{
jac.stop();
}
}
}



代码很简单,相信有java基础的人一看就懂了,但是缺点也很明显,扩展性特别差,现在只有两种车,if-else语句还算简单,假设以后有100种车呢?是不是要改很多文件,写很多的if-else语句,效率很低。



5.首先观察江淮和奇瑞两个类

完全一模一样的代码,我们开始抽离,写个抽象类或者接口都可以,这里我们写一个接口,有人纳闷为啥不写抽象类呢?根据我的个人见解,抽象类的抽象方法可以自己实现,但是接口的方法都是由子类实现的,车的功能我们具体细节我们不知道,没必要写出它的实现,(但是如果我们很清楚功能的具体细节可以写抽象类)所以,我们由子类实现,代码如下:

/**
* @Classname ICar
* @Description TODO
* @Date 2020/6/11 6:26 PM
* @Author by lixin
*/
public interface ICar {
void run();
void stop();
}



6.其次来我们看定义

A.高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。

B.抽象不应该依赖于具体实现,具体实现应该依赖于抽象。



在上述代码中,我们的AutoSys类严重依赖于具体的子类,比如江淮,奇瑞,依赖了低层模块,而定义里已经说明,我们应该依赖于抽象,于是我们改造AutoSys代码

代码如下:



/**
* @Classname JAC
* @Description TODO
* @Date 2020/6/11 6:32 PM
* @Author by lixin
*/
public class JAC implements ICar{
@Override
public void run() {
System.out.println("江淮汽车启动");
}
@Override
public void stop() {
System.out.println("江淮汽车停止");
}
}
/**
* @Classname QQ
* @Description TODO
* @Date 2020/6/11 6:31 PM
* @Author by lixin
*/
public class QQ implements ICar{
@Override
public void run() {
System.out.println("奇瑞汽车启动");
}
@Override
public void stop() {
System.out.println("奇瑞汽车停止");
}
}
/**
* @Classname AutoSys
* @Description TODO
* @Date 2020/6/11 6:30 PM
* @Author by lixin
*/
public class AutoSys {
private ICar iCar;
public AutoSys(ICar iCar){
this.iCar=iCar;
}
public void AutoRun(){
iCar.run();
}
public void AutoStop(){
iCar.stop();
}
}
/**
* @Classname Test
* @Description TODO
* @Date 2020/6/11 6:13 PM
* @Author by lixin
*/
public class Test {
public static void main(String[] args) {
AutoSys autoSys = new AutoSys(new QQ());
autoSys.AutoRun();
}
}
执行结果:
奇瑞汽车启动



这样,我们就抽出了它的共同点。正好也呼应了B点,具体应该依赖于抽象,而不是抽象依赖于具体



以下是整篇文章demo源码地址,还会陆续更新更多的demo。

码云:https://gitee.com/pengasan/study.git



以上就是5个基本的面向对象设计原则,它们就像面向对象程序设计中的金科玉律,遵守它们可以使我们的代码更加鲜活,易于复用,易于拓展,灵活优雅。不同的设计模式对应不同的需求,而设计原则则代表永恒的灵魂,需要在实践中时时刻刻地遵守。就如ARTHUR J.RIEL在那边《OOD启示录》中所说的:“你并不必严格遵守这些原则,违背它们也不会被处以宗教刑罚。但你应当把这些原则看做警铃,若违背了其中的一条,那么警铃就会响起。”



用户头像

彭阿三

关注

java工程师 2019.06.28 加入

一个慵懒的程序员。

评论

发布
暂无评论
面向对象五大基本原则