面向对象的七大设计原则
文章目录
前言
面向对象的七大设计原则包括:开闭原则(Open Close Principle)、里氏替换原则(Liskov Substitution Principle)、迪米特法则(Law Of Demeter)、单一职责原则(Single Responsibility Principle)、接口隔离原则(Interface Segregation Principle)、依赖倒置原则(Dependence Inversion Principle)、组合/聚合复用原则(Composite/Aggregate Reuse Principle CARP)。
一、七大原则之间的关系
开闭原则是面向对象的可复用设计的基础。其他设计原则是实现开闭原则的手段和工具。
一般地,可以把这七个原则分成以下两个部分:
设计目标:开闭原则、里氏替换原则、迪米特法则
设计方法:单一职责原则、接口分隔原则、依赖倒置原则、组合/聚合复用原则
二、开闭原则
软件实体面向修改关闭,面向扩展开放
1.概念:
开闭原则是判断面向对象设计是否正确的最基本的原理之一。
修改关闭:其实体现的核心就是“抽象”.把相同的代码抽取出来,便于重用。
如果某模块被其他模块调用,如果该模块的源代码不允许修改,则该模块是修改关闭的。提高了软件系统功能的稳定性。
扩展开放:把不同的代码也抽取出来,便于功能的扩展。
如果某模块的功能是可扩展的,则该模块是扩展开放的。提高了软件系统功能上的扩展能力。
开闭原则是设计原则的核心原则,其他设计原则都是开闭原则的体现和补充。
2.系统设计遵循开闭原则的原因:
①稳定性。开闭原则要求扩展功能不修改原来的代码,这可以让软件系统在变化中保持稳定。
②扩展性。开闭原则要求对扩展开放,通过扩展提供新的或改变原有的功能,让软件系统具有灵活的可扩展性。
遵循开闭原则的系统设计,可以让软件系统可复用,并且易于维护。
3.开闭原则的实现方法:
为了满足开闭原则的对修改关闭原则以及扩展开放原则,应该对软件系统中的不变的部分进行抽象,在面向对象的设计中:
①可以把这些不变的部分加以抽象成不变的接口,这些不变的接口可以应对未来的扩展;
②接口的最小功能设计原则。根据这个原则,原有的接口要么可以应对未来的扩展;不足的部分可以通过定义新的接口来实现;
③ 模块之间的调用通过抽象接口进行,这样即使实现层发生变化,也无需修改调用方的代码。
接口可以被复用,但接口的实现却不一定能被复用。
接口是稳定的,关闭的,但接口的实现是可变的,开放的。
可以通过对接口的不同实现以及类的继承行为等为系统增加新的或改变系统原来的功能,实现软件系统的柔性扩展。
优点:提高系统的复用性和维护性。
简单地说,软件系统是否有良好的接口(抽象)设计是判断软件系统是否满足开闭原则的一种重要的判断基准。现在多把开闭原则等同于面向接口的软件设计。
4.设计模式之开闭原则示例
以一个关于商品的例子展示开闭原则:
/**
* 商品接口
*/
public interface IProduct {
public String getProduct();//获取商品名称
public Double getPrice();//获取商品价格
public String getType();//获取商品类型
}
/**
* 商品接口实现类
*/
public class product implements IProduct {
private String name;
private Double price;
private String type;
public product(String name, Double price, String type) {
this.name = name;
this.price = price;
this.type = type;
}
@Override
public String getProduct() {
return name;
}
@Override
public Double getPrice() {
return price;
}
@Override
public String getType() {
return type;
}
}
/**
* 测试
*/
public class Test {
public static void main(String[] args) {
IProduct pro = new product("鸡肉", 20.0, "熟食");
System.out.println("食物名称:"+pro.getProduct()+"\n"+
"食物价格:"+pro.getPrice()+"\n"+
"食物类型:"+pro.getType()+"\n");
}
}
项目上线,商品正常销售,但是我们的甲方爸爸说,你们要再增加一个需求,我要特定商品可以打折。那么问题就出现了,如果用了开闭原则来实现甲方爸爸的需求,可以考虑下面三种方案:
(1)修改接口
在之前的商品接口中添加一个方法 getSalePrice() 专门用来获取打折后的价格;
如果这样修改就会产生两个问题,所以第一个方案PASS
1.IProduct 接口不应该被经常修改,否则接口作为契约的作用就失去了
2.并不是所有的商品都需要打折,假如还有鲍鱼,大闸蟹等都实现了这一接口,但是只有鸡肉打折,与实际需求不符,那样会被甲方爸爸骂个狗血淋头(此处仅供娱乐)。
/**
* 商品接口
*/
public interface IProduct {
public String getProduct();//获取商品名称
public Double getPrice();//获取商品价格
public String getType();//获取商品类型
public Double getSalePrice();//新增打折接口
}
(2)修改实现类
在接口实现类里直接修改 getPrice()方法,此方法会导致获取原价出问题;或添加获取打折的接口 getSalePrice(),这样就会导致获取价格的方法存在两个,所以这个方案也否定。
(3)通过扩展实现变化
直接添加一个子类 SaleChickenProduct,重写 getPrice()方法,这个方案对源代码没有影响,符合开闭原则,所以是可执行的方案,代码如下,代码如下:
/**
* 新增打折方法
*/
public class SaleChickenProduct extends product {
public SaleChickenProduct(String name, Double price, String type) {
super(name, price, type);
}
//重写getPrice方法
@Override
public Double getPrice(){
return super.getPrice()*0.9;
}
}
/**
* 测试
*/
public class Test {
public static void main(String[] args) {
IProduct pro = new product("鸡肉", 20.0, "熟食");
System.out.println("食物名称:"+pro.getProduct()+"\n"+
"食物价格:"+pro.getPrice()+"\n"+
"食物类型:"+pro.getType()+"\n"+"-------------");
IProduct product = new SaleChickenProduct("鸡肉",22.4 ,"熟食" );
System.out.println("食物名称:"+product.getProduct()+"\n"+
"食物折后价格:"+product.getPrice()+"\n"+
"食物类型:"+product.getType()+"\n");
}
}
结果如下
综上所述,如果采用第三种,即开闭原则,以后不管加什么,饮料啊等等的价格变动都可以采用此方案,维护性极高而且也很灵活。
(4)开闭原则的相对性
软件系统的重构过程中,模块的功能抽象,模块与模块间的关系,都不会从一开始就非常清晰明了,所以构建100%满足开闭原则的软件系统是相当困难的,这就是开闭原则的相对性。
但在设计过程中,通过对模块功能的抽象(接口定义),模块之间的关系的抽象(通过接口调用),抽象与实现的分离(面向接口的程序设计)等,可以尽量接近满足开闭原则。
三、里氏替换原则
里氏替换原则强调的是设计和实现要依赖于抽象而非具体;子类只能去扩展基类,而不是隐藏或者覆盖基类。所有引用基类的地方必须能透明地使用其派生类的对象。
1.概念
父类出现的地方子类一定可以替换,如果父类的方法在子类中不适用,或者在子类中不适用,或者在子类中发生了畸变,则应该断开父子关系。父类的方法子类无条件继承,很可能导致父类的方法在子类中不适用的情况
2.意义
里氏替换原则(LSP)是使代码符合开闭原则的一个重要保证。
同时LSP体现了:
类的继承原则:如果一个派生类的对象可能会在基类出现的地方出现运行错误,则该派生类不应该从该基类继承,或者说,应该重新设计它们之间的关系。
动作正确性保证:从另一个侧面上保证了符合LSP设计原则的类的扩展不会给已有的系统引入新的错误。
里式替换原则为我们是否应该使用继承提供了判断的依据,不再是简单地根据两者之间是否有相同之处来说使用继承。
3.里氏替换的4个原则
(1)子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法,父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。举例:
class C {
public int func(int a, int b){
return a+b;
}
}
class C1 extends C{
@Override
public int func(int a, int b) {
return a-b;
}
}
public class Test{
public static void main(String[] args) {
C c = new C1();
System.out.println("2+1=" + c.func(2, 1));
}
}
结果如下:
上面的运行结果明显是错误的。类C1继承C,后来需要增加新功能,类C1并没有新写一个方法,而是直接重写了父类C的func方法,违背里氏替换原则,引用父类的地方并不能透明的使用子类的对象,导致运行结果出错。
(2)子类可以有自己的方法
在继承父类属性和方法的同时,每个子类也都可以有自己的方法,在父类的基础上扩展自己的功能。前面其实已经提到,当功能扩展时,子类尽量不要重写父类的方法,而是另写一个方法,所以对上面的代码加以更改,使其符合里氏替换原则,代码如下:
class C {
public int func(int a, int b){
return a+b;
}
}
class C1 extends C{
public int func2(int a, int b) {
return a-b;
}
}
public class Test{
public static void main(String[] args) {
C1 c = new C1();
System.out.println("2-1=" + c.func2(2, 1));
}
}
结果如下:
(3)覆盖或实现父类的方法时输入参数可以被放大
当子类的方法重载父类的方法时,方法的形参要比父类方法的输入参数更宽松,通过代码演示一下:
class Parent {
public void say(CharSequence str) {
System.out.println("Parent hello " + str);
}
}
class Child extends Parent {
public void say(String str) {
System.out.println("child hello " + str);
}
}
/**
* 测试
*/
public class Test3 {
public static void main(String[] args) {
Parent parent = new Parent();
parent.say("world");
Child child = new Child();
child.say("world");
}
}
//结果如下:
//Parent hello world
//child hello world
以上代码中我们并没有重写父类的方法,只是重载了同名方法,具体的区别是:子类的参数 String 实现了父类的参数 CharSequence。此时执行了子类方法,在实际开发中,通常这不是我们希望的,父类一般是抽象类,子类才是具体的实现类,如果在方法调用时传递一个实现的子类可能就会产生非预期的结果,引起逻辑错误,根据里氏替换的子类的输入参数要宽于或者等于父类的输入参数,我们可以修改父类参数为String,子类采用更宽松的 CharSequence,如果你想让子类的方法运行,就必须覆写父类的方法。代码如下: 备注(CharSequence为String父类)
class Parent {
public void say(String str) {
System.out.println("parent hello " + str);
}
}
class Child extends Parent {
public void say(CharSequence str) {
System.out.println("child hello " + str);
}
}
public class Test3 {
public static void main(String[] args) {
Parent parent = new Parent();
parent.say("world");
Child child = new Child();
child.say("world");
}
}
//结果如下:
//parent hello world
//parent hello world
(4)覆写或实现父类的方法时输出结果可以被缩小
当子类的方法实现父类的抽象方法时,方法的返回值要比父类更严格。代码如下:
abstract class Father {
public abstract Map hello();
}
class Son extends Father {
@Override
public Map hello() {
HashMap map = new HashMap();
System.out.println("son execute");
return map;
}
}
public class Test4 {
public static void main(String[] args) {
Father father = new Son();
father.hello();
}
}
//结果:
//son execute
4.里氏替换原则优点
保证了父类的复用性,同时也能够降低系统出错误的故障,防止误操作,同时也不会破坏继承的机制,这样继承才显得更有意义。
增强程序的健壮性,版本升级是也可以保持非常好的兼容性.即使增加子类,原有的子类还可以继续运行.在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑.
综上所述
继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。
里氏替换原则的目的就是增强程序健壮性,版本升级时也可以保持非常好的兼容性。
四、迪米特法则(俗称:最少知道原则)
迪米特原则(Law of Demeter)又叫最少知道原则(Least Knowledge Principle),意思就是:只与你的朋友们玩耍,不要跟“陌生人”说话。
1.概念
如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
迪米特法则中的“朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。
软件实体之间应该尽量减少交互,不要因为一个类业务的变化而导致另一个类的变化
2.迪米特法则的实现注意事项
从迪米特法则的定义和特点可知,它强调以下两点:
- 从依赖者的角度来说,只依赖应该依赖的对象。
- 从被依赖者的角度说,只暴露应该暴露的方法。
所以,在运用迪米特法则时要注意以下 6 点。
1.在类的划分上,应该创建弱耦合的类。类与类之间的耦合越弱,就越有利于实现可复用的目标。
2.在类的结构设计上,尽量降低类成员的访问权限。
3.在类的设计上,优先考虑将一个类设置成不变类。
4.在对其他类的引用上,将引用其他对象的次数降到最低。
5.不暴露类的属性成员,而应该提供相应的访问器(set 和 get 方法)。
6.谨慎使用序列化(Serializable)功能。
3.迪米特法则的代码实例
明星与经纪人关系实例
分析:明星由于全身心投入艺术,所以许多日常事务由经纪人负责处理,如与粉丝的见面会,与媒体公司的业务洽淡等。这里的经纪人是明星的朋友,而粉丝和媒体公司是陌生人,所以适合使用迪米特法则
public class Test3
{
public static void main(String[] args)
{
Agent agent=new Agent();
agent.setStar(new Star("林心如"));
agent.setFans(new Fans("粉丝韩丞"));
agent.setCompany(new Company("中国传媒有限公司"));
agent.meeting();
agent.business();
}
}
//经纪人
class Agent
{
private Star myStar;
private Fans myFans;
private Company myCompany;
public void setStar(Star myStar)
{
this.myStar=myStar;
}
public void setFans(Fans myFans)
{
this.myFans=myFans;
}
public void setCompany(Company myCompany)
{
this.myCompany=myCompany;
}
public void meeting()
{
System.out.println(myFans.getName()+"与明星"+myStar.getName()+"见面了。");
}
public void business()
{
System.out.println(myCompany.getName()+"与明星"+myStar.getName()+"洽淡业务。");
}
}
//明星
class Star
{
private String name;
Star(String name)
{
this.name=name;
}
public String getName()
{
return name;
}
}
//粉丝
class Fans
{
private String name;
Fans(String name)
{
this.name=name;
}
public String getName()
{
return name;
}
}
//媒体公司
class Company
{
private String name;
Company(String name)
{
this.name=name;
}
public String getName()
{
return name;
}
}
//粉丝韩丞与明星林心如见面了。
//中国传媒有限公司与明星林心如洽淡业务。
五、依赖倒置原则
面向抽象编程,不要面向具体编程。尽量使用抽象耦合代替具体耦合。低耦合就是依赖倒置原则。高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。中心思想是面向接口编程
1.概念:
在Java中,抽象就是指接口或者抽象类,两者都是不能直接被实例化的;细节就是实现类,实现接口或者继承抽象类而产生的就是细节,以关键字new产生对象。
高层与低层
通俗来讲高层模块就是调用端,低层模块就是具体实现类。
2.在JAVA语言中的表现:
------1.模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。
-------2.接口或抽象类不依赖实现类
-------3.实现类依赖接口或抽象类
简单的定义就是“面向接口编程”—OOD(Object-Oriented Design,面向对象设计)的精髓之一。
3.依赖倒置原则的优点:
采用依赖倒置原则可以减少类间的耦合性,提高系统的稳定,降低并行开发引起的风险,提高代码的可读性和可维护性。
4.有关依赖倒置原则的代码实例:
没有用依赖倒置原则会产生什么?
//故事背景:代驾开奔驰
class Driver {
public void drive(Benz benz){
benz.run();
}
}
class Benz {
public void run(){
System.out.println("奔驰行驶----");
}
}
public class Test {
public static void main(String[] args) {
Driver zhangsan = new Driver();
Benz benz = new Benz();
zhangsan.drive(benz);
}
}
//运行结果:奔驰运行
但是张三做为一个代驾,需要随叫随到,不管什么车要送顾客回去。所以张三必须会开任何车。如果现在一个土豪顾客需要张三来开车,而土豪的代步工具是兰博基尼,但是张三没有兰博基尼这个开车方法,还需要修改driver方法,下面引入了依赖倒置原则,代码如下:
//司机接口
interface IDiver{
public void drive(ICar car);
}
//司机实现类
class Driver implements IDiver{
public void drive(ICar car){
car.run();
}
}
//汽车接口
interface ICar{
public void run();
}
//奔驰实现类
class Benz implements ICar{
public void run(){
System.out.println("奔驰车启动");
}
}
//兰博基尼实现类
class Lamborghini implements ICar{
public void run(){
System.out.println("兰博基尼启动");
}
}
//测试类(这里是高层业务逻辑,它对底层模块的依赖(关联)都建立在抽象上)
public class Test {
public static void main(String[] args) {
IDiver zhangsan = new Driver();
ICar benz = new Benz();
ICar Lamborghini = new Lamborghini();
zhangsan.drive(Lamborghini);
zhangsan.drive(benz);
}
}
//结果:
//兰博基尼启动
//奔驰车启动
这样代驾就可以开任何品牌的汽车,而不只是开奔驰,还可以开兰博基尼,添加先的品牌车不需要修改司机类。依赖倒置原则就是代码解耦的原则,通过增加一个抽象层来解耦低层和高层。
六、接口隔离原则
使用专门的接口比用统一接口好,便于项目的组织和分工,不要让开发者面对自己用不到的方法
1.概念
简而言之就是使用多个专门的接口比使用单一的总接口好。
它包含了2层意思:
1.接口的设计原则:接口的设计应该遵循最小接口原则,不要把用户不使用的方法塞进同一个接口里。如果一个接口的方法没有被使用到,则说明该接口没用,应该将其分割成几个功能对应的小接口。
2. 接口的依赖(继承)原则:如果一个接口a继承另一个接口b,则接口a相当于继承了接口b的方法,那么继承了接口b后的接口a也应该遵循上述原则:不应该包含用户不使用的方法。 反之,则说明接口a被b给污染了,应该重新设计它们的关系。
如果用户被迫依赖他们不使用的接口,当接口发生改变时,他们也不得不跟着改变。换而言之,一个用户依赖了未使用但被其他用户使用的接口,当其他用户修改该接口时,依赖该接口的所有用户都将受到影响。这显然违反了开闭原则,也不是我们所期望的。
总而言之,接口分隔原则指导我们:
1 一个类对一个类的依赖应该建立在最小的接口上
2.建立单一的小接口,不要建立繁杂庞大的一个接口
3.尽量细化接口,接口中的方法尽量少
2.接口隔离原则的优点
接口分隔原则从对接口的使用上为我们对接口抽象的颗粒度建立了判断基准:在为系统设计接口的时候,使用多个专门的接口代替单一的胖接口。
符合高内聚低耦合的设计思想,从而使得类具有很好的可读性、可扩展性和可维护性。
注意适度原则,接口分隔要适度,避免产生大量的细小接口。
3.接口隔离实现代码:
//动物属性接口
interface IAnimal{
void eat();
void fly();
void swim();
}
//鸟实现类
class Bird implements IAnimal{
@Override
public void eat() {
}
@Override
public void fly() {
}
@Override
public void swim() {
}
}
//狗实现类
class Dog implements IAnimal{
@Override
public void eat() {
}
@Override
public void fly() {
}
@Override
public void swim() {
}
}
这样使用单一接口,会造成混乱。鸟有了游泳的功能,狗有了飞的功能。这很显然不符合规则,现在用接口隔离原则来优化代码:
//吃的行为
public interface IEatAnimal {
void eat();
}
//飞的行为
public interface IFlyAnimal {
void fly();
}
//游泳的行为
public interface ISwimAnimal {
void swim();
}
//鸟有吃和飞的行为
public class IBird implements IEatAnimal, IFlyAnimal{
@Override
public void eat() {
}
@Override
public void fly() {
}
}
//小狗有吃和游泳的行为
public class IDog implements IEatAnimal, ISwimAnimal{
@Override
public void eat() {
}
@Override
public void swim() {
}
}
接口隔离原则和单一职责原则的区别
单一职责强调的是接口、类、方法的职责是单一的,强调职责,方法可以多,针对程序中实现的细节;
接口分隔原则主要是约束接口,针对抽象、整体框架。
七、单一职责原则
如果一个类需要改变,改变它的理由永远只有一个。如果存在多个改变它的理由,就需要重新设计该类。
1.概念
单一职责原则(高内聚,职责越单一,内聚度越高)
一个类应该只有一个引起它变化的原因,不要让一个类拥有多种变化的理由。即一个类只应该完成一个职责相关的业务,不要让一个类承担过多的职责。粒度大小根据业务来,即简单的职责可以让一个类兼任。复杂的职责必须独立
2.为什么一个类不可以多个职责?
如果一个类具有一个以上的职责,那么就会有多个不同的原因引起该类变化,而这种变化将影响到该类不同职责的使用者(不同用户):
1.如果一个职责使用了外部类库,则使用另外一个职责的用户却也不得不包含这个未被使用的外部类库。
2.某个用户由于某个原因需要修改其中一个职责,另外一个职责的用户也将受到影响,他将不得不重新编译和配置。
这违反了设计的开闭原则,也不是我们所期望的。
3.问题由来:
类T负责两个不同的职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。
4.解决方法:
分别建立两个类T1、T2,使T1完成职责P1功能,T2完成职责P2功能。这样,当修改类T1时,不会使职责P2发生故障风险;同理,当修改T2时,也不会使职责P1发生故障风险。
八、聚合/组合复用原则
能用合成/聚合的地方,绝不用继承。
1.概念
尽量使用聚合/组合完成代码复用,少用继承复用。继承在Java中只能单根继承,不能通过继承实现多个类代码的复用,但聚合/组合可以
2.为什么要尽量使用合成/聚合而不使用类继承?
① 对象的继承关系在编译时就定义好了,所以无法在运行时改变从父类继承的子类的实现
②子类的实现和它的父类有非常紧密的依赖关系,以至于父类实现中的任何变化必然会导致子类发生变化
③ 当你复用子类的时候,如果继承下来的实现不适合解决新的问题,则父类必须重写或者被其它更适合的类所替换,这种依赖关系限制了灵活性,并最终限制了复用性。
总结:这些原则在设计模式中体现的淋淋尽致,设计模式就是实现了这些原则,从而达到了代码复用、增强了系统的扩展性。所以设计模式被很多人奉为经典。我们可以通过好好的研究设计模式,来慢慢的体会这些设计原则。