七大设计原则

最近看了新版的射雕英雄传,有这么一段剧情:洪七公在传授郭靖降龙十八掌时耍的虎虎生风,而这么好的外家功夫到了郭靖手里打出来就显得平淡无奇,当时在场的黄蓉瞬间明白:靖哥哥的内功与师傅的相差甚远,所以耍出来只是”有其形而无其神”,就是再厉害的功夫也是需要有高深的内功支撑的。让我想到了设计模式和设计原则,设计模式就是相当于降龙十八掌,而设计原则就是内功,在设计原则的基础上才可以更好的了解到设计模式的精髓。

在如今设计模式已经贯穿了我们的软件,的确,设计模式可以让我的软件拥有更好的可维护性与扩展性。当对各个设计模式了解之后,会发现它们之间并没有直接的联系,每个都显得很孤立。此时,设计原则就出现了,它是隐藏在设计模式背后更加基本的思想原则,是设计模式必须遵守的原则。

单一职责原则(SRP)

就一个类而言,应该仅有一个引起它变化的原因。意思就是说一个类只描述一种功能,在软件设计过程中,合理的遵循该职责,后期项目维护时你会庆幸当时自己的选择。
该原则优点就是在解耦和,假如在设计时候把不同的职责都耦合到一个类里,当修改其中一个职责时就会影响该类的其他职责,甚至对程序造成意想不到的破坏。
下面以登陆注册为例

public interface Demo {

    void regist(String name);
    void login(String name);
}
class Regiest implements Demo{
    @Override
    public void regist(String name) {
        System.out.println(name+"注册成功");
    }
    @Override
    public void login(String name) {}
}
class Login implements Demo{

    @Override
    public void regist(String name) {}
    @Override
    public void login(String name) {
        System.out.println(name+"登陆成功");
    }
}

以上例子有两个方法,注册和登陆,当我们执行注册regist时候,和登陆login逻辑是没有任何关系的,而这里却和login方法关联起来了,这不符合系统的设计规则,假如以后因修改注册逻辑而导致login变化,就会影响到没有变化的登录login逻辑。
根据单一职责原则,只需将登录和注册放到不同的接口中,登录接口只关注登录功能,而注册接口只关注注册功能,代码一目了然,对以后的维护扩展也有很大的帮助。

public interface Regist {

    void regist(String name);
}
public interface Login {

    void login(String name);
}

单一职责原则作为七大设计原则,也是在我们项目中出现最频繁的原则。比如商城系统的登录、注册、商品信息等在设计的时候都是作为单独的模块功能来设计的,一个模块对应一个功能,使得程序分层更加清晰,每个模块具体功能一目了然。

里氏替换原则(LSP)

若对于每个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,用o1替换o2后,程序P的行为不变,则S是T的子类型(引自Java与模式)。意思就是说:在继承体系中,父类出现的地方,都必定可以使用其子类代替。

来看这么一个故事:大圣人孔子,其父叔梁纥,任职陬邑大夫,博学多才;其弟,名曰孟皮,嗜好武术。孔子年轻时候,父亲经常在讲堂授课, 孔子就在一旁听课,他兄弟就去练武,久而久之,孔子的才华日益增高。话说一天,孔子父亲因病不能授课,于是就让孔子授课,孔子不辱父命,穿上父亲的衣服完成授课。针对该典故来看一下其代码

//父类
public class Parent {

    public void teach(){
        System.out.println("坐在凳子上给大伙们讲课");
    }
}

//孔子
public class Kongzi extends Parent {

    @Override
    public void teach() {
        System.out.println("坐在凳子上给大伙们讲课");
    }
}

//教室类
public class Classroom {
    //授课方法,需要传递父类类型
    public void teach(Parent p){
        p.teach();
    }

    public static void main(String[] args) {
        Classroom classroom = new Classroom();
        classroom.teach(new Parent());
        //里氏替换原则,Kongzi代替Parent
        classroom.teach(new Kongzi());
    }
}

可以看到孔子重写其父teach方法可以代替其父亲授课了,然后在实际开发中不建议重写父类方法的,父类方法是对外公开的,子类重写后可能会改变父类对外展示的行为。里氏替换原则要求子类可以代替父类并且可以正常运行,假如出现以下情况,就违反了这一规定

//孔子有事外出,不能代替父亲授课,只能拜托其兄孟皮,而孟皮善武
public class Mengpi extends Parent {

    @Override
    //孟皮不会授课,只会功夫,只能抛出异常
    public void teach() {
        throw new RuntimeException();
    }
}

在以上的ClassRoom的main方法中

public static void main(String[] args) {
    Classroom classroom = new Classroom();
    classroom.teach(new Parent());
    classroom.teach(new Mengpi());
}

传递父类可以正常运行,而传递子类就抛出异常或者出现和父类完全不同的行为,就以上例子比如孔子兄弟在授课时不是抛出异常而是打起了拳法,显然有违我们的原则。
在Java中对于以上代码的重构思想就是将父类设计为抽象类,其子类实现方法,虽然在一定程度上避免了违背该原则的概率,但问题不是绝对的,就好比以上的抛出异常,依然没有解决。所以在开发过程中,无需死守原则,做到活学活用,才可以设计出令人刮目相看的系统。

里氏替换原则UML图
里氏替换原则UML图

依赖倒转原则(DIP)

高层模块不依赖于底层模块,二者都应该依赖于抽象,抽象不应该依赖于细节,细节应该依赖于抽象。可以这样理解,针对抽象编程,不要针对实现编程,因为抽象是稳定的,而实现是多变的,当依赖于抽象,实现变化时不会影响调用者。

我们已经看到了依赖倒转原则带来的各种好处了,但是再厉害思想也不适合的场景

在现在生活中,也有非常多的应用到该原则的例子,下面以我们用的电脑为例,假如有一天电脑CPU坏了,我们只需要根据电脑兼容的CPU类型更换一块即可,而不是针对具体的某一款CPU,这就是针对抽象编程,而不是针对实现,假如针对实现来设计,CPU就应该对应某一个品牌的主板,更换CPU的同时也需要更换主板或者只能购买具体品牌的CPU,这就尴尬了。想想仅CPU就存在这样的问题,电脑中还有其他的内存、硬盘等部件,显然针对实现设计时不行的。

只需依赖抽象设计,针对不同部件设置不同的接口,假如CPU坏了,只需要更换CPU即可,而无需去关注具体的某个品牌。这和我们的依赖倒转原则是非常相似的,程序的设计同样需要针对抽象编程。

我们做一个平台管理系统,业务层,数据库访问层都编写的非常严谨,突然一天领导说oracle数据库收费的,最近公司经费紧张,需要更换成mysql,并且需要在最短时间内完成数据库切换,我们的数据库访问层是依赖oracle写的,更换的时候每个类都需要更换,工作量太大了。其实在程序设计的时候可以将数据库访问层抽象成接口,提供访问数据库的能力,mysql和oracle的具体实现只需实现该接口,而业务层只需使用定义好的接口,当数据库切换时,只需要切换成对应的具体实现就行了,就是以后换成了sqlserver,只需写一套针对sqlserver的实现就可以了,对我们的系统扩展性是一个质的提升。

针对以上描述写一个简单的例子

//数据库访问层接口
public interface DBMapper {

    void add(String str);
}

//mysql实现
public class MysqlMapper implements DBMapper {

    @Override
    public void add(String str) {
        System.out.println("mysql--add:"+str);
    }
}

//oracle实现
public class OracleMapper implements DBMapper {

    @Override
    public void add(String str) {
        System.out.println("oracle--add:"+str);
    }
}

//业务层,业务层也可以写成接口,为了简单描述,定义成类
public class DBService {
    //依赖数据库访问层接口
    private DBMapper mapper;
    public DBService(DBMapper mapper) {
        this.mapper = mapper;
    }
    public void add(String str){
        mapper.add(str);
    }
}

最后在客户端测试一下

public class Client {

    public static void main(String[] args) {
        //使用mysql实现
        DBService s = new DBService(new MysqlMapper());
        s.add("小李");
        //使用oracle实现
        s = new DBService(new OracleMapper());
        s.add("小红");
    }
}

打印结果

mysql--add:小李
oracle--add:小红

可以看到无论使用哪个数据库,我们的业务层都无需改变,无需关注具体使用的哪个数据库,只要可以实现连接数据库就可以了,这就是依赖于抽象的灵活性(使用了里氏替换原则)。

可以看到依赖倒转原则是多么的强大,但是,再厉害的原则也有不适合的场景,该原则假定所有的实现类都是变化的,其实也不是完全正确的,比如我们项目中的工具类就是相当稳定很少甚至不会发生变化的,调用者完全可以依赖该工具类而没有必要再次进行抽象。

看一下依赖倒转原则的UML图
这里写图片描述

接口隔离原则(ISP)

不应该强迫客户端依赖于他们不用的方法,就是说我们设计接口时其拥有的方法应该尽量的小。

盖房子时,需要给房子装上门Door,当然门可以(open)开着也可以关着(close),还要给门装上防盗警报器(alarm),现在我们理所应当的就会这样来设计的我们的门接口了

public interface Door {

    void open();
    void close();
    void alarm(); //警报器
}

现在可以给大门安装警报器了,但是这样的设计真的好么,房子的大门装上警报器无可厚非,而每间屋子连接的小门SmallDoor也装上警报器就显得多余了。

public class SmallDoor implements Door {

    @Override
    public void open() {
        System.out.println("打开小门");
    }
    @Override
    public void close() {
        System.out.println("关门小门");
    }
    @Override
    public void alarm() {} //警报器

}

可以看到,我们的小门是不需要警报器的,却不得不实现警报器alarm方法。事实上,我们最初的Door和警报器功能alarm是没有任何关系的。在Door加入警报器alarm方法是为了为Door的子类提供好处,如果一直这么做,以后在Door中又增加了猫眼、门铃等功能,使的Door接口越来越”胖”,再创建SmallDoor类时,就需要实现一堆没有关系的方法,这就是所谓的接口”污染”。

可以发现,小门和防盗门对功能的需求不同,就相当于两个客户端,就可以针对它们设计出两个不同的接口AlarmDoor和Door,让AlarmDoor接口继承Door接口,小门类实现Door接口实现开关功能,防盗门实现AlarmDoor接口拥有警报器功能,不会再有多余的方法实现,这样就做到接口最小化了。

//防盗门
public interface AlarmDoor extends Door {
    //警报器方法
    void alarm();
}

在开发设计过程中,应该尽可能的遵守该原则,让我们设计的接口尽量”小”,但也要有限度,太小的话就会导致系统接口泛滥,不容易维护。就如Door接口,我们不能把open和close方法分别封装到两个不同的接口,就是说接口”小”的底限是必须满足单一职责。

迪米特法则(LoD)

一个类应该对其它的类尽可能少的了解,又叫做最少知道原则。该原则的目标就是用来减少类与类之间的耦合的,所谓耦合既是一个类A对于另外的一个类B依赖过多,当类B修改时,导致A类也需要大量修改。

为了更清楚的理解该原则,引入“朋友”和“陌生人”,还以A、B类为例,如果B类的引用出现在A类的成员变量、方法参数、方法返回值中,根据迪米特法则来说B类就是A类的“朋友”,而如果B类的引用出现在A类方法的局部变量位置,那么B类相对于A类就属于“陌生人”。迪米特法则规定,只能和“朋友”交互,而不能和“陌生人”直接交互,也就是说作为“陌生人”B类尽量不要在A类的局部变量位置出现,看下图示例
这里写图片描述
根据上图,假如现在B类的构造方法修改了,局部变量位置的代码就需要修改,这种一个类修改的同时就需要另一个类修改的关系就是所谓的“强耦合”了,这就违背了程序设计的中心思想“高内聚,弱耦合”。那么如何改进呢?假设现在有一中间类C,是A类和B类的朋友,就可以借助C类来完成A类调用B类的方法了。

class A {
    public void test(C c){
        c.test();
    }
}
class C {
    public B test(){
        return new B();
    }
}

此时无论B类如何修改,都不会再影响到A类了。

还是那句话,再厉害的思想都有其不适合的场景,在程序设计的过程中,不可能一味的遵循该法则,上面那么简单的一个例子就需要多出一个没有任何与系统业务逻辑相关的方法,当程序中出现大量这种方法的时候,就会造成程序过度复杂。使用该法则虽然每一个局部都不会和远距离的对象发生直接关联,但是,这也会导致程序的不同模块通讯率变低,使得程序的各个模块之间的关系不容易协调。因此在设计时候,一定要反复权衡,不能为了追求“低耦合”而导致程序设计的过度复杂。

合成/聚合复用原则(CARP)

合成/聚合复用原则又叫做合成复用原则,该原则就是在一个新的对象中使用一些已有的对象,使之成为新对象的一部分;新的对象通过向这些对象的委派达到复用已有功能的目的(摘自Java与模式)。简言之就是多用合成/聚合,少用继承。

“高复用性”一直是我们程序设计的最为追求的目标之一,然而现实总是不尽人意,设计出来的的系统总感觉与理想中相差太多!

看一下“复用性”是如何定义的:复用又叫重用,是重复使用的意思;复用的好处就是可以得到较高的生产效率以及随之而来的成本降低、较高的软件质量以及恰当的使用复用可以改善系统的可维护性(摘自百度百科)。

在面向对象的设计中,有两种基本的方法可以实现设计的复用,就是通过合成/聚合以及继承,两者有什么区别呢,为什么说多用合成/聚合而少用继承呢?
使用继承复用破坏了封装,因为继承将父类的实现细节暴露给了子类;如果父类实现发生了改变,子类实现也不得不跟着改变,增加了类与类之间的耦合性;从父类继承而来的实现是静态的, 不能在运行时发生改变,因此没有足够的灵活性。而合成/聚合是将已有对象纳入到 新对象中,该对象的内部实现细节对于新对象而言是不可见的,该对象的内部实现变化时对新对象的影响不大,相对于继承而言耦合度较低;合成/聚合复用是在运行时动态进行,新对象可以动态的引用与该对象类型相同的对象。综上所述,应该多用合成/聚合,少用继承。

那么什么时候使用继承呢,假如有两个类A、B,类A、类B必须满足里氏替换原则才可以使用继承,既是任何使用A类的场景都可以使用B类,此时B类才可以称为A类的子类,可以使用继承。如果A类和B类不存在以上描述关系,则不可以使用继承。

用车子作为例子,车子属于交通工具,是交通工具的一种,符合里氏替换原则,而车子是由门、车轮子、方向盘等组成的,代码就可以如下

//交通工具
class Vehicle {}

//车子属于交通工具,使用继承
class BenzCar extends Vehicle {
    /*
     * 车门、轮子、方向盘使用聚合
     */
    //车门
    private Door door;
    //轮子
    private Wheel wheel;
    //方向盘
    private SteeringWheel steeringWheel;
}

开闭原则(OCP)

开闭原则指的是开发过程中设计一个模块时,应该使得该模块在不被修改的情况下进行扩展,通俗来说就是对修改关闭对扩展开放。

刚看到这句话时可能会有疑惑,怎么可能同时不修改而又可以扩展呢?先来看一个生活中的例子,有一宠物店,出售的宠物有宠物狗、猫、小兔子等,为了 拓展业务,决定引进宠物鸟
这里写图片描述
宠物店类PetShop的raise()方法

public void raise(String pet){
    if("dog".equals(pet)){
        Dog d = new Dog();
        d.raise();
    }else if("cat".equals(pet)){
        Cat c = new Cat();
        c.raise();
    }else if("rabbit".equals(pet)){
        Rabbit r = new Rabbit();
        r.raise();
    }
}

显然,按照以上的设计在增加宠物鸟时必须修改raise()方法了,违背了“开闭”原则。重构如下,只需增加一个抽象的宠物类Pet,让具体的宠物作为该类的子类,宠物店PetShop聚合了抽象类Pet,只针对抽象类Pet
这里写图片描述
在不修改宠物类Pet的同时扩展了Bird类,这就是宠物店的“开-闭”原则。这里的Pet是抽象的,而各个具体的宠物则是具体的,用面向对象的思想来说就是不修改系统的抽象层,而是对实现层进行扩展。

使用“开闭”原则是非常有意义的,如果我们系统设计符合“开闭”原则,就可以再不修改现有代码的情况下进行扩展,使得系统具备了良好的复用性。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值