オブジェクト指向設計においては、独立性の高いプログラム部品(オブジェクト)の集合としてシステムを構成します。そのため、システム内部の部品と部品の間の役割分担を適切に設計することが大変重要です。このような役割分担は多くの場合「インターフェース」によって表現されます。
オブジェクト間のインターフェースを設計する上でのポイントのひとつは、「相手に対して必要以上のことを期待しない」ということです。
インターフェースする以上、相手に何らかの期待を持つことは必然です。しかし期待に応えるにはコストがかかります。必要以上のことを期待するとその期待が相手方の負担となります。ですからインターフェースは必要最小限の期待を表現するよう設計するのが適切だというわけです。今回は、そうした観点から、ツリーのように、複数の要素からなり要素と要素の間に関係がある構造[*1]を表すインターフェースについて、設計アプローチを検討してみます。
(なお、末尾にサンプルソースコードの圧縮ファイルをつけています)
[*1] ツリーの場合、要素=ツリーのノード(枝の分かれ目)、要素間の関係=ノード間の親子関係となります。
おことわりと事例紹介
ご説明は、ある簡単なシステムの設計と改善をステップバイステップで追っていくという形で行ないます:
- Step.0 - 動かす
- Step.1 - 分割する
- Step.2 - 分離する
- Step.3 - 隠蔽する
- Step.4 - 期待を軽くする
最初にお詫びしておきますが、Step 3 まではごく一般的なリファクタリングです。オブジェクト指向設計に精通している方にとっては退屈でしょうが、再確認も兼ねて順を追って読んで頂ければ有り難いです。
事例として使うのは、地域の自治会や学校といったコミュニティで用いられる「電話連絡網」を管理するシステムです。このシステムでは、電話連絡網を表すツリー構造のデータを保持する必要があります。織田さんが明智さんと羽柴さんと柴田さんに連絡し、羽柴さんは石田さんと徳川さんに連絡し、といったツリーです。システムには色々な機能がありますが、今回はその中でもっとも単純な機能、すなわち「電話連絡網に含まれるメンバの数をカウントして表示する」という機能だけに着目します。
Step.0 - 動かす
まずは汚くても動くコードを書くのが先決です。2つのクラス、PhoneCallingTree(電話連絡網)とMember(電話連絡網のメンバ)を作り、「電話連絡網に含まれるメンバの数をカウントして返す」メソッドを前者に付けましょう:
… public class PhoneCallingTree { private final Member origin; public PhoneCallingTree(Member origin) { this.origin = origin; } public Member getOrigin() { return origin; } public int getNumberOfMembers(){ return countMembers(origin); } /** * 指定されたメンバを頂点とするツリーに含まれるメンバの数を返す。 * 指定されたメンバも数に含める。 */ private static final int countMembers(Member top){ int count = 1; for (Member child : top.getNextMembers()) { count += countMembers(child); } return count; } }
origin は、電話連絡網の起点となっているメンバです(上述の例では織田さんです)。
countMembers メソッドにご注目ください。再帰を使っているので少し分かりにくいかもしれませんが、ロジックそのものは今回の話とあまり関係がないので、topを頂点とするツリーを辿ってノード数(メンバ数)をカウントしているのだと理解して頂ければ十分です。
… public class Member { private final String name; private final String telephoneNumber; private final List<Member> nextMembers = new ArrayList<Member>(); public Member(String name, String telephoneNumber) { this.name = name; this.telephoneNumber = telephoneNumber; } public void addNextMember(Member nextMember){ if (nextMember == null) throw new NullPointerException("メンバを指定してください!"); this.nextMembers.add(nextMember); } public List<Member> getNextMembers(){ return Collections.unmodifiableList(nextMembers); } }
nextMembersコレクションは、このメンバが次に連絡すべきメンバを保持しています。メンバが織田さんなら、nextMembers には、明智さん、羽柴さん、柴田さんが入っています。
Step.1 - 分割する
さて上述した countMembers メソッドのアルゴリズムは電話連絡網に限らずツリー構造一般に適用することができます。そこで、これを PhoneCallingTree から取り出して独立させましょう。それに、パッケージも分けて、電話連絡網に固有なクラス(PhoneCallingTreeとMember)を含む community パッケージと、独立させたツリー構造用ユーティリティクラス(TreeUtil)を含む treetools パッケージを設けましょう:
package community; import treetools.TreeUtil; public class PhoneCallingTree { private final Member origin; public PhoneCallingTree(Member origin) { this.origin = origin; } public Member getOrigin() { return origin; } public int getNumberOfMembers(){ return TreeUtil.countNodes(origin); } }
(パッケージ以外、Step 0 と同じ)
package treetools; import community.Member; public final class TreeUtil { /** * 指定されたメンバを頂点とするツリーに含まれるメンバの数を返す。 * 指定されたメンバも数に含める。 */ public static final int countNodes(Member top){ int count = 1; for (Member child : top.getNextMembers()) { count += countNodes(child); } return count; } }
Step.2 - 分離する
Step 1 で分割はできましたがまだ駄目ですね。TreeUtil の countNodes メソッドの引数が Member 型ですので、電話連絡網以外にこのメソッドを用いることができません。そこで、ツリーのノードを表すインターフェース TreeNode を作成し、それを用いるよう countNodes メソッドを修正しましょう:
package treetools; … public interface TreeNode { Collection<? extends TreeNode> getChildren(); }
TreeNodeインターフェースは、TreeUtilクラスからその使用者への期待(要求事項)を表現していますから、TreeUtilと同じパッケージに含めるのが定石です。
package treetools; … public final class TreeUtil { /** * 指定されたツリーノードを頂点とするツリーに含まれるノードの数を返す。 * 指定されたノードも数に含める。 */ public static final int countNodes(TreeNode top){ int count = 1; for (TreeNode child : top.getChildren()) { count += countNodes(child); } return count; } }
これで、countNodes は、電話連絡網(すなわち community パッケージ)に依存しなくなり、ツリー構造を表すデータをもつあらゆるシステムで使えるようになりました。
community パッケージ側では、Member クラスに TreeNode インターフェースを実装する修正が必要です:
package community; import treetools.TreeNode; … public class Member implements TreeNode{ … public List<Member> getNextMembers(){ return Collections.unmodifiableList(nextMembers); } public Collection<Member> getChildren() { return getNextMembers(); } }
Step.3 - 隠蔽する
以上でも良いと言えば良いのですが修正の余地もあります。Member のgetChildren()メソッドです。このメソッドがMemberクラスのユーザーに対して公開されたら、公開された方では「あっ、これは各メンバの「子供」のコレクションを返してくれるんだな」と勘違いしないでしょうか(織田さんの子供なら信忠さん、信雄さんなど)。このメソッドはそもそも Member を TreeNode として扱うために設けたわけですが、「Member は 実は TreeNode でもあるんです」などという話は community パッケージを作る人たちだけが知っていれば良いことであって、このパッケージのユーザーに対しては公開しても意味がないし却って混乱するだけです。
TreeNodeインターフェースを使っているという事実を Member のユーザーから隠蔽(いんぺい)する方法として、「Adapter」があります(これは「デザインパターン」のひとつです)。すなわち、Member が TreeNode インターフェースを実装するのをやめにして、かわりに、TreeNode インターフェースと Member の「インターフェース」の差異を吸収するオブジェクトを置く手法です:
Member は TreeNodeインターフェースを実装せず、懸案の getChildren()メソッドも不要になります。そのかわり、Adapter のインスタンスを返すメソッドを付け加えておきましょう:
package community; import treetools.TreeNode; … public class Member { … private final TreeNode treeNode = new MemberTreeNodeAdapter(this); … TreeNode asTreeNode(){ return treeNode; } }
TreeNodeインターフェースに対するリクエストをMemberのインスタンスに転送するAdapterです:
package community; import treetools.TreeNode; … class MemberTreeNodeAdapter implements TreeNode{ private final Member adaptee; MemberTreeNodeAdapter(Member adaptee) { this.adaptee = adaptee; } public Collection<TreeNode> getChildren() { Collection<TreeNode> c = new ArrayList<TreeNode>(); for (Member nextMember : adaptee.getNextMembers()) { c.add(nextMember.asTreeNode()); } return c; } }
上記のコード例のように可視性をパッケージプライベートとしておけば MemberTreeNodeAdapter をユーザーから隠すことができます。
Memberの修正にともない、PhoneCallingTree クラスの getNumberOfMembers() も修正します:
… public int getNumberOfMembers(){ return TreeUtil.countNodes(origin.asTreeNode()); } …
ここで Adapterを用いたのは TreeNodeインターフェースを Memberのユーザーに公開するのを避けるためですが、別の理由でAdapterパターンを使用することもあります。community パッケージがサードパーティなどから提供されていて開発者が修正できないと想定してみてください。Memberのコードに手を入れられないので Adapter を使うしかありません。Adapterを使う理由としてはむしろこちらの方が一般的かもしれません。この場合のAdapterの例を以下に示します:
package external; import treetools.TreeNode; import community.Member; … class MemberTreeNodeAdapter2 implements TreeNode{ private final Member adaptee; MemberTreeNodeAdapter(Member adaptee) { this.adaptee = adaptee; } public Collection<TreeNode> getChildren() { Collection<TreeNode> c = new ArrayList<TreeNode>(); for (Member nextMember : adaptee.getNextMembers()) { c.add(new MemberTreeNodeAdapter2(nextMember)); } return c; } }
このコードの欠点は、ひとつの Member に対して Adapter がいくつもできてしまう可能性があることです。これが不都合ならば、getChildren()でのアダプタ生成処理に工夫を加えて対処できます。たとえば、こんな風に:
package external; import community.Member; import treetools.TreeNode; … /** * メンバひとつにつきアダプタがひとつしか生成されないことを保証したバージョン。 */ class MemberTreeNodeAdapter3 implements TreeNode { private final Map<Member, MemberTreeNodeAdapter3> adapters; private final Member adaptee; MemberTreeNodeAdapter3(Member adaptee) { this(new HashMap<Member, MemberTreeNodeAdapter3>(), adaptee); } private MemberTreeNodeAdapter3(Map<Member, MemberTreeNodeAdapter3> adapters, Member adaptee) { this.adapters = adapters; this.adaptee = adaptee; } public Collection<TreeNode> getChildren() { Collection<TreeNode> c = new ArrayList<TreeNode>(); for (Member nextMember : adaptee.getNextMembers()) { c.add(getOrCreateAdapter(nextMember)); } return c; } private MemberTreeNodeAdapter3 getOrCreateAdapter(Member member) { if (adapters.containsKey(member)) return adapters.get(member); MemberTreeNodeAdapter3 adapter = new MemberTreeNodeAdapter3(adapters, member); adapters.put(member, adapter); return adapter; } }
うーん。だんだんいやな感じが醸しだされてきましたね(*_*)。
Step.4 - 期待を軽くする
さて、ここまでは前置きで、これからが本題です。僕らがAdapterに期待しているのは、異なる2つのインターフェース間でリクエストを変換することだけです。ごちゃごちゃ言わずに右から来たリクエストを左に流してくれれば、それで十分なはずです。そういう視点からみると MemberTreeNodeAdapter3 は必要以上に複雑に見えます。これは、不特定多数の Adapterインスタンスの生成と管理という付加的な責務が TreeNode 用の Adapter 実装者に課せられているからです。Adapterがそうした責務を負わないで済むようにする設計案をここでご紹介します。
この案ではツリーの各ノードを表すインターフェースを設けず、かわりにノード間の関係を表すインターフェースを作ります。名前は、「親子関係(ParentChildRelation)」 としておきましょう:
package treetools; … public interface ParentChildRelation<T> { Collection<T> getChildrenOf(T node); }
getChildrenOf() メソッドは与えられたノードの子のコレクションを返します。ノードの型は任意です。
TreeUtilクラスは、ParentChildRelationを用いるように変更します:
package treetools; public final class TreeUtil { /** * 指定された親子関係によって表されるツリーにおいて、 * 指定されたノードを頂点とするサブツリーに含まれるノードの数を返す。 * 指定されたノードも数に含める。 */ public static final <T> int countNodes(ParentChildRelation<T> relation, T top){ int count = 1; for (T child : relation.getChildrenOf(top)) { count += countNodes(relation,child); } return count; } }
PhoneCallingTree クラスの getNumberOfMembers() も少し修正します:
… public int getNumberOfMembers(){ return TreeUtil.countNodes(new PhoneCallingOrder(),origin); } …
ここで、PhoneCallingOrder クラスは、ParentChildRelationインターフェースの実装です。そのコードは以下のようになります:
package community; import treetools.ParentChildRelation; … class PhoneCallingOrder implements ParentChildRelation<Member> { public Collection<Member> getChildrenOf(Member node) { return node.getNextMembers(); } }
PhoneCallingOrder は以前の MemberTreeNodeAdapter3 に相当しますが、ずっと単純です。インターフェース変換に専念していてAdapterインスタンスの生成と管理の責任を負っていないからです。PhoneCallingOrder のインスタンスは一つだけあればよいので処理中に生成したり管理する必要がないわけです。じっさい、PhoneCallingOrder に Singletonパターンを適用することもできます。
ParentChildRelationインターフェースと比較してみて、TreeNodeインターフェースのどこに問題があるのか、考えてみましょう:
package treetools; … public interface TreeNode { Collection<? extends TreeNode> getChildren(); }
このインターフェースは、良く考えると、(treetoolsの開発者が抱いている)2つの異なる期待を表明しています:
- treetools を利用するプログラムは、親子関係で結び付けられた複数の「要素(=ツリーノード)」からなるデータを扱っているという期待
- 親要素(を表すオブジェクト)が子要素のコレクションを提供できるという期待
このうち一番目はツリー構造を扱う以上当然のことですから期待しても差し支えないのですが、二番目はツリー構造の存在という「本質」に関わることではなく、ツリー構造データを「表現」する手法に関する期待です。この二番目の期待に応えようとして、MemberTreeNodeAdapter3 はあのように膨れ上がっていたんですね。
対照的に、ParentChildRelation インターフェースは一番目の期待しか表明していません。そのため、それを実装するPhoneCallingOrder のコードはごくシンプルなものとなりました。
まとめ ― 一般化すると
ここまではツリー構造を例にご説明してきましたが、こうした設計問題が発生するのはツリーに限ったことではありません。以下に他の例を挙げます:
- PERT図 … 作業の順序を表すPERT図では、各ノード(作業)が先行ノードと後続ノードをそれぞれ複数持ちます。こうした構造を「有向グラフ」と呼びます。
- 部品表 … 部品表は有向グラフに似ていますがさらに複雑です。ノード(部品)だけでなくノードとノードを結ぶ線にも属性があるからです。属性の代表例は「原単位(親部品1単位を作成するのに必要な子部品の量)」です。
これらのデータ構造を表現するインターフェースを設計する際にも、ツリー構造の場合と同様の選択肢があります。さらに言えば、複数の要素とその間の関係を表現するインターフェース一般においてここで説明した2つの設計アプローチがあり得ます。その2つの設計アプローチもまた一般化して表現すれば以下のようです:
- 要素指向インターフェース … インターフェースする相手方に含まれると想定される要素のタイプごとにインターフェースを作成し、要素間の関係を表現するメソッドをそれらのインターフェースに含めて定義する。
- 関係指向インターフェース … 要素を表すインターフェースは作らず、要素間に存在する関係を表現するインターフェースを作成する(関係の種類ごとにインターフェースを作っても良いし、全部の関係をひとつのインターフェースに盛り込んでも良い)。
関係指向インターフェースの方がシンプルな実装に結びつくのは、直観に反するかもしれませんがある意味自然なことです。というのはこれらのデータ構造の特色を真に表現しているのはその要素ではなく要素間の関係だからです。例えばPERT図の代わりにコンピュータ処理のジョブネット(処理の実行依存関係を示した図)を考えてみましょう。PERT図の要素は人間の作業、ジョブネットの要素はコンピュータの処理(ジョブ)です。要素は異なりますが要素間の関係は同じように有向グラフとして表現できます。ですから、この2つの類似性をインターフェースとして抽出するには、関係に着目する方が良いと思うのです。
多くの場合、設計の途上で最初に思いつくのは、要素指向インターフェースだと思います。要素指向インターフェースは実装クラスをもとにごく自然に発想できるからです。しかしそこで止まらずもう一歩進んで、関係指向の考え方を導入すれば、よりシンプルで実装しやすいインターフェースができるかもしれません。