デザインパターンstateについて勉強した
stateパターンとは
stateは状態という意味です。
現在の状態をクラスとして考えるパターンをstateパターンといいます。
例として菊池さんの1日を考えてみます。
菊池さんは5〜8時は新聞配達、その後18時まで会社員、24時までアルバイトをしているとします。
1日をコードで表すとき、どのように書けばいいでしょうか。
まずはstateパターンでない書き方を見てみます。
public void greeting(int clock){ //greeting=挨拶 if(5<=clock && clock<=7) { System.out.println("新聞配達です"); }else if(8<=clock && clock<=17){ System.out.println("会社員です"); }else if(18<=clock && clock<=23) { System.out.println("アルバイトです"); } } public void work(int clock){ if(5<=clock && clock<=7) { System.out.println("新聞を配達しています"); }else if(8<=clock && clock<=17){ System.out.println("プログラムを書いています"); }else if(18<=clock && clock<=23) { System.out.println("レジを打っています"); } }
間違いではありませんが、半分近くが同じコードになっています。
これにさらにいくつかメソッドが増えると、どんどんコード量だけが膨れ上がっていきます。
さらに、もしアルバイトの時間を21時までに変更したら、すべてのメソッドを調べて条件を変更することになります。
一つでも見落とせばバグになります。
コード例
今度はstateパターンを見てみます。
State インタフェース
public interface State { void greeting(); void work(); }
「状態」に相当するインタフェースです。
これを実装して「新聞配達員」「会社員」「アルバイト」などの状態を作っていきます。
NewspaperDeliveryクラス
public class NewspaperDelivery implements State { @Override public void greeting() { System.out.println("新聞配達員の菊池です!おはようございます!"); } @Override public void work() { System.out.println("新聞をポストに投函します。"); } }
新聞配達員の状態を表すクラスです。
greetingメソッドもworkメソッドも新聞配達員専用の実装がされています。
何時から何時までのような情報もなく、ただシンプルに仕事内容だけが書かれています。
SystemEngineersクラス
public class SystemEngineers implements State { @Override public void greeting() { System.out.println("システムエンジニアの菊池です。おはようございます。"); } @Override public void work() { System.out.println("エクセルにスクショを貼り付けています。"); } }
日中はシステムエンジニアとして働いています。
「日中は」といいましたが、このクラスにも新聞配達同様に時間の情報は含まれていません。
PartTimeクラス
public class PartTime implements State { @Override public void greeting() { System.out.println("コンビニバイトの菊池です。いらっしゃいませ"); } @Override public void work() { System.out.println("レジ打ちをします"); } }
PartTimeはアルバイトのことです。
Sleepクラス
public class Sleep implements State { @Override public void greeting() { System.out.println("菊池は仕事を終えました。おやすみなさい"); } @Override public void work() { System.out.println("...zzz"); } }
仕事が終わればやっと眠ることができます。
Statemanクラス
public class Stateman { private State state ; public void setState(State state) { if(this.state!=null && this.state.getClass()==state.getClass()) return; this.state = state; state.greeting(); } public void setClock(int clock) { if(5<=clock && clock<=7) { setState(new NewspaperDelivery()); }else if(8<=clock && clock<=17){ setState(new SystemEngineers()); }else if(18<=clock && clock<=23) { setState(new PartTime()); }else { setState(new Sleep()); } } public Stateman() { for(int c=5;;c=(c+1)%24) { setClock(c); System.out.print(c+":00\t"); state.work(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { new Stateman(); } }
メインとなるクラスです。
stateフィールドは現在の状態を保持するフィールドです。
setStateメソッドはstateフィールドに保持されているクラス型が同じ場合は何もしません。
異なる場合はstateフィールドを変更して菊池さんが挨拶します。
setClockメソッドは現在時刻を渡します。
渡された時刻によって現在の状態を「新聞配達員」「システムエンジニア」「アルバイト」「睡眠」の4つから選択しています。
コンストラクタではforによる無限ループが行われています。
変数iには0~23の数字が順に入れられます。
setClockメソッドに時間を入力し、stateフィールドのworkメソッドを呼び出しています。
実行
新聞配達員の菊池です!おはようございます! 5:00 新聞をポストに投函します。 6:00 新聞をポストに投函します。 7:00 新聞をポストに投函します。 システムエンジニアの菊池です。おはようございます。 8:00 エクセルにスクショを貼り付けています。 9:00 エクセルにスクショを貼り付けています。 10:00 エクセルにスクショを貼り付けています。 11:00 エクセルにスクショを貼り付けています。 12:00 エクセルにスクショを貼り付けています。 13:00 エクセルにスクショを貼り付けています。 14:00 エクセルにスクショを貼り付けています。 15:00 エクセルにスクショを貼り付けています。 16:00 エクセルにスクショを貼り付けています。 17:00 エクセルにスクショを貼り付けています。 コンビニバイトの菊池です。いらっしゃいませ 18:00 レジ打ちをします 19:00 レジ打ちをします 20:00 レジ打ちをします 21:00 レジ打ちをします 22:00 レジ打ちをします 23:00 レジ打ちをします 菊池は仕事を終えました。おやすみなさい 0:00 ...zzz 1:00 ...zzz 2:00 ...zzz 3:00 ...zzz 4:00 ...zzz 新聞配達員の菊池です!おはようございます! 5:00 新聞をポストに投函します。
特徴
プログラム全体を見て、時間による分岐をしているのがsetClockメソッド内ただ一つであることを確認してください。
また、各状態(各仕事)のクラスにはそれに関係のある仕事しか書かれていないことにも注目してください。
これらにより新聞配達クラスを見れば新聞配達中は何をすべきかがひと目でわかり、どの時間にはどの仕事をすべきかがすぐにわかります。
アルバイトを21時に終わるように変更することも簡単ですし(setClockメソッドのif条件を1文字変更するだけです)、アルバイトの後に新たに「警備員」の仕事を追加することだってどうすればいいかすぐにわかります。(警備員クラスを作り、setClockメソッドにちょこっと追記するだけです)
まとめ
Stateインタフェースの実装クラスを、その仕事について書かれたマニュアルである、
setClockに書かれた時間が来たらそのマニュアルを手元に置いて仕事をする、
というように考えるとイメージしやすいのではないかと思います。
デザインパターン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は赤四角を描画するものでした。
では実行してみます。
それぞれ選択したペンが使えること、undo、redoができることを確認してみましょう。
またundoした後にペンで描くとredoができなくなることも確認できます。
補足
undoをしても点が一つずつしか消えていかないので少し面倒でしたね。
やり方によればドラッグを開始(クリック)してから終了する(クリックを離す)までを一つの線として扱うこともできます。
PaintElementを継承したLinePenを作れます。
そうすれば線ごとにundoができるようにもなります。
デザインパターン Adapterパターンについて勉強した
Adapterパターンとは
adapte(アダプター)と聞いて何を思い浮かべるでしょうか。
代表的なものの一つにACアダプターがあります。
ACアダプターはコンセントから供給される100vの交流電流を5vの直流電流に変換したりするものです。
adapterパターンではシグネチャAのメソッドで用意されている機能をシグネチャBで呼び出したいような時に使用します。
簡単に例えると、コンソール出力に毎回System.out.println("hogehuga")を書くと長くなるから新たにprint(Object)メソッドを作るといったイメージです。
public void print(Object object){ System.out.println(object); }
シグネチャAの機能を使うのにシグネチャBを呼び出す。シグネチャBからシグネチャAを呼び出す。
このようにシグネチャAの前に挟まれたシグネチャBがアダプター(変換器)の役割を果たします。
コード例1(継承
adapterパターンでは継承を使用するものと委譲を使用するものの2パターンが存在します。
まずは継承の例から見ていきます。
引数に"年","月","日"を入力すると"yyyy年mm月dd日"の形式でコンソール出力をするprintaDateメソッドを持つPrinterクラスが既に作られているものとします。
そして上記のメソッドと同じ機能を持ったprintCalendarメソッドをMyCalendarクラスで実装したい場合を考えます。
Printerクラス
public class Printer{ public void printDate(int year,int month,int day) { System.out.println(String.format("%d年%d月%d日",year,month,day)); } }
入力された年月日を特定のフォーマットでコンソール出力するメソッドを持っています。
CalendarInterfaceインタフェース
public interface CalendarInterface { void printCalendar(int year,int month,int day); }
printCalendarはyear(年),month(月),day(日)を渡すと何かしらのフォーマットに整えてコンソール出力することを期待して宣言されています。
MyCalendarクラス
public class MyCalendar extends DefaultCalendar implements CalendarInterface{ @Override public void printCalendar(int year, int month, int day) { printDate(year,month,day); } }
CalendarInterfaceで宣言されたprintCalendarメソッドを実装しています。
printCalendarメソッドの実装では、親クラスのprintDateメソッドに全て処理を任せています。
これだけで考えると「最初からPrintクラスのprintDateメソッドを呼ぶか、PrintクラスにCalendarInterfaceを実装すればいいのではないか?MyCalendarクラスは不要ではないか?」と思うかもしれません。
しかし、例えばPrintクラスとCalendarInterfaceインタフェースが既に作られているフレームワークであったりした場合、そのように変更することはできません。
MyCalendarはPrintクラスとCalendarInterfaceインタフェースの橋渡し役として実装されているのです。
実装例1(継承
public class Main { public Main() { CalendarInterface calendar = new MyCalendar(); calendar.printCalendar(2021, 6, 12); } public static void main(String[] args) { new Main(); } }
MyCalendarクラスをインスタンス化し、printCalendarメソッドを呼び出しています。
calendarの型がCalendarInterfaceになっているのは、仮にCalendarInterfaceを実装したDefaultCalendarクラスに置き換えたくなった時でも、即座に書き換えられるようにするためです。
実行してみます。
2021年6月12日
コード例2(委譲
もう一つ、委譲を利用するパターンについても説明してみます。
DefaultCalendarクラス
public class DefaultCalendar implements CalendarInterface{ private Print print = new Print(); @Override public void printCalendar(int year, int month, int day) { print.printDate(year,month,day); } }
こちらはMyCalendarと若干似ていますが、Printクラスを継承せず、代わりに委譲をしています。
実装例2(委譲
public class Main { public Main() { //CalendarInterface calendar = new MyCalendar(); CalendarInterface calendar = new DefaultCalendar(); calendar.printCalendar(2021, 6, 12); } public static void main(String[] args) { new Main(); } }
コンストラクタ1行目では、実装例1で使用したMyCalendarクラスがコメント化されています。
そして2行目では同じ変数名でDefaultCalendarのインスタンスが作られています。
2021年6月12日
実装例1と同じ結果が表示されました。
特徴
Printクラスで実装されているメソッドをCalendarInterfaceインタフェースで宣言されているメソッドで呼び出したい。
しかしPrintクラスにCalendarInterfaceインタフェースを実装することはできない。と言った場面を例に解説してみました。
結構基本的で、誰もが知らないうちに使っているようなパターンではないかと思います。
デザインパターン observerについて勉強した
observerパターンとは
java標準にもあるActionListenerやMouseListenerなどのようなイベントリスナーが代表的な例です。
ボタンを押した、マウスをクリックしたなどの特定の状態が起きたときに用意された処理が開始されます。
コード例
NumberListenerインタフェース
public interface NumberListener { void threeObserve(NumberEvent event); }
イベントを「受け取る側」が実装するインタフェースです。
threeObserveメソッドはイベントが発生したときに実行されるメソッドです。
ActionListenerクラスでいうところのactionPreferredメソッドと同じ立ち位置になります。
NumberEventインタフェース
public interface NumberEvent { int getNumber(); }
イベントを「送る側」が実装するインタフェースです。
このクラスはActionListenerクラスのイベントで呼び出されるActionEventクラスと同じ立ち位置になります。
observerパターンで主役となるのがこの2つのクラス(インタフェース)です。
主役とは言ってもこれだけでは想像がつきませんね。
実装例
Counterクラス
public class Number implements NumberEvent{ private int number ; private Random random = new Random(); private ArrayList<NumberListener> listeners = new ArrayList<NumberListener>(); public void addNumberListener(NumberListener listener) { listeners.add(listener); } private void threeObserve() { for(NumberListener l : listeners) { l.threeObserve(this); } } public int next() { number = random.nextInt(99); if(0<number && (number%3)==0) { threeObserve(); return number; } if((number/10)==3) { threeObserve(); return number; } if((number%10)==3) { threeObserve(); return number; } return number; } @Override public int getNumber() { return number; } }
このクラスはランダムに選ばれた数字が3の倍数か3のつく数字である場合にイベントを発生させます。
numberフィールドは直前に選ばれた数字を保持するためのフィールドです。
randomフィールドは乱数を生成するためのRandomクラスのインスタンスを保持しています。
listenersフィールドは追加されたNumberListenerを登録しておくためのフィールドです。
addNumberListenerメソッドに渡されたNumberListenerインスタンスはlistenersフィールドへ全て保持されます。
threeObserveメソッドはイベントを発生させるときの補助メソッドです。
次で説明するnextメソッド内では同じ処理が3箇所出てくるので、コードの重複を防ぐために準備しました。
nextメソッドは数字の選択をし、3の倍数か3の付く数字であればイベントを発生させます。
getNumberメソッドは最後に選択された時の数字を返します。
NumberEventインタフェースで宣言されていたメソッドです。
Nabeatsuクラス
public class Nabeatsu implements NumberListener{ private Number number = new Number(); public Nabeatsu() { number.addNumberListener(this); } public void start() { for(int i=0;i<100;i++) { System.out.println("\n"); System.out.println(" "+number.next()); } } @Override public void threeObserve(NumberEvent event) { System.out.println("\\\\ AHO //"); } }
あの方の名前をお借りしております。
numberフィールドはNumberクラスのインスタンスです。
コンストラクタではnumberフィールドに自身をNumberListenerとして登録しています。
これによってnumberフィールドで3の倍数か3の付く数字を選択したときに、numberフィールドから後述するthreeObserveメソッドが呼び出されるようになります。
startメソッドは連続で100回、nextメソッドを呼び出します。
threeObserveメソッドは3の倍数か3の付く数字の時に、numberフィールドから呼び出されるメソッドです。
numberフィールドからNumberEventが渡されてくるので、ここから実際に選択された数字を取り出すことができます。
Mainクラス
public class Main { public Main() { Nabeatsu nabeatsu = new Nabeatsu(); nabeatsu.start(); } public static void main(String[] args) { new Main(); } }
実際に実行してみましょう。
29 \\ AHO // 90 \\ AHO // 36 16 \\ AHO // 12 \\ AHO // 24 (続く)
特徴
ボタンを押す、マウスやキーボードを操作する、ファイルを更新する、特定の数字が入力、選択される等した時にイベントを発生させるのがobserverパターンです。
observerという英単語は観察者という意味です。
観察する対象の状態が変化したり、特定の条件を満たした時に通知をしてくる働きをします。
補足
observerは観察者という意味であることは先ほど言いました。
観察者というとListenerがEventの状態を定期的に見にいって、特定の状態になっていたらイベントを発生するというようなイメージになってしまいます。
しかしコード例を見てみると逆にevent側が自身の状態を常時監視しており、特定の状態になったらlistenerに報告するような関係性になっています。
そうであればパターン名は報告者という意味のreporter(リポーター)が正しいような気がしてきます。
観察者という言葉の意味に惑わされないように気をつけて覚えましょう。
デザインパターン mementoについて勉強した
mementoパターンとは
mementoとは思い出という意味です。
写真のようなものでその時の状態を記録しておくと、写真を見た時にその時のことを思い出すことができます。
プログラムではテキストエディタなどのアンドゥ機能もmementoパターンで考えられます。
コード例
ここではゲームのセーブ機能でmementoパターンを実装してみます。
プレイヤーはHEROを操作し、VILLAINを倒すことが目的です。
HEROは自分のHPなどの状態を保存しておき、ピンチになったら保存した状態まで時を戻して復活することができます。
HeroStatusクラス
package hero; public class HeroStatus implements Cloneable{ int hp ; HeroStatus(int hp){ this.hp = hp; } public HeroStatus clone() { try { return (HeroStatus)super.clone(); } catch (CloneNotSupportedException e) { e.printStackTrace(); } return null; } }
HeroStatusクラスはheroパッケージに配置しています。
これはhpフィールドやコンストラクタが同じパッケージ内のクラス、後述するHeroクラスからしかアクセスできないようにするためです。
hpフィールドはHEROの体力を表すパラメータです。
アクセス修飾子がないので、パッケージ内からのアクセスのみ許可されます。
コンストラクタの引数はhpです。
インスタンス化の際にhpを指定します。
これもアクセス修飾子がないので、パッケージ内からのみアクセス可です。
Cloneableインタフェースの実装やcloneメソッドのオーバライドがされており、このクラスはインスタンスの複製が可能になっています。
Heroクラス
package hero; import java.util.Random; public class Hero { private HeroStatus status ; private int mp ; public Hero(int hp,int mp) { status = new HeroStatus(hp); this.mp = mp; } public HeroStatus save() { if(mp<=0) { System.out.println("MPが足りない!"); return null; } System.out.println("状態を保存した!!"); mp--; return status.clone(); } public void load(HeroStatus status) { if(status!=null) { if(mp<=0) { System.out.println("MPが足りない!"); return; } System.out.println("HEROの時が戻った"); mp--; this.status = status.clone(); }else { System.out.println("しかし何も起きなかった"); } } public int getHP() { return status.hp; } public int getMP() { return mp; } public int attack() { Random random = new Random(); return random.nextInt(10); } public void damage(int damage) { System.out.println(String.format("HEROは%dダメージを受けた",damage)); status.hp -= damage; } }
ゲームユーザが操作するキャラクターです。
heroパッケージに入っています。
HeroStatusはフィールドでhpを持っていますが、mpフィールドはHeroクラスが持っています。
これにより、以前の状態に戻すことができるのはHeroStatusに含まれているHPだけになります。
コンストラクタではhpとmpの値を指定します。
saveメソッドがmementパターンの「記録」の部分です。
return status.clone();の行によって現在のstatusの状態を複製しています。
loadメソッドがmementパターンの「復元」の部分です。
this.status = status.clone();の行によって、渡されたインスタンスの複製を自身のフィールドに取り込んでいます。
getHPメソッド、getMPメソッドはhp,mpのゲッターです。
attackメソッドでランダムな攻撃力を返しています。
damageメソッドは渡された値を相手の攻撃力として自身のhpから減算します。
VillainStatusクラス
package villain; public class VillainStatus{ int hp ; VillainStatus(int hp) { this.hp = hp; } }
VILLAINのステータスを保持するクラスです。
やっていることはほぼHeroStatusクラスと同じです。
Villainクラス
package villain; import java.util.Random; public class Villain { private VillainStatus status ; public Villain(int hp) { status = new VillainStatus(hp); } public int getHP() { return status.hp; } public int attack() { Random random = new Random(); return random.nextInt(10); } public void damage(int damage) { System.out.println(String.format("VILLAINは%dダメージを受けた",damage)); status.hp -= damage; } }
こちらもHeroクラスとほぼ同じことをしています。
HeroクラスとVillainクラスの親となる共通のクラスを作って継承した方がシンプルになったかもしれません。
今回は勢いで作ってしまったのでこのままいきます。
実行
import java.util.Scanner; import hero.Hero; import hero.HeroStatus; import villain.Villain; public class Main { private Hero hero = new Hero(100,10); private Villain villain = new Villain(500); private HeroStatus save_data ; public Main() { Scanner scanner = new Scanner(System.in); while(hero.getHP()>0 && villain.getHP()>0) { System.out.println(String.format("HERO HP:%d MP:%d",hero.getHP(),hero.getMP())); System.out.println(String.format("VILLAIN HP:%d",villain.getHP())); System.out.println(); System.out.println(String.format("%-8s %-8s %-8s","a:攻撃","s:セーブ","l:ロード")); System.out.print("行動を選択:"); switch(scanner.nextLine()){ case "a": System.out.println("HEROの攻撃!"); villain.damage(hero.attack()); break; case "s": System.out.println("HEROは状態を保存しようとした"); save_data = hero.getMP()>0 ? hero.save() : save_data; break; case "l": System.out.println("HEROは状態を戻そうとした"); hero.load(save_data); save_data = null; break; default: continue; } if(villain.getHP()>0) { System.out.println("VILLAINの攻撃!"); hero.damage(villain.attack()); } System.out.println("----------------"); } if(hero.getHP()>0) { System.out.println("you win!"); }else { System.out.println("you lose..."); } scanner.close(); } public static void main(String[] args) { new Main(); } }
いよいよゲームとして仕上げていきます。
heroフィールドはHERO、villainフィールドはVILLAINを操作するインスタンスです。
save_dataはheroが保存した状態を保持するフィールドです。初期状態では何も保存されていないのでnullが入っています。
whileループの条件は、どちらもHPが0より大きい場合、つまりどちらも倒れていない場合となります。
プログラムが少し長いので、mementoパターンに関係のある箇所を説明していきます。
switch内のcase "s"とcase "l"がその場所です。
case "s"ではheroのmpが0より多ければsaveメソッドを呼ぶようにしています。
これがmementoパターンの「記録」です。
記録した状態はsave_dataフィールドに保管されます。
case "l"では記録とは逆にsave_dataフィールドをheroのloadメソッドに渡しています。
これがmementoパターンの「復元」です。
特徴
例のようにゲームデータの保存のような場面で登場するパターンです。
そのほかにリドゥ、アンドゥであったり、テキストエディタでテキストを保存したり開いたりするときにも使えるかもしれません。
補足
HeroStatusクラスのhpフィールドは修飾子を付けませんでした。
修飾子をつけない場合、フィールドやメソッド、コンストラクタは同じパッケージ内からのみアクセスを許可します。
今回の例ではなぜ修飾子をつけなかったのでしょうか。
HeroStatusのインスタンスはHeroクラスのsaveメソッドから取得し、loadメソッドでHeroクラスの内部に戻されます。
ということは一度Heroクラスの外に出て行くわけです。
Heroクラスの外に出されたHeroStatusのインスタンスは誰がどうやって使おうとするかわかりません。
もしかしたら外部でhpに-100を入れようとするかもしれませんし、Heroが作っていないようなhp=999のHeroStatusインスタンスを勝手に作ろうとするかもしれません。
しかしHeroStatusのコンストラクタにもhpフィールドにも修飾子はついていないので、パッケージ外部のクラスではせいぜい変数を作ってそれに既存のHeroStatusインスタンスを入れておくくらいのことしかできません。
ですのでHeroやHeroStatusのようなheroパッケージ内のクラスは、HeroStatusに外部から不正な値が入れられることを心配する必要がなくなります。
デザインパターン compositeについて勉強した
compositeパターンとは
身近なものではパソコンのファイルがいい例です。
パソコンのファイル構造ではまずディレクトリがあり、その中に複数のファイルやサブディレクトリを入れます。
サブディレクトリの中にはまたファイルやサブディレクトリを入れることができます。
ディレクトリの中に入れるものはファイルやディレクトリのような「ディレクトリに入れられるもの」という一つの概念として考えられます。
ディレクトリの中にはサブディレクトリだけがあっても構いませんし、ファイルだけがあっても構いません。
さらに、サブディレクトリやファイルが数百入っていても、もしくは何も入っていなくても構いません。
このような、多重の入れ子構造を作るプログラムのパターンをcompositeパターンと呼びます。
コード例
※ここではFileクラスなどが出てきますが、Javaの標準クラスではありません。全て自作クラスになっています。
Entryクラス
public abstract class Entry { private String name ; public void setName(String name) { this.name = name; } public String getName() { return name; } public abstract int getSize() ; public void printPath() { printPath(""); } public abstract void printPath(String parent) ; @Override public String toString() { return String.format("%s(%d)",getName(),getSize()); } }
Entryクラスは「ディレクトリに入れられるもの」を表すクラスです。
ファイルやディレクトリに最低限必要なものを記述していきます。
ファイルやディレクトリには名前が必要なのでnameフィールドと、そのゲッター、セッターを作ります。
getSize抽象メソッドは、ファイルならそのファイルサイズを、ディレクトリなら格納されている全てのファイル、ディレクトリの合計サイズを返します。
printPathメソッドはオーバーロードしています。
引数ありのprintPathメソッドは抽象メソッドです。
引数には親のディレクトリ名が渡されることを想定しています。
渡された親ディレクトリ名と自身のnameフィールドを結合してファイルパスを作成します。(することを想定します)
引数なしのprintPathメソッドは呼び出し用を想定しています。
つまり、ファイルchildからprintPath()メソッドを呼び出した時は"child"という文字列を、ファイルchildを格納したディレクトリparentからprintPath()メソッドを呼び出した時は"parent/child"という文字列を表示されることを想定しています。
toStringメソッドはファイルならファイル名とそのサイズ、ディレクトリならディレクトリ名と格納されている全ファイルの合計サイズの文字列表現を返します。
Fileクラス
public class File extends Entry { private int size ; public File(String name,int size) { setName(name); this.size = size; } @Override public int getSize() { return size; } @Override public void printPath(String parent) { System.out.println(String.join("/", parent,getName())); } }
FileクラスはEntryクラスを継承しています。
ファイルは自身のファイルサイズを持っているので、ファイルサイズを表すsizeフィールドを作成します。
コンストラクタの引数では、ファイル名とファイルサイズを受け取ります。
コンストラクタの引数にすることで、ファイル名やサイズの設定し忘れを防ぐことができます。
getSizeメソッドでは自身のファイルサイズを返しています。
printPathメソッドはEntryクラスで宣言された抽象メソッドです。
渡された親のパス名と自身のnameフィールドを"/"で結合して表示しています。
Directoryクラス
import java.util.ArrayList; public class Directory extends Entry { private ArrayList<Entry> entrys = new ArrayList<Entry>(); public Directory(String name) { setName(name); } @Override public int getSize() { int size = 0; for(Entry e : entrys) { size += e.getSize(); } return size; } @Override public void printPath(String parent) { String path = String.join("/", parent,getName()); for(Entry e : entrys) { e.printPath(path); } } public void add(Entry entry) { entrys.add(entry); } }
DirectoryクラスはEntryクラスを継承しています。
entrysフィールドはこのディレクトリに入っているファイルやサブディレクトリを格納するためのフィールドです。
Entry型になっているので、ファイルやディレクトリを区別する必要がなくなっています。
コンストラクタの引数にはディレクトリ名を要求しています。
Fileクラスと違いサイズを必要としていませんが、ディレクトリは入れ物であり自身のサイズを持たないためです。
getSizeメソッドではentrysに格納されている(自身のディレクトリに格納されている)全てのファイルサイズの合計を計算しています。
もしentrysにサブディレクトリが含まれていた場合、サブディレクトリのgetSizeが呼ばれてそこに格納されているファイルサイズを計算します。
その中にさらにサブディレクトリが含まれている場合も同様です。
こうして再帰を繰り返し、最終的には全てのファイルサイズが計算されることになります。
printPathメソッドはEntryクラスで実装されなかったメソッドです。
Fileクラスでは親ディレクトリ名と自身のnameフィールドを結合して表示していましたが、こちらでは少し違います。
親ディレクトリと自身のnameフィールドを結合するところまでは一緒ですが、結合した文字列を表示せずにentrysコレクション達に渡しています。
渡された先がFileクラスであればその先でファイル名を結合して画面表示されます。
渡された先がDirectoryクラスであればまたディレクトリ名を結合してentrysコレクションに渡されるという再帰構造となっています。
addメソッド
Entryクラスでは宣言されていない、Directoryクラスオリジナルのメソッドです。
やっていることは単純で、渡された引数をentrysメンバーに追加します。
実装例
public class Main { public Main() { Directory group = new Directory("group"); Directory ando = new Directory("ando"); Directory baba = new Directory("baba"); Directory chiyoda = new Directory("chiyoda"); Directory daiwa = new Directory("daiwa"); File ando_profile = new File("profile",100); File baba_profile = new File("profile",100); File chiyoda_profile = new File("profile",100); File daiwa_profile = new File("profile",100); File group_task = new File("task",1000); File ando_task = new File("task",200); File baba_task = new File("task",150); File chiyoda_task = new File("task",180); File daiwa_task = new File("task",500); group.add(ando); group.add(baba); group.add(chiyoda); group.add(daiwa); group.add(group_task); ando.add(ando_task); ando.add(ando_profile); baba.add(baba_task); baba.add(baba_profile); chiyoda.add(chiyoda_task); chiyoda.add(chiyoda_profile); daiwa.add(daiwa_task); daiwa.add(daiwa_profile); System.out.println("--------"); group.printPath(); System.out.println("--------"); System.out.println(group); System.out.println(group_task); } public static void main(String[] args) { new Main(); } }
初めに各ディレクトリ、ファイルを宣言しています。
構造としては1つのグループがあり、グループには「安藤さん」「馬場さん」「千代田さん」「大和さん」が含まれています。
各人はプロフィールと自身のタスクを持っています。
また、グループ内共有のタスクファイルが1つあります。
各ディレクトリにそれぞれの持つディレクトリやプロフィール、タスクを格納します。
最後に一番上位に位置するgroupディレクトリからprintPathメソッドを呼び出すことで、groupディレクトリに含まれる全てのファイルパスを表示しています。
また、printlnでgroupとgroup_taskを表示しています。
groupはgroupディレクトリのディレクトリ名と、そこに含まれるファイルの合計サイズ、
group_taskはgroup_taskファイルのファイル名とそのファイルサイズを表示します。
実行してみます。
-------- /group/ando/task /group/ando/profile /group/baba/task /group/baba/profile /group/chiyoda/task /group/chiyoda/profile /group/daiwa/task /group/daiwa/profile /group/task -------- group(2430) task(1000)
上から安藤さんの持つファイル、馬場さんの持つファイル、千代田さんの、大和さんの、グループ内共有のタスクファイルのパスが表示されています。
下のgroup(2430)は確かにgroupディレクトリに含まれる全ファイルの合計サイズが計算されています。
task(1000)はグループ内共有のタスクファイルのサイズです。
使用する利点
ディレクトリとファイルを区別することなく扱うことができます。
またディレクトリは、その中に含まれているサブディレクトリやファイルもまとめて一つのものとして扱うことができます。
実際のpcでもあっちのディレクトリAからこっちのディレクトリBへディレクトリCをドラッグ&ドロップする時、ディレクトリCにどんなファイルがいくつ入っているかを意識する必要はありませんね。
まとめ
参考にした書籍によると、Directoryクラスで宣言したaddメソッドは本来Entryクラスで宣言すべきだそうです。
全ての引数宣言(groupやchiyoda_taskなど)をEntry型にすることで、クラスの再利用性を高める。そのためにはaddメソッドをDirectoryクラスで宣言するとaddメソッドを使うたびにクラスキャストが必要になってしまうから、Entryクラスで宣言しておけばキャストは不要だということです。
ではEntryクラスでaddメソッドを宣言した場合、どのように実装するのかというと、
1. 抽象メソッドとして定義する
2. Entryクラスでは例外が発生するように実装し、Directoryクラスでは使いたいようにオーバライドする(間違えてFileクラスから呼び出すと例外)
3. 空のメソッドを実装し、Directoryクラスでオーバライドする
僕はこれには否定的で、Entryクラスで定義してしまうと、
1. 抽象メソッドで定義すると、addメソッドが不要なFileクラスでも実装を強要される
2. 例外を仕込んでも間違えて呼んでバグが発覚するのはランタイム中。キャストを省く代わりに例外対策として型チェックが必要になる
3. 空メソッドは2よりも状況が悪く、例外すら発生しないためバグの発見まで困難になる
それならばDirectoryクラスで宣言することでFileクラスからは呼び出せない(そもそもaddメソッドが宣言されていない)とすればコンパイル時に弾いてくれるのでバグは生まれません。
再利用性を優先するか、ささやかなコードの簡素化を優先するか、正解がなさそうな問題です。