设计模式(0)面向对象设计原则

253 阅读12分钟

什么是设计模式

设计模式,最早源自 GoF 那本已成经典的《设计模式:可复用面向对象软件的基础》一书。该书自诞生以来,在程序设计领域已被捧为“圣经”。

软件设计模式也是参考了建筑学领域的经验,早在建筑大师克里斯托弗·亚历山大(Christopher Alexander)的著作《建筑的永恒之道》中,已给出了关于“模式”的定义:

每个模式都描述了一个在我们的环境中不断出现的问题,然后描述了该问题的解决方案的核心,通过这种方式,我们可以无数次地重用那些已有的成功的解决方案,无须再重复相同的工作。

单一职责原则

单一职责原则(Single responsibility principle)的定义如下:

each software module should have one and only one reason to change.

每个软件模块都应有一个且只有一个更改理由;也可以理解为一个类只负责一个功能领域中的相应职责。

先看一下下面一段代码:

public class CustomerDataChart{
    // 查询客户信息
    public List<Customer> findCustomers(){}
    // 修改客户信息
    public Customer updateCustomers(Customer customer){}
    // 创建图表
    public void createChart(Chart chart){}
    // 显示图表
    public void displayChart(){}
}

从代码中可以看出,CustomerDataChart 类有两种职责,一是客户信息相关的,二是图表相关的,显然违背了单一职责原则,那么违背了职责会有什么影响呢?

假定 A 负责客户信息部分,B 负责图表部分。某一天 A 在做查询客户信息的逻辑修改时,误修改了创建图表的代码,此时 B 是不知情的。

有一天领导想创建一个新的图表,发现创建失败,此时就认为责任在 B,毕竟是他负责图表模块的。但是 B 却懵逼了,我这几天都没动过代码,怎么会创建失败?

如何避免这种背锅行为?

将客户信息和图表两个职责拆分到不同的类,那样 A 和 B 都负责不同的类,那样 A 在修改自己负责的模块代码时不会出现误修改 B 负责的模块代码,那样自然就不会出现背锅行为。

public class CustomerData{
    // 查询客户信息
    public List<Customer> findCustomers(){}
    // 修改客户信息
    public Customer updateCustomers(Customer customer){}
}

public class ChartData{
    // 创建图表
    public void createChart(Chart chart){}
    // 显示图表
    public void displayChart(){}
}

开放-关闭原则

开闭原则(Open-Closed Principle)的定义如下:

software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification

软件实体(类,模块,功能等)应当对扩展开放,对修改关闭。

先看一下下面一段代码:

public class ChartDisplay{
    // 根据输入的类别显示对应类别的图表
	public void display(String type){
    	if (type.equals("pie")) {
			PieChart chart = new PieChart();
			chart.display();
		}else if (type.equals("bar")) {
            BarChart chart = new BarChart();
            chart.display();
        }
    }
}

public class PieChart{
    // 显示饼状图
	public void display(){}
}

public class BarChart{
    // 显示条形图
	public void display(){}
}

上述代码在没有新需求的时候能够很好的满足要求,但是有一天客户还想看一下折线图,代码就需要作出如下更改:

public class ChartDisplay{
    // 根据输入的类别显示对应类别的图表
	public void display(String type){
    	if (type.equals("pie")) {
			PieChart chart = new PieChart();
			chart.display();
		}else if (type.equals("bar")) {
            BarChart chart = new BarChart();
            chart.display();
        }else if (type.equals("line")) {
            LineChart chart = new LineChart();
            chart.display();
        }
    }
}

// ...

为了给 ChartDisplay 扩展一个折线图的逻辑,需要对它的源代码进行修改,通过开放-关闭原则的定义就很容易知道,它不符合开放-关闭原则的。

修改源代码就修改源代码,很容易啊,为什么还要遵守开放-关闭原则呢?

如果可以扩展系统中所有模块的行为而不修改它们,那么您可以在不修改任何旧代码的情况下向该系统添加新功能。仅通过编写新代码即可添加功能。而且,由于所有旧代码均未更改,因此无需重新编译,因此无需重新部署。添加新的功能,将涉及离开旧代码的地方,仅部署新的代码。

如果要符合开放-关闭原则,我们应该怎么做?

最简单方法是在继承原始类实现的新派生(子)类上实现新功能。另一种方法是使用抽象接口来介导客户端对原始类的访问,因此可以在通过相同接口访问的新类上实现新功能。

我们使用第一种方法,代码如下:

public class ChartDisplay{
	private AbstractChart chart;
    public void setChart(AbstractChart chart){
    	this.chart = chart;
    }

    public void display(){
    	chart.display()
    }
}

public abstract class AbstractChart{
	public abstract void display();
}

public class PieChart extends AbstractChart{
	public void display(){}
}

public class BarChart extends AbstractChart{
	public void display(){}
}

如果要增加折线图的逻辑,ChartDisplay 不需要变动,只需要创建个新类 LineChart,让它继承 AbstractChart 即可。

结论:

像每个原则一样,OCP 只是一个原则。进行灵活的设计需要花费更多的时间和精力,并且引入了新的抽象级别,从而增加了代码的复杂性。因此,该原则应适用于最有可能更改的区域。

里氏替换原则

里氏替换原则(Liskov Substitution Principle)的定义如下:

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

所有引用基类的地方必须能透明地使用其子类的对象。

里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。

先看一个违背里氏替换原则的例子:

public class Bird{
    public void fly(){}
}
public class Duck extends Bird{}

鸭子会飞,因为它是鸟。但是这呢:

public class Ostrich extends Bird{}

鸵鸟是鸟,但是它不会飞,鸵鸟类是鸟类的子类型,但是它不能使用 fly 方法,这意味着我们正在打破里氏替换原则的原理。

这些问题的解决方案是正确的继承层次结构,在这个例子中,只需要通过一个带有 fly 功能的类就可以解决问题。

public class Bird{
}
public class FlyingBirds extends Bird{
    public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{} 

结论:

该原则只是“开放-关闭原则”的扩展,它意味着我们必须确保新的派生类在不改变其行为的情况下扩展了基类。

依赖倒转原则

依赖倒转原则(Dependency Inversion Principle)的定义如下:

High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details.Details should depend upon abstractions.

高层模块不应该依赖于底层模块,二者都应该依赖于抽象。抽象不应该依赖于具体实现,具体实现应该依赖于抽象。通俗点理解的就是面向接口编程。

还是先看下不遵守依赖倒转原则的例子:

public class Worker {
	public void work() {
		// ....working
	}
}

public class Manager {
	Worker worker;
	public void setWorker(Worker worker) {
		this.worker = worker;
	}
	public void manage() {
		worker.work();
	}
}

public class SuperWorker {
	public void work() {
		//.... working much more
	}
}

假设 Manager 类非常复杂,包含非常复杂的逻辑。现在,我们要引入新的 SuperWorker,则必须对其进行修改。让我们看看缺点:

  • 我们必须更改 Manager 类(这是一个复杂的类,这将花费时间和精力进行更改)。
  • manager 类当前的某些功能可能会受到影响。
  • 重做单元测试。

所有这些问题可能需要花费很多时间才能解决,并且可能在旧功能中引发新的错误。如果应用程序是按照依赖倒置原则设计的,情况将有所不同。

public interface IWorker {
	public void work();
}

public class Worker implements IWorker{
	public void work() {
		// ....working
	}
}

public class SuperWorker  implements IWorker{
	public void work() {
		//.... working much more
	}
}

public class Manager {
	IWorker worker;

	public void setWorker(IWorker w) {
		this.worker = w;
	}

	public void manage() {
		worker.work();
	}
}

结论:

当应用此原理时,这意味着高级类不能直接与低级类一起工作,而是将接口用作抽象层。

接口隔离原则

接口隔离原则(Interface Segregation Principle)的定义如下:

Clients should not be forced to depend upon interfaces that they don't use.

客户端不应该依赖它不需要的接口。也就是说不应强迫客户端依赖于不使用的接口。

还是通过代码示例来说明,先看下违背接口隔离原则的例子。

public interface IWorker {
	public void work();
	public void eat();
}

public class Worker implements IWorker{
	public void work() {
		// ....working
	}
	public void eat() {
		// ...... eating in launch break
	}
}

public class SuperWorker implements IWorker{
	public void work() {
		//.... working much more
	}

	public void eat() {
		//.... eating in launch break
	}
}

public class Manager {
	IWorker worker;

	public void setWorker(IWorker w) {
		this.worker=w;
	}

	public void manage() {
		worker.work();
	}
}

Worker 代表普通工人,SuperWorker 资深人,此时有一种新的工人,机器人 Robot 要加入工作。

public class Robot implements IWorker{
	public void work() {
		//.... working much more
	}

	public void eat() {
		//.... eating in launch break
	}
}

一方面,新的 Robot 类需要实现 IWorker 接口,因为机器人可以工作。另一方面,不必执行它,因为他们不吃东西。

如果保留当前的设计,则新的 Robot 类将被强制实现 eat 方法。我们可以编写一个不执行任何操作的假类(假设每天的启动时间为1秒),并且会对应用程序产生不良影响(例如,管理人员看到的报告将报告所吃的午餐多于人数)。

如何避免这种不良影响?

public interface IWorkable {
	public void work();
}

public interface IFeedable{
	public void eat();
}

public class Worker implements IWorkable, IFeedable{
	public void work() {
		// ....working
	}

	public void eat() {
		//.... eating in launch break
	}
}

public class Robot implements IWorkable{
	public void work() {
		// ....working
	}
}

public class SuperWorker implements IWorkable, IFeedable{
	public void work() {
		//.... working much more
	}

	public void eat() {
		//.... eating in launch break
	}
}

public class Manager {
	Workable worker;

	public void setWorker(Workable w) {
		this.worker=w;
	}

	public void manage() {
		worker.work();
	}
}

结论:

需要花费额外的时间和精力才能在设计期间应用它,并增加代码的复杂性。但是它产生了灵活的设计。如果我们将超出要求的范围进行应用,则会导致代码包含具有多个接口的单一方法,因此应基于经验和常识来进行应用,以确定可能更容易发生代码扩展的区域。

如果设计已经完成,则可以使用 Adapter 模式隔离胖接口。

合成复用原则

合成复用原则(Composite Reuse Principle)的定义如下:

尽量使用对象组合,而不是继承来达到复用的目的。

老规矩还是先看一下不满足合成复用原则的代码示例:

public class Employee {
    // 计算该雇员的年度奖金的方法
    Money computeBonus() {
    	// 默认奖金
    }
}

员工的不同子类:经理,程序员等可能希望重写此方法,以反映以下事实:某些类型的员工(经理)比其他人(程序员)获得的奖金更多:

public class Manager extends Employee {
	Money computeBonus() {
    	// 巨额奖金
    }
}

该解决方案存在几个问题:

  • 所有程序员都获得相同的奖金。如果我们想改变程序员之间的奖金计算,该怎么办? 继续引入子类?
  • 如果我们想更改特定员工的奖金计算,该怎么办?

也就是说不同的员工可以使用不同的奖金计算器,而不管他们实例化的类别是什么。更好的是,可以动态更改特定员工使用的奖金计算器。

如何解决上述的问题?

public interface BonusCalculator{
	Money computeBonus();
}

// 默认奖金类
public class Skimpy implements BonusCalculator{
	Money computeBonus() {}
}

// 巨额奖金类
public class Generous implements BonusCalculator{
	Money computeBonus() {}
}

public abstract class Employee{
	private BonusCalculator calculator;
    public void setCalculator(BonusCalculator calculator){
    	this.calculator = calculator;
    }
    public Money compute(){
    	this.calculator.computeBonus();
    }
}

public class Manager extends Employee{
    public Manager(BonusCalculator calculator){
    	super(calculator);
    }

    public Money compute(){
        // 自定义逻辑
        // 调用父类的基础逻辑
    	super.compute();
    }
}

采用依赖注入将 BonusCalculator 对象注入到 Employee,可以使用构造注入,也可以使用 Setter 注入。如果需要对 BonusCalculator 的功能进行扩展,可以通过其实现类来实现。通过扩展 Employee 类的子类也可以实现不同员工的不同奖金逻辑。

迪米特法则

迪米特法则(Law of Demeter)的定义如下:

一个软件实体应当尽可能少地与其他实体发生相互作用。它也被称为最少知道原则。

如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易,这是对软件实体之间通信的限制,迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。

先看下不遵守迪米特法则的例子:

public class Button{
	private Lable lable;
    private TextBox textBox;
    private ComboBox comboBox;
    public void click(){
    	lable = new Lable();
        textBox = new TextBox();
        comboBox = new ComboBox();
        // 调用上述三个类的方法
    }
}

// 文本标签类
public class Label{}
// 文本框类
public class TextBox{}
// 组合框类
public class ComboBox{}

如果增加新的界面控件时需要修改与之交互的其他控件的源代码,系统扩展性较差,也不便于增加和删除新控件。

如何解决?

public class Mediator{
	private Lable lable;
    private TextBox textBox;
    private ComboBox comboBox;
    private Button button;
}

public class Button{}

// 文本标签类
public class Label{}
// 文本框类
public class TextBox{}
// 组合框类
public class ComboBox{}

通过引入一个专门用于控制界面控件交互的中间类(Mediator)来降低界面控件之间的耦合度。引入中间类之后,界面控件之间不再发生直接引用,而是将请求先转发给中间类,再由中间类来完成对其他控件的调用。当需要增加或删除新的控件时,只需修改中间类即可,无须修改新增控件或已有控件的源代码。