【Engineering】SOLID 原则

Posted by 西维蜀黍 on 2018-02-23, Last Modified on 2023-07-19
缩写 全称 中文
S The Single Responsibility Principle 单一责任原则
O The Open-Closed Principle 开放封闭原则
L Liskov Substitution Principle 里氏替换原则
I The Interface Segregation Principle 接口分离原则
D The Dependency Inversion Principle 依赖倒置原则

单一职责原则(The Single Responsibility Principle)

A class should have one and only one reason to change, meaning that a class should have only one job.

理解

  • 一个类只应承担一种责任(single responsibility);
  • 让一个类只做一件事。如果它需要承担更多的工作,那么就将这个类进行分解(成多个类),这些类中的每个类仍然都只做一件事;
  • 一个类应该有且只有一个去改变它的理由,这意味着一个类应该只负责一项工作(如果这个类负责两项工作,那么就会有两个理由改变其实现的理由);
  • 把一个事物分离成多个子部分,以便于能够被复用和集中管理。

例子

例如,假设我们有一些Shape(形状),并且我们想求所有shape的面积的和。

Shape类

public class Circle : Shape
{
    public int radius;
    public Circle(int radius)
    {
        this.radius = radius;
    }
}

public class Square : Shape
{
    public int length;
    public Square(int length)
    {
        this.length = length;
    }
}

首先,我们创建shape类,让构造函数设置需要的参数。接下来,我们继续通过创建AreaCalculator类,然后编写求取所提供的shape对象们的面积之和的逻辑。

AreaCalculator类

public class AreaCalculator
{
    protected List<Object> shapes;
    public AreaCalculator(List<Object> shapes)
    {
        this.shapes = shapes;
    }

    public List<double> sum()
    {
        // logic to sum the areas
        return 0;
    }

    public string output()
    {
        return "<h1>Sum of the areas of provided shapes:" + sum() + "</h1>";
    }
}

调用

使用AreaCalculator类,我们简单地实例化类,同时传入一个shape数组,并在页面的底部显示输出。

List<Object> shapes = new List<Object>();
shapes.Add(new Circle(1));
shapes.Add(new Circle(2));
shapes.Add(new Square(1));

AreaCalculator calculator = new AreaCalculator(shapes);
calculator.output();

分析

输出方法的问题在于,AreaCalculator 既包含了求取所提供的shape面积之和的逻辑,还包含了处理输出数据的逻辑,这是违反单一职责原则(SRP)的。

AreaCalculator 类应该只包含对提供的shape进行面积求和,而不应该包括处理输出数据的逻辑。

解决

因此,为了解决这个问题,可以创建一个 SumCalculatorOutputter 类,来具体处理对提供shape进行面积求和后如何显示的逻辑。

而且,在上面的例子( AreaCalculator中的 output()方法),我们采用了以硬编码(Hard Code)的方式将输出格式写死成了HTML。事实上,用户可能不仅仅只需要HTML的输出格式,还需要其他形式的输出格式(如JSONXML)。

调用

SumCalculatorOutputter类按如下方式工作:

List<Object> shapes = new List<Object>();
shapes.Add(new Circle(1));
shapes.Add(new Circle(2));
shapes.Add(new Square(1));

AreaCalculator calculator = new AreaCalculator(shapes);
calculator.output();
SumCalculatorOutputter outputter = new SumCalculatorOutputter(calculator);

// output different formalism
outputter.outputJSON();
outputter.outputXML();
outputter.outputHTML();

现在,不管需要何种格式的输出数据,皆由SumCalculatorOutputter类具体处理。

开放封闭原则(The Open-Closed Principle)

软件实体应该是可扩展的(extensible),而不可修改的 (unmodifiable)。也就是说,对扩展是开放的,而对修改是封闭的。

The open/closed principle states software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification"; that is, such an entity can allow its behaviour to be extended without modifying its source code.

换句话说,当软件要增加新的需求(requirement)时,可以通过扩展来实现新的需求(增加新的代码),且并不需要修改现有的代码。

这就意味着一个类应该能够无需通过修改类的实现,而完成扩展(比如增加新的功能)。

例子

让我们看看 AreaCalculator 类,尤其是它的 sum 方法。

public double sum()
{
    var sum = 0
    foreach (var element in this.squares)
    {
        // logic to sum the areas
        if (element is Square)
        {
            var length = ((Square)element).length;
            sum = sum + length* length;
        }
        else if (element is Circle)
        {
            var r = ((Circle)element).radius;
            sum = sum + Math.PI * r * r;
        }
    }
    return sum;
}

如果我们希望sum()方法能够对更多类型的shape进行面积求和,我们可以添加更多的If / else块来实现。但是,这违背了开放封闭原则。

能让这个sum()方法做的更好的一种方式是,将计算每个Shape求面积的逻辑sum()方法中移出,将它附加到特定的Shape类上,命名为GetArea()

public class Square : Shape
{
    public int length { set; get; }
    public Square(int length)
    {
        this.length = length;
    }
    public double GetArea()
    {
        return Math.PI * length * length;
    }
}

对Circle类应该做同样的事情,添加GetArea()方法。现在,获取包含任何Shape面积的和就非常简单了,而且当需要增加新的Shape时,只需要增加这个Shape对应的类(且增加对应的GetArea()方法),而不再需要修改现有的代码:

public class AreaCalculator
{
    // omit some methods
    
    public List<double> sum()
    {
        var sums = new List<double>();
        foreach (Shape element in this.squares)
        {
            sums.Add(element.GetArea());
        }
        return sums;
    }
}

里氏替换原则 (Liskov Substitution Principle)

里氏替换原则是指,每一个子类或派生类对象都可以替换它们基类或父类对象,且替换后不会影响原有功能。

Java语言对里氏代换原则的支持

对于Java而言,在编译时期,Java语言编译器会检查一个程序是否符合里氏代换,当然这是一个无关实现的、纯语法意义上的检查。

里氏代换要求凡是基类型使用的地方,子类型一定适用,因此子类型必须具备及类型的全部接口。或者说,子类型的接口必须包括全部的及类型的接口,而且还有可能更宽。如果一个Java程序破坏这一条件,Java编译器就会给出编译时期错误。

举例而言,一个基类Base声明了一个public方法 method(),那么其子类型Sub可否将这个方法的访问权限的声明,从public改换成private呢?换言之,子类型可否使用一个更低访问权限的方法private method(),将超类型的方法public method()进行重写(override)呢?

从里氏代换的角度考察这个问题,就不难得出答案,因为客户端完全有可能调用超类型的公开方法,如果以子类型代之,这个方法却变成了私有的,客户端不能调用。显然这是违反里氏代换法则的,Java编译器根本就不会让这样的程序过关。

例子

//抽象父类电脑
public abstract class Computer {
    public abstract void use();
}
 
class IBM extends Computer{
    @Override
    public void use() {
        System.out.println("use IBM Computer.");
    }
}
 
class HP extends Computer{
    @Override
    public void use() {
        System.out.println("use HP Computer.");
    }
}
 
public class Client{
    public static void main(String[] args) {
        Computer ibm = new IBM();
        Computer hp = new HP();//引用基类的地方能透明地使用其子类的对象。
         
        ibm.use();
        hp.use();
    }
}

含义

换句话说,子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:

  • 子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法。
  • 子类中可以增加自己特有的方法。
  • 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

1 子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法

在我们做系统设计时,经常会设计接口或抽象类,然后由子类来实现抽象方法,这里使用的其实就是里氏替换原则。子类可以实现父类的抽象方法很好理解,事实上,子类也必须完全实现父类的抽象方法(哪怕写一个空方法,当然不推荐这样做),否则会编译报错

里氏替换原则的关键点在于不能覆盖父类的非抽象方法。父类中凡是已经实现好的方法,实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些规范,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。

在面向对象的设计思想中,继承这一特性为系统的设计带来了极大的便利性,但是由之而来的也潜在着一些风险。

反例

比如,当类C1继承类C时,可以通过添加新方法的方式来完成新增功能,而尽量不要重写父类C中的方法。否则可能带来难以预料的风险,比如:

public class C {
    public int func(int a, int b){
        return a+b;
    }
}
 
public class C1 extends C{
    @Override
    public int func(int a, int b) {
        return a-b;
    }
}
 
public class Client{
    public static void main(String[] args) {
        C c = new C1();
        System.out.println("2+1=" + c.func(2, 1)); //2+1=1
      
        C c2 = new C();
        System.out.println("2+1=" + c2.func(2, 1)); //2+1=3
    }
}

分析

上面的运行结果明显是错误的(我们期望的运行结果:2+1=3)。类C1继承C后,需要增加新功能,类C1并没有新写一个方法,而是直接重写了父类C的func()方法,违背了里氏替换原则。最终引用父类的地方并不能透明的使用子类的对象,导致运行结果出错。

而事实上,我们期待上面的两种调用方式的输出是相同的。

2 子类中可以增加自己特有的方法

在继承父类属性和方法的同时,每个子类也都可以有自己的个性,即在父类的基础上扩展自己的功能。前面其实已经提到,当功能扩展时,子类尽量不要重写父类的方法,而是另写一个方法。

所以可以对上面的代码加以更改,使其符合里氏替换原则,代码如下:

public class C {
    public int func(int a, int b){
        return a+b;
    }
}
 
public class C1 extends C{
    public int func2(int a, int b) {
        return a-b;
    }
}
 
public class Client{
    public static void main(String[] args) {
        C1 c = new C1();
        System.out.println("2-1=" + c.func2(2, 1));
    }
}

运行结果:2-1=1

3 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松

import java.util.HashMap;
public class Father {
    public void func(HashMap m){
        System.out.println("执行父类...");
    }
}
 
import java.util.Map;
public class Son extends Father{
    // Map是一个接口,HashMap是类。HashMap 实现了 Map 接口
    public void func(Map m){//方法的形参比父类的更宽松
        System.out.println("执行子类...");
    }
}
 
import java.util.HashMap;
public class Client{
    public static void main(String[] args) {
        Father f = new Son();//引用基类的地方能透明地使用其子类的对象。
        HashMap h = new HashMap();
        f.func(h);
    }
}

运行结果:执行父类…

注意Son类的func方法前面是不能加@Override注解的,因为否则会编译提示报错,因为这并不是重写(Override),而是重载(Overload),因为方法的输入参数不同。

4 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格

import java.util.Map;
public abstract class Father {
    public abstract Map func();
}
 
import java.util.HashMap;
public class Son extends Father{
     
    @Override
    public HashMap func(){//方法的返回值比父类的更严格
        HashMap h = new HashMap();
        h.put("h", "执行子类...");
        return h;
    }
}
 
public class Client{
    public static void main(String[] args) {
        Father f = new Son();//引用基类的地方能透明地使用其子类的对象。
        System.out.println(f.func());
    }
}

执行结果:{h=执行子类…}

接口分离原则 (The Interface Segregation Principle)

不能强迫用户去依赖那些他们不使用的接口。换句话说,使用多个专门的小接口比使用单一的大接口总要好

例子

举个简单的例子:

IBird接口包含很多鸟类的行为,包括Fly()行为。现在如果一个Bird类(如 Ostrich 鸵鸟)实现了这个接口,那么它需要实现不必要(或称为没有意义)的Fly()行为(因为 Ostrich 不会飞)。

因此,这个IBird“胖接口” 应该被拆分为两个不同的接口(IBirdIFlyingBird),IFlyingBird 继承自 IBird

  • 这里如果一种鸟不会飞(如 Ostrich),那它实现 IBird 接口
  • 如果一种鸟会飞(如 KingFisher),那么它应实现 IFlyingBird 接口

总的来说,就是将接口尽量拆分,使得实现这些接口的类不需要被迫实现在接口中不被需要或没有意义的方法


仍然以Shape为例,我们知道也有立体Shape,如果我们也想计算 Shape 的体积,我们可以新添加GetVolumn()方法到 Shape这个 Interface 中:

public interface Shape
{
    double GetArea();
    double GetVolumn();
}

这样修改之后,任何我们创建的Shape必须实现GetVolumn()方法,但是我们知道正方形(Square)是平面形状没有体积,所以这个接口将迫使正方形类(Square)实现一个对它来说没有意义方法

这样做违反了接口隔离原则(ISP)。更好的实践,是新创建一个名为 SolidShape 的接口,它有一个GetVolumn()方法,对于立体形状(比如立方体等等),可以实现这个接口:

public interface Shape
{
    double GetArea();
}

public interface SolidShape
{
    double GetVolumn();
}

依赖反转原则 (The Dependency Inversion Principle)

在传统的应用架构中,低层次的组件设计用于被高层次的组件使用,这一点提供了逐步的构建一个复杂系统的可能。在这种结构下,高层次的组件直接依赖于低层次的组件去实现一些任务,但这种对于低层次组件的依赖限制了高层次组件被重用的可行性

在面向对象编程领域中,依赖反转原则(Dependency inversion principle,DIP)是指一种特定的解耦形式,使得高层次的模块不依赖于低层次的模块的实现细节,依赖关系被颠倒(反转),从而使得低层次模块依赖于高层次模块的需求抽象。

理解

  • 高层次的模块不应依赖低层次的模块,他们都应该依赖于抽象。
  • 抽象不应依赖于具体实现,具体实现应依赖抽象。

高层次的和低层次的对象都应该依赖于相同的抽象接口,一般来说,高层次实体引用接口,而低层次类实现该接口,而不是高层次类直接引用低层次类,以此实现解耦。


应用依赖反转原则同样被认为是应用了适配器模式。

例如:高层的类定义了它自己的适配器接口(高层类所依赖的抽象接口)。被适配的对象同样依赖于适配器接口的抽象(这是当然的,因为它实现了这个接口),同时它的实现则可以使用它自身所在低层模块的代码。通过这种方式,高层组件则不依赖于低层组件,因为它(高层组件)仅间接的通过调用适配器接口多态方法使用了低层组件,而这些多态方法则是由被适配对象以及它的低层模块所实现的。

例子

举个例子:

public class PasswordSaver
{
    private MySQLConnection conn;
    public PasswordSaver(MySQLConnection conn)
    {
        this.conn = conn;
    }
}

MySQLConnection是低层次模块(用于在保存密码时,连接MySQL数据库),PasswordSaver是高层次模块(用于保存密码)。根据依赖倒置原则高层次的模块不应依赖低层次的模块,上述代码违反了这一原则,即高层次模块(PasswordSaver)依赖了低层次模块(MySQLConnection)。

同时,如果之后我们希望更换数据库引擎(比如使用SQL Server),那么这时还需要修改PasswordSaver类,这一修改也违反了开闭原则(The Open-Closed Principle)。


因此,PasswordSaver不应该依赖于使用哪种具体的数据库。基于面向接口编程的思想,我们可以创建一个DBConnectionInterface 接口,使得PasswordSaver依赖于这个接口(而不再是MySQLConnection):

interface DBConnectionInterface
{
    public bool connect();
}
  • DBConnectionInterface接口有一个connect()方法,MySQLConnection 类实现该接口。
  • 在PasswordReminder类的构造函数不再依赖MySQLConnection类,而是依赖DBConnectionInterface接口。

这样做之后,无论管我们是什么类型的数据库,或者当需要更换数据库引擎时,PasswordSaver 类都可以正常工作,且无需修改现有代码。

public class PasswordReminder
{
    private DBConnectionInterface conn;
    public PasswordReminder(DBConnectionInterface conn)
    {
        this.conn = conn;
    }
}

class MySQLConnection : DBConnectionInterface
{
    public bool connect()
    {
        // do something
        return true;
    }
}

Reference