菜鸟成长系列-面向对象的6种设计原则

2,899 阅读16分钟

菜鸟成长系列拖了一周多了,今天继续之前的思路来进行。按照之前的规划,这篇主要来学习设计原则先关知识。通过本文学习,希望大家一方面能是能够认识这些原则是什么,能够在日常的开发中起到怎样的约束,并且用这些原则来提高代码的复用性和可维护性,另一方面是对后续的设计模式的学习能够有一些基础。

菜鸟成长系列-概述
菜鸟成长系列-面向对象的四大基础特性
菜鸟成长系列-多态、接口和抽象类


设计原则,在java与模式这本书中有提到,用于提高系统可维护性的同时,也提高系统的可复用性。这本书中主要讲了六种设计原则:

  • “开-闭”原则
  • 里氏替换原则
  • 依赖倒置原则
  • 接口隔离原则
  • 单一职责原则
  • 迪特米法则

这些设计原则首先都是复用的原则,遵循这些原则可以有效的提高系统的复用性,同时也提高了系统的可维护性。

“开-闭”原则

网上看到一个人的解释,他是这样来比喻的:一个本子,已经写完了,你不可能撕几张纸粘上去吧,最好的办法是买个新的。
道理就是这样,一个已经做好的程序,不支持修改的,因为修改的话,有可能造成程序无法运行或报错,所以,通常程序只支持扩展,不支持修改。

  • 1.为什么会有这样一个原则来作为程序设计的一种约束呢?
    在软件的生命周期内,由于软件功能或者结构的变化、升级和维护等原因需要对软件原有代码进行修改,在修改的过程中可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且还需要进行软件的重新测试,因此我们希望在软件设计之初,能够用一种原则来进行一些基本的约束,使得在软件后期的功能变更、扩展或者维护更加容易
  • 2.开闭原则解决的问题是什么?
    当软件需要进行改变时,我们应该尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。通过这样一种原则,可以很好的实现在保证原有功能稳定的前提下扩展新的功能
  • 3.什么是开闭原则呢?
    一个软件实体(类、模块或函数)应当对扩展开放,对修改关闭。也就是说在扩展或者修改软件功能时,应尽量在不修改原有代码的情况下进行

举个简单的栗子:现在有这样一个需求,系统需要通过QQ来进行验证登录。OK,我们来撸代码:

  • 用户类User
package com.glmapper.framerwork;
/**
 * 用户信息类
 * @author glmapper
 * @date 2017年12月9日下午10:54:09
 *
 */
public class User {
	private String userName;//用户名
	private String passWord;//密码
	public String getUserName() {
		return userName;
	}
	public void setUserName(String userName) {
		this.userName = userName;
	}
	public String getPassWord() {
		return passWord;
	}
	public void setPassWord(String passWord) {
		this.passWord = passWord;
	}
}

  • QQ核心验证逻辑
package com.glmapper.framerwork;
/**
 * QQ验证器
 * @author glmapper
 * @date 2017年12月9日下午10:49:24
 */
public class QQAuther {
	/**
	 * 用于验证QQ登录信息
	 */
    public boolean validateQQ(User user)
    {
        //模拟下逻辑
        return user.toString()==null?false:true;
    }
}

  • 核心验证服务类
package com.glmapper.framerwork;
/**
 * 
 * 用于验证的核心服务
 * @author glmapper
 * @date 2017年12月9日下午10:47:04
 *
 */
public class AuthService {
	//持有一个QQ验证器对象
	private QQAuther qqAuther;
	//通过构造器注入qqAuther对象
	public AuthService(QQAuther qqAuther) {
		this.qqAuther = qqAuther;
	}
	/*
	 * 验证用户合法性
	 */
	public boolean validateUser(User user){
		return qqAuther.validateQQ(user);
	}
}

  • 客户端
package com.glmapper.framerwork;
/**
 * 客户端调用验证
 * @author glmapper
 * @date 2017年12月9日下午10:50:13
 *
 */
public class AuthClient {
	
	public static void main(String[] args) {
		//获取用户信息
		User user = UserHolder.getUser();
		QQAuther qqAuther = new QQAuther();
		AuthService authService = new AuthService(qqAuther);
		//获取验证结果
		boolean isOK = authService.validateUser(user);
		System.out.println(isOK);
	}
}

OK,完事了!但是现在需要接入微博的开放平台接口;修改代码...。 增加一个微博验证器:

package com.glmapper.framerwork;
/**
 * 微博核心验证器
 * @author glmapper
 * @date 2017年12月9日下午11:01:10
 */
public class WeiBoAuther {
	/**
	 * 用于验证QQ登录信息
	 */
    public boolean validateWeiBo(User user)
    {
        return user.toString()==null?false:true;
    }
}

核心验证服务修改:

package com.glmapper.framerwork;
/**
 * 
 * 用于验证的核心服务
 * @author glmapper
 * @date 2017年12月9日下午10:47:04
 *
 */
public class AuthService {
	//持有一个QQ验证器对象
	private Object obj;
	//通过构造器注入qqAuther对象
	public AuthService(Object obj) {
		this.obj = obj;
	}
	/*
	 * 验证用户合法性
	 */
	public boolean validateUser(User user){
	    //这里仅作为模拟,一般情况下会通过使用定义枚举&工厂模式来完成
		if (obj instanceof QQAuther) {
			return new QQAuther().validateQQ(user);
		}
		if(obj instanceof WeiBoAuther){
			return new WeiBoAuther().validateWeiBo(user);
		}
		return false;
	}
}

客户端改变:

package com.glmapper.framerwork;
/**
 * 客户端调用验证
 * @author glmapper
 * @date 2017年12月9日下午10:50:13
 *
 */
public class AuthClient {
	
	public static void main(String[] args) {
		//获取用户信息
		User user = UserHolder.getUser();
		
		//QQ
		QQAuther qqAuther = new QQAuther();
		boolean isQQOK = new AuthService(qqAuther).validateUser(user);
		System.out.println(isQQOK);
		
		
		//微博
		WeiBoAuther weiBoAuther = new WeiBoAuther();
		boolean isWeiBoOK = new AuthService(weiBoAuther).validateUser(user);
		System.out.println(isWeiBoOK);
	}
}

OK,改进完成!但是又有新的需求,接入微信....。假如我们现在把微信开放平台也接入了,然后又来需求要接入支付宝账户、苏宁易购账户等等。。。就需要不断的修改代码。那么这个时候就需要在设计之初用到我们的开闭原则来做一个约束了。继续撸:
首先我们需要需要定义一个接口用于约束:

  • 验证器接口,用于被QQ/WEIBO/微信/苏宁易购等开发平台验证器实现
package com.glmapper.framerwork;
/**
 * 定义一个约束接口 
 * @author glmapper
 * @date 2017年12月9日下午11:32:32
 *
 */
public interface ValidateInteface {
	/**
	 * 提供一个验证入口
	 */
	boolean validate(User user);
}

  • QQ修改之后
package com.glmapper.framerwork;
/**
 * QQ验证器
 * @author glmapper
 * @date 2017年12月9日下午10:49:24
 */
public class QQAuther implements ValidateInteface{
	/**
	 * 用于验证QQ登录信息
	 */
	@Override
	public boolean validate(User user) {
		return user.toString()==null?false:true;
	}
}

  • 微博修改之后
package com.glmapper.framerwork;
/**
 * 微博核心验证器
 * @author glmapper
 * @date 2017年12月9日下午11:01:10
 */
public class WeiBoAuther implements ValidateInteface{
	/**
	 * 用于验证QQ登录信息
	 */
	@Override
	public boolean validate(User user) {
		// TODO Auto-generated method stub
		 return user.toString()==null?false:true;
	}
}
  • 核心验证服务
package com.glmapper.framerwork;
/**
 * 用于验证的核心服务
 * @author glmapper
 * @date 2017年12月9日下午10:47:04
 */
public class AuthService {
	//持有一个QQ验证器对象
	private ValidateInteface validate;
	//通过构造器注入qqAuther对象
	public AuthService(ValidateInteface validate) {
		this.validate = validate;
	}
	/*
	 * 验证用户合法性
	 */
	public boolean validateUser(User user){
		return validate.validate(user);
	}
}

  • 客户端
package com.glmapper.framerwork;
/**
 * 客户端调用验证
 * @author glmapper
 * @date 2017年12月9日下午10:50:13
 *
 */
public class AuthClient {
	public static void main(String[] args) {
		//获取用户信息
		User user = UserHolder.getUser();
		//QQ
		ValidateInteface qqAuther = new QQAuther();
		boolean isQQOK = new AuthService(qqAuther).validateUser(user);
		System.out.println(isQQOK);
		//微博
		ValidateInteface weiBoAuther = new WeiBoAuther();
		boolean isWeiBoOK = new AuthService(weiBoAuther).validateUser(user);
		System.out.println(isWeiBoOK);
	}
}

改进之后我们可以发现,对于原来的核心验证服务类、各验证器类,无论增加什么方式接入,我们都不需要去修改它的代码了。而此时我们需要做的就是新增一个验证器(例如苏宁易购验证器),然后继承ValidateInterface接口就行了。总体来首,开闭原则的核心是:

  • 抽象化
  • 对可变性的封装原则(1.不可变性不应该散落在代码的多处,而应当被封装到一个对象里面;2.一种可变性不应当与另外一种可变性混合在一起)

(大家如果有更简单暴力的例子,可以留言;这个例子想了很多都感觉不是很恰当,还是从工作中抽象出来的)。

里氏替换原则

任何父类可以出现的地方,子类一定可以出现
里氏替换原则算是对“开闭”原则的补充,上面也提到,实现“开闭”原则的关键步骤是抽象化,而父类与子类的继承关系就是抽象化的一种具体体现,所以里氏替换原则是对实现抽象化的具体步骤的规范。

摘自java与模式中的定义:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。

下图中描述了一种继承关系,从最高层的动物一直衍生出具体的动物。OK,写一段断码来看看:

  • 顶层抽象父类-Animal
package com.glmapper.framework.model.lsp;
/**
 * 顶层抽象父类动物类
 * @author glmapper
 * @date 2017年12月10日上午10:51:30
 */
public abstract class Animal {
	//提供一个抽象方法,以供不同子类来进行具体的实现
	public abstract void eatFood(String foodName);
}
  • 具体动物类型-Dog
 package com.glmapper.framework.model.lsp;
/**
 *子类-小狗
 * @author glmapper
 * @date 2017年12月10日上午10:54:17
 *
 */
public class Dog extends Animal{
	@Override
	public void eatFood(String foodName) {
		System.out.println("小狗吃"+foodName);
	}
}
  • 具体动物-哈士奇
 package com.glmapper.framework.model.lsp;
/**
 * 具体小狗的种类-子类哈士奇
 * @author glmapper
 * @date 2017年12月10日上午10:56:59
 *
 */
public class HSQDog extends Dog{
	/**
	 * 重写父类方法
	 */
	@Override
	public void eatFood(String foodName) {
		System.out.println("哈士奇吃"+foodName);
	}
}
  • 客户端
package com.glmapper.framework.model.lsp;
//客户端程序
public class ClientMain {
	public static void main(String[] args) {
		//子类
		HSQDog hsqdog=new HSQDog();
		hsqdog.eatFood("饼干");
		//父类
		Dog dog = new HSQDog();
		dog.eatFood("饼干");
		//顶层父类
		Animal animal = new HSQDog();
		animal.eatFood("饼干");
	}
}
  • 运行结果
哈士奇吃饼干
哈士奇吃饼干
哈士奇吃饼干

可以看出我们最开始说的那句话任何父类可以出现的地方,子类一定可以出现,反过来是不成立的。我的理解是子类通过集成获取的父类的属性和行为,并且子类自身也具有自己的属性和行为;父类可以出现的地方必然是需要用到父类的属性或者行为,而子类都涵盖了父类的这些信息,因此可以做到替换。反过来不行是因为父类在上述的例子中只是充当了一种类型约束,它可能不具有子类的某些特征,因此就无法做到真正的替换。

里氏替换原则是继承复用的基石,只有当子类可以替换掉基类,软件单位的功能不会受到影响时,基类才能被真正的复用,而子类也才能够在基类的基础上增加新的功能。

依赖倒转原则

实现“开闭”原则的关键是抽象化,并且从抽象化导出具体化实现。如果说开闭原则是面向对象设计的目标的话,依赖倒转原则就是面向对象设计的主要机制(java与模式)。
依赖倒转原则:要依赖与抽象,不依赖于具体实现。

怎么理解呢?

  • 1)高层模块不应该直接依赖于底层模块的具体实现,而应该依赖于底层的抽象。换言之,模块间的依赖是通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。

  • 2)接口和抽象类不应该依赖于实现类,而实现类依赖接口或抽象类。这一点其实不用多说,很好理解,“面向接口编程”思想正是这点的最好体现

首先是第一点,从复用的角度来说,高层次的模块是设计者应当复用的。但是在传统的过程性的设计中,复用却侧重于具体层次模块的复用。比如算法的复用,数据结构的复用,函数库的复用等,都不可避免是具体层次模块里面的复用。较高层次的结构依赖于较低层次的结构,然后较低层次的结构又依赖于更低层次的结构,直到依赖到每一行代码为止。然后对低层次修改也会逐层修改,一直到最高层的设计模块中。

对于一个系统来说,一般抽象层次越高,它的稳定性就越好,因此也是作为复用的重点

“倒转”,实际上就是指复用应当将复用的重点放在抽象层上,如果抽象层次的模块相对独立于具体层次模块的话,那么抽象层次的模块的复用便是相对较为容易的了。

在很多情况下,一个java程序需要引用一个对象,如果这个对象有一个抽象类型的话,应当使用这个抽象类型作为变量的静态类型。 在上面我们画了动物和小狗的类图关系,在客户端调用的时候有三种方式:

//子类(方式1)
HSQDog hsqdog=new HSQDog();
hsqdog.eatFood("饼干");
//父类(方式2)
Dog dog = new HSQDog();
dog.eatFood("饼干");
//顶层父类(方式3)
Animal animal = new HSQDog();
animal.eatFood("饼干");

如果我们需要一个哈士奇(HSQDog)的话,我们不应当使用方式1,而是应当使用方式2或者方式3。

接口隔离原则

接口隔离原则:使用多个专门的接口比使用单一的总接口要好。换句话说,从一个客户类的角度来讲:一个类对另外一个类的依赖性应当是建立在最小的接口上的。 这个其实在我们实际的开发中是经常遇到的。比如我们需要编写一个完成一个产品的一些操作接口。

package com.glmapper.framework.model.isp;
/**
 * 一个产品服务接口
 * @author glmapper
 * @date 2017年12月10日下午12:01:31
 */
public interface ProductService {
	//增加产品
	public int addProduct(Product p);
	//删除产产品
	public int deleteProduct(int pId);
	//修改产品
	public int updateProduct(Product p);
	//查询一个产品
	public Product queryProduct(int pId);
}

OK,我们在ProductService中提供了对产品的增删改查;但是随着需求升级,我们需要可以增加对产品新的批量导入和导出。OK,这时在接口中继续新增两个方法:

//从excel中批量导入
public void batchImportFromExcel();
//从excel中批量导导出
public void batchExportFromExcel();

然后需求又需要扩展,需要增加增加购买产品、产品订单生产、查询订单、订单详情....;这样一来,我们的ProductService就会慢慢的急速膨胀。与此对应的具体的实现逻辑ProductServiceImpl类也会变得非常的庞大,可能单类会超过数千行代码。

那么我们就需要进行接口隔离,将产品的基本操作如增删改查放在一个接口,将产品订单处理放在一个接口,将产品申购放在一个接口,将批量操作放在一个接口等等...对于每一个接口我们只关心某一类特定的职责,这个其实就是和单一职责原则有点挂钩了。 通过这种设计,降低了单个接口的复杂度,使得接口的“内聚性”更高,“耦合性”更低。由此可以看出接口隔离原则的必要性。

迪特米法则

迪特米法则:又称为最少知识原则,就是说一个对象应当对其他对象尽可能少的了解;看下迪特米法则的几种表述:
1.只与你直接的朋友们通信
2.不跟陌生人说话
3.每一个软件单位对其他的单位都只有最少知识,而且局限于那些与本单位密切相关的软件单位

也就是说,如果两个雷不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果其中一个类需要电泳另一个类的某一个方法的话,可以通过第三者进行消息的转发。代码看下:

  • 某个人
package com.glmapper.framework.model.isp;
/**
 * 某个人
 * @author glmapper
 * @date 2017年12月10日下午12:39:45
 */
public class SomeOne {
	//具体oprateion行为
	public void oprateion(Friend friend){
		Stranger stranger =friend.provide();
		stranger.oprateion3();
	}
}
SomeOne具有一个oprateion方法,该方法接受Friend为参数,根据上面的定义可以知道Friend是SomeOne的“朋友”(直接通信了)
  • 朋友
package com.glmapper.framework.model.isp;
/**
 * 朋友
 * @author glmapper
 * @date 2017年12月10日下午12:40:09
 */
public class Friend {
	private Stranger stranger = new Stranger();
	public Stranger provide(){
		return stranger;
	}
	public void opration2(){
	}
}
很明显SomeOne的opration方法不满足迪特米法则,因为这个方法中涉及到了陌生人Stranger,Stranger不是SomeOne的朋友

OK,我们来通过迪特米法则进行改造。

  • 改造之后的SomeOne
package com.glmapper.framework.model.isp;
/**
 * 某个人
 * @author glmapper
 * @date 2017年12月10日下午12:39:45
 *
 */
public class SomeOne {
	//具体oprateion行为
	public void oprateion(Friend friend){
		friend.forward();
	}
}
  • 改造之后的朋友
package com.glmapper.framework.model.isp;
/**
 * 朋友
 * @author glmapper
 * @date 2017年12月10日下午12:40:09
 *
 */
public class Friend {
	private Stranger stranger = new Stranger();
	public void opration2(){
		
	}
	//进行转发
	public void forward() {
		stranger.oprateion3();
	}
}

由于调用了转发,因此SomeOne中就不会和陌生人Stranger直接的关系就被忽略了。满足了直接和朋友通信、不与陌生人说话的条件。
但是迪特米法则带来的问题也是很明显的:即会在系统中造出大量的小方法散落在系统的各个角落,这些方法仅仅是传递消息的调用,与系统的业务逻辑没有任何关系。

单一职责

上面在接口隔离中有提到过,单一职责其实很好理解,解释尽量的使得我们的每一个类或者接口只完成本职工作以内的事情,不参与其他任何逻辑。比如说苹果榨汁机我就只用来榨苹果汁,如果你需要榨黄瓜汁的话,你就得买一个黄瓜榨汁机。

总结

OK ,至此,设计原则部分就复习完了。总结一下:

    1. 单一职责原则要求实现类要职责单一;
    1. 里氏替换原则要求不要去破坏继承系统;
    1. 依赖倒置原则要求面向接口编程;
    1. 接口隔离原则要求在设计接口的时候要精简单一;
    1. 迪米特法则要求要降低耦合;
    1. 开闭原则是总纲,要求对扩展开发,对修改关闭。

大家周末愉快!(如果有不当之处,希望大家及时指出,多谢!)