软件设计原理2-SOLID原则

设计的两个基本原则:高内聚,低耦合。

开闭原则:不修改代码实现变更

OCP

开闭原则:软件实体(模块、类、函数等等)应该对扩展是开放的,对修改是关闭的。

1. 使用策略模式实现开闭原则

strategy

2. 使用适配器模式实现开闭原则

adapter

3. 使用观察者模式实现开闭原则: 如果要实现多个控制,使用观察者模式+策略+适配器模式

observer

4. 使用模板方法模式实现开闭原则:做一些前置或后置的处理,使用观察者模式+策略+适配器+模版方法

template

5. 使用工厂方法实现开闭原则:观察者+策略+适配器+模板+工厂

小结实现开闭原则的关键是抽象。当一个模块依赖的是一个抽象接口的时候,就可以随意对这个抽象接口进行扩展,这个时候,不需要对现有代码进行任何修改,利用接口的多态性,通过增加一个新实现该接口的实现类,就能完成需求变更。不同场景进行扩展的方式是不同的,这时候就会产生不同的设计模式,大部分的设计模式都是用来解决扩展的灵活性问题的。

开闭原则可以说是软件设计原则的原则,是软件设计的核心原则,其他的设计原则更偏向技术性,具有技术性的指导意义,而开闭原则是方向性的,在软件设计的过程中,应该时刻以开闭原则指导、审视自己的设计:当需求变更的时候,现在的设计能否不修改代码就可以实现功能的扩展?如果不是,那么就应该进一步使用其他的设计原则和设计模式去重新设计。

依赖倒置原则:如何不依赖代码却复用它的功能

Dependency Inversion Principal:如何不依赖代码却复用它的功能

依赖倒置原则是这样的:

  • 高层模块不应该依赖低层模块,二者都应该依赖抽象。
  • 抽象不应该依赖具体实现,具体实现应该依赖抽象。

举例:

  • 代码并不直接依赖数据库的驱动,而是依赖 JDBC。各种数据库的驱动都实现了 JDBC,当应用程序需要更换数据库的时候,不需要修改任何代码。这正是因为应用代码,高层模块,不依赖数据库驱动,而是依赖抽象 JDBC,而数据库驱动,作为低层模块,也依赖 JDBC。
  • Java 开发的 Web 应用也不需要依赖 Tomcat 这样的 Web 容器,只需要依赖 J2EE 规范,Web 应用实现 J2EE 规范的 Servlet 接口,然后把应用程序打包通过 Web 容器启动就可以处理 HTTP 请求了。这个 Web 容器可以是 Tomcat,也可以是 Jetty,任何实现了 J2EE 规范的 Web 容器都可以。同样,高层模块不依赖低层模块,大家都依赖 J2EE 规范。

这是我们习惯上的层次依赖示例,策略层依赖方法层,方法层依赖工具层。

normal

这样分层依赖的一个潜在问题是,策略层对方法层和工具层是传递依赖的,下面两层的任何改动都会导致策略层的改动,这种传递依赖导致的级联改动可能会导致软件维护过程非常糟糕。

解决办法是利用依赖倒置的设计原则,每个高层模块都为它所需要的服务声明一个抽象接口,而低层模块则实现这些抽象接口,高层模块通过抽象接口使用低层模块。

dip

高层模块就不需要直接依赖低层模块,而变成了低层模块依赖高层模块定义的抽象接口,从而实现了依赖倒置,解决了策略层、方法层、工具层的传递依赖问题。

依赖倒置原则中,除了具体实现要依赖抽象,最重要的是,抽象是属于谁的抽象。也就是说,接口被高层模块定义,高层模块拥有接口,低层模块实现接口

dip

总结:

依赖倒置原则通俗说就是,高层模块不依赖低层模块,而是都依赖抽象接口,这个抽象接口通常是由高层模块定义,低层模块实现。

遵循依赖倒置原则有这样几个编码守则:

  • 应用代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。
  • 不要继承具体类,如果一个类在设计之初不是抽象类,那么尽量不要去继承它。对具体类的继承是一种强依赖关系,维护的时候难以改变。
  • 不要重写(override)包含具体实现的函数。

依赖倒置原则最典型的使用场景就是框架的设计。框架提供框架核心功能,比如 HTTP 处理,MVC 等,并提供一组接口规范,应用程序只需要遵循接口规范编程,就可以被框架调用。程序使用框架的功能,但是不调用框架的代码,而是实现框架的接口,被框架调用,从而框架有更高的可复用性,被应用于各种软件开发中。

里氏替换原则:正方形可以继承长方形吗?

Liskov Substitution Principle,LSP。关于如何设计类的继承关系,怎样使继承不违反开闭原则,实际上有一个关于继承的设计原则,叫里氏替换原则。

一个违反里氏替换规则的例子

1
2
3
4
5
6
7
8
9
void drawShape(Shape shape) {
if (shape.type == Shape.Circle ) {
drawCircle((Circle) shape);
} else if (shape.type == Shape.Square) {
drawSquare((Square) shape);
} else {
……
}
}

首先看到这样的 if/else 代码,就可以判断违反了开闭原则:当增加新的 Shape 类型的时候,必须修改这个方法,增加 else if 代码。

其次也因为同样的原因违反了里氏替换原则:当增加新的 Shape 类型的时候,如果没有修改这个方法,没有增加 else if 代码,那么这个新类型就无法替换基类 Shape。

正方形可以继承长方形吗?

正方形在如下情况下不可以:

1
2
3
4
5
void testArea(Rectangle rect) {
rect.setWidth(3);
rect.setHeight(4);
assert 12 == rect.calculateArea();
}

从而得出一个结论:

子类不能比父类更严格

当子类继承父类的时候,根据里氏替换原则,使用者可以在使用父类的地方使用子类替换,那么从契约的角度,子类的契约就不能比父类更严格,否则使用者在用子类替换父类的时候,就会因为更严格的契约而失败。

实践中,当你继承一个父类仅仅是为了复用父类中的方法的时候,那么很有可能你离错误的继承已经不远了。一个类如果不是为了被继承而设计,那么最好就不要继承它。粗暴一点地说,如果不是抽象类或者接口,最好不要继承它。

如果你确实需要使用一个类的方法,最好的办法是组合这个类而不是继承这个类,这就是人们通常说的组合优于继承

如果类 B 需要使用类 A 的方法,这时候不要去继承类 A,而是去组合类 A,也能达到使用类 A 方法的效果。这其实就是对象适配器模式了,使用这个模式的话,类 B 不需要继承类 A,一样可以拥有类 A 的方法,同时还有更大的灵活性,比如可以改变方法的名称以适应应用接口的需要。

继承接口或者抽象类也并不保证你的继承设计就是正确的,最好的方法还是用里氏替换原则检查一下你的设计:使用父类的地方是不是可以用子类替换?

违反里氏替换原则不仅仅发生在设计继承的地方,也可能发生在使用父类和子类的地方,错误的使用方法,也可能导致程序违反里氏替换原则,使子类无法替换父类。

单一职责原则:为什么说一个类文件打开最好不要超过一屏

Single responsibility principle, SRP

类的职责应该是单一的,也就是引起类变化的原因应该只有一个,这样类的代码通常也是比较少的。

接口隔离原则:如何对类的调用者隐藏类的公有方法?

Interface Segregation Principle, ISP

接口隔离原则说:不应该强迫用户依赖他们不需要的方法。

通过使用接口隔离原则,我们可以将一个实现类的不同方法包装在不同的接口中对外暴露。应用程序只需要依赖它们需要的方法,而不会看到不需要的方法。

ISP