单一职责原则(SRP)
定义:简单来说就是一个类中应该是一种相关性很高的函数、数据的封装
示例一:用户接口
上面类图可以看出用户的接口设计得有问题,用户的属性和用户的行为没有分开。应该把用户信息抽成一个BO(Business Object,业务对象),把行为抽成一个Biz(Business Logic,业务逻辑)。按照这个逻辑对类图进行修正。
重新拆分成两个接口,IUserBO 负责用户的属性,IUserBiz 负责用户的行为。在使用过程中可以区分使用。
......
IUserInfo userInfo = new UserInfo();
// 在需要使用用户属性的时候我们使用IUserBO
IUserBO userBO = (IUserBO)userInfo;
userBO.setPassWord("passWord");
// 在需要使用用户行为的时候我们使用IUserBiz
IUserBiz userBiz = (IUserBiz)userInfo;
userBiz.changePassword("changePasswrod");
以上我我们把一个接口拆成两个接口的动作,就是依赖的单一职责原则,那么什么是单一职责原则呢?
单一职责原则的定义:应该有且仅有一个原因引起类或接口的变更。
示例二:电话过程
public interface IPhone{
//拨打电话
public void dial(String phoneNumber);
// 通话
public void chat(Object o);
//通话结束,挂电话
public void hangup();
}
一个拨打电话的接口,看起来这个接口没什么问题,拥有拨打电话,通话,挂断电话的功能,但是仔细观察可以看出 IPhone 这个接口不是只有一个职责,它包含了两个职责:一个是协议管理,一个是数据的传送。dial() 和 hangup() 两个方法实现的是协议管理,分别负责拨号接通和挂机,chat() 实现的数据的传送。首先协议接通的变化会引起这个接口或实现类的变化,数据传送同样会引起这个接口或实现类的变化,所以我们应该将上面的接口根据职责重新定义成两个接口。
一类实现两个接口,把两个职责融合到一个类中,你会觉得这个 Phone 有两个职责了,但是别忘记我们是面向接口编程,我们对外公布的是接口而不是类。
示例三
单一职责适用于接口、类,同事也适用于方法,一个方法尽可能做一件事情。比如一个方法修改用户密码,不要把这个方法放到修改用户信息方法中,上面这个方法的颗粒度很粗。 按照单一职责原则去修改上面的方法,如下图。
通过上面的例子我们来总结一下单一职责原则有什么好处:
- 类的复杂性降低,实现什么职责都有清晰明确的定义
- 可读性提高
- 可维护性提高
- 变更引起的风险降低,变更是必不可少的,如果接口单一职责做得好,一个接口的修改只对相应的实现类有影响,对其他接口无影响,这对系统的扩展性,维护性都有非常大的帮助
单一职责最难划分的就是一个职责,一个职责就是一个接口,但是问题是是 “职责“ 没有一个量化的标准,一个类到底要负责哪些职责?这些职责要怎样去细化?细化之后是否都要一个接口或者类,这些都需要从项目的实际项目去考虑,项目要考虑可变因素和不可变因素,以及相关的收益成本比率。
注意:单一职责原则提出了一个编写程序的标准,用 “职责” 或者 “变化原因” 来衡量接口或者类的设计是否优良,但是 “职责“ 和 “变化原因” 都是不可度量的, 因项目而异,因环境而异。
里氏替换原则(LSP)
定义:所有引用基类的地方必须能够透明的使用其子类的对象。
在面向对象语言中,继承是必不可少的、非常优秀的语言机制。
优点:
- 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性
- 提高代码的重用性
- 提高代码的可扩展性
- 提高产品或者项目的开放性
缺点:
- 继承是入侵性的,只要继承,就必须拥有父类的方法和属性
- 降低代码的灵活性
- 增强了耦合性,当父类的常量、变量和方法修改时,需要考虑子类的修改
Java用extends关键字来实现继承,它采用了单一继承规则,而C++采用多重继承的规则,一个类可以继承多个父类,从整体上看,利大于弊,怎么才能让 ”利“ 的因素发挥最大的作用, 同时减少 ”弊” 带来的麻烦呢,解决方案是引入 里氏替换原则。
示例一
/** * 枪支的抽象类 */
public abstract class AbstractGun {
public abstract void shoot();
}
/** * 手枪的实现类 */
public class Handgun extends AbstractGun {
@Override
public void shoot() {
System.out.println("手枪射击...");
}
}
/** * 步枪的实现类 */
public class Rifle extends AbstractGun {
@Override
public void shoot() {
System.out.println("步枪射击...");
}
}
/** * 机枪的实现类 */
public class MachineGun extends AbstractGun {
@Override
public void shoot() {
System.out.println("机枪射击...");
}
}
/** * 士兵的类 */
public class Soldier {
private AbstractGun mGun;
/**
* 给士兵一把枪
* @param gun 传入的枪支
*/
public void setGun(AbstractGun gun) {
this.mGun = gun;
}
public void killEnemy() {
System.out.println("士兵开始杀人...");
mGun.shoot();
}
}
/** * 场景类 */
public class Client {
public static void main(String[] args){
//产生士兵
Soldier soldier = new Soldier();
//给士兵一把步枪
soldier.setGun(new Rifle());
soldier.killEnemy();
}
}
运行结果:
士兵开始杀人...
步枪射击...
在这个程序中,我们给了这个士兵一把步枪,然后就开始杀敌了,如果士兵要使用机枪,当然也可以,直接把 soldier.setGun(new Rifle()) 修改为 soldier.setGun(new MachineGun()) 即可,在编写程序时,Soldier 士兵类根本就不用知道是那种枪(子类)被传入。
注意:在类中调用其他类时务必要使用父类或者接口,如果不能使用父类或接口,则说明类的设计已经违背LSP原则
示例二
/** * 玩具枪的实现类 */
public class ToyGun extends AbstractGun {
@Override
public void shoot() {
//玩具枪不能用来射击,杀不死人
}
}
/** * 场景类 */
public class Client {
public static void main(String[] args){
//产生士兵
Soldier soldier = new Soldier();
//给士兵一把步枪
soldier.setGun(new ToyGun());
soldier.killEnemy();
}
}
增加一个玩具枪的是实现类,但是玩具枪是不能射击的,所以shoot()不能实现,代码运行如下:
结果:
士兵开始杀人...
这里的士兵拿着玩具枪不能杀人,在这种情况下,我们的业务调用类已经出现了问题,正常的业务逻辑已经不能运行。针对这种情况有联众解决方案:
- 在Soldier类中增加 instanceof的判断,如果是玩具枪,就不能杀人,这个方法可以解决问题,但是在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,如果你的产品出现了问题,因为修正了这样一样Bug,要求所有与这个父类有关系的类都需要增加一个判断,所以这个方案是不可用的。
- ToyGun 脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbstractGun建立关联委托关系。
可以在AbstractToy中声明将声音、形状都委托给AbstractGun处理,然后两个基类下的子类自由延展,互不影响。
在Java的基础知识中都会讲到继承,继承就是告诉你拥有父类的方法和属性,然后你就可以重写父类的方法,按照继承原则,我们上面的玩具枪继承AbstractGun是绝对没有问题的,但是在具体的应用场景中就要考虑子类是否能够完整的实现父类的业务。
注意:如果子类不能完整的实现父类的方法,或者父类的某些方法已经在子类中发生“畸变”, 则建议断开父子继承关系,采用依赖、聚集、组合等关系来代替
示例三
/** * AK47的实现类 */
public class AK47 extends Rifle {
@Override
public void shoot() {
System.out.println("AK47射击...");
}
}
/** * AUG的实现类 */
public class AUG extends Rifle {
public void zoomOut(){
System.out.println("通过望远镜观察敌人...");
}
@Override
public void shoot() {
System.out.println("AUG射击...");
}
}
/** * AUG狙击手的实现类 *
/public class Snipper {
public void killEnemy(AUG aug) {
aug.zoomOut();
aug.shoot();
}
}
/** * 场景类 */
public class Client {
public static void main(String[] args) {
//生产AUG狙击手
Snipper snipper = new Snipper();
//给狙击手一把AUG狙击枪
snipper.killEnemy(new AUG());
}
}
结果:
通过望远镜观察敌人...AUG射击...
通过上面的例子发现,子类也可以有自己的行为和外观,也就是方法和属性。父类出现的地方子类可以使用,但是子类出现地方,父类未必就可以胜任。
在这里系统直接调用的子类,狙击手是很依赖枪支的,别说换一个型号的枪了,就是换同一个型号的枪也会影响射击的,所以这里把子类直接传递了进来。
如果把父类传递进来,会在运行时期抛出 java.lang.ClassCastException 异常,这也是大家经常说的向下转型(downcast)是不安全的,从里氏替换原则看,就是子类出现的地方父类未必就可以出现
采用里氏替换原则的目的就是增加程序的健壮性,版本升级时也可以保持非常好的兼容性,即使增加紫烈,原有的子类也可以继续运行,在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑。
在项目中,采用历史替换原则时,尽量避免子类的 ”个性“,一旦子类有 ”个性“,这个子类和父类之间的关系就很难调和了,把子类当做父类使用,子类的 ”个性“ 就被抹杀,把子类单独作为一个业务来使用,则会让代码间的耦合变得扑朔迷离,缺乏里氏替换原则的标准。
依赖倒置原则(DIP)
定义:面向接口编程
- 模块间的依赖通过抽象产生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或者抽象类产生的
- 接口或者抽象类不依赖实现类
- 实现类依赖接口或者抽象类
采用依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性
示例一
/** * 奔驰车类 */
public class Benz {
public void run(){
System.out.println("奔驰车开始行驶...");
}
}
/** * 司机类 */
public class Driver {
//司机的职责就是驾驶汽车
public void driver(Benz benz){ benz.run();
}
}
public class Client {
public static void main(String[] args){
Driver jack = new Driver() ;
//jack开奔驰车
jack.driver(new Benz());
}
}
结果:
奔驰车开始行驶
上面的例子是一个司机驾驶奔驰汽车的例子,但是这个司机就只能驾驶奔驰汽车,如果这个司机想要驾驶宝马汽车,那么就需要修改Driver类,增加一个方法去驾驶宝马车,所以上面类的设计师有问题的,司机类和奔驰车类时紧耦合关系,其导致的结构就是系统的可维护性大大降低,可读性减低。根据依赖倒置原则我们修改上面类的设计。
/** * 汽车接口 */
public interface ICar {
public void run();
}
/** * 司机抽象类 */
public interface IDriver {
public void driver(ICar car);
}
/** * 司机类 */
public class Driver implements IDriver {
@Override
public void driver(ICar car){
car.run();
}
}
/** * 奔驰车类 */
public class Benz implements ICar {
@Override
public void run(){
System.out.println("奔驰车开始行驶...");
}
}
/** * 宝马车类 */
public class BMW implements ICar {
@Override
public void run(){
System.out.println("宝马车开始行驶...");
}
}
public class Client {
public static void main(String[] args){
IDriver jack = new Driver() ;
jack.driver(new Benz());
jack.driver(new BMW());
}
}
结果:
奔驰车开始行驶...
宝马车开始行驶...
建立两个接口,IDriver和ICar跟别定义司机和汽车的各个职能。
接口只是一个抽象的概念,对一类事物最抽象的描述,具体的实现代码由相应的子类去完成。
在IDriver中,通过传入ICar接口实现了接口的依赖关系。
在业务场景中,我们贯彻 “抽象不应该依赖细节” 也就是我们认为抽象(ICar接口)不依赖BWM和Benz两个实现类(细节),因此在高层次的模块中我们应用都是抽象。
在新增加低层次模块时,只修改了业务场景类,也就是高层模块类,对其他的底层模块入Driver类不需要做任何修改,把 “变更” 引起的风险扩散到最低。
注意:在Java中,只要定义变量就必然要有类型,一个变量可以有俩种类型,表面类型和时机类型,表面类型是在定义的时候赋予的类型,实际类型是对象的类型,如jack的表面类型是IDriver,实际类型是Driver。
依赖的三种写法:
-
构造函数传递依赖对象
public interface IDriver{ public void driver(); }
public class Driver implements IDriver{ private ICar mCar; // 构造函数注入 public Driver(ICar car){ this.mCar = car; } @Override public void dirver(){ mCar.run() } }
-
Setter方法传递依赖对象
public interface IDriver{ public void setCar(ICar car); public void driver(); }
public class Driver implements IDriver{ private ICar mCar; @Override public void setCar(ICar car){ this.mCar = car; } @Override public void dirver(){ mCar.run() } }
-
接口声明依赖对象
public interface IDriver { public void driver(ICar car); }
public class Driver implements IDriver { @Override public void driver(ICar car){ car.run(); } }
依赖倒置原则的本质就是通过抽象(接口或者类)是各个类或者模块的实现彼此独立,不互相影响,实现模块间的松耦合,开发中应该遵循以下的规则:
- 每个类尽量都有接口或者抽象类,或者抽象类和接口都具备
- 变量的表面类型尽量是接口或者抽象类
- 任何类都不应该从具体派生
- 尽量不要复写基类的方法
- 结合里氏替换原则使用
依赖倒置原则是6个设计原则中最难以实现的原则,它是实现开闭原则重要的途径,依赖倒置原则没有实现,就别想实现对扩展开放,对修改关闭,在项目中,大家只要记住是 “面向接口编程” 就基本上抓住了依赖倒置原则的核心。
接口隔离原则
定义:建立单一接口,不要建立臃肿庞大的接口
- 客户端不应该依赖它不需要的忌口
- 类与类之间的依赖关系应该建立在最小的接口上
示例一
定义了一个IPettyGirl接口,声明所有美女都应该有goodLooking、niceFigure、greatTemperament(),然后定义了一个抽象类AbstractSearcher,其作用搜索美女并显示其信息。
/** * 美女接口类 */
public interface IPettyGirl {
public void goodLooking();
public void niceFigure();
public void greatTemperament();
}
/** * 美女实现类 */
public class PettyGirl implements IPettyGirl {
private String mName;
public PettyGirl(String name) {
this.mName = name;
}
@Override
public void goodLooking() {
System.out.println(mName + "---脸蛋很漂亮");
}
@Override
public void niceFigure() {
System.out.println(mName + "---身材非常棒");
}
@Override
public void greatTemperament() {
System.out.println(mName + "---气质非常好");
}
}
/** * 星探抽象类 */
public abstract class AbstractSearcher {
protected IPettyGirl mPettyGirl;
public AbstractSearcher(IPettyGirl pettyGirl){
this.mPettyGirl = pettyGirl;
}
//搜索美女信息
public abstract void show();
}
/** * 星探实现类 */
public class Searcher extends AbstractSearcher {
public Searcher(IPettyGirl pettyGirl) {
super(pettyGirl);
}
@Override
public void show() {
System.out.println("--------美女信息如下--------");
super.mPettyGirl.goodLooking();
super.mPettyGirl.niceFigure();
super.mPettyGirl.greatTemperament();
}
}
/** * 场景类 */
public class Client {
public static void main(String[] args) {
IPettyGirl lily = new PettyGirl("莉莉");
AbstractSearcher searcher = new Searcher(lily);
searcher.show();
}
}
结果:
--------美女信息如下--------
莉莉---脸蛋很漂亮
莉莉---身材非常棒
莉莉---气质非常好
星探寻找美女的程序开发完成了,运行结果也正确,我们思考一下 IPettyGirl 这个接口,这个接口是否做到了最优化设计?答案是没有,我们还可以对接口进行优化。
把原有的 IPettyGirl 接口拆分成两个接口,一种是外形美的美女 IGoodBodyGirl, 这类的美女的特点是脸蛋和身材好,但是气质不好,一种气质好的美女 IGreatTemperamentGirl,这类美女的特点是谈吐和修养都非常好,我们把一个比较臃肿的接口拆分成了两个专门的接口,灵活性提高了,可维护性也提高了,不管以后是要外形美还是气质好的美女都可以轻松的通过PettyGril 定义。
通过这样重构以后,不管以后是要气质美女还是外形美女,都可以保持接口的稳定。
以上把一个臃肿的接口变更为两个独立的接口所依赖的原则就是接口隔离原则,让星探AbstractSearcher 依赖两个专用的接口比依赖一个综合的接口要灵活,接口使我们设计时对外提供的契约,通过分散定义多个接口,可以预防未来变更的扩散,提供系统的灵活性和可维护性。
接口隔离是对接口进行规范约束,包括4层含义
-
接口要尽量小:这个接口隔离原则的核心定义,不出现臃肿的接口,但是小是有限度的,首先就是不能违反单一职责原则。
-
接口要高内聚:提高接口、类、模块的处理能力,减少对外的交互。
-
定制服务,一个系统或者系统内的模块必然会有耦合,有耦合就要有相互访问的接口,我们设计时就需要为各个访问者定制服务,定制服务就是单独为一个个体提供优良的服务。
-
接口的设计时有限度的:接口的设计颗粒度越小,系统越灵活,但是灵活的同时带来的结构的复杂化,开发难度增加,可维护性减低,所以接口设计一定要注意适度,这个 “度“ 需要经验和常识判断,没有一个滚定或可测量的标准
迪米特法则(LoD)
定义:迪米特法则也被称为最少知识原则:一个对象应该对其他对象有最少的了解
示例一
老师让体育委员确认全班女生有没有来齐,下面是类图:
/** * 老师类 */
public class Teacher {
public void command(GroupLeader groupLeader){
//初始化女生
List<Girl> girls = new ArrayList<>() ;
for (int i=0; i<20; i++){
girls.add(new Girl());
}
//告诉体育委员开始清查任务
groupLeader.countGirls(girls);
}
}
/** * 体育文员类 */
public class GroupLeader {
//清查女生数量
public void countGirls(List<Girl> girls){
System.out.println("女生的数量是:" + girls.size());
}
}
/** * 班级女生类 */
public class Girl {}
/** * 场景类 */
public class Client {
public static void main(String[] args) {
Teacher teacher = new Teacher();
//老师发布命令
teacher.command(new GroupLeader());
}
}
结果:
女生的数量是:20
上面的示例体育委员按照老师的要求对女生进行了清点,并得出的数量,我们回头来思考一下这个程序有什么问题,首先确定Teacher类仅有一个朋友类——GroupLeader。为什么Girl类不是朋友类呢?Teacher也对它产生了依赖关系,
朋友类的定义:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类。
迪米特法则告诉我们只和朋友类交流,但是我们刚刚定义的 command 方法却与 Girl 类有了交流。方法是类的一个行为,类竟然不知道自己的行为与其他类产生了依赖关系,这是不允许的,严重违反了迪米特法则。
问题已经发现了,我们修改下程序,将类图稍作修改。
修改之后的类:
/** * 老师类 */
public class Teacher {
public void command(GroupLeader groupLeader){
//告诉体育委员开始清查任务
groupLeader.countGirls();
}
}
/** * 体育文员类 */
public class GroupLeader {
private List<Girl> mGirls ;
//传递全班的女生进来
public GroupLeader(List<Girl> girls){
this.mGirls = girls;
}
//清查女生数量
public void countGirls(){
System.out.println("女生的数量是:" + mGirls.size());
}
}
/** * 场景类 */
public class Client {
public static void main(String[] args) {
Teacher teacher = new Teacher();
List<Girl> girls = new ArrayList<>() ;
for (int i=0; i<20; i++){
girls.add(new Girl());
}
//老师发布命令
teacher.command(new GroupLeader(girls));
}
}
结果:
女生的数量是:20
对程序进行了简单的修改,把Teacher对List的初始化,移动到了场景类中,同事在GroupLeader中增加了对Girl的注入,避开了Teacher类对陌生类Girl的访问,减低了系统之间的耦合,提高了系统的健壮性
示例二
实现软件的安装过程
类的实现
/** * 导向类 */
public class Wizard {
private Random rand = new Random(System.currentTimeMillis()) ;
//第一步
public int first(){
System.out.println("执行第一个方法");
return rand.nextInt(100);
}
//第二步
public int second(){
System.out.println("执行第二个方法");
return rand.nextInt(100);
}
//第三步
public int third(){
System.out.println("执行第三个方法");
return rand.nextInt(100);
}
}
/** * 软件安装类 */
public class InstallSoftware {
public void installWizard(Wizard wizard) {
int first = wizard.first();
if (first > 50) {
int second = wizard.second();
if (second > 50) {
int third = wizard.third();
if (third > 50) {
wizard.first();
}
}
}
}
}
/** * 场景类 */
public class Client {
public static void main(String[] args){
InstallSoftware install = new InstallSoftware();
install.installWizard(new Wizard());
}
}
以上程序很简单,运行结果和随机数有关,每次运行的结果都不同。程序虽然简单,但是其实隐藏着问题,Wizard 类把太多的方法暴露给 InstallSoftware 类,两者的关系太紧密了,耦合关系变得异常牢固,如果要将 Wizard 类中的 first的返回值的类型由int改为boolean,就需要修改InstallSoftware 类,从而把修改变更的风险扩散了,因此这样的耦合是极度不合适的,我们需要对设计进行重构,重构后的类图如下:
修改后的类实现
/** * 导向类 */
public class Wizard {
private Random rand = new Random(System.currentTimeMillis()) ;
//第一步
private int first(){
System.out.println("执行第一个方法");
return rand.nextInt(100);
}
//第二步
private int second(){ System.out.println("执行第二个方法");
return rand.nextInt(100);
}
//第三步
private int third(){ System.out.println("执行第三个方法");
return rand.nextInt(100);
}
//软件安装过程
public void installWizard(){
int first = this.first();
if (first > 50) {
int second = this.second();
if (second > 50) {
int third = this.third();
if (third > 50) {
this.first();
}
}
}
}
}
/** * 软件安装类 */
public class InstallSoftware {
public void installWizard(Wizard wizard) {
wizard.installWizard();
}
}
结果:
执行第一个方法
执行第二个方法
将三个步骤的访问权限修改 private,同时把 InstallSoftware 中的方法 installWizard 移动到 Wizard类中,通过这样的重构后,Wizard类就只对外公布一个public方法,及时要修改first方法的返回值,影响的也仅仅只是Wizard本身,其他类不受影响,这显示了类的高内聚特性。
一个类公开的public属性或方法越多,修改设计的面也就越大,变更引起的风险扩展的也就越大,因此,为了保持和朋友类间的距离,在设计时要反复衡量:是否还可以在减少public属性和方法。
在实际应用中 会出现这样一个方法,放在本类中也可以,放在其他类也没错,那么怎么去衡量呢?你可以坚持这样原则:如果一个方法放在本类中,既不增加类间的关系,也对本类不产生负面影响,那么就放置在本类中。
迪米特法则的核心就是类间解耦,弱耦合,然后迪米特法则要求类间解耦,但是解耦是有限度的,除非是计算机的最小单元,二进制的0和1才是完全解耦,在实际的项目中,需要适度的考虑这个原则。
开闭原则
定义:对扩展是开放,是修改时关闭的
示例一
书店销售书籍 类图
/** * 书籍接口 *
/public interface IBook {
//书籍有名称
public String getName();
//书籍有售价
public int getPrice();
//书籍有作者
public String getAuthor();
}
/** * 小说类 *
/public class NovelBook implements IBook {
private String name;
private int price;
private String author;
//通过构造函数传入书籍数据
public NovelBook(String name, int price, String author){
this.name = name;
this.price = price;
this.author = author;
}
@Override
public String getName() {
return name;
}
@Override
public int getPrice() {
return price;
}
@Override
public String getAuthor() {
return author;
}
}
/** * 书店售书类 */
public class BookStore {
private final static List<IBook> books = new ArrayList<>() ;
static {
books.add(new NovelBook("西游记", 32, "吴承恩"));
books.add(new NovelBook("红楼梦", 32, "曹雪晴"));
books.add(new NovelBook("三国演义", 32, "罗贯中"));
books.add(new NovelBook("水浒传", 32, "施耐庵"));
}
public static void main(String[] args){
for (IBook book : books){
System.out.println("书籍名称:" + book.getName()
+ "\t书籍作者:" + book.getAuthor()
+ "\t书籍价格:¥" + book.getPrice() + "元");
}
}
}
结果:
书籍名称:西游记 书籍作者:吴承恩 书籍价格:¥32元
书籍名称:红楼梦 书籍作者:曹雪晴 书籍价格:¥32元
书籍名称:三国演义 书籍作者:罗贯中 书籍价格:¥32元
书籍名称:水浒传 书籍作者:施耐庵 书籍价格:¥32元
简单的一个书店售书的程序。项目上线之后,这时来了新的需求,需要将所有图书进行9折销售,对已经上线的项目来说,这是一个变化,我们改如果应对这样一个需求变化?
- 修改接口
在IBook 上面增加一个方法 getOffPrice(),专门用于打折处理,所有的实现类实现该方法,但是这样修改的后果就是,实现类NovelBook也需要修改,BookStore中的main也需要修改,同事IBook作为接口应该是稳定可靠的,不应该经常发生变化,否则接口作为契约的作用就失去了效能,因此,此方案否定。
- 修改实现类
修改NovelBook类中的方法,直接在getPrice方法中实现打折处理,这也是大家在项目中经常使用的方法,通过class文件替换的方式可以完成部分业务变化。该方法在项目有明确的章程(团队内部约束)或优良的架构设计时,是一个非常优秀的方法,但是该方法还是存在缺陷,例如采购书籍人员也是要看价格的,由于该方法已经实现了打折处理,因此采购人员看到也是打折之后的价格,会因为信息不对称出现决策失误的情况,因此,改方案也不是一个最优的方案。
- 通过扩展实现变化
增加一个子类OffNovelBook,复写getPrice方法,高层次模块通过OffNovelBook类产生新的对象,完成业务变化对系统最小化开发。修改类图如下:
/** * 打折销售的小说类 */
public class OffNovelBook extends NovelBook {
public OffNovelBook(String name, int price, String author) {
super(name, price, author);
}
@Override
public int getPrice() {
//原来的价格
int price = super.getPrice();
int offPrice = price;
if(price > 40){
offPrice = price * 90 / 100 ;
}
return offPrice;
}
}
修改书店售书类
/** * 书店售书类 */
public class BookStore {
private final static List<IBook> books = new ArrayList<>() ;
static {
books.add(new OffNovelBook("西游记", 42, "吴承恩"));
books.add(new OffNovelBook("红楼梦", 42, "曹雪晴"));
books.add(new OffNovelBook("三国演义", 42, "罗贯中"));
books.add(new OffNovelBook("水浒传", 42, "施耐庵"));
}
public static void main(String[] args){
for (IBook book : books){
System.out.println("书籍名称:" + book.getName()
+ "\t书籍作者:" + book.getAuthor()
+ "\t书籍价格:¥" + book.getPrice() + "元");
}
}
}
结果:
书籍名称:西游记 书籍作者:吴承恩 书籍价格:¥37元
书籍名称:红楼梦 书籍作者:曹雪晴 书籍价格:¥37元
书籍名称:三国演义 书籍作者:罗贯中 书籍价格:¥37元
书籍名称:水浒传 书籍作者:施耐庵 书籍价格:¥37元
在上面的程序中,我们发现BookStroe类的static静态代码块中修改了部分代码,但是注意,该部分属于高层次的模块,是由持久层产生的,在业务规则改变的情况下,高层模块必须有部分改变以适应新业务,改变要尽量的少,放置变化风险的扩散。
注意:开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,底层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。
我们可以将变化归纳为三种类型:
- 逻辑变化:只变化一个逻辑,而不涉及其他模块,比如原来的一个算法是 a+b*c,现在需要修改为 a*b*c,可以通过修改原有类中的方法来完成,前提条件是所有依赖或者关联的类都按照相同的逻辑处理
- 子模块变化:一个模块变化,会对其他模块产生影响,特别一个低层次的模块变化必然引起高层模块的变化,因此在通过扩展完成变化时,高层次模块的修改时必然的
- 可见视图变化:可见是提供给客户端使用的,最司空见惯的是业务耦合变化,一个展示数据的列表,按照原有的逻辑是6列,突然有一天要增加一列,而且这一列要跨N张表,处理M个逻辑才能展现出来,但还是可以通过扩展来完成变化,这就要看我们原有的设计是否灵活
为什么要使用开闭原则?
- 开闭原则对测试影响:所有已经投入生产的代码都是有意义的,并且都受系统规则的约束,这样的代码都要经过 ”千锤百炼“ 的测试过程,不仅保证逻辑是正确的,还是保证条件苛刻下不产生 ”有毒代码“ 因此有变化提出时,我们就需要思考一下,原来的健壮代码是否可以不修改,仅仅通过扩展实现变化呢。否则就要把原来的测试过程回笼一遍。
- 开闭原则可以提高复用性:在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑,只有这样代码才能复用,粒度越小,被复用的可能性就越大。那为什么要复用呢?减少代码量,避免相同的逻辑分散在多个角落,避免日后维护人员为了修改一个微小的缺陷或增加新功能二要在整个项目中到处查找相关的代码。
- 开闭原则可以提供维护性:一款软件投产后,在不断的迭代,后续开发人员最乐意做的事情就是要扩展一个类,而不是修改一个类,甭管原有的代码写的多么优秀,还是糟糕,让维护人员读懂原有的代码,然后在修改,是一件很痛苦的事情。
- 面向对象开发的要求:万物皆对象,我们需要把所有的事物都抽象成对象,然后针对对象进行操作,但是万物皆运动,有运动就会有变化,有变化就需要有策略去应对,这就需要在设计之初考虑到所有可能变化的因素,然后留下接口,等待 ”可能“ 转变成 “现实”。
如何使用开闭原则?
- 抽象约束:抽象是对一组事物的通用描述,没有具体的实现,也就表示他可以有非常多的可能性,可以跟随需求的变化而变化,因此,通过接口或者抽象类可以约束一组可能变化的行为,并且能够实现对扩展开发,期中包含三层含义:第一,通过接口或者抽象约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类不存在的public方法;第二,参数类型,引用对象尽量使用接口或者抽象类,而不是实现类;第三,抽象层尽量保持稳定,一旦确定即不允许修改。
- **元数据控制模块行为:**元数据是用来描述环境和数据的数据,通俗的说就是配置参数,参数可以从文件中获得,也可以从数据库中获得。
- 制定项目章程:在一个团队中,建立项目章程是非常重要的,因此章程中指定了所有人员都必须遵守的约定,对项目来说,约定优于配置。
- 封装变化:对变化的封装包含俩层含义:第一,将相同的变化封装到一个接口或者抽象类中,第二,将不同的变化封装到不同的抽象类中,不应该有两个不同的变化出现在同一个接口或者抽象类。
开闭原则是一个非常虚的原则,前面5个原则时对封闭原则的具体解释。软件设计最大的难题就是应对需求的变化,但是纷繁复杂的需求变化又是不可预料的,我们要为不可预料的事情最好准备,这本身就是一个痛苦的事情,但是大师们还是给我们提供了非常的好的六大设计原则和23个设计模式来 “封装” 未来的变化。
面向对象的六大设计原则到这就讲完了,希望本篇文章能帮助你更好的理解面向对象六大原则。在之后的实际项目中,多运用六大设计原则的原理,写出健壮、灵活的代码。
《设计模式之禅》
《Android源码设计模式与实战》
PS:觉得文章不还错的话可以添加公众号关注下,文章都会同步到公众号。