デザインパターン 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メソッドが宣言されていない)とすればコンパイル時に弾いてくれるのでバグは生まれません。

再利用性を優先するか、ささやかなコードの簡素化を優先するか、正解がなさそうな問題です。