到了最后的几种模式, 其实已经超过了简单对象的范畴, 都是复杂对象的组合了. 可以说是一种更大层面上的设计模式了.

  1. Flyweight 享元模式
  2. 练习
  3. Proxy 代理模式
  4. 练习
  5. Command 命令模式
  6. 练习

Flyweight 模式

这个模式的关键在于通过共享空间来避免new实例出来.

这个其实可以如下思考, 如果一个对象就有一个原始数据域, 并且不可变, 那么这个对象本身就可以当成这个数据域来使用. 对Python有过了解就会知道, Python中的小常数是固定的一片内存区域中存放的, 不会每次都读写新的内存来创建常数.

Flyweight就是这个意思, 即如果一个对象的功能可以由若干小对象组成, 那么尽量复用这些小对象就比一个一个new出来要快并且占用内存空间也少.

作者举的例子是一个类似字库的例子, 即用读取的字体显示一个字符串. 由于字体需要从文件中载入, 如果显示每一个字符都要去载入一次, 就慢了. 这个时候可以通过一个数据结构保存字体数据, 先查找是否可以复用已经读取过的字体.

首先我们来看被复用的类, 也就是表示一个字体的对象BigChar:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class BigChar {

    private char charname;

    private String fontdata;

    public BigChar(char charname) {
        this.charname = charname;
        try {
            //读取字体数据到fontdata中
            BufferedReader bufferedReader = new BufferedReader(new FileReader("big" + charname + ".txt"));
            String line;
            StringBuilder buffer = new StringBuilder();
            while ((line = bufferedReader.readLine()) != null) {
                buffer.append(line).append("\n");
            }
            bufferedReader.close();
            this.fontdata = buffer.toString();
        } catch (IOException e) {
            this.fontdata = charname + "?";
        }
    }

    public void print() {
        System.out.print(fontdata);

    }
}

这个类会根据字符去读取字体数据, 目前可以使用0-9以及减号字符. 之后是BigCharFactory, 这个类负责复用BigChar类:

import java.util.HashMap;

public class BigCharFactory {

    //单例模式
    private BigCharFactory() {

    }

    private static BigCharFactory singleton = new BigCharFactory();
    public static BigCharFactory getInstance() {
        return singleton;
    }

    //复用类的关键, 使用了一个Map, 保存已经生成的实例. 如果尚未生成, 就生成一个然后放入其中.
    private HashMap<String, BigChar> pool = new HashMap<>();

    public synchronized BigChar getBigChar(char charname) {
        BigChar bigChar = pool.get(charname + "");
        if (bigChar == null) {
            bigChar = new BigChar(charname);
            pool.put(charname + "", bigChar);
        }
        return bigChar;
    }

}

BigCharFactory类的功能就是对外提供BigChar类, 可能是新创建的, 也可能是复用的, 但是用过之后, 就肯定是复用了.

然后是如何将一个字符串转换成BigChar的类, 这个类其实是Flyweight模式的使用者, 对外提供真正的服务, 前边的两个类都是提供底层的共享服务.

public class BigString {

    private BigChar[] bigChars;

    public BigString(String string) {
        bigChars = new BigChar[string.length()];

        BigCharFactory bigCharFactory = BigCharFactory.getInstance();

        for (int i = 0; i < string.length(); i++) {
            bigChars[i] = bigCharFactory.getBigChar(string.charAt(i));
        }
    }

    public void print() {
        for (BigChar bigChar : bigChars) {
            bigChar.print();
        }
    }

}

这个类很简单, 就是将字符串转换为BigChar类型的数组. 然后挨个显示出来.

使用起来也很简单:

public class Main {

    public static void main(String[] args) {
        String n = "21898-";

        new BigString(n).print();
    }
}

这个模式的重点是依赖Map数据结构做的重用. 仔细思考一下这个模式, 还是很有意思的. 比如只要修改一个字体, 则会在所有使用该字体的程序中都会发生变化.

此外哪些数据可以共享, 哪些数据不需要共享, 功能添加在哪一块, 是需要根据具体要求来定的, 还可以和其他的设计模式结合, 比如现在要增加一个显示红色字符串的功能, 很显然可以增加在BigString中, 如果修改BigChar, 则所有的地方都会变成红色.

练习

练习20-1

这个题目很简单, true=共享, 即通过BigCharFactory来生产对象即可. 如果设置为false, 就直接new新对象放入数组中即可.

public BigString(String string, boolean shared) {
    bigChars = new BigChar[string.length()];
    if (shared) {
        //和单参数构造器一样的代码
        BigCharFactory bigCharFactory = BigCharFactory.getInstance();
        for (int i = 0; i < string.length(); i++) {
            bigChars[i] = bigCharFactory.getBigChar(string.charAt(i));
        }
    //如果不共享, 新建BigChar对象填充数组
    } else{
        for (int i = 0; i < string.length(); i++) {
            bigChars[i] = new BigChar(string.charAt(i));
        }
    }
}

练习20-3 synchronized

这个涉及到多线程的安全问题. 由于每次生成BigChar的时候必须比对一次, 如果Map中没有该字符, 需要创建并放入进去.

这个过程必须同步, 否则可能一个线程在放入对象之前被打断, 另外一个线程放入一个对象, 然后恰好一个线程取得了这个对象的引用, 然后原来线程紧接着又放入一个新对象, 这两个对象是不同的对象, 实际上就没有达到复用的要求.

Proxy 模式

Proxy可以说是一种惰性思想, 即A类代理B类, 但不是完全代理, A类自己也能实现一些功能. 在使用A类提供服务的时候, 如果一些服务依靠A类就可以完成, 那就无需创建B类的对象.

只有确实使用到了需要B类才能够提供的服务, 再创建B类的对象. 这个模式本身的理念比较简单, 作者举了一个打印不同字符的例子:

先是一个接口, 被代理的类和代理类都实现这个接口, 这样才能完美互相替换.

public interface Printable {

    void setPrinterName(String name);

    String getPrinterName();

    void print(String s);
}

之后是Printer类, 这个是被代理的类:

public class Printer implements Printable {

    private String name;

    public Printer() {
        heavyJob("正在生成实例...");
    }

    public Printer(String name) {
        this.name = name;
        heavyJob("正在生成" + name + "实例...");
    }

    @Override
    public synchronized void setPrinterName(String name) {
        this.name = name;
    }

    @Override
    public String getPrinterName() {
        return name;
    }

    @Override
    public void print(String s) {
        System.out.print("=====");
        System.out.print(name);
        System.out.println("=====");
        System.out.println(s);
    }

    private void heavyJob(String msg) {
        System.out.println(msg);

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            System.out.print(e);
        }
        System.out.println("生成完毕");
    }
}

这个类很简单, 就是刻意在构造函数这里睡两秒, 以好看出来到底有没有创建这个对象.

然后是代理类:

public class PrinterProxy implements Printable {

    private String name;

    //给被代理类留的一个指针
    private Printer real;

    public PrinterProxy(String name) {
        this.name = name;
    }

    @Override
    public synchronized void  setPrinterName(String name) {
        this.name = name;
        if (real != null) {
            real.setPrinterName(name);
        }
    }

    @Override
    public String getPrinterName() {
        return name;
    }

    @Override
    public void print(String s) {
        createPrinter();
        real.print(s);
    }

    private synchronized void createPrinter() {
        real = new Printer(name);
    }
}

可以看出, 代理的本质还是委托. 只不过会先判断委托对象是否存在, 不存在, 用到了才会创建.

代理类自己实现的功能是设置名称. 如果仅仅是设置和获取名称, 可以发现并不会创建Printer对象. 但是一旦要打印了, 就会创建Printer对象. 来使用一下:

public class Main {

    public static void main(String[] args) {
        Printable proxy = new PrinterProxy("HP");

        System.out.println(proxy.getPrinterName());
        proxy.setPrinterName("Canon");
        System.out.println(proxy.getPrinterName());
        System.out.println("==============================");

        proxy.print("Test Page");
    }
}

从运行结果可以看到, 在操作打印机名称的时候, 无需等待, 说明没有创建Printer对象. 调用print()方法之后, 就真的创建了被代理对象.

这个本质上也有点类似于装饰器模式, 代理类和被代理类都实现了同一个接口. 所以代理也可以起到装饰的作用, 实现各种灵活的功能.

练习

练习21-1

这个要求有很多种实现方法, 当然, 简单的修改成接口类型并不好, 因为这可能会导致传入另外一个代理类. 可以用很多新创建实例的方式即可, 比如工厂模式或者原型模式.

作者的要求是在构造函数中传入字符串, 那么可以来采用反射的方式创建类, 给代理类新增一个构造器, 使用这个构造器的时候一开始就创建实际的打印机:

public PrinterProxy(String name, String classname) {
    this.name = name;
    try {
        //反射方式获取类名, 然后调用单字符串参数的构造函数, 之后使用构造函数创建实例
        Class printer = Class.forName(classname);
        Constructor con = printer.getConstructor(String.class);
        real = (Printable) con.newInstance(name);
    } catch (Exception e) {
        //如果找不到类, 就创建默认的打印机
        System.out.println("创建打印机错误, 将创建默认打印机");
        real = new Printer("默认打印机");
    }
}

使用起来很类似:

public class Main {

    public static void main(String[] args) {
        Printable proxy = new PrinterProxy("HP", "designpatterns.proxy.Printer");

        proxy.print("Test Page");
    }
}

如果故意让程序找不到类, 就可以发现创建了默认打印机.


习题21-2

原因很简单, java中不是基础数值直接赋值的都不是原子操作, 必须要加上同步, 在这个程序里如果不加, 可能会导致使用同一个打印机的代理对象出现问题.

Command 命令模式

我们将一个动作写在一个方法里, 每次这个方法执行的时候, 确实完成了一项工作, 然而并不会留下方法的历史记录. 命令模式则是把一项动作抽象成物品, 这样可以管理多个命令, 还可以执行已经执行过的命令.

在GUI编程中, 一个事件就类似于一个Command对象, 好比鼠标点击, 点击就点击了, 但是这个点击发生的位置, 都会包装成一个事件对象, 供相关程序进行处理. 如果不这样处理, 那点击这个事情就会立刻消失并且无法追踪.

作者的示例比较有意思, 也是利用了GUI, 用户拖动鼠标的时候绘制一个红色圆点, 这里没有用事件监听一直去画图, 而是包装了一个在某个位置绘制点的命令, 只要复用这个实例就可以了:

类的组成首先就是Command接口, 表示最基础的命令接口, 然后有单个命令类DrawCommand 和 多个命令整合成一个复杂命令的类 MacroCommand.

之后是使用这些命令的接口Drawable 和具体的接口实现类DrawCanvas.

public interface Command {

    void execute();

}

Command接口一般只定义一个执行命令的方法. 然后来看MacroCommand类:

import java.util.Stack;

public class MacroCommand implements Command {

    //使用栈结构来存放命令
    private Stack<Command> commands = new Stack<>();

    //宏命令的执行方法是执行所有其中装载的命令
    @Override
    public void execute() {
        for (Command command : commands) {
            command.execute();
        }
    }

    //添加一个命令
    public void append(Command command) {
        if (command != null) {
            commands.push(command);
        }
    }

    //弹出最后的命令, 相当于undo
    public void undo() {
        if (!commands.isEmpty()) {
            commands.pop();
        }
    }

    //清空所有命令
    public void clear() {
        commands.clear();
    }

    public void showCommands() {
        for (Command command : commands) {
            System.out.println(command);
        }
    }
}

从这个MacroCommand中可以看出宏命令设计的思想, 使用栈结构来追踪命令, 正好利用了栈结构的特点. 在执行的时候很方便的可以undo上一条命令.

来看具体的DrawCommand:

import java.awt.*;

public class DrawCommand implements Command {

    protected Drawable drawable;

    private Point position;

    public DrawCommand(Drawable drawable, Point position) {
        this.drawable = drawable;
        this.position = position;
    }

    @Override
    public void execute() {
        drawable.draw(position.x, position.y);
    }

    @Override
    public String toString() {
        return "DrawCommand{" +
                "position=" + position +
                '}';
    }
}

具体命令的类引入了一个Drawable对象和一个Point对象, 然后执行这个命令, 就是在指定的位置进行绘图.

之后来看看绘图的类, 虽然还没有开始看, 但是这里已经可以看出命令模式的特点了, 可以有单个命令, 也可以有宏命令.

public interface Drawable {

    void draw(int x, int y);
}

绘图接口就一个方法, 在指定的坐标绘制. 然后看实现类:

import java.awt.*;

public class DrawCanvas extends Canvas implements Drawable {

    private Color color = Color.red;

    private int radius = 6;

    private MacroCommand history;

    public DrawCanvas(int width, int height, MacroCommand history) {
        setSize(width, height);
        setBackground(color);
        this.history = history;
    }

    //这个命令用于重复执行一次, 也就是重复绘制一次
    public void paint(Graphics graphics) {
        history.execute();
    }

    public void draw(int x, int y) {
        Graphics g = getGraphics();
        g.setColor(color);
        g.fillOval(x - radius, y - radius, radius * 2, radius * 2);
    }

}

虽然有一些AWT的类, 但是不影响理解.先设置好颜色, 然后用构造器传入画布的大小, 在画布的中间区域进行绘制. 绘制有两种方法, 一种是在指定的点进行绘制, 一种是直接执行一次宏命令.

为了实现功能, Main类要来使用这个命令和绘图, 还要设置AWT的事件监听, 有些复杂, 不过重点还是关注命令对象是如何发挥作用的:

import javax.swing.*;
import java.awt.event.*;

public class Main extends JFrame implements ActionListener, MouseMotionListener, WindowListener {

    private MacroCommand history = new MacroCommand();

    private DrawCanvas canvas = new DrawCanvas(400, 400, history);

    private JButton clearButton = new JButton("CLEAR");

    public Main(String title) {
        super(title);
        this.addWindowListener(this);
        canvas.addMouseMotionListener(this);
        clearButton.addActionListener(this);

        Box buttonBox = new Box(BoxLayout.X_AXIS);
        buttonBox.add(clearButton);
        Box mainBox = new Box(BoxLayout.Y_AXIS);
        mainBox.add(buttonBox);
        mainBox.add(canvas);
        getContentPane().add(mainBox);
        pack();
        show();
    }

    //以下是监听事件和鼠标事件的接口方法

    //点击clear按钮之后, 清除历史命令, 重置画布
    @Override
    public void actionPerformed(ActionEvent e) {
        if (e.getSource() == clearButton) {
            history.showCommands();
            history.clear();
            canvas.repaint();
        }

    }

    //这个方法处理鼠标拖动的事件
    //每次拖动鼠标, 创建一个保存了当前位置的command命令, 执行, 之后加入到历史命令中
    @Override
    public void mouseDragged(MouseEvent e) {
        Command command = new DrawCommand(canvas, e.getPoint());
        command.execute();
        history.append(command);
    }

    //这个留空, 即鼠标移动的时候不做处理
    @Override
    public void mouseMoved(MouseEvent e) {

    }

    //以下是WindowListener的方法, 仅仅使用到了窗口关闭的功能, 其他都留空
    @Override
    public void windowOpened(WindowEvent e) {

    }

    @Override
    public void windowClosing(WindowEvent e) {
        System.exit(0);
    }

    @Override
    public void windowClosed(WindowEvent e) {

    }

    @Override
    public void windowIconified(WindowEvent e) {

    }

    @Override
    public void windowDeiconified(WindowEvent e) {

    }

    @Override
    public void windowActivated(WindowEvent e) {

    }

    @Override
    public void windowDeactivated(WindowEvent e) {

    }

    public static void main(String[] args) {
        new Main("Command Pattern");
    }
}

开始的几个地方准备了宏命令变量,画布和设置一些按钮. Main方法用来准备画布和添加按钮, 核心的方法是mouseDragged方法, 每次拖动鼠标的时候, 将鼠标的位置传给一个新命令对象, 执行命令(即在当前点画红点), 然后将命令保存至宏命令中.

点击CLEAR按钮的时候会打印出宏命令中的所有命令, 然后清空画布.

这个模式的关键在于可以追踪操作的全过程, 如果有必要的话, 可以执行history的execute()方法来复现用户的操作.

练习

练习22-1

添加一个设置颜色的功能, 可以绘制新的颜色.

这个功能如果要添加, 就涉及到添加新的按钮了, 来尝试一下吧. 目前绘制点的颜色是在DrawCanvas类中的color域控制的, 只要针对这个域做文章就可以了. 首先需要改造一下, 以让外部可以设置颜色:

//添加一个设置颜色的方法
public void setColor(Color color) {
    this.color = color;
}

之后添加两个按钮:

public class Main extends JFrame implements ActionListener, MouseMotionListener, WindowListener {

    ......

    //新建一个绿色按钮和红色按钮
    private JButton greenButton = new JButton("GREEN");
    private JButton redButton = new JButton("RED");

    public Main(String title) {
        ......

        //为颜色按钮添加事件监听
        greenButton.addActionListener(this);
        redButton.addActionListener(this);

        ......

        //添加颜色按钮到界面上
        buttonBox.add(greenButton);
        buttonBox.add(redButton);

        ......

    }

    @Override
    public void actionPerformed(ActionEvent e) {
        if (e.getSource() == clearButton) {
            history.showCommands();
            history.clear();
            canvas.repaint();
        }

        //为绿色按钮编写事件
        if (e.getSource() == greenButton) {
            canvas.setColor(Color.green);
        }
        //为红色按钮编写事件
        if (e.getSource() == redButton) {
            canvas.setColor(Color.red);
        }
    }

    ......
}

习题22-2

删除上一次画的点, 也可以理解成在上一次画的点的地方画与底色相同的颜色或者是无色, 然后再重复绘制一下历史命令里的上一条命令. 只要再添加一个按钮, 按的时候从History中弹出一个命令, 然后用这个命令来绘制底色相同的点, 在重复新的宏命令的最后一个点, 这样就平滑很多了.

为此需要先修改MacroCommand, 增加几个新方法:

public class MacroCommand implements Command {

    ......

    //新添加命令, 仅撤销最后一个
    public Command pop() {
        if (!commands.isEmpty()) {
            return commands.pop();
        } else {
            return null;
        }
    }

    //新添加命令, 获取栈的最后一个元素
    public Command getLastCommand() {
        if (!commands.isEmpty()) {
            return commands.lastElement();
        } else {
            return null;
        }
    }

}

然后给DrawCanvas增加一个返回当前颜色的方法:

public class DrawCanvas extends Canvas implements Drawable {

    ......

    public Color getColor() {
        return color;
    }
}

最后来修改Main, 添加按钮和事件:

public class Main extends JFrame implements ActionListener, MouseMotionListener, WindowListener {

    ......

    //创建撤销按键
    private JButton undoButton = new JButton("Undo");

    public Main(String title) {
        ......

        //为撤销按键添加事件监听
        undoButton.addActionListener(this);


        //添加撤销按钮到界面上
        buttonBox.add(undoButton);

        ......
    }



    //关键方法
    @Override
    public void actionPerformed(ActionEvent e) {

        ......

        //编写撤销按键的事件
        if (e.getSource() == undoButton) {
            //弹出历史中的最后一个命令并且绘制底色, 然后恢复原来的颜色
            Command command = history.pop();
            if (command != null) {
                Color color = canvas.getColor();
                canvas.setColor(Color.white);
                command.execute();
                canvas.setColor(color);
            }

            //获取剩下的宏命令中的最后一个命令, 绘制一次
            command = history.getLastCommand();
            if (command != null) {
                command.execute();
            }
        }
    }

    ......
}