软件设计原则

146 阅读17分钟

软件设计原则

设计原则,指的是抽象性比较高、编程都应该遵循的原则,对应的设计模式是解决具体场景下特定问题的套路,设计模式要遵循设计原则。

开闭原则

定义

开闭原则:对扩展开放,对修改关闭在程序需要进行扩展的时候,不去修改原有的代码,而是在原有的代码的基础上进行扩展

优点

开闭原则对扩展开放,对修改关闭,该特性在产品化的环境中是特别有价值的,在这种环境中,改变源代码需要代码审查,单元测试以及诸如此类的用以确保产品使用品质的过程。遵循这种原则的代码在扩展时并不发生改变,因此无需上述的过程。

问题

  • 开闭原则为了什么?

    开闭原则的主旨是为了拥抱变化,并且在变化过程中保持系统的可维护性代码的重用性

  • 何为对扩展开放?

    模块对扩展开放,就意味着需求变化时,可以对模块扩展,使其具有满足那些改变的新行为。换句话说,模块通过扩展的方式去应对需求的变化

  • 何为对修改关闭?

    模块对修改关闭,表示当需求变化时,关闭对模块源代码的修改,当然这里的“关闭”应该是尽可能不修改的意思,也就是说,应该尽量在不修改源代码的基础上面扩展组件。

  • 如何做到开闭原则?

    开闭原则只是一个思想,没有具体实际的操作方法。其他原则都是为了实现这个开闭思想的一些方法和工具。

注意点

  • 前瞻性设计

    想要遵守开闭原则,就需要一个设计合理的系统。可以说在系统设计(概要设计、详细设计)的时候就要考虑到未来的扩展和改变

  • 用抽象构建框架,用实现扩展细节

    因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,用从抽象派生的实现类来进行扩展,当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是抽象要合理,要对需求的变更有前瞻性和预见性才行。

  • 开闭原则是总纲

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

里氏代换原则

里氏代换原则:任何基类可以出现的地方,子类一定可以出现,通俗来说,在可以使用基类的地方,一定可以使用子类,不会出现任何代码逻辑层面的问题,尽量不要重写父类的方法

定义

里氏代换原则:派生类(子类)对象可以在程序中代替其他基类(超类)对象。

由来

有一功能P1,由类A完成,现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。

解决方案

当使用继承类的时候,类B继承类A时,除添加新的方法完成新增功能P2,尽量不要修改父类方法预期的行为。

优点

  • 里氏代换原则是实现开闭原则的重要方式之一
  • 里氏代换原则克服了继承中重写父类造成的可复用性变差的缺点
  • 里氏代换原则是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性
  • 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性、降低需求变更时引入的风险

问题

  • 里氏代换原则在阐述什么?

    历史代换原则主要阐述了有关继承的一些原则,也就是**什么时候应该使用继承,什么时候不应该使用继承,**以及其中蕴含的原理

  • 子类可代替基类是什么意思?

    任何基类可以出现的地方,子类一定可以出现

  • 如何遵循里氏代换原则?

    • 子类必须完全实现父类的抽象方法,但不能覆盖父类的非抽象方法
    • 子类可以实现自己特有的方法
    • 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松
    • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格(类向上转换是安全的,向下转换则不一定是安全)
    • 子类的实例可以替代任何父类的实例,但反之不成立

注意点

  • 尽量不要重写父类的方法

    子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。里氏代换原则的重点在不影响原功能,而不是不覆盖原方法

  • 里氏代换是继承复用的基石

    只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为

  • 里氏代换原则是对开闭原则的补充

    实现开闭原则的关键步骤就是抽象化,而基类与子类的继承关系就是抽象化的具体实现,所以里氏替代换原则是对实现抽象化的具体步骤的规范

依赖倒转原则

依赖倒转:高层模块不应该依赖于低层模块,两者都应该依赖其抽象,简单的说就是要求对抽象(接口)进行编程,不要对实现(类)进行编程

定义

依赖倒转原则:

  • 高层模块不应该依赖低层模块,二者都应该依赖其抽象
  • 抽象不应该依赖细节
  • 细节应该依赖抽象

也就是说高层模块、低层模块、细节都应该依赖抽象

由来

类A直接依赖类B,假如要将类B改为类C,则必须通过修改类A的代码来达成。类A一般是高层模块,负责复杂的业务逻辑。类B和类C是低层模块,负责基本的原子操作。修改类A,会给程序带来不必要的风险

解决方案

将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率

优点

依赖倒转原则可以减少类间的耦合性,提高系统的稳定,降低并行开发引起的风险,提高代码的可读性和可维护性

问题

  • 依赖倒转原则跟面向接口编程是什么关系?

    依赖倒转原则的核心思想就是面向接口编程

  • 什么是细节?什么是抽象?它们有什么区别?

    所谓细节就是较为具体的东西,比如具体的类,就比如上面的类B与类C,有具体的实现

    所谓抽象就是具有契约性、共同性、规范性的表达,比如上面的接口I。它表达了一种契约-需要实现funcA和funcB才能被当成I来对待

    相对于细节的多变性,抽象的东西要稳定的多

注意点

  • 分清细节与抽象

    虽然依赖倒转原则有很大的好处,但也不是所有的类都需要有抽象一个接口去对应,要视情况而定

  • 变量的声明类型尽量是抽象类或接口

    注意是尽量,而不是全部

  • 尽量不要覆写基类的方法

    如果基类是一个抽象类,而这个方法已经实现了,子类尽量不要覆写。类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会有一定的影响

  • 继承要遵循里氏代换原则

    不要破坏继承体系

接口隔离原则

接口隔离原则:类不应该被迫依赖于它不使用的方法,通俗来讲,一个类对另一个类的依赖应该建立在最小的接口上

定义

接口隔离原则:客户端不应该依赖它不需要的接口。一个类对另一个类的依赖应该建立在最小的接口上

由来

类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法。

解决方案

将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。

优点

建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少(有点接口单一职责的意思)也就是说,我们要为各个类建立专用的接口,而不要视图去建立一个很庞大的接口供所有依赖它的类去调用。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活,接口是设计时对外部设定的契约,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。

问题

  • 单一职责和接口隔离相似嘛?

    单一职责原则注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口,主要针对抽象,针对程序整体框架的构建。

注意点

  • 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度
  • 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来,只有专注地为一个模块提供定制服务,才能建立最小的依赖关系
  • 提高内聚,减少对外交互,使接口用最少的方法去完成最多的事情
  • 运用接口隔离原则,一定要适度,接口设计的过大或过小都不好,设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则

迪米特法则

迪米特法则(最少知识原则):只和你的直接朋友交谈,不跟“陌生人”说话,其含义是:如果两个类之间无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。目的是降低类之间的耦合度,提高模块的独立性。

定义

迪米特法则:又叫做最少知识原则。只与你的直接的朋友交谈,不跟“陌生人”说话。其含义是:如果两个软件实体无须直接通信,那么就不应当发证直接地相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。

由来

类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大

解决方案

每个类尽量减少对其他类的依赖

优点

  • 迪米特法则中的“朋友”指什么?

    当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法

  • 迪米特法则在强调什么?

    • 从依赖者的角度来说,只依赖应该依赖的对象
    • 从被依赖者的角度来说,只暴露应该暴露的方法
  • 如何实现迪米特法则?

    • 在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及
    • 在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限
    • 在类的设计上,只要有可能,一个类型应当设计成不变类
    • 在对其它类的引用上,一个对象对其他对象的引用应当降到最低
    • 不暴露类的属性成员,而应该提供相应的访问器
    • 谨慎使用序列化功能:当通过序列化进行对象传输的时候,如果对象修改了属性的访问权限,而传输的另一方没有进行同步修改,则会报序列化失败

注意点

  • 防止过度使用

    过度使用迪米特法则会使系统产生大量的中介类,从而增加系统的复杂性,使模块之间的通信效率降低。所以,在采用迪米特法则时需要反复权衡,确保高内聚和低耦合的同时,保证系统的结构清晰

合成复用原则

合成复用原则:尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现,在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新对象通过向这些对象的委派达到复用已有功能的目的。就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的。

定义

合成复用原则:在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新对象通过向这些对象的委派达到复用已有功能的目的。就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的

复用方法

在面向对象的设计里,有两种基本的方法可以在不同的环境中复用已有的设计和实现,即通过组合或继承

组合

由于组合可以将已有的对象纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做有下面的好处:

  • 新对象存取成分对象的唯一方法是用过成分对象的接口
  • 这种复用是黑箱复用,因为成分对象的内部细节是新对象所看不见的
  • 这种复用所需的依赖较少
  • 每一个新的类可以将焦点集中在一个任务上
  • 这种复用可以在运行时间内动态进行,作为整体的新对象可以动态地引用与部分对象类型相同的对象。也就是说,组合/聚合是动态行为,即运行时行为。可以通过组合/聚合的方式在设计上获得更高的灵活性

组合复用的缺点就是用组合复用建造的系统会有较多的对象需要管理

继承

组合几乎可以用到任何环境中去,但是继承只能用到一些环境中

继承复用通过扩展一个已有对象的实现来获得新的功能,基类明显的捕获共同的属性和方法,而子类通过增加新的属性和方法来扩展超类的实现。

继承的优点:

  • 新的实现比较容易,因为基类的大部分功能都可以通过继承自动的进入子类
  • 修改或扩展继承而来的实现较为容易

继承的缺点:

  • 继承复用破坏了包装,因为继承超类的实现细节暴露给子类,由于超类的内部细节常常对子类是透明的,因此这种复用是透明的复用,又称“白箱”复用。
  • 如果超类的实现发生改变,那么子类的实现也不得不发生改变。因此,当一个基类发生改变时,这种改变就会像水中投入石子引起的水波一样,将变化一圈又一圈的传导到一级又一级的子类,使设计师不得不相应地改变这些子类,以适应超类的变化。
  • 从超类继承而来的实现是静态的,不可能在运行时间内发生改变,因此没有足够的灵活性

问题

  • 使用组合还是继承?

    满足Is-A的关系才是可以使用继承,而组合却是一种Has-A(整体与部分)的关系

注意点

  • 首先组合,然后才是继承

单一职责原则

**单一职责原则:一个类应该只有一个发生变化的原因,即一个类只负责一项职责。**如果一个类有多个职责,这些职责就耦合在了一起。当一个职责发生变化时,可能会影响其他的职责。另外,多个职责耦合在一起会影响复用性。

由来

类A负责两个职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类A时,有可能会导致原本运行正常的职责P2功能发生故障

解决方案

遵循单一职责原则,分别建立两个类A1、A2使A1完成职责P1,A2完成职责P2。这样修改类A1时,不会影响到职责A2:同理,当修改A2时,也不会影响到职责P1

优点

  • 降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多。
  • 提高类的可读性,提高系统的可维护性。
  • 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著的降低对其他功能的影响。