StrategyパターンとVisitorパターンの使い分けに関する考察(4)

これまで[1][2]にStrategyパターンとVisitorパターンそれぞれで作った引力モデルのシミュレーションに対して、以下のような要素を追加してみる。

  1. 物体の種類として、Square
    Squareの特徴:
    • 縦か横にしか動かない(縦方向か横方向かどちらか速度の大きい方に動く)
    • 但し速度は2次元(一方が空回り)で、全方向の引力の作用を受ける
    • 標準の引力は通常の距離の2乗に反比例する引力
    • 標準色は緑
  2. 引力計算の種類に、引力の方向が相手の方向より少しずれるCurlGravityを追加する
  3. 色設定の種類に、Blinkを追加する
それぞれ、PhysicalObject, GravityStrategy/Visitor, ColorStrategy/Visitorのクラス階層へ何かを追加するという例である。

Strategyパターンによるものに対する変更は、次のようになる。
class diagram
元のクラス図に対して、黄色で示されたBlink, Square, CurlGravityの3つのクラスを追加している。

これに基づいて変更したソースコードとJavaアプレットを、次のリンク先のページに置く。
・StrategySample3のアプレットとソースコードのページ
ソースコードの差分は、詳しいdiffの出力を末尾に載せているが、 Square.java, CurlGravity.java, Blink.java の3ファイルが新規に作られた他は、変更されたファイルは SampleApp.java の1つのみであり、その変更も、新たに追加したSquare, CurlGravity, Blinkの3つが選択できるようにGUIのボタンを追加したりボタンの動作を変更したりするものだけであり、それらを使わないアプリケーションには必要の無い変更である。
つまり、本体側のサブクラスの追加もStrategyの追加も、既存のクラスに全く手を入れることなく行うことができる(†追記部分に補足あり)ということであり、いずれの場合も、オブジェクト指向設計の基本であるであるOpen-Closed Principleを満たしていることが明らかである。

一方、Visitorパターンによるものに対する変更はかなり多い。
class diagram
元のクラス図に対して、黄色で示されたBlink, Square, CurlGravityの3つのクラスを追加した他に、Visitorの階層のGravityVisitor, DefaultColorizerを除く全ての既存のクラスに変更が入っている。

これは、Visitorインターフェースにvisit(Square)を追加した為、Visitorインターフェースを実装する全てのクラスにvisit(Square)を追加する必要があるからである。もちろんVisitor階層で実装を継承できる場合は追加不要だが、Visitorパターンがその効果を発揮する用途ではVisitor同士は役割が類似しないことが多い(後述)というよりむしろ全く異なることが多い為、Visitorインターフェースが変更されても変更せずに済むのは、親クラスを僅かに拡張をするようなクラスに限られる。
また、Visitorインターフェースにvisit(Square)を追加しなくても良いのは、SquareがVisitorを一切acceptしない(accept()が何もしないか例外を投げる)場合のみである。(accept()でのvisit(this)はコンパイルエラーになる)

これに基づいて変更したソースコードとJavaアプレットを、次のリンク先のページに置く。
・VisitorSample3のアプレットとソースコードのページ
ソースコードの差分は、同様にdiffの出力を末尾に載せているが、 Square.java, CurlGravity.java, Blink.java の3ファイルが新規に作られた他に、 SampleApp.java, ColorVisitor.java, DefaultGravityApplier.java, Gradation.java, NegativeColorizer.java, NormalGravityApplier.java, RepulsionApplier.java, Visitor.java の8つのファイルが変更されている。
SampleApp.javaの変更は、Strategyパターンの場合と同様、使用者側が新たなクラスを使う為の変更であるが、その他は既に実装されているメソッドをcopy&pasteしたような怠惰な追加である。末尾のdiffの出力は先頭が+の行が追加された行であり、変更箇所の前後3行が参考の為に表示されているが、SampleApp.javaを除く前述の7ファイルの追加内容は、それらのすぐ上に同一または類似の処理があることが見て取れると思う。いかにも冗長である。

ただ、これら7ファイルは全て、PhysicalObjectの階層にSquareクラスが追加されたことに対応してvisit(Square)が追加されたものであり、CurlGravityやBlinkの追加に伴って発生した変更ではない。よって、VisitorパターンはVisitorの追加に対しては変更箇所が閉じている(Open-Closed Principleを満たしている)ことがわかる。
繰り返しになるが、visit(Square)を追加しなくて良いのはSquareがVisitorをacceptしない場合のみであり、それはVisitorパターンの適用外ともいうべきもので、そういう場合には変更に閉じていると言えるものではない。

以上より、StrategyパターンとVisitorバターンの違いとしては、次のようなことが見られる。

  • Visitorパターンの場合、本体側のクラス階層にサブクラスが追加されると、Visitor階層全体に変更が及ぶ。
    Visitorパターンの宿命のようである。
  • Visitorパターンでは、いずれのVisitorクラスも、本体側の全ての具象クラスについて別々のメソッドを持つ。
    具象クラス毎に処理が異なることが少ない場合は、適しているとは言い難い。一方、Strategyパターンでは、具象クラス毎に処理が異なるStrategyのみ、具象クラス毎にStrategyのクラスを別にすることが可能である。
  • Visitorパターンでは、本体側のクラスが主体となってその処理を実行することができない。
    accept()が呼ばれるまではVisitorが実装する処理を実行できない。その為、例えばメソッドの途中でVisitor側にある処理を呼ぶことは困難である。(抽象Visitorクラスがイベント発行要求を受け付けて、Visitorインスタンスを管理するクラスに通知して、acceptを呼ぶような、トリプルディスパッチが必要かも知れない)
  • Strategyパターンが本体側のクラスのインスタンス毎にStrategyを持つのに対して、Visitorパターンは特定のインスタンスとの関連が無い。
    切替可能な処理を繰り返し実行するのであれば、Visitorパターンの場合はVisitorのインスタンスを別に管理する必要が生じる。
    切替可能な処理を1回だけ、複数のオブジェクトに対して実行するのであれば、Strategyパターンの方が処理が煩雑になる。全オブジェクトに(抽象化された)同じ処理を1回だけ適用したい場合には、Visitorパターンが有利である。
  • Strategyパターンの場合は、Strategyの使われ方が決まっているので、追加できるStrategyの自由度は限定的だが、Visitorパターンの場合は任意と言うに近い自由度で処理が追加できる。

そもそも、Visitorパターンが有効な場合は相当限られると思う。
適用されるクラス群は抽象化されているのだからそれなりに共通の特徴を持つだろう が、
・Visitorから見るとクラス毎に処理が違うくらいにはサブクラスは異なる特徴を持ち、
・Visitorが行う抽象的な処理の種類はStrategyとして共通の特徴を持たないくらいには異なる、
ような場合ということになる。筆者がこれまでに見たことがある例は

  • オブジェクトダンプのようなデバッグ表示(toString+printのようなもの)
  • ファイル保存などのための、オブジェクトのフォーマッティング
くらいであるが、他にはなかなか思い付かない。ファイルハンドルのように、ストレージはファイルシステムかHDDかデータベースかネットワーク越しの何かかわからないようなものに対する処理を追加できるようにしておくような場合だろうか。


(†部分の補足)但し、Strategyパターンは、本体のクラス階層でStrategyの種類を意識するように実装している部分があると、Strategyの追加/変更による影響を受ける可能性がある。例えば、今回のGravityStrategyに関するdefault strategyのインスタンス生成(下記コードの赤字部分)もそうであるが、具体的なStrategyのインスタンスを内部で生成することはありがちである。

public class Disc extends PhysicalObject {
	public int radius;

	public Disc(Position p, int radius){
		super(p);
		this.color = Color.CYAN;
		setGravityUpdator(new NormalGravity());
		setColorModifier(null);
		this.radius = radius;
	}
この例においてそれを避けるには、default strategyの処理をクラス内部に持つか、もしくはこのクラスのインスタンスが作成された後に必ずStrategyが設定されることが保証される必要がある。しかし、前者はdefault strategyが永久不変であるという前提が必要だし、Strategyが他のクラスに依存しない(Strategyの依存を全て引き継ぐことになってしまう)ことも条件だし、このクラスがdefault strategyと強結合することになる。後者はユーザー側に影響することであり、例えばStrategyパターンの適用がリファクタリングの一環であれば、このクラスを使っているユーザーコードを全て修正しないといけないことになってしまう。

デザインパターン的には、このような問題はFaçadeパターンを使用してStrategyの具象クラスとの関連を避けることになっているようであるが、たった1ヶ所の抽象性の確保の為にそこまでするのは神経質すぎる気がする。

今回の例のColorStrategyについては、Strategyがnullの場合の処理を空にすることにより、default strategyへの依存の問題を回避できているように見えるが、default strategyの処理がコンストラクタに移動しているだけなので、やはり回避できていない。しかも、他のstrategyにした後にdefault strategyに切り替えられるとまずいので、setGravityUpdator()がnullを許容しないようにするか、やはりStrategyがnullの場合の処理をdefault strategyにする必要があった。

●Strategyパターンによるサンプルの拡張でのソースコードの変更点
(StrategySample2→StrategySample3のdiff -rの出力)
bash-3.2$ diff -u -r StrategySample[23]
diff -u -r StrategySample2/src/app/SampleApp.java StrategySample3/src/app/SampleApp.java
--- StrategySample2/src/app/SampleApp.java
+++ StrategySample3/src/app/SampleApp.java
@@ -18,10 +18,13 @@
 
 import objects.Disc;
 import objects.PhysicalObject;
+import objects.Square;
 import objects.Star;
 import objects.WiredStar;
 import physicalmodel.Field;
 import physicalmodel.SampleField;
+import strategies.Blink;
+import strategies.CurlGravity;
 import strategies.NormalGravity;
 import strategies.Gradation;
 import strategies.NegativeColor;
@@ -33,14 +36,14 @@
 	public SampleField field;
 	Thread th;
 
-	enum Shape {DISC, WIRED_STAR, STAR};
+	enum Shape {DISC, WIRED_STAR, STAR, SQUARE};
 	Shape selectedshape = Shape.DISC;
 
 	//selected strategies
 	int gravityStrategy = 0;
 	int colorStrategy = 0;
 
-	static final String[] buttonNames = {"Disc", "Wired Star", "Star", "\n",
+	static final String[] buttonNames = {"Disc", "Wired Star", "Star",  "Square", "\n",
 		"Switch gravity strategy", "Switch color strategy", "\n",
 		"Clear", "Preset 1", "Preset 2", "Preset 3", "\n"};
 
@@ -155,6 +158,9 @@
 		case STAR:
 			newobj = new Star(new Position(e.getX(), e.getY()), 12);
 			break;
+		case SQUARE:
+			newobj = new Square(new Position(e.getX(), e.getY()), 10);
+			break;
 		}
 
 		switch(gravityStrategy){
@@ -167,6 +173,9 @@
 		case 3:
 			newobj.setGravityUpdator(new NoGravity());
 			break;
+		case 4:
+			newobj.setGravityUpdator(new CurlGravity());
+			break;
 		default:
 			/* keep default */
 		}
@@ -178,6 +187,9 @@
 		case 2:
 			newobj.setColorModifier(new Gradation());
 			break;
+		case 3:
+			newobj.setColorModifier(new Blink());
+			break;
 		default:
 			/* keep default */
 		}
@@ -202,6 +214,9 @@
 		else if (e.getActionCommand().equals("Star")){
 			selectedshape = Shape.STAR;
 		}
+		else if (e.getActionCommand().equals("Square")){
+			selectedshape = Shape.SQUARE;
+		}
 		else if (e.getActionCommand().equals("Clear")){
 			field.clear();
 		}
@@ -215,14 +230,14 @@
 			putPreset3();
 		}
 		else if (e.getActionCommand().equals("Switch gravity strategy")){
-			String[] labelname = {"Default", "Normal", "Repulsion", "No gravity"};
+			String[] labelname = {"Default", "Normal", "Repulsion", "No gravity", "Curl gravity"};
 			Button b = (Button)e.getSource();
 			gravityStrategy = (gravityStrategy + 1) % labelname.length;
 			b.setLabel("Gravity strategy: " + labelname[gravityStrategy]);
 			validate();
 		}
 		else if (e.getActionCommand().equals("Switch color strategy")){
-			String[] labelname = {"Default", "Negate", "Gradiate"};
+			String[] labelname = {"Default", "Negate", "Gradiate", "Blink"};
 			colorStrategy = (colorStrategy + 1) % labelname.length;
 			Button b = (Button)e.getSource();
 			b.setLabel("Color strategy: " + labelname[colorStrategy]);
Only in StrategySample3/src/objects: Square.java
Only in StrategySample3/src/strategies: Blink.java
Only in StrategySample3/src/strategies: CurlGravity.java
bash-3.2$ 
●Visitorパターンによるサンプルの拡張でのソースコードの変更点
(VisitorSample2→VisitorSample3のdiff -rの出力)
bash-3.2$ diff -u -r VisitorSample[23]
diff -u -r VisitorSample2/src/app/SampleApp.java VisitorSample3/src/app/SampleApp.java
--- VisitorSample2/src/app/SampleApp.java
+++ VisitorSample3/src/app/SampleApp.java
@@ -20,10 +20,13 @@
 
 import objects.Disc;
 import objects.PhysicalObject;
+import objects.Square;
 import objects.Star;
 import objects.WiredStar;
 import physicalmodel.Field;
 import physicalmodel.SampleField;
+import visitors.Blink;
+import visitors.CurlGravityApplier;
 import visitors.DefaultColorizer;
 import visitors.DefaultGravityApplier;
 import visitors.Gradation;
@@ -37,14 +40,14 @@
 	public SampleField field;
 	Thread th;
 
-	enum Shape {DISC, WIRED_STAR, STAR};
+	enum Shape {DISC, WIRED_STAR, STAR, SQUARE};
 	Shape selectedshape = Shape.DISC;
 
 	//selected visitor types
 	int gravityVisitorType = 0;
 	int colorVisitorType = 0;
 
-	static final String[] buttonNames = {"Disc", "Wired Star", "Star", "\n",
+	static final String[] buttonNames = {"Disc", "Wired Star", "Star", "Square", "\n",
 		"Switch gravity strategy", "Switch color strategy", "\n",
 		"Clear", "Preset 1", "Preset 2", "Preset 3", "\n"};
 
@@ -159,6 +162,9 @@
 		case STAR:
 			newobj = new Star(new Position(e.getX(), e.getY()), 12);
 			break;
+		case SQUARE:
+			newobj = new Square(new Position(e.getX(), e.getY()), 12);
+			break;
 		}
 
 		field.put(newobj);
@@ -174,6 +180,9 @@
 		case 3:
 			field.appendGravityVisitor(new RepulsionApplier(newobj));
 			break;
+		case 4:
+			field.appendGravityVisitor(new CurlGravityApplier(newobj));
+			break;
 		default:
 			field.appendGravityVisitor(new DefaultGravityApplier(newobj));
 		}
@@ -197,6 +206,9 @@
 		else if (e.getActionCommand().equals("Star")){
 			selectedshape = Shape.STAR;
 		}
+		else if (e.getActionCommand().equals("Square")){
+			selectedshape = Shape.SQUARE;
+		}
 		else if (e.getActionCommand().equals("Clear")){
 			field.clear();
 		}
@@ -210,7 +222,7 @@
 			putPreset3();
 		}
 		else if (e.getActionCommand().equals("Switch gravity strategy")){
-			String[] labelname = {"Default", "Normal", "No gravity", "Repulsion"};
+			String[] labelname = {"Default", "Normal", "No gravity", "Repulsion", "Curl gravity"};
 			Button b = (Button)e.getSource();
 			gravityVisitorType = (gravityVisitorType + 1) % labelname.length;
 			b.setLabel("Gravity strategy: " + labelname[gravityVisitorType]);
@@ -231,6 +243,9 @@
 				case 3:
 					visitors.add(new RepulsionApplier(obj));
 					break;
+				case 4:
+					visitors.add(new CurlGravityApplier(obj));
+					break;
 				default:
 					visitors.add(new DefaultGravityApplier(obj));
 				}
@@ -238,7 +253,7 @@
 			field.setGravityVisitors(visitors);
 		}
 		else if (e.getActionCommand().equals("Switch color strategy")){
-			String[] labelname = {"Default", "Negate", "Gradiate"};
+			String[] labelname = {"Default", "Negate", "Gradiate", "Blink"};
 			colorVisitorType = (colorVisitorType + 1) % labelname.length;
 			Button b = (Button)e.getSource();
 			b.setLabel("Color strategy: " + labelname[colorVisitorType]);
@@ -252,6 +267,9 @@
 			case 2:
 				field.setColorVisitor(new Gradation());
 				break;
+			case 3:
+				field.setColorVisitor(new Blink());
+				break;
 			default:
 				field.setColorVisitor(new DefaultColorizer());
 			}
Only in VisitorSample3/src/objects: Square.java
Only in VisitorSample3/src/visitors: Blink.java
diff -u -r VisitorSample2/src/visitors/ColorVisitor.java VisitorSample3/src/visitors/ColorVisitor.java
--- VisitorSample2/src/visitors/ColorVisitor.java
+++ VisitorSample3/src/visitors/ColorVisitor.java
@@ -4,6 +4,7 @@
 
 import objects.Disc;
 import objects.PhysicalObject;
+import objects.Square;
 import objects.Star;
 import objects.WiredStar;
 
@@ -23,4 +24,8 @@
 	public void visit(Star obj) {
 		obj.setColor(Color.YELLOW); //default color
 	}
+
+	public void visit(Square obj) {
+		obj.setColor(Color.GREEN); //default color
+	}
 }
Only in VisitorSample3/src/visitors: CurlGravityApplier.java
diff -u -r VisitorSample2/src/visitors/DefaultGravityApplier.java VisitorSample3/src/visitors/DefaultGravityApplier.java
--- VisitorSample2/src/visitors/DefaultGravityApplier.java
+++ VisitorSample3/src/visitors/DefaultGravityApplier.java
@@ -4,6 +4,7 @@
 
 import objects.Disc;
 import objects.PhysicalObject;
+import objects.Square;
 import objects.Star;
 import objects.WiredStar;
 
@@ -50,4 +51,8 @@
 	public void visit(Star obj) {  // repulsion
 		obj.apply(repulsionAgainstOpponent(obj));
 	}
+
+	public void visit(Square obj) {  // normal gravity
+		obj.apply(normalGravityTowardOpponent(obj));
+	}
 }
diff -u -r VisitorSample2/src/visitors/Gradation.java VisitorSample3/src/visitors/Gradation.java
--- VisitorSample2/src/visitors/Gradation.java
+++ VisitorSample3/src/visitors/Gradation.java
@@ -3,6 +3,7 @@
 import java.awt.Color;
 
 import objects.Disc;
+import objects.Square;
 import objects.Star;
 import objects.WiredStar;
 
@@ -21,4 +22,9 @@
 	public void visit(Star obj) {
 		obj.setColor(new Color(Color.HSBtoRGB((float)((System.currentTimeMillis() + 2000) % 3000) / 3000, 1.0f, 1.0f)));
 	}
+
+	@Override
+	public void visit(Square obj) {
+		obj.setColor(new Color(Color.HSBtoRGB((float)((System.currentTimeMillis() + 1500) % 3000) / 3000, 1.0f, 1.0f)));
+	}
 }
diff -u -r VisitorSample2/src/visitors/NegativeColorizer.java VisitorSample3/src/visitors/NegativeColorizer.java
--- VisitorSample2/src/visitors/NegativeColorizer.java
+++ VisitorSample3/src/visitors/NegativeColorizer.java
@@ -3,6 +3,7 @@
 import java.awt.Color;
 
 import objects.Disc;
+import objects.Square;
 import objects.Star;
 import objects.WiredStar;
 
@@ -25,4 +26,9 @@
 		obj.setColor(new Color(obj.getColor().getRGB() ^ 0x00ffffff));
 	}
 
+	@Override
+	public void visit(Square obj) {
+		super.visit(obj);
+		obj.setColor(new Color(obj.getColor().getRGB() ^ 0x00ffffff));
+	}
 }
diff -u -r VisitorSample2/src/visitors/NormalGravityApplier.java VisitorSample3/src/visitors/NormalGravityApplier.java
--- VisitorSample2/src/visitors/NormalGravityApplier.java
+++ VisitorSample3/src/visitors/NormalGravityApplier.java
@@ -2,6 +2,7 @@
 import common.*;
 import objects.Disc;
 import objects.PhysicalObject;
+import objects.Square;
 import objects.Star;
 import objects.WiredStar;
 
@@ -36,4 +37,8 @@
 	public void visit(Star obj) {
 		obj.apply(gravityTowardOpponent(obj));
 	}
+
+	public void visit(Square obj) {
+		obj.apply(gravityTowardOpponent(obj));
+	}
 }
diff -u -r VisitorSample2/src/visitors/RepulsionApplier.java VisitorSample3/src/visitors/RepulsionApplier.java
--- VisitorSample2/src/visitors/RepulsionApplier.java
+++ VisitorSample3/src/visitors/RepulsionApplier.java
@@ -4,6 +4,7 @@
 
 import objects.Disc;
 import objects.PhysicalObject;
+import objects.Square;
 import objects.Star;
 import objects.WiredStar;
 
@@ -38,4 +39,8 @@
 	public void visit(Star obj) {
 		obj.apply(gravityTowardOpponent(obj));
 	}
+
+	public void visit(Square obj) {
+		obj.apply(gravityTowardOpponent(obj));
+	}
 }
\ No newline at end of file
diff -u -r VisitorSample2/src/visitors/Visitor.java VisitorSample3/src/visitors/Visitor.java
--- VisitorSample2/src/visitors/Visitor.java
+++ VisitorSample3/src/visitors/Visitor.java
@@ -1,6 +1,7 @@
 package visitors;
 
 import objects.Disc;
+import objects.Square;
 import objects.Star;
 import objects.WiredStar;
 
@@ -8,4 +9,5 @@
 	public void visit(Disc obj);
 	public void visit(WiredStar obj);
 	public void visit(Star obj);
+	public void visit(Square obj);
 }
bash-3.2$