六大设计原则(上)

本文探讨了编程中的两大基本原则——单一职责原则和里氏替换原则。单一职责原则强调类或方法应有单一职责,以降低复杂性和提高可维护性。里氏替换原则指出子类应能在所有父类出现的地方替换父类,且行为一致,确保继承的正确性。文章通过实例详细解释了这两个原则,并讨论了它们在实际开发中的应用和注意事项。
摘要由CSDN通过智能技术生成

​ 为了督促自己读书,开启了三十天读一本书的计划,第一本书就是《设计模式之禅》,今天正式开启读书计划,在博客中进行读书笔记的整理,《设计模式之禅》中第一章内容没有讲述设计模式,而是先介绍了编码的六大原则,我将会依照该书的顺序进行整理。下边介绍软件设计中的六大原则中的两个。

1.单一职责原则

定义:一个类(或者方法)的职责应该是单一的,应该有且仅有一个原因引起类(或者方法)的变更。 说的通俗一点就是一个类(或者方法)只允许干一件事,不能把所有的事情都揉在一起。

类的单一职责

​ 举个例子:

public interface Iphone{
  //拨打电话
	public void dial(String phoneNumber);
  //通话
  public void chat(Object o);
  //挂断电话
  public void hangup();
}

​ 上边这个电话接口类包括了拨打电话、保持通话、挂断电话三个动作,我们一般写电话的接口都是这样写的,会有什么问题呢?其实如果从单一职责原则的角度来看,这个接口并不符合,这个接口其实包含了两个职责,一个是协议管理一个是数据传送。三个方法中拨打电话和挂断电话都是协议管理职责,保持通话是数据传送职责。这样无论是协议的变化还是数据传送的变化都会引起这个接口的变动,也就是有两个原因会引起接口的变化,所以在理论上是不符合职责单一原则的。

​ 那么如何更改呢?既然是两个职责那么我们拆分成两个接口不就可以了吗?如下:

​ 上图我们将一个接口拆成了协议管理的接口和数据传送管理的接口,如果我们想实现电话类那么我们可以同时实现这两个接口。这样就完成了单一职责原则。但是我们发现我们在强调的是接口的单一职责而不是类的单一职责,最终Ipone类还不是单一的,还是会有两个原因引起变化,但是我们是面向接口编程,并且类的接口单一是很难实现的,只能依靠这种方式。这会引起类间的耦合过重,类的数量增加问题。

​ 看到这里我们想是不是工作中都要用这种方式写代码呢?答案肯定是否定的,因为职责是很难划分的它没有一个量化的标准。这个职责是需要从实际项目出发,根据项目自己去评估,它并不是一个统一的标准。如果我们只是照科宣本会带来很多问题,如果我们严格按照学究理论来进行开发可能会造成类的膨胀,使得后期的维护成本增加。所以单一职责只是提供了一个写程序的标准,具体的实现还是需要因项目而异,因环境而异。

方法的单一职责

​ 上边提到了类的职责单一原则,其实对于方法而言也是同样适用这个原则的,并且对于方法而言更好理解更好实现一点。如下图:

上图左侧的接口一个更新用户信息的方法中同时更新了很多的用户相关的信息,如果要是全部更新所有信息可能会方便一点,但是如果遇到更新某一个信息,可能就难以维护了。如果改为右侧的接口,想要更新某个信息只需要调用相应的方法就可以了,不仅开发简单并且便于日后维护。

职责单一原则的好处

  1. 类的复杂性降低,实现什么职责都有清晰明确的定义。
  2. 可读性提高,可维护性提高。
  3. 变更引起的风险更低

2.里氏替换原则

定义:里氏是人名(Liskov),替换就是所有引用基类的地方,都能够被子类的对象替换。通俗点说就是只要父类能出现的地方子类就可以出现,并且替换成子类也不会出现任何错误。但是反过来就不可以了,有子类出现的地方父类未必可以适应。

官方定义:What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为S的对象o1,都有类型为T的对象o2,是的以T定义的所有程序P在所有的对象o1都代换成o2时,程序p的行为没有发生变化,那么类型S是类型T的字类型。)

里氏替换原则其实为良好的继承定义了一个规范,定义了父子关系之前应当符合什么样的标准,即类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。同时这个定义包含了四层含义。

子类必须完全实现父类的方法

​ 父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。

​ 这一层含义直白点讲就是父类会的子类也必须会,且不能改变实现。如下图:

上图我们定义了枪的抽象类-AbstractGun,其中这个类包含了射击这个方法,他的三个子类都要去实现这个射击的方法,这时在士兵类(Soldier)中定义了方法setGun(),方法的参数是AbstractGun类型的,此时对于这个方法而言,它不必要关心到底传入的是手枪、步枪还是机枪,它只需要调用AbstractGun的shoot()方法即可。

注意:在类中调用其他类时务必要使用父类或者接口,如果不能使用父类或者接口,则说明的类的设计已经违背了里氏替换原则。

​ 如果此时有个玩具枪(ToyGun),它虽然拥有真枪的很多属性,但是它却不能射击,所以对于玩具枪来说它就没法去实现shoot()方法,对于这种情况如果让ToyGun强行继承AbstractGun此时就是违反里氏替换原则的,那么应该如何设计呢?如下图:

通过这种设计让ToyGun脱离原来的继承关系,可以让AbstractToy和AbstractGun拥有依赖关系,将玩具枪的形状声音都委托给AbstractGun,两个基类下的子类自由延展互不影响。

注意:如果子类不能完整的实现父类的方法,或者父类的方法在子类中已经发生了"畸变",则建议断开父子继承关系,采用依赖、聚集、组合等关系替代继承。

子类可以有自己的个性

​ 这层含义理解起来很简单,从里氏替换原则来看就是有子类出现的地方父类未必就可以出现。在子类继承父类以后肯定也会拥有自己的一些属性和方法,这是子类自己的扩展,这也就是说在子类出现的地方父类未必可以胜任,因为父类没有子类的扩展属性。

覆盖或者实现父类的方法时输入参数可以被放大

先定义一个父类:

public class Father{
  public Collection doSomthing(HashMap map){
    System.out.println("父类被执行......");
    return map.values();
  }
}

再定义一个子类:

public class Son extends Father{
  	//放大了入参的类型
    public Collection doSomthing(Map map){
    	System.out.println("子类被执行......");
    	return map.values();
  	}
}

在这个例子中doSomthing()方法,子类不是覆写而是重载,因为方法名字相同但是输入的参数不同。根据里氏替换原则我们实现一下场景类。

public class Client{
  public static void invoker(){
    //父类
    Father f=new Father();
    HashMap map=new HashMap();
    f.doSomthing(map);
  }
  public static void main(String[] args){
    invoker();
  }
}
public class Client{
  public static void invoker(){
    //子类
    Son s=new Son();
    HashMap map=new HashMap();
    s.doSomthing(map);
  }
  public static void main(String[] args){
    invoker();
  }
}

如果父类可以出现的地方子类就可以出现,并且在没有覆写的情况下,那么两个结果应当是相同的,我们可以看到最终的执行结果确实相同。但是我们反过来会出现什么情况呢,也就是说如果Son类的方法参数如果缩小会产生什么问题呢?

先定义一个父类:

public class Father{
  public Collection doSomthing(Map map){
    System.out.println("父类被执行......");
    return map.values();
  }
}

再定义一个子类:

public class Son extends Father{
  	//放大了入参的类型
    public Collection doSomthing(HashMap map){
    	System.out.println("子类被执行......");
    	return map.values();
  	}
}

再次执行场景类就会发现,父类的场景类执行结果为“父类被执行…”,子类的执行结果为“子类被执行…”,结果变了!在没有覆写的情况下子类被执行了,父类出现的地方子类不能出现了,影响了实际的业务逻辑。里氏替换原则告诉我们子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更加宽松。

覆写或者实现父类的方法时输出结果可以被缩小

​ 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。如果父类的一个方法的返回值是类型T,子类的相同方法的返回值为S,里氏替换原则要求S必须小于或者等于T。因为覆写的要求就是要S小于T,如果出现S大于T编译阶段就会报错。

总结:我们发现在我们的开发中其实还是很容易就会违背里氏替换原则的,虽然有时业务上不会出现很大的问题,但是写代码有错误的几率也是大大增加的。

​ 上图我们将一个接口拆成了协议管理的接口和数据传送管理的接口,如果我们想实现电话类那么我们可以同时实现这两个接口。这样就完成了单一职责原则。但是我们发现我们在强调的是接口的单一职责而不是类的单一职责,最终Ipone类还不是单一的,还是会有两个原因引起变化,但是我们是面向接口编程,并且类的接口单一是很难实现的,只能依靠这种方式。这会引起类间的耦合过重,类的数量增加问题。

​ 看到这里我们想是不是工作中都要用这种方式写代码呢?答案肯定是否定的,因为职责是很难划分的它没有一个量化的标准。这个职责是需要从实际项目出发,根据项目自己去评估,它并不是一个统一的标准。如果我们只是照科宣本会带来很多问题,如果我们严格按照学究理论来进行开发可能会造成类的膨胀,使得后期的维护成本增加。所以单一职责只是提供了一个写程序的标准,具体的实现还是需要因项目而异,因环境而异。

方法的单一职责

​ 上边提到了类的职责单一原则,其实对于方法而言也是同样适用这个原则的,并且对于方法而言更好理解更好实现一点。如下图:

在这里插入图片描述

上图左侧的接口一个更新用户信息的方法中同时更新了很多的用户相关的信息,如果要是全部更新所有信息可能会方便一点,但是如果遇到更新某一个信息,可能就难以维护了。如果改为右侧的接口,想要更新某个信息只需要调用相应的方法就可以了,不仅开发简单并且便于日后维护。

职责单一原则的好处

  1. 类的复杂性降低,实现什么职责都有清晰明确的定义。
  2. 可读性提高,可维护性提高。
  3. 变更引起的风险更低

2.里氏替换原则

定义:里氏是人名(Liskov),替换就是所有引用基类的地方,都能够被子类的对象替换。通俗点说就是只要父类能出现的地方子类就可以出现,并且替换成子类也不会出现任何错误。但是反过来就不可以了,有子类出现的地方父类未必可以适应。

官方定义:What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为S的对象o1,都有类型为T的对象o2,是的以T定义的所有程序P在所有的对象o1都代换成o2时,程序p的行为没有发生变化,那么类型S是类型T的字类型。)

里氏替换原则其实为良好的继承定义了一个规范,定义了父子关系之前应当符合什么样的标准,即类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。同时这个定义包含了四层含义。

子类必须完全实现父类的方法

​ 父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。

​ 这一层含义直白点讲就是父类会的子类也必须会,且不能改变实现。如下图:

在这里插入图片描述

上图我们定义了枪的抽象类-AbstractGun,其中这个类包含了射击这个方法,他的三个子类都要去实现这个射击的方法,这时在士兵类(Soldier)中定义了方法setGun(),方法的参数是AbstractGun类型的,此时对于这个方法而言,它不必要关心到底传入的是手枪、步枪还是机枪,它只需要调用AbstractGun的shoot()方法即可。

注意:在类中调用其他类时务必要使用父类或者接口,如果不能使用父类或者接口,则说明的类的设计已经违背了里氏替换原则。

​ 如果此时有个玩具枪(ToyGun),它虽然拥有真枪的很多属性,但是它却不能射击,所以对于玩具枪来说它就没法去实现shoot()方法,对于这种情况如果让ToyGun强行继承AbstractGun此时就是违反里氏替换原则的,那么应该如何设计呢?如下图:
在这里插入图片描述

通过这种设计让ToyGun脱离原来的继承关系,可以让AbstractToy和AbstractGun拥有依赖关系,将玩具枪的形状声音都委托给AbstractGun,两个基类下的子类自由延展互不影响。

注意:如果子类不能完整的实现父类的方法,或者父类的方法在子类中已经发生了"畸变",则建议断开父子继承关系,采用依赖、聚集、组合等关系替代继承。

子类可以有自己的个性

​ 这层含义理解起来很简单,从里氏替换原则来看就是有子类出现的地方父类未必就可以出现。在子类继承父类以后肯定也会拥有自己的一些属性和方法,这是子类自己的扩展,这也就是说在子类出现的地方父类未必可以胜任,因为父类没有子类的扩展属性。

覆盖或者实现父类的方法时输入参数可以被放大

先定义一个父类:

public class Father{
  public Collection doSomthing(HashMap map){
    System.out.println("父类被执行......");
    return map.values();
  }
}

再定义一个子类:

public class Son extends Father{
  	//放大了入参的类型
    public Collection doSomthing(Map map){
    	System.out.println("子类被执行......");
    	return map.values();
  	}
}

在这个例子中doSomthing()方法,子类不是覆写而是重载,因为方法名字相同但是输入的参数不同。根据里氏替换原则我们实现一下场景类。

public class Client{
  public static void invoker(){
    //父类
    Father f=new Father();
    HashMap map=new HashMap();
    f.doSomthing(map);
  }
  public static void main(String[] args){
    invoker();
  }
}
public class Client{
  public static void invoker(){
    //子类
    Son s=new Son();
    HashMap map=new HashMap();
    s.doSomthing(map);
  }
  public static void main(String[] args){
    invoker();
  }
}

如果父类可以出现的地方子类就可以出现,并且在没有覆写的情况下,那么两个结果应当是相同的,我们可以看到最终的执行结果确实相同。但是我们反过来会出现什么情况呢,也就是说如果Son类的方法参数如果缩小会产生什么问题呢?

先定义一个父类:

public class Father{
  public Collection doSomthing(Map map){
    System.out.println("父类被执行......");
    return map.values();
  }
}

再定义一个子类:

public class Son extends Father{
  	//放大了入参的类型
    public Collection doSomthing(HashMap map){
    	System.out.println("子类被执行......");
    	return map.values();
  	}
}

再次执行场景类就会发现,父类的场景类执行结果为“父类被执行…”,子类的执行结果为“子类被执行…”,结果变了!在没有覆写的情况下子类被执行了,父类出现的地方子类不能出现了,影响了实际的业务逻辑。里氏替换原则告诉我们子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更加宽松。

覆写或者实现父类的方法时输出结果可以被缩小

​ 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。如果父类的一个方法的返回值是类型T,子类的相同方法的返回值为S,里氏替换原则要求S必须小于或者等于T。因为覆写的要求就是要S小于T,如果出现S大于T编译阶段就会报错。

总结:我们发现在我们的开发中其实还是很容易就会违背里氏替换原则的,虽然有时业务上不会出现很大的问题,但是写代码有错误的几率也是大大增加的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值