デザインパターンcommandについて勉強した

commandパターンとは

例えばいくつかのボタンが並んでいて、あるボタンを押すと機能Aが実行され、別のボタンを押すと機能Bが実行されるとします。
多くの場合は各ボタンに対して必要な機能を持たせます。

button_a.addActionListener( e -> System.out.println("a"));

これでも問題はないのですが、例えばこの後で同じ手順をもう一度繰り返したい、一つ前の操作状態に戻したいと言った場面も考えられます。
一つ前の操作状態に戻す、これはundo機能としてあらゆるエディタでも実装されている機能ですね。
手順を繰り返したり状態を戻すには、操作の履歴を残す必要があります。

メソッドを呼び出した順を記憶することはできません。(メソッドごとに通し番号を割り当てて配列に保存しておけばできるかもしれませんが、機能追加や管理のことを考えるととてもやりたくはありません)

ではどうするのか。
一つのクラスに一つの機能を持たせて、そのクラスのインスタンスを配列に保存しておくのです。

コード例

例では簡単なペイントソフトを作ってみます。
機能は各種ペン(黒丸、黒角、赤丸、赤角)で点を書き、undo、redoができます。

PaintElementクラス

public abstract class PaintElement implements Cloneable{

	private int x ;
	private int y ;

	public void setX(int x) {
		this.x = x;
	}

	public int getX() {
		return x;
	}

	public void setY(int y) {
		this.y = y;
	}

	public int getY() {
		return y;
	}

	public abstract void execute(Graphics graphics) ;

	public PaintElement clone(int x,int y) {
		try {
			PaintElement element = (PaintElement)super.clone();
			element.setX(x);
			element.setY(y);
			return element;
		} catch (CloneNotSupportedException e) {
			e.printStackTrace();
		}
		return null;
	}
}

このクラスは各種ペンの親クラスとなる抽象クラスです。
フィールドx,yは描画する点の位置を保持します。

セッター、ゲッターの説明は省きます。

excecuteメソッドはこのクラスが持つ「機能」を実装するメソッドです。
詳しい説明は後述するCanvasPanelクラスや、CirclePenクラスを読んでください。

cloneメソッドはこのクラスを複製します。
後述するCanvasPanelクラスを見るとわかりますが、現在選択しているペンの管理、ペンの切り替えが簡単に書けるようになります。
prototypeパターン的な考え方です。
commandパターンとしては必要ありません。


CanvasPanelクラス

public class CanvasPanel extends JPanel implements MouseMotionListener{

	private Stack<PaintElement> elements = new Stack<PaintElement>();
	private Stack<PaintElement> memory = new Stack<PaintElement>();
	private PaintElement pen = new CirclePen();

	public CanvasPanel() {
		setPreferredSize(new Dimension(600,500));
		addMouseMotionListener(this);
	}

	public void setPen(PaintElement pen) {
		this.pen = pen;
	}

	public void addElement(PaintElement element) {
		elements.add(element);
		memory.clear();

		repaint();
	}

	public void clear() {
		elements.removeAllElements();
		memory.removeAllElements();

		repaint();
	}

	public void undo() {
		if(elements.isEmpty()) return;

		memory.add(elements.pop());

		repaint();
	}

	public void redo() {
		if(memory.isEmpty()) return;

		elements.add(memory.pop());

		repaint();
	}

	public void execute(Graphics graphics) {
		for(PaintElement e : elements) {
			e.execute(graphics);
		}
	}

	@Override
	public void paintComponent(Graphics graphics) {
		super.paintComponent(graphics);

		execute(graphics);
	}

	@Override
	public void mouseDragged(MouseEvent e) {
		addElement(pen.clone(e.getX(), e.getY()));
	}

	@Override
	public void mouseMoved(MouseEvent e) {

	}
}

実際に描画されるGUIです。

elementsフィールドは操作の履歴を保存するためのリストです。
点を描画するたびにPaintElementの子クラスが追加されていきます。
memoryフィールドはundoの履歴を保存するためのリストです。undoをするとelementsフィールドから1つだけ要素が移され、redoするとmemoryフィールドからelementsフィールドへ要素が移されていきます。
penフィールドは現在使用しているペンを保持します。

コンストラクタではこのパネルのサイズとイベントリスナーの登録をしています。
今回はこのパネルをドラッグすることで点を描画するので、MouseMotionListenerを登録しています。

setPenメソッドは使いたいペンを切り替えるためのメソッドです。
実際に渡されるのはPaintElementを継承した子クラスとなり、子クラスによってどういう描画がされるのかが変わります。

addElementメソッドは新たに点を描画します。
ここでは描画されていませんが、elementsリストに入っているすべての点が描画されるので、ここではリストに追加するだけで問題ありません。
描画リストelementsに要素を追加、undo用のmemoryリストは不要になるので全て消去してrepaintで再描画します。

clearメソッドは描画されたすべての点を消去します。
elementsリストもmemoryリストも消去したのち、repaintで再描画します。

undoメソッドは一つ前の操作に戻します。
if文により一つ前の操作がある場合のみ実行されます。
elementsリストからは最後に操作した要素を取り除きたいところですが、redo操作をするためには忘れるわけにもいかないので、memoryリストに避難させています。
ここでもリストの内容が確定したらrepaintをします。

redoメソッドはundoメソッドと逆です。
undo操作をやり直します。

executeメソッドはelementsリストに保存されたすべての点を描画します。
各要素のexecute内容は実際のPaintElement継承クラスによって異なります。

paintComponentメソッドはComponentクラスから用意されている描画用クラスです。
executeメソッドを呼び出しています。

mouseDraggedメソッドはMouseMotionListenerの宣言メソッドです。
イベントが発生したポイントを現在選択中のペンに渡してaddElementメソッドに渡しています。

実装例

まずはペンを実装していきます。

CirclePenクラス

public class CirclePen extends PaintElement {

	@Override
	public void execute(Graphics graphics) {
		graphics.setColor(Color.BLACK);
		graphics.fillOval(getX()-4, getY()-4, 8, 8);
	}
}

黒丸を担当するペンです。
setColorで黒を指定し、fillOvalメソッドで8*8の丸を描画します。

RectanglePenクラス

public class RectanglePen extends PaintElement {

	@Override
	public void execute(Graphics graphics) {
		graphics.setColor(Color.BLACK);
		graphics.fillRect(getX()-4, getY()-4, 8, 8);
	}
}

黒角を担当するペンです。
setColorで黒を指定し、fillRectメソッドで8*8の四角を描画します。

RedCirclePenクラス

public class RedCirclePen extends PaintElement {

	@Override
	public void execute(Graphics graphics) {
		graphics.setColor(Color.RED);
		graphics.fillOval(getX()-4, getY()-4, 8, 8);
	}
}

赤丸を担当するペンです。
setColorで赤を指定し、fillOvalメソッドで8*8の丸を描画します。

RedRectanglePenクラス

public class RedRectanglePen extends PaintElement {

	@Override
	public void execute(Graphics graphics) {
		graphics.setColor(Color.RED);
		graphics.fillRect(getX()-4, getY()-4, 8, 8);
	}
}

赤角を担当するペンです。
setColorで赤を指定し、fillRectangleメソッドで8*8の四角を描画します。

DefaultFrameクラス

public class DefaultFrame extends JFrame implements WindowListener{

	public DefaultFrame() {
		setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		addWindowListener(this);
	}

	@Override
	public void windowOpened(WindowEvent e) {
		pack();
		setLocationRelativeTo(null);
	}

	@Override
	public void windowClosing(WindowEvent e) {}

	@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) {}
}

画面に表示するためのフレームです。
xボタンでフレームを閉じ、WindowListenerを追加しています。
WindowListenerではフレームサイズと位置を設定しています。

packメソッドは中に配置されているコンポーネントによってウィンドウサイズを設定します。

setLocationRelativeToメソッドは引数で渡したコンポーネントの中心に自身を配置します。
ここではnullを渡しているので、画面中央に表示されます。

Mainクラス

public class Main {

	public Main() {
		JFrame frame = new DefaultFrame();
		CanvasPanel canvas = new CanvasPanel();
		JMenuBar menubar = new JMenuBar();
		JMenuItem circle = new JMenuItem("黒丸");
		JMenuItem rectangle = new JMenuItem("黒角");
		JMenuItem red_circle = new JMenuItem("赤丸");
		JMenuItem red_rectangle = new JMenuItem("赤角");
		JMenuItem undo = new JMenuItem("undo");
		JMenuItem redo = new JMenuItem("redo");
		JMenuItem clear = new JMenuItem("クリア");

		frame.add(canvas);
		frame.setJMenuBar(menubar);

		menubar.add(circle);
		menubar.add(rectangle);
		menubar.add(red_circle);
		menubar.add(red_rectangle);
		menubar.add(undo);
		menubar.add(redo);
		menubar.add(clear);

		circle.addActionListener(e -> canvas.setPen(new CirclePen()));
		rectangle.addActionListener(e -> canvas.setPen(new RectanglePen()));
		red_circle.addActionListener(e -> canvas.setPen(new RedCirclePen()));
		red_rectangle.addActionListener(e -> canvas.setPen(new RedRectanglePen()));
		undo.addActionListener(e -> canvas.undo());
		redo.addActionListener(e -> canvas.redo());
		clear.addActionListener(e -> canvas.clear());

		frame.setVisible(true);
	}

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

大半がボタンなどの配置なので、説明は省略します。

コンストラクタの下の方にある各ボタンにaddActionListenerをしている部分を見てください。
ラムダ式によってボタンを押したときの動作を設定しています。

circleボタンを押すとsetPenメソッドにCirclePenクラスのインスタンスを渡します。
CirclePenのexecuteは黒丸を描画するものでした。

rectangleボタンを押すとsetPenメソッドにRectanglePenクラスのインスタンスを渡します。
RectanglePenのexecuteは黒四角を描画するものでした。

red_circleボタンを押すとsetPenメソッドにRedCirclePenクラスのインスタンスを渡します。
RedCirclePenのexecuteは赤丸を描画するものでした。

red_rectangleボタンを押すとsetPenメソッドにRedRectanglePenクラスのインスタンスを渡します。
RedRectanglePenのexecuteは赤四角を描画するものでした。

では実行してみます。
f:id:chiopino:20210613195915p:plain


それぞれ選択したペンが使えること、undo、redoができることを確認してみましょう。
またundoした後にペンで描くとredoができなくなることも確認できます。

特徴

エディタのようにundo、redoのあるようなものや、
別のパターンになりますが、インタラプタパターン(インタラプタはスクリプト言語のこと)を考える上で必要になります。

補足

undoをしても点が一つずつしか消えていかないので少し面倒でしたね。
やり方によればドラッグを開始(クリック)してから終了する(クリックを離す)までを一つの線として扱うこともできます。
PaintElementを継承したLinePenを作れます。
そうすれば線ごとにundoができるようにもなります。