软件开发的 SOLID 原则

目录

怎么判断一个 机器 有没有坏?这个简单,先开机,再使用,能用就是好的。坏了可以修,修不好可以买个新的。怎么判断一个 软件 有没有坏?打开软件,能用。但是,它可能已经坏了。坏了很难修,维修费很贵,可能比研发一个新产品还贵。

随便举几个例子。

  • 软件能用,但不好用,比如卡顿、崩溃、异常。
  • 软件能用,但不可靠。比如一个计算器,虽然加减乘除都可以算,但结果偶尔出错,用户很难发现。
  • 软件能用,但功能迭代,跟不上需求变化。
  • 软件能用,但它膈应人。它不干正事,比如广告弹窗、泄漏隐私、删用户照片。

用户买的虽然是软件,其实是服务。软件不像硬件,它不会磨损变坏。哪怕是崭新的,有可能已腐坏;哪怕没有坏,也容易在升级和维护中腐坏。它的坏,用户不容易发现,等到被发现,已经来不及修了。

腐坏的软件

Bob 大叔(Robert C. Martin) 说,腐坏的软件有四个症状,分别是僵化(Rigidity)、脆弱(Fragility)、低复用(Immobility)和高粘性(Viscosity)(参考 Design Principles and Design Patterns)。

僵化

说的是修改很麻烦。各模块的耦合性高,牵一发而动全身。比如增加一个新功能,需要联动多个模块,导致协作成本高、研发效率低。

脆弱

人脆弱的时候,控制不了情绪,表现就是“一点就炸”。脆弱的软件也是这样。

做个搜索功能,然后登陆出问题;搞定登陆,又发现支付故障;支付搞定了,又不能改昵称。总之就是,修改一个模块,崩溃其它模块。让人崩溃的是,模块之间的崩溃,看起来没有直接的联系。

这种软件,用户见了害怕,而且开发和测试成本高。

低复用

不同的软件项目,有很多功能相似的代码,这就是低复用。重复代码意味着开发效率不高,错误也可能重复出现。

这种情况不是开发者不想复用,而是代码的抽象程度不高,导致移植成本比重写代码的成本更高。

高粘性

被粘住了,就不能动了,说明阻力大。高粘性说的是环境因素,主要是设计的粘性和开发环境的粘性,导致程序员开发不方便或者动力不足,从而影响效率。

例如,要实现的一个简单功能,但软件设计很复杂,要遵循一大堆编码规范。于是程序员懒得守规范,最后的软件也难维护。这是设计的粘性。

再介绍开发环境的粘性。编辑器难用、编译时间长、发布测试流程复杂等等,都会导致程序员不愿意做麻烦的修改,然后就开始走捷径。

软件开发的原则

为了防止(或者减缓)软件腐化,Bob 大叔总结了五条软件开发原则,称为 SOLID 原则

  • 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)

下面解释这五个原则。

单一职责原则

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

我的理解是,一个 class 做一件事。换句话说,类的功能不要太复杂,要不然编译花时间,其他人也不好理解。

考虑一个 Rectangle 类,它有两个方法:

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

主流的开发方式是前后端分离,画图属于前端,计算属于后端。 Rectangle 把前后端功能放在一起做,就显得不符合单一职责原则。

当然,也不是说一定不能这样做。不同的人设计思路不同,对单一职责的理解也不同,因此没有绝对的答案。

开闭原则

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

要改变软件的功能,应该尽量去扩展代码,而不是修改原有的代码。

为什么要这样做呢?

一个模块有可能被其它模块调用。如果去修改它,可能引发一系列未知的问题。更好的做法是,通过继承接口的方式,来增加新功能。

下面举例说明。要实现一个画图形的模块,支持画圆形和矩形。

第一步,抽象出要实现的功能并定义接口。

//ShapeDrawer.java
public interface ShapeDrawer {
    public void draw();  // 画几何形状
}

第二步,继承接口,增加一个功能:画圆形。

// CircleShapeDrawerImpl.java
public class CircleShapeDrawerImpl implements ShapeDrawer {
    @Override
    public void draw() {
        System.out.println("Circle is drawn.");
    }
}

第三步,继承接口,增加一个功能:画三角形。

// RectangleShapeDrawerImpl.java
public class RectangleShapeDrawerImpl implements ShapeDrawer {
    @Override
    public void draw() {
        System.out.println("Rectangle is drawn.");
    }
}

可以看到,要增加新的画图功能,只要增加新的“实现”即可,这就是可扩展性。要做到这一点,需要对实现的功能做合理的抽象。

里氏替换原则

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 继承长方形,需要重写所有的 set 和 get 方法。

public class Square extends Rectangle {

    private double side;

    @Override
    public void setLength(double length) {
        side = length;
    }

    @Override
    public void setWidth(double width) {
        side = width;
    }

    @Override
    public double getLength() {
        return side;
    }

    @Override
    public double getWidth() {
        return side;
    }
}

注意,上面 Squre.setLengthSquare.setWidth 已经改变了原先的功能,现在这两个函数功能一样了,都是设置边长。

如果一个程序调用了 Rectangle 对象的 set 和 get 方法,然后你把它替换成 Square 对象,就会出错。

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(); // OK
        runSquare(); // 失败
	  }
}

里氏替换原则告诉我们, Square 不适合作为 Rectangle 的子类。

接口分离原则

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

回顾前面的开闭原则,它说开发功能要扩展代码,也就是要依赖接口进行扩展。

那怎么设计接口呢?

接口分离原则说,不要让模块依赖不相关的接口。否则,如果要修改这个接口,所有依赖的模块都要修改。

依赖倒置原则

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

B. Abstractions should not depend upon details. Details should depend upon abstractions. B. 抽象不应该依赖细节。细节应该依赖抽象。

抽象指的是抽象类(abstract class)或接口(例如 Java、Golang 中的 Interface)。细节指的是抽象类或接口的实现类(Implementation class)。

依赖倒置原则说的是面向接口做开发。粗略地说,分三步走:

第一步,梳理要实现的功能。

第二步,定义接口(定义抽象)。

第三步,实现接口(细节依赖抽象)。

例如,要实现画图功能,支持三角形、圆形和矩形。下面是接口和实现类的示意图:

总结

软件跟硬件相比,它虽然不会磨损,但是需要持续维护和迭代。软件的好坏,主要体现在它后续的研发成本和效率,比如维护成本、协作成本、迭代效率、稳定性等等。

为了防止软件腐坏,Bob 大叔给了一些开发建议。简单来说,就是要做顶层设计,包括功能设计、接口设计和类的设计等等,目标是降低各模块的耦合度,使得代码更容易开发和维护。

标签 :