当前位置: 代码迷 >> 综合 >> 面向对象设计原则: The SOLID Principles
  详细解决方案

面向对象设计原则: The SOLID Principles

热度:90   发布时间:2023-12-15 10:59:19.0

本文主要参考Robert C. Martin. Design Principles and Design Patterns1和butUncleBob.com2.

设计糟糕的表现(Symptoms of Rotting Design)

僵化(Rigidity)

软件变得难以修改, 每次修改都会造成对应依赖模块的修改. 换句话说, 模块之间耦合性太严重, 因此在中大型项目中多人合作难以协同.

脆弱(Fragility)

每次修改软件之后在许多地方发生崩溃, 而且崩溃发生的地方往往是看起来与修改部分并无直接的关联. 这样的软件不仅难以维护, 而且容易给用户造成不可靠的印象.

低复用(Immobility)

现有的项目或模块很难在其它项目中得到复用. 例如, 一个常发生的场景是项目A中的某个模块与项目B中的某模块类似, 但移植过来需要做较多的修改. 那么项目A的工程师可能更倾向于重写该模块. 这样容易重复造轮子, 影响开发效率.

高粘性(Viscosity)

它包含两个方面: 设计的粘性与开发环境的粘性.

  • 设计的粘性: 修改软件时有多种方式, 其中有些方式能保持最初的设计, 有些则不能, 例如破解(hacks). 如果保持设计的修改方式比破解(hacks)困难, 那么设计的粘性高.

  • 开发环境的粘性: 开发环境低效, 例如如果编译时间太长或IDE的开发效率低下, 工程师会避免做一些需要耗时长的修改.

总结: 上述四个特点是相关的, 但它们体现了软件问题的不同层次. 其中僵化体现了设计的不合理;脆弱体现了软件的质量不可靠; 低复用体现了软件开发的低效率; 高粘性体现了软件开发过程的不友好.

软件开发的原则(The SOLID Principles)

为了避免上述问题, Robert C. Martin12从方法论的角度总结了软件开发的原则, 它们是:

  • S - The Single Reponsibility Principle (SRP)
  • O - The Open Closed Principle (OCP)
  • L - The Liskov Substitution Principle (LSP)
  • I - The Interface Segregation Principle (ISP)
  • D - The Dependency Inversion Principle (DIP)
1. 单一职责原则(SRP)

A class should have only one reason to change.
一个类只有一个修改的理由.

一个职责(Responsibility)可以理解为一个修改的理由. 考虑一个Rectangle类, 它包含两个方法:

  • draw() 在图形界面上画出矩形(由图形程序和操作界面(GUI)调用).
  • area() 计算矩形的面积(由几何计算程序调用).

从上图的依赖关系我们发现, 有两个程序调用Rectangle类: 一个是几何计算程序, 它需要计算矩形的面积, 但不会用来画图; 另一个是图形程序, 用来画图(或许也会计算矩形的面积). 因此, Rectangle类实际上负担了两个职责: 计算和画图, 因而不符合单一职责原则. 在这种情况下, 如果图形程序的修改导致Rectangle类需要修改, 那么我们需要重新编译, 测试和部署 几何计算程序.

2. 开闭原则(OCP)

Software entities (classes, modules, functions etc.) should be open for extension, but closed for modification.
软件实体(类, 模块, 函数等)对扩展是开放的, 对修改是封闭的.

当我们改变软件的功能时, 应该尽量去扩展代码, 而不是去修改原有的代码. 下面我们举个例子来说明. 实现一个画图形的模块, 支持画圆形和矩形. 下面的代码是违反开闭原则的实现: 定义Shape基类, 令Rectangle类和Circle类继承Shape, 并各自实现画图的功能.

// Shape.java
public class Shape {
    public String type;
}// Cirlce.java
public class Circle extends Shape {
    Circle() {
    type = "circle";}void drawCircle() {
     System.out.println("Circle is drawn."); }
}// Rectangle.java
public class Rectangle extends Shape {
    Rectangle() {
    type = "rectangle";}void drawRectangle() {
    System.out.println("Rectangle is drawn."); };
}// DrawShapes.java
class DrawShapes {
    public void draw(Shape shape) {
    if (shape.type.equals("circle")) {
    Circle circle = (Circle) shape;circle.drawCircle();} else if (shape.type.equals("rectangle")) {
    Rectangle rectangle = (Rectangle) shape;rectangle.drawRectangle();}}
}// Test.java
public class Test {
    public static void main(String[] args){
    DrawShapes drawShapes = new DrawShapes();drawShapes.draw(new Rectangle());drawShapes.draw(new Circle());}
}

如果我们要增加画三角形的功能, 首先要增加Triangle的类(继承Shape), 其次要修改DrawShape’s类, 这是违反开闭原则的(如下所示).

// Triangle.java
public class Triangle extends Shape {public Triangle() {type = "triangle";}public void drawTriangle() { System.out.println("Triangle is drawn."); };
}// DrawShapes.java
class DrawShapes {public void draw(Shape shape) {if (shape.type.equals("circle")) {Circle circle = (Circle) shape;circle.drawCircle();} else if (shape.type.equals("rectangle")) {Rectangle rectangle = (Rectangle) shape;rectangle.drawRectangle();} else if (shape.type.equals("triangle")) {Triangle triangle = (Triangle) shape;triangle.drawTriangle();}}}

相比上面的实现相比, 更好的做法是遵循开闭原则.

  1. 抽象出要实现的功能并定义接口.
//ShapeDrawer.java
public interface ShapeDrawer {public void draw();  // 画几何形状
}
  1. 通过继承接口, 我们实现需要支持的功能: 画圆形/三角形/矩形等. 当需要改变功能时只需要增加新的实现(Implementation)即可.
// CircleShapeDrawerImpl.java
public class CircleShapeDrawerImpl implements ShapeDrawer {@Overridepublic void draw() {System.out.println("Circle is drawn.");}
}// TriangleShapeDrawerImpl.java
public class TriangleShapeDrawerImpl implements ShapeDrawer {@Overridepublic void draw() {System.out.println("Triangle is drawn.");}
}// RectangleShapeDrawerImpl.java
public class RectangleShapeDrawerImpl implements ShapeDrawer {@Overridepublic void draw() {System.out.println("Rectangle is drawn.");}
}
  1. 实现画图的功能. DrawShapes类可以简化为如下形式(调用useDrawer方法画形状).
class DrawShapes {public void useDrawer(ShapeDrawer shapeDrawer) {shapeDrawer.draw();}
}

调用方式如下.

// Test.java
public class Test {public static void main(String[] args){DrawShapes drawShapes = new DrawShapes();drawShapes.useDrawer(new CircleShapeDrawerImpl()); // 画圆形drawShapes.useDrawer(new TriangleShapeDrawerImpl()); // 画三角形drawShapes.useDrawer(new RectangleShapeDrawerImpl()); // 画矩形}
}
3. 里氏替换原则(LSP)

Functions that use pointers or references to base classes must be able to use objects of derived classes without knownig it.
函数中对基类的引用必须能用其派生类的对象替代(且不会影响程序的运行和功能).

一个经典的例子是: 正方形不是长方形的子类(否则违反里氏替换原则), 因为长方形和正方形有不同的结构, 即: 长方形有长和宽, 而正方形只有边长. 如果在程序中把长方形对象替换成正方形对象则可能产生错误.

定义Rectangle类代表长方形.

public class Rectangle {private double length;private double width;public double getLength() {return length;}public void setLength(double length) {this.length = length;}public double getWidth() {return width;}public void setWidth(double width) {this.width = width;}
}

令Square继承Rectangle, 并重写所有set和get方法.

public class Square extends Rectangle {private double side;@Overridepublic void setLength(double length) {side = length;}@Overridepublic void setWidth(double width) {side = width;}@Overridepublic double getLength() {return side;}@Overridepublic double getWidth() {return side;}
}

上述实现是违反里氏替换原则的(参考下面的代码).

public class Test {// 设置矩形的长和宽, 并判断设置是否正确private static void runRectangle() {Rectangle rectangle = new Rectangle();rectangle.setLength(10);rectangle.setWidth(5);assert(rectangle.getLength() == 10);assert(rectangle.getWidth() == 5);}// 把Rectangle对象替换成Square对象private static void runSquare() {Square square = new Square();square.setLength(10);square.setWidth(5);assert square.getLength() == 10;assert square.getWidth() == 5;}public static void main(String[] args){runRectangle(); // OKrunSquare(); // 失败}
}

遵循里氏替换原则的好处是防止滥用继承, 并保持代码的健壮(派生类替换基类对程序的功能没有影响).

4. 接口分离原则(ISP)

Clients should not be forced to depend upon interfaces that do not use.
客户端不应该被强制依赖它不需要的接口.

客户端(Clients)可以理解为应用程序或模块, 而服务(Service)可以理解为接口功能的实现. 考虑三个客户端(ABC):

  • Client A 依赖 client A methods
  • Client B 依赖 client A methods + client B methods
  • Client C 依赖 client C methods

下面的设计把所有的方法通过一个服务来实现, 并提供给三个客户端调用. 这样设计的弊端是对接口的一次修改导致三个客户端的修改.

集成的接口

更好的设计是把三个客户端的接口分开, 由三个服务分别实现.

接口分离

5. 依赖倒置原则(DIP)

A. High level modules should not depend upon low level modules. Both should depend upon abstractions.
B. Abstractions should not depend upon details. Details should depend upon abstractions.
A. 上层模块不应该依赖下层模块. 两者都应当依赖抽象.
B. 抽象不应该依赖细节. 细节应该依赖抽象.

抽象指的是抽象类(abstract class)或接口(例如Java, Golang中的Interface). 细节指的是抽象类或接口的实现类(Implementation class). 例如我们需要实现画图的功能, 支持画三角形/圆形/矩形. 下面是接口和实现类的示意图:

说明

  1. 接口中的方法draw()是抽象出的画图形的方法.
  2. 具体负责画图的实现类依赖此接口.
  3. 画图功能与实现类之间彼此独立. 当需要增加新的功能, 例如画多边形, 只需要增加新的实现类即可.

参考文献


  1. Robert C. Martin. Design Principles and Design Patterns. https://fi.ort.edu.uy/innovaportal/file/2032/1/design_principles.pdf ?? ??

  2. http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod ?? ??

  相关解决方案