AkiraZheng's Time.

设计模式0:面对对象的8大设计原理

Word count: 2.8kReading time: 11 min
2024/02/08

前言

底层思维来理解,面向对象是继承、封装、多态
抽象思维层面来理解,面向对象能隔离变化,将变化带来的影响降到最低。
微观层面来看,面向对象可以实现各司其职(采用相同的抽象接口,通过不同的具体类实现不同的功能)

使用设计模式的最大优势是能抵御变化、实现代码复用

一、为设计模式原则的理解提供前景代码例子(C++)

1.1 未优化的代码

  • 高层模块:MainForm窗体类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    class MainForm:public Form{
    private:
    vector<Line*> lines;
    vector<Rect*> rects;
    //画图程序用鼠标表达,用P1、P2表示鼠标的起始点和终点
    Point P1;
    Point P2;
    public:
    MainForm(){
    //...
    }
    protected:
    virtual void OnMouseDown(MouseEventArgs e);
    virtual void OnMouseUp(MouseEventArgs e);
    virtual void OnPaint(PaintEventArgs e);
    };

    void MainForm::OnMouseDown(MouseEventArgs e){//当鼠标按下时,记录鼠标两点的位置
    P1.x = e.X;
    P1.y = e.Y;

    //...
    Form::OnMouseDown(e);
    }

    void MainForm::OnMouseUp(MouseEventArgs e){//当鼠标抬起时,记录鼠标两点的位置
    P2.x = e.X;
    P2.y = e.Y;

    if (rbLine.Checked){
    lines.push_back(new Line(P1, P2));
    }
    else if (rbRect.Checked){
    //P1和P2两点呈对角线,可以确定一个矩形
    int width = abs(P1.x - P2.x);
    int height = abs(P1.y - P2.y);
    rects.push_back(new Rect(P1, width, height));
    }

    //...
    this->Refresh();

    Form::OnMouseUp(e);
    }

    void MainForm::OnPaint(PaintEventArgs e){//当执行Refresh()时,会调用OnPaint()方法

    //针对直线
    for (int i = 0; i < lines.size(); i++){
    e.Graphics.DrawLine(Pens::Black,
    lines[i]->start.x,
    lines[i]->start.y,
    lines[i]->end.x,
    lines[i]->end.y);//取vector中的坐标点
    }

    //针对矩形
    for (int i = 0; i < rects.size(); i++){
    e.Graphics.DrawRectangle(Pens::Black,
    rects[i]->left_top;
    rects[i]->width,
    rects[i]->height);//取vector中的坐标点
    }
    //...
    Form::OnPaint(e);
    }
  • 底层模块、细节类:LineRect操作类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class Point{
    public:
    int x;
    int y;
    };
    class Line{
    public:
    Point start;
    Point end;
    Line(Point start, Point end):start(start), end(end){};
    };
    class Rect{
    public:
    Point left_top;
    int width;
    int height;
    Rect(Point left_top, int width, int height):left_top(left_top), width(width), height(height){};
    };
  • 未优化代码的流程图

1.2 优化后的代码

  • 高层模块:MainForm窗体类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    class MainForm:public Form{
    private:
    vector<Shape*> shapes;//针对所有类型(Line和Rect),需要多态性的值一定要用指针,因为可能会有多种派生类型
    //画图程序用鼠标表达,用P1、P2表示鼠标的起始点和终点
    Point P1;
    Point P2;
    public:
    MainForm(){
    //...
    }
    protected:
    virtual void OnMouseDown(MouseEventArgs e);
    virtual void OnMouseUp(MouseEventArgs e);
    virtual void OnPaint(PaintEventArgs e);
    };

    void MainForm::OnMouseDown(MouseEventArgs e){//当鼠标按下时,记录鼠标两点的位置
    P1.x = e.X;
    P1.y = e.Y;

    //...
    Form::OnMouseDown(e);
    }

    void MainForm::OnMouseUp(MouseEventArgs e){//当鼠标抬起时,记录鼠标两点的位置
    P2.x = e.X;
    P2.y = e.Y;

    if (rbLine.Checked){
    shapes.push_back(new Line(P1, P2));
    }
    else if (rbRect.Checked){
    //P1和P2两点呈对角线,可以确定一个矩形
    int width = abs(P1.x - P2.x);
    int height = abs(P1.y - P2.y);
    shapes.push_back(new Rect(P1, width, height));
    }

    //...
    this->Refresh();

    Form::OnMouseUp(e);
    }

    void MainForm::OnPaint(PaintEventArgs e){//当执行Refresh()时,会调用OnPaint()方法

    //绘制时将直线和矩形统一绘制
    for (int i = 0; i < lines.size(); i++){
    shapes[i]->draw(e.Graphics);
    }

    //...
    Form::OnPaint(e);
    }
  • 抽象:Shape抽象方法 && LineRect类继承于抽象方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    class Shape{
    public:
    virtual void draw(Graphics g) = 0;
    };
    class Line:public Shape{
    public:
    Point start;
    Point end;
    Line(Point start, Point end):start(start), end(end){};
    void draw(Graphics g){//实现自己的绘画
    g.DrawLine(Pens::Black,
    start.x,
    start.y,
    end.x,
    end.y);
    }
    };
    class Rect:public Shape{
    public:
    Point left_top;
    int width;
    int height;
    Rect(Point left_top, int width, int height):left_top(left_top), width(width), height(height){};
    void draw(Graphics g){//实现自己的绘画
    g.DrawRectangle(Pens::Black,
    left_top,
    width,
    height);
    }
    };
  • 未优化代码的流程图

1.3 优化后代码的优点说明

  • 假设现在客户需求发生了变化,需要增加一个Circle类,那么在1.1中的代码中除了需要增加Circle类外,在MainForm中的代码也需要进行修改,首先vector需要增加一种新类型来存储Circle类,其次在OnMouseUpOnPaint中也需要增加对Circle类的处理,需要改动的部分很多

  • 而在1.2中的代码中,只需要增加一个Circle类,然后在MainFormOnMouseUp中增加对Circle类的处理,这样就实现了隔离变化,将变化带来的影响降到最低。因为绘制的实现都抽象出draw方法由各个类去实现了,因此也不需要改动OnPaint方法

二、设计模式的八大设计原则

1. 依赖倒置原则(DIP)

高层模块(稳定)不应该依赖于底层模块(变化),二者都应该依赖于抽象(稳定)。

抽象(稳定)不应该依赖于细节(变化),细节(变化)应该依赖于抽象(抽象)。

中的代码例子来分析这两句话,可以将代码中的各种类划分为:

  • 高层模块:比如MainForm窗体类(稳定)
  • 底层模块、细节类:比如LineRect操作类(变化)
  • 抽象:比如Shape抽象方法(稳定)

对第一句话(高层模块不应该依赖于底层模块,二者都应该依赖于抽象)的理解:

  • 当使用1.1中未优化的MainForm设计代码时,Line和Rect还未继承于统一接口的基类,那么在MainForm中创建相关对象就会依赖于变化的Line和Rect类,这样就会导致MainForm的稳定性受到影响。

  • 而当使用了1.2的抽象方法后,MainForm就直接依赖于稳定的抽象方法,而Line和Rect类继承于抽象方法,相当于被隔离了,这样MainForm就稳定了,后续两个变化类的改动也不会影响到MainForm的使用

对第二句话(抽象不应该依赖于细节,细节应该依赖于抽象)的理解:

  • 1.2中的代码中,抽象方法Shape不依赖于LineRect类,抽象方法提供的接口是稳定的、不变的

  • LineRect类依赖于Shape抽象方法提供的接口,这样就实现了抽象不依赖于细节,细节依赖于抽象的设计原则

2. 开放封闭原则(OCP)

扩展开放,对更改封闭

对扩展开发、对更改封闭的理解:

  • 这里的意思是指类模块应该是可扩展(被继承)的,但是不可修改(修改源类)的

  • 举个例子,有个家具加工厂,根据甲方要求生产了一批不防火的家具,后来甲方又要求生产防火家具

    • 这时候第一种做法是全面修改原来的家具生产线,但是这样会造成大量的修改工作,不符合开闭原则的类(生产原先家具的原始生产线不可修改)
    • 而第二种做法是继承原来的家具生产线,然后在新的家具中添加(扩展)防火功能,比如涂上防火材料,这样就是符合对扩展开放,对更改封闭
  • 从上述例子可以看到,不符合OCP原则的话,会导致需要对源代码重新进行编译、测试、部署改变的代价很高

  • OPC要求我们在设计代码时:当需求变更的时候要求我们不要去改原始代码,而是去扩展原始代码,这样就能保证原始代码的稳定性

3. 单一职责原则(SRP)

一个类应该只有一个引起它变化的原因
变化的方向隐含着类的责任

理解:

  • 这个原则要求我们一个类的设计中不应该太臃肿,比如在一个类中存进了七八十种方法

  • 在桥模式中,可以感受到这个原则的意义

4. Liskov替换原则(LSP) – 里氏替换原则

子类必须能够替换它们的基类(IS-A)

理解:

  • 这个原则是IS-A的另一种说法,意思就是说子类在继承父类是,必须是完全可以替换父类的,也就是说子类需要是可以继承并使用父类的所有公有接口的

  • 比如当一个子类继承父类时,但是该子类又不可以用到父类中的某些方法,那么当他遇到这种使用父类的方法时就会出现问题,他可能会选择当Client用到这些方法时,选择直接抛出异常,这种就是不符合LSP原则的

  • 又比如当两个类之间是组合关系时也不应该用继承关系,因为组合关系是HAS-A关系,而不是IS-A关系

5. 接口隔离原则(ISP)

不应该强迫客户程序依赖它们不用的方法
要求:接口要尽量小而完备

理解:

  • 这个原则要求我们在设计类的公用接口时要谨慎,不要将一些客户端用不到的,仅有类内部使用的方法放到公用接口中。这种方法应该放在类的private中

  • 因为一旦接口被设计成公有接口,客户端就会对这些公有接口产生依赖,这样一旦这些接口发生变化,就可能会导致客户端的代码也需要改动

6. 优先使用对象组合,而不是类继承(COP)

类继承通常为白箱复用,对象组合通常为黑箱复用

理解:

  • 继承是强耦合的,比如父类发生改变,子类也会跟着改变,在某种程度上破坏了类的封装性
  • 组合是弱耦合的,对象组合只要求被组合对象具有良好的接口

7. 封装变化点

封装变化点,就是封装那些可能变化的东西

理解:

  • 这个原则要求我们在设计类的时候要尽量将可能变化的东西封装起来,比如将变化的东西抽象成一个接口,然后在类中使用这个接口,这样就可以实现隔离变化,将变化带来的影响降到最低(当设计者在具体类种进行修改时,对另一侧抽象类不造成影响)

8. 针对接口编程,而不是针对实现编程

理解:

  • 这个原则要求我们在设计时,尽量不要将变量类型声明为某个特定的具体类,而是将其声明为某个接口(比如抽象接口)
  • 这样可以减少代码的耦合
  • 这个原则跟依赖倒置原则有一定的关联,一般违背了依赖倒置原则就会违背了针对接口编程,而不是针对实现编程原则

举例说明

  • 比如在1.1中的代码中,如果将LineRect类声明为具体类,那么在MainForm中的代码vector<Line*> lines;``vector<Rect*> Rect;就会导致MainForm对具体类的依赖
  • 而如果像1.2中的代码一样,将LineRect类声明为抽象类Shape,那么MainForm就只依赖于抽象类,在代码中体现为vector<Shape*> shapes;,这样就实现了针对抽象接口Shape编程,而不是针对实现编程

三、推荐一个讲得很好得设计模式课程

CATALOG
  1. 前言
  2. 一、为设计模式原则的理解提供前景代码例子(C++)
    1. 1.1 未优化的代码
    2. 1.2 优化后的代码
    3. 1.3 优化后代码的优点说明
  3. 二、设计模式的八大设计原则
    1. 1. 依赖倒置原则(DIP)
    2. 2. 开放封闭原则(OCP)
    3. 3. 单一职责原则(SRP)
    4. 4. Liskov替换原则(LSP) – 里氏替换原则
    5. 5. 接口隔离原则(ISP)
    6. 6. 优先使用对象组合,而不是类继承(COP)
    7. 7. 封装变化点
    8. 8. 针对接口编程,而不是针对实现编程
  4. 三、推荐一个讲得很好得设计模式课程