谈谈有关设计模式的思想精髓:变继承关系为组合关系如何创建对象(单例状态装饰者模式)

Posted 鸽一门

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了谈谈有关设计模式的思想精髓:变继承关系为组合关系如何创建对象(单例状态装饰者模式)相关的知识,希望对你有一定的参考价值。

说起设计模式,最广为人知的就是“四人帮”编写的设计模式,其书的副标题为软件中可重用的元素,此书本来是一篇博士论文,它将很多通用的设计思想总结并命名成设计模式,希望开发人员之间通过专有的模式名称交流,可惜最为熟悉的是被滥用最多的Singleton单例模式,但是此书中有许多值得学习的思想。

从当今角度看此书,其中一部分模式是教我们如何对现实的事物去建模,这是“设计”。而有一部分则是受到了“语言限制”。由于此书是用C++编写的,后期Java在此基础上编写,两者都有语言带来的限制,因此衍生了一些为消除语言限制的模式,例如“Visit Pattern”。随着设计模式的发展,除了此书中代码实现的模式,即软件的代码组织结构之外,还增加了一些软件架构层面上的模式,例如并发模式、架构模式。

此篇博文不去详细介绍模式的定义、代码实现等基础内容,而是从设计思想层面来探索,进而结合部分设计模式详细讲解,涉及到的知识点如下:

  • 变继承关系为组合关系
  • 如何创建对象
  • 单例模式
  • 状态模式
  • 装饰者模式

一.再谈Singleton单例模式

通常谈到设计模式,大家第一就会想到Singleton单例模式,但是它其实是被滥用最多的,从它本身的设计而言,并不希望它是最常用的模式,但由于其简单易理解,所以被开发人员滥用,模式使用还是应当结合实际。

1. Singleton优缺点

  • 确保全局至多只有一个对象。
  • 主要用于:构造缓慢的对象;需要统一管理的资源。
  • 缺点:很多全局状态;线程安全性。

2. Singleton的创建

如何来保证这个类在全局当中只有一个对象,一般是通过一个变量去保存,判断此变量是否为空,为空则创建,否则直接获取其引用,可大致分为以下三个方法:

  • 双重锁模式 Double Checked Locking
    当考虑到多线程情况时,需要用到双重锁模式,也就是Check两次,第一次检查全局变量是否为null,若为空不可直接创建,因为其他线程可能也在创建,所以需要锁住对象,再Check一次,还是null再次创建。

但是!并不建议这种双重锁模式,最好的方法还是利用系统特性,例如Java语言中的静态变量。

  • 作为Java类的静态变量
    静态变量属于类,只此一份,将对象存储在静态变量中。缺点:在程序初始化的时候就要创建这个变量,一旦运行之后就无法控制其创建,还可能增加初始化时间。

  • 使用框架提供的能力
    基于以上缺点,还有一种方法就是使用框架提供的能力,通常系统中在一个依赖注入框架中,它们有提供Singleton对象的能力。



二. 状态模式-----变继承关系为组合关系

以上Singleton单例模式并不是设计模式中的重点,只是因为其简单易理解所以被广泛应用,从此点开始重点在于设计模式的思想精髓,先介绍变继承关系为组合关系。

1. 继承关系再认识

(1)描述“is-a”关系

回顾一下继承关系的定义,不仅可以复用对基类的成员变量或者函数,还可以增加或修改基类的行为。这个定义放到实际应用中是比较含糊的。这种定偏向于结构化,子类可以增加或修改基类的行为,这种特性对基类的结构是有非常高的要求。

什么是什么,一般不是从结构的角度来判断,大部分是从功能的角度来判断,例如现实生活中交通工具的定义?可以载人或载物,因此汽车、三轮等等就是交通工具,而不是说有发动机或轮子的就是交通工具。因此有了这种惯性思维后,以继承关系去建模会遇到很多局限性。

(2)不要用继承关系来实现复用

事实上希望类中的变量限制范围是public 或是 private,实际中protected也被广泛应用,即基类的成员变量或函数只得子类使用,以这种方式来实现复用更为简单?其实不然,采取的方法便是使用设计模式来实现复用,后续会详细讲解。


2. 如果Employee升级成了Manager?

这是google面试过的一个问题,在学习Java语言继承特性时,有一个常见的代码示例,即Employee(基类)、Manager(派生类),如果Employee升级成了Manager?,代码方面应该怎么做?

(只贴重点类代码,所以代码在文章末尾链接处)

public class Employee 
  public static List<Employee> allEmployees;

  private final String name;
  private final int salary;

  public Employee(String name, int salary) 
    this.name = name;
    this.salary = salary;
  

  public Employee(String name) 
    this(name, 0);
  

  public void doWork() 
  

  public void getPaid(BankEndPoint bank) 
    bank.payment(name, salary);
  

  // Package private for logic in the package to control
  // when employees are loaded.
  static void loadAllEmployees() 
    // Loads all employees from database.
  

  @Override
  public String toString() 
    return "Employee [name=" + name
        + ", salary=" + salary + "]";
  

  public String getName() 
    return name;
  

  public int getSalary() 
    return salary;
  

public class Manager extends Employee 
  private final List<Employee> reporters;

  public Manager(String name, int salary,
      List<Employee> reporters) 
    super(name, salary);
    this.reporters = Collections.unmodifiableList(
        new ArrayList<>(reporters));
  

  @Override
  public void getPaid(BankEndPoint bank) 
    super.getPaid(bank);
    getStocks();
  

  @Override
  public void doWork() 
    Employee worker = selectReporter();
    worker.doWork();
  

  @Override
  public String toString() 
    return "Manager [name=" + getName()
        + ", salary=" + getSalary() + "]";
  

  private Employee selectReporter() 
    loadReporters();
    return null;
  

  private void getStocks() 
  

  private void loadReporters() 
    reporters.clear();
    reporters.add(new Employee("John", 10000));
    reporters.add(new Employee("Mary", 20000));
  

以上就是常见的“Employee为Manager父类”的代码示例,代码并不复杂,查看即可理解。也许你认为Employee和Manager的继承关系并无差错,不错,Manager is an Employee,从构成的角度也符合。但是,思考以上提出的问题,一旦**Employee升级成了Manager **怎么办?

例如以下代码Tester 类中对象employee1 ,它创建之后如何升级成一个Manager ?没有办法!但是在实际处理中这是一个常见的需求,而且在Employee中将成员变量name、salary设置成final 了,更不用谈升级了,除非重新去创建,把原来的引用换成新的引用。

public class Tester 
  public static void main(String[] args) 
    Employee employee1 = new Employee("Mary", 20000);
	
	employee1 = new Manager("Mary",40000);
	

你也许认为Employee升级成了Manager后很多成员变量、行为改变了,所以不再使用以前的对象, 一定要新建一个换掉原来引用。在这个场景下此答案可以接受,但它并不是问题本身想问的。

**一个对象要改变,需要改变其状态。**从实际来考虑,当Employee升级成了Manager后,人还是这个人,只是职能发生了改变,以此来建模。


3. 状态模式解决问题

查看上图,Employee中的doWork()函数保留,只是其中的role变化了,从一开始Engineer的工作方式改变为Manager的工作方式,从代码的角度解释就是说:

  • 当role是Engineer时,调用属于Engineer的doWork()
  • 当role是Manager时,调用属于Manager的doWork()

由此你可以再次意思到“is-a”关系的定义含糊,导致滥用继承关系,以前一直以来的固化思维“Employee是Manager的父类”,其实是滥用,实际上应该去理解真实事物到底是什么样关系,建模的结果如上所示。

下面是代码修改过程:

(1)Role接口

首先定义一个Role接口,其中只有一个方法doWork(),不同的角色实现不同的工作内容。

public interface Role 
  void doWork();

(2)Engineer实现Role接口

public class Engineer implements Role 
  @Override
  public void doWork() 
    System.out.println("Doing engineer work.");
  

  @Override
  public String toString() 
    return "Engineer";
  

(3)Manager实现Role接口

public class Manager implements Role 
  private final List<Employee> reporters;

  public Manager(List<Employee> reporters) 
    this.reporters = Collections.unmodifiableList(
        new ArrayList<>(reporters));
  

  @Override
  public void doWork() 
    System.out.println("Dispatching work");
    Employee worker = selectReporter();
    worker.doWork();
  

  @Override
  public String toString() 
    return "Manager";
  

	......

(4)Employee添加成员变量Role

public class Employee 

  private final String name;
  private final int salary;
  private Role role; //成员变量

  public Employee(String name, int salary, Role role) 
    this.name = name;
    this.salary = salary;
    this.role = role; //构造方法中需要传入
  

  public void doWork() 
    role.doWork(); //不同角色处理不同工作
  

	.....
 

(5)测试

我们在测试类中创建了两个Employee 雇员,后来将其中一个雇员升级为Manager,再次打印调用它们工作函数后的结果。

public class Tester 
  public static void main(String[] args) 
    Employee employee1 = new Employee("John", 10000,
        new Engineer());
    Employee employee2 = new Employee("Mary", 20000,
        new Engineer());

	.....
    employee2.setRole(new Manager(Arrays.asList(employee1)));
   
   .....
    employee1.doWork();
    .....
    employee2.doWork();
  

结果截图

由此图可知,Mary由一开始的Employee升级成了Manager,此问题完美被解决,主要解决思路采用了“状态模式”,变继承关系为组合关系,同时意识到了“is-a”继承关系的滥用问题,应当根据实际事务需求具体分析!




三. 装饰器模式------变继承关系重点内容为组合关系

1. 提出问题

Java中有一个接口Runnable,其中方法就是run(),它其实就是一个任务,将需要做的事情封装在run()方法中,此类便成为一个task,可被调度。此部分对于学习过Java的开发人员是再基础不过的,那么思考一个问题:

假设现在有一个类LoggingRunnable用来记录任务开始结束的时间。将任务包装在Tansaction里,在开始时beginTansaction,才会开始执行run()方法,结束时commit,有异常是roll back,这就是TansactionalRunnable。那么如何实现LoggingRunnable、TansactionalRunnable?


2. 有缺陷的实现方法

此部分将介绍一个最容易想到“使用继承”思想的实现方法,注意此方法是有缺陷的,正确方法将在后续介绍。

(1)CodingTask

首先是一个简单的CodingTask类,用于模拟工作任务,其run()中线程睡眠5秒用来模拟正在工作。

public class CodingTask implements Runnable 
  @Override
  public void run() 
    System.out.println("Writing code.");

    try 
      Thread.sleep(5000);
     catch (InterruptedException e) 
      throw new RuntimeException(e);
    
  

(2)LoggingRunnable

LoggingRunnable用来记录任务开始结束的时间,所以在其run()中记录时间开始及结束,那么真正的任务执行函数被抽象出来为doRun(),按照大家最熟知的操作,将它设为抽象方法,同时将此类设为抽象类,这样继承该类时需要实现该抽象方法,实现doRun()中真正需要做的事。

以上“继承”思想在平时开发中最为常见,但并非适用于每个场景,关于此缺陷后续会讲解。代码如下:

public abstact class LoggingRunnable implements Runnable 
  protected abstract void doRun();

  @Override
  public void run() 
    long startTime = System.currentTimeMillis();
    System.out.println("Task started at "
        + startTime);

    doRun();

    System.out.println("Task finished. Elapsed time: "
        + (System.currentTimeMillis() - startTime));
  

(3)修改CodingTask

在完成以上LoggingRunnable抽象类设计后,需知CodingTask不再是实现Runnable,而是继承LoggingRunnable,实现doRun

public class CodingTask implements LoggingRunnable 
	......
  @Override
  public void doRun() 
	  .....
  

(4)Tester测试类

public class Tester 

  public static void main(String[] args) 
    new CodingTask().run();
  

结果

分析

结果也没什么问题,为何一直声称此种实现方式不太好呢?

注意CodingTask修改之前和之后的对比,职责未变,始终是用来代表工作的执行,但是修改之后,CodingTask默认加了一个前提,那就是每次调度CodingTask执行都会打印日志。

(5)TansactionalRunnable

不止如此,我们还有一个类TansactionalRunnable,它是用来管理Tansactiona,同样那它应该也有一个抽象的doRun()方法,继续按照之前“继承”的思想去设计此类,后续你将发现问题。

public class TransactionalRunnable implements Runnable 
  protected abstract void doRun();

  @Override
  public void run() 
    boolean shouldRollback = false;
    try 
      beginTransaction();
      doRun();
     catch (Exception e) 
      shouldRollback = true;
      throw e;
     finally 
      if (shouldRollback) 
        rollback();
       else 
        commit();
      
    
  

  private void commit() 
    System.out.println("commit");
  

  private void rollback() 
    System.out.println("rollback");
  

  private void beginTransaction() 
    System.out.println("beginTransaction");
  

(6)再度修改CodingTask

现在需要管理CodingTask,即完成时commit,有异常时roll back,实现这些只需要继承TransactionalRunnable 即可!

灾难性的问题已经出现!现在想要记录打印CodingTask,并且管理CodingTask,那么需要使CodingTask继承LoggingRunnable和TransactionalRunnable!

这下总该知道问题所在了吧,Java语言中不支持多继承,也许你还在认为这种设计思想没问题只是语言的限制,例如C++就可以实现多继承,但是两个父类中的抽象方法同名!该如何分辨?因此后续发展的语言中并不支持多继承的机制。


###3. “组合”思想实现方法

以上实现方式采用的是开发人员最为熟知的“继承”思想,但是这一路实现发现其总多弊端,接下来转换实现思想:变继承关系为组合关系,采用Decorator Pattern 装饰模式来实现。

(1)CodingTask

作为一个可复用的系统需要注重低耦合度,之前的CodingTask绑在了LoggingRunnable或TransactionalRunnable上,这是非常不灵活的,保持单一原则,CodingTask只做自己的事,那就是写代码(模拟),至于需不需要打印log、管理与否与它没有任何关系!

public class CodingTask implements Runnable 
  @Override
  public void run() 
    System.out.println("Writing code.");

    try 
      Thread.sleep(5000);
     catch (InterruptedException e) 
      throw new RuntimeException(e);
    
  

(2)LoggingRunnable

LoggingRunnable也是一个Runnable,但是它内部又有一个runnable,与这个CodingTask进行连接,即执行LoggingRunnable实际上是在执行内部中真正的runnable,并且此runnable是可以配置的。这样CodingTask其实与LoggingRunnable没有任何关系,保持了系统的低耦合度!

如上图,以new LoggingRunnable(new CodingTask().run())这种组合的方式来调用。

类中的成员应该是private或者public,当看到protected限制范围时,需自省使用它的原由,是否合理必须!

public class LoggingRunnable implements Runnable 

  private final Runnable innerRunnable;

  public LoggingRunnable(Runnable innerRunnable) 
    this.innerRunnable = innerRunnable;//构造的时候去配置内部真正执行的runnable
  

  @Override
  public void run() 
    long startTime = System.currentTimeMillis();
    System.out.println("Task started at "
        + startTime);

    innerRunnable.run();

    System.out.println("Task finished. Elapsed time: "
        + (System.currentTimeMillis() - startTime));
  


(3)TransactionalRunnable

和LoggingRunnable 同理,TransactionalRunnable也做出相应的改变:

public class TransactionalRunnable implements Runnable 

  private final Runnable innerRunnable;

  public TransactionalRunnable(Runnable innerRunnable) 
    this.innerRunnable = innerRunnable;
  

  @Override
  public void run() 
    boolean shouldRollback = false;
    try 
      beginTransaction();
      innerRunnable.run();
     catch (Exception e) 
      shouldRollback = true;
      throw e;
     finally 
      if (shouldRollback) 
        rollback();
       else 
        commit();
      
    
  

  private void commit() 
    System.out.println("commit");
  

  private void rollback() 
    System.out.println("rollback");
  

  private void beginTransaction() 
    System.out.println("beginTransaction");
  

完成以上代码之后,再次强调一下重点:保持低耦合度和单一原则,CodingTask只是做一个任务:模拟写代码,与其他类毫无关联,并且以组合的方式同LoggingRunnable或TransactionalRunnable结合,注意此处LoggingRunnable或TransactionalRunnable内部中是以配置的方式来决定内部真正的runnable

(4)Tester测试类

  • 执行CodingTask,单纯写代码
new CodingTask().run();

结果

执行正常,写了5秒代码停止。

  • 执行CodingTask,同时需要记录时间
new LoggingRunnable(
            new CodingTask(0)).run();

结果

执行正常,写了5秒代码停止,并且在“写代码”开始结束的时候打印日志。

  • 执行CodingTask,同时需要记录时间,还要Transaction,再套一层
new TransactionalRunnable(
        new LoggingRunnable(
            new CodingTask())).run();

结果

执行正常,写了5秒代码停止,并且在“写代码”开始结束的时候打印日志,同时打印了commit,但是以上代码还是有点问题,那就是打印时间的日志位置应当在开始和末尾,所以我们需要对测试的代码进行一些改动,先开始记录,再开始Transaction:

new LoggingRunnable(
        new TransactionalRunnable(
            new CodingTask(0))).run();

结果

总结

使用装饰模式后,使得这几个类之间可以互相组合使用,还可以套更多的层,使用Transaction的逻辑去装饰CodingTask,用Logging的逻辑再去装饰Transaction。

CodingTask是一个实际的task,真正做事的runnable,而Logging或Transaction是一个装饰,内部会放置真正的runnable,它执行实际上是在执行内部的runnable,这样便可在此基础上根据特定的需求完成逻辑。




四. 如何创建对象

此部分将讲解设计模式中另一个重要思想,即如何创建对象。

1. 使用new 创建的缺点

第一反应创建对象不是一件很简单么?直接 new 就行了,但是殊不知其中的隐患,使用new 来创建的缺点:

  • 编译时必须决定创建哪个类的对象
  • 参数意义不明确
new LoggingRunnable(
        new TransactionalRunnable(
            new CodingTask(0))).run();

结合以上代码示例来理解第一个缺点,即写代码时就要决定创建哪些类对象,在上例中,CodingTask、TransactionalRunnable、LoggingRunnable是在写代码的时候就要决定的,但是并不一定是这样!现在只需要知道run一个task,至于需不需要Transaction或者Logging不是现在需要决定的。

第二个缺点很常见,若直接查看创建new语句,很难直接根据参数理解其具体意义,需要我们更进一步查看其类实现,事实上足够优秀的代码可以做到见参数知其意。

接下来介绍两个方法解决此问题。


###2. Abstact Factory Pattern

task = new LoggingTask(new CodingTask());

task = taskFactory.createCodingTask();

比较以上两种实现方式,第一种很常见,但是其绑定了LoggingTask、CodingTask。

第二种只绑定了taskFactory,它是一个抽象的接口,可以createCodingTask,没有绑定任何类,其含义也很直接,获取CodingTask,其内部是如何实现也不需要知道,因此此方式完全杜绝了“编译时必须决定创建哪个类的对象”的缺点。


###3. Builder Pattern

以上抽象工厂模式方法解决了第一个问题“编译时必须决定创建哪个类的对象”,而第二个缺点“参数意义不明确”可根据Builder模式解决。首先查看比较以下两种实现方式:

employee = new Employee(
			oldEmployee.getName(), 15000);

employee = Employee.fromExisting(oldEmployee)
					.withSalary(15000)
					.build();

同样第一种方式是最常见的,但是这一句创建代码根本无法理解其参数意义,除非进一步查看其类的构造函数实现。

而第二种方式就是采用Builder模式实现对象创建,其实相信大家对Builder模式并不模式,它采用一种链式方法来构造对象中的成员,非常适合成员变量多的类构造,可动态选择创建参数。

想要更仔细理解Builder模式可查看Builder模式 详解及学习使用




以上只谈论了有关设计模式的一部分思想,其中着重谈论了“变继承关系为组合关系”的思想,继承关系确实是Java语言的一大特性,但是这并不意味着可以滥用继承关系,虽然继承可以复用对基类的成员变量或者函数,还可以增加或修改基类的行为,但它同时也有其限制性,这之间的利弊需要开发者自行衡量。但是还有别的方法,转换思维,学会变继承关系为组合关系,会让整个系统的耦合度更低,这也是设计模式的精髓所在。

若有错误,虚心指教~

以上是关于谈谈有关设计模式的思想精髓:变继承关系为组合关系如何创建对象(单例状态装饰者模式)的主要内容,如果未能解决你的问题,请参考以下文章

Java 面向对象程序设计思想

Java 面向对象程序设计思想

计模式之结构型模式

面向对象之组合

桥接模式

组合委托与继承,面向对象中类之间的基本关系漫游