← 一覧に戻る
構造に関するパターン

Composite

*容器と中身を同一視して再帰的構造を作る*

概要

再帰。

親と子に同じ振る舞いや属性を持たせる

マンガでわかる Composite

マンガでわかる Composite #デザインパターン - Qiita

でざぱたんで覚える Composite

ちびキャラは「コンポジットたん」。「耳がついていて4つ足の生き物は猫」というルールだけを頼りに、鼠にも犬にも「にゃー」と鳴く猫耳ホムンクルス。単体(Leaf)でも入れ子(Composite)でも、同じインターフェースなら同じに扱う——器と中身の同一視がCompositeで、本書は「擬人化パターンと覚えておくと良い」とまとめている。

出典: いしだけ『でざぱたん: ちびキャラで覚えるデザインパターン』(P.178〜)

登場人物

  • Leaf: 中身。ファイル
  • Composite: 複合体。容器を表す。ディレクトリ
  • Component: 中身と複合体を同一とみなすためのスーパークラス
  • Client: 利用者

クラス図

Composite pattern

このサイトの実装(ファイルシステムの例)での対応関係:

classDiagram
  class Entry {
    <<abstract>>
    +getName() String
    +getSize() int
    +add(entry) Entry
    +printList()
    #printList(prefix)
    +toString() String
  }
  class File {
    -name String
    -size int
    +getName() String
    +getSize() int
    #printList(prefix)
  }
  class Directory {
    -name String
    -directory ArrayList
    +getName() String
    +getSize() int
    +add(entry) Entry
    #printList(prefix)
  }
  class FileTreatmentException
  Entry <|-- File
  Entry <|-- Directory
  Directory o-- Entry
  Entry ..> FileTreatmentException

やり方

  • 親にも子にも同一の抽象クラスを定義させる
  • 親が子(または子の一覧)をプロパティに持てば良い

注意点

  • データ構造が必ず木構造になること。
  • 循環したら再帰により無限ループが発生する

ユースケース

ファイルシステム

ファイルとディレクトリ(まとめてディレクトリエントリという)

HTMLの構文解析

Java
Entry.java
public abstract class Entry {
    public abstract String getName();                               // 名前を得る
    public abstract int getSize();                                  // サイズを得る
    public Entry add(Entry entry) throws FileTreatmentException {   // エントリを追加する
        throw new FileTreatmentException();
    }
    public void printList() {                                       // 一覧を表示する
        printList("");
    }
    protected abstract void printList(String prefix);               // prefixを前につけて一覧を表示する
    public String toString() {                                      // 文字列表現
        return getName() + " (" + getSize() + ")";
    }
}
File.java
public class File extends Entry {
    private String name;
    private int size;
    public File(String name, int size) {
        this.name = name;
        this.size = size;
    }
    public String getName() {
        return name;
    }
    public int getSize() {
        return size;
    }
    protected void printList(String prefix) {
        System.out.println(prefix + "/" + this);
    }
}
Directory.java
import java.util.Iterator;
import java.util.ArrayList;

public class Directory extends Entry {
    private String name;                    // ディレクトリの名前
    private ArrayList directory = new ArrayList();      // ディレクトリエントリの集合
    public Directory(String name) {         // コンストラクタ
        this.name = name;
    }
    public String getName() {               // 名前を得る
        return name;
    }
    public int getSize() {                  // サイズを得る
        int size = 0;
        Iterator it = directory.iterator();
        while (it.hasNext()) {
            Entry entry = (Entry)it.next();
            size += entry.getSize();
        }
        return size;
    }
    public Entry add(Entry entry) {         // エントリの追加
        directory.add(entry);
        return this;
    }
    protected void printList(String prefix) {       // エントリの一覧
        System.out.println(prefix + "/" + this);
        Iterator it = directory.iterator();
        while (it.hasNext()) {
            Entry entry = (Entry)it.next();
            entry.printList(prefix + "/" + name);
        }
    }
}
FileTreatmentException.java
public class FileTreatmentException extends RuntimeException {      // RuntimeExceptionで正しいか?
    public FileTreatmentException() {
    }
    public FileTreatmentException(String msg) {
        super(msg);
    }
}
Main.java
public class Main {
    public static void main(String[] args) {
        try {
            System.out.println("Making root entries...");
            Directory rootdir = new Directory("root");
            Directory bindir = new Directory("bin");
            Directory tmpdir = new Directory("tmp");
            Directory usrdir = new Directory("usr");
            rootdir.add(bindir);
            rootdir.add(tmpdir);
            rootdir.add(usrdir);
            bindir.add(new File("vi", 10000));
            bindir.add(new File("latex", 20000));
            rootdir.printList();

            System.out.println("");
            System.out.println("Making user entries...");
            Directory yuki = new Directory("yuki");
            Directory hanako = new Directory("hanako");
            Directory tomura = new Directory("tomura");
            usrdir.add(yuki);
            usrdir.add(hanako);
            usrdir.add(tomura);
            yuki.add(new File("diary.html", 100));
            yuki.add(new File("Composite.java", 200));
            hanako.add(new File("memo.tex", 300));
            tomura.add(new File("game.doc", 400));
            tomura.add(new File("junk.mail", 500));
            rootdir.printList();
        } catch (FileTreatmentException e) {
            e.printStackTrace();
        }
    }
}
Go

Entryはinterfaceで表現。Javaのprotectedな共通実装(printList(), toString())は、GoではPrintList(e Entry)のような自由関数に切り出している(FactoryMethod版のCreate()と同じ考え方)。

実行: go run ./GoF/patterns/Composite/go

$ go run ./GoF/patterns/Composite/go
entry.go
package main

import (
	"errors"
	"fmt"
)

// ---- entry 層(Java版の Entry.java / FileTreatmentException.java に相当)----

// ErrFileTreatment はJava版 FileTreatmentException 相当。
// 子を持てないEntry(File)に対してAdd()しようとしたときに返す。
var ErrFileTreatment = errors.New("this entry cannot contain other entries")

// Entry はファイルシステムのエントリ(File/Directory共通)を表す抽象。
//
// Java版のEntryは抽象“クラス”で、add()やprintList()(引数なし)に共通のデフォルト実装を
// 持つ。Goのinterfaceはメソッド本体を持てないため、そうした共通デフォルトは
// FactoryMethod/go版のCreate()と同じ流儀で「Entryを受け取る自由関数」に外出しする
// (下のPrintList/entryStringを参照)。
//
// printListだけ非公開メソッド(小文字始まり)にしてある。Java版のprotected abstractに
// 相当させる意図で、パッケージ外からEntryを実装できないよう縛っている
// (Goでは同一パッケージ内でしか非公開メソッドを実装できないので、事実上“sealed”になる)。
type Entry interface {
	Name() string                   // 名前を得る
	Size() int                      // サイズを得る
	Add(entry Entry) (Entry, error) // エントリを追加する。Fileはデフォルトでエラーを返す
	printList(prefix string)        // prefixを前につけて一覧を表示する
}

// PrintList はJava版 Entry.printList()(引数なし・公開)相当のテンプレートメソッド。
// 「先頭のprefixは空文字から始める」という決め事だけをここで固定し、
// 実際の表示ロジックはEntry.printList(prefix)(各具象型の実装)に委ねる。
func PrintList(e Entry) {
	e.printList("")
}

// entryString はJava版 Entry.toString() 相当の共通ロジック。
// File/Directoryどちらの String() もこれを呼ぶだけにする。
func entryString(e Entry) string {
	return fmt.Sprintf("%s (%d)", e.Name(), e.Size())
}
file.go
package main

import "fmt"

// ---- file 層(Java版の File.java に相当)----

// File はEntryのConcreteComponent(Leaf = 子を持てない末端)。
type File struct {
	name string
	size int
}

// NewFile はコンストラクタ相当。
func NewFile(name string, size int) *File {
	return &File{name: name, size: size}
}

func (f *File) Name() string { return f.name }
func (f *File) Size() int    { return f.size }

// Add はJava版で「Fileはadd()をオーバーライドしない」=Entryのデフォルト実装
// (常に例外)をそのまま使う、を明示的に書き下したもの。
// Goには継承のデフォルトメソッドが無いので、「Fileにaddしたら常にエラー」を
// ここに書くしかない。
func (f *File) Add(entry Entry) (Entry, error) {
	return nil, ErrFileTreatment
}

func (f *File) printList(prefix string) {
	fmt.Println(prefix + "/" + f.String())
}

// String は fmt.Stringer を満たす(Java版 toString() 相当)。
func (f *File) String() string {
	return entryString(f)
}
directory.go
package main

import "fmt"

// ---- directory 層(Java版の Directory.java に相当)----

// Directory はEntryのConcreteComponent(Composite = 子Entryの集合を持てる)。
type Directory struct {
	name    string
	entries []Entry
}

// NewDirectory はコンストラクタ相当。
func NewDirectory(name string) *Directory {
	return &Directory{name: name}
}

func (d *Directory) Name() string { return d.name }

// Size は自分が抱える全エントリのサイズ合計(再帰的に集計される)。
func (d *Directory) Size() int {
	total := 0
	for _, e := range d.entries {
		total += e.Size()
	}
	return total
}

// Add はエントリの追加。Java版と同様、追加後は自分自身を返す。
func (d *Directory) Add(entry Entry) (Entry, error) {
	d.entries = append(d.entries, entry)
	return d, nil
}

func (d *Directory) printList(prefix string) {
	fmt.Println(prefix + "/" + d.String())
	for _, e := range d.entries {
		e.printList(prefix + "/" + d.name)
	}
}

// String は fmt.Stringer を満たす(Java版 toString() 相当)。
func (d *Directory) String() string {
	return entryString(d)
}
main.go
package main

import "fmt"

// 実行: go run ./GoF/patterns/Composite/go
//
// Java版Main.javaと同じツリー(root/bin,tmp,usr以下)を組み立てて2回表示する。
// 末尾に「Fileにadd()するとエラーになる」ことの確認を追加している
// (Java版はtry/catchで全体を囲むだけで、実際にはサンプル中で例外を踏まない)。
func main() {
	fmt.Println("Making root entries...")
	rootdir := NewDirectory("root")
	bindir := NewDirectory("bin")
	tmpdir := NewDirectory("tmp")
	usrdir := NewDirectory("usr")
	mustAdd(rootdir, bindir)
	mustAdd(rootdir, tmpdir)
	mustAdd(rootdir, usrdir)
	vi := NewFile("vi", 10000)
	mustAdd(bindir, vi)
	mustAdd(bindir, NewFile("latex", 20000))
	PrintList(rootdir)

	fmt.Println()
	fmt.Println("Making user entries...")
	yuki := NewDirectory("yuki")
	hanako := NewDirectory("hanako")
	tomura := NewDirectory("tomura")
	mustAdd(usrdir, yuki)
	mustAdd(usrdir, hanako)
	mustAdd(usrdir, tomura)
	mustAdd(yuki, NewFile("diary.html", 100))
	mustAdd(yuki, NewFile("Composite.java", 200))
	mustAdd(hanako, NewFile("memo.tex", 300))
	mustAdd(tomura, NewFile("game.doc", 400))
	mustAdd(tomura, NewFile("junk.mail", 500))
	PrintList(rootdir)

	fmt.Println()
	fmt.Println("Trying to add a file under a file (should fail)...")
	if _, err := vi.Add(NewFile("evil", 1)); err != nil {
		fmt.Println("error:", err)
	}
}

// mustAdd はサンプルツリーの組み立て専用ヘルパー。ここで呼ぶAddは全部Directoryへの
// 追加なので本来失敗しないが、Entry.Add()が返すエラーを黙って握りつぶさず一応チェックする。
func mustAdd(parent Entry, child Entry) {
	if _, err := parent.Add(child); err != nil {
		panic(err)
	}
}
PHP
index.php
<?php
ini_set("display_errors", "1");
ini_set("safe_mode", 1);
/**
 * composite: 複合
 * 入れ子構造に同じインタフェースを実装して
 * 同一に扱う
 * 
 */

interface Removable
{
  public function remove();
}


class RemovableDirectory  implements Removable
{

  private $path;

  public function __construct($path)
  {
    if (!is_dir($path)) {
      throw new Exception("ディレクトリではありません");
    }
    $this->path = preg_replace("#/$#", "", $path);
  }

  public function getList()
  {
    $result = array();
    $d = opendir($this->path);
    while ($f = readdir($d)) {
      if (preg_match("#^\.+$#", $f)) {
        continue;
      }
      $path = $this->path . "/" . $f;
      if (is_file($path)) {
        $result[] = new File($path);
      } elseif (is_dir($path)) {
        $result[] = new RemovableDirectory($path);
      } else {
        throw new Exception("ファイルでもディレクトリでもありません");
      }
    }
    return $result;
  }

/**
 * getList("tmp/composite")
 *  opendir("tmp/composite")
 *   $f = tmp/composite/hoge
 *   $f = tmp/composite/hoge
 *
 * @return void
 */
  public function remove()
  {
    $result = array();
    var_dump($this->getList());
    foreach ($this->getList() as $obj) {//$obj = RemovableDirectory()
      foreach ($obj->remove() as $log) {
        $result[] = $log;
      }
      $result[] = "D=>" . $this->path;
      rmdir($this->path);
      return $result;
    }
  }
  public function makeDirectory($name)
  {
    mkdir($this->path . "/" . $name);
  }
}


class File  implements Removable
{
  private $path;

  public function __construct($path)
  {
    if (!is_file($path)) {
      throw new Exception("ファイルではありません");
    }
    $this->path = preg_replace("#/$#", "", $path);
  }

  public function remove()
  {
    unlink($this->path);
    return array("F=>" . $this->path);
  }

  public function saveFile($name, $data)
  {
    file_put_contents($this->path . "/" . $name, $data);
  }
}
TypeScript

abstract class Entryに共通のadd()/printList()を実装し、サブクラスだけが実装すべき部分を抽象メソッドにする、というJava版そのままの設計。

実行: npx tsx GoF/patterns/Composite/typescript/main.ts

$ npx tsx GoF/patterns/Composite/typescript/main.ts
entry.ts
// Composite パターン: エントリ層 (Java版の Entry.java / FileTreatmentException.java 相当)
// 単体では実行不可。エントリポイントは main.ts (npx tsx main.ts)。
//
// Java版のEntryは抽象クラスで、add()とprintList()(引数なし)に共通のデフォルト実装を
// 持つ。TypeScriptにもabstractクラスがあるので、FactoryMethod版TS実装と同じ方針で
// Javaのクラス階層をほぼそのまま書ける(Go版のように共通デフォルトを自由関数へ
// 外出しする必要がない)。
//
// ただしJava版はprintList()(公開・引数なし)とprintList(String prefix)(protected)を
// 同名オーバーロードで表現しているが、TypeScriptは可視性の異なるオーバーロードを
// 許さないため、prefix付きの方はprintListRecという別名にした。
// また、prefix付きの方はDirectory.printListRec内で「Entry型を経由して」他のインスタンス
// (子のFile/Directory)へアクセスする必要があるが、TypeScriptのprotectedは
// 「基底クラス型を経由したアクセス」を許さない(Javaより厳格)ため、ここではpublicにした。

// FileTreatmentError はJava版 FileTreatmentException 相当。
export class FileTreatmentError extends Error {
  constructor(message = "this entry cannot contain other entries") {
    super(message);
    this.name = "FileTreatmentError";
  }
}

export abstract class Entry {
  abstract getName(): string;
  abstract getSize(): number;

  // Java版Entry.add()のデフォルト実装(常に例外)相当。Directoryだけがoverrideする。
  add(_entry: Entry): Entry {
    throw new FileTreatmentError();
  }

  // Java版Entry.printList()(引数なし・公開)相当のテンプレートメソッド。
  // 「先頭のprefixは空文字から始める」という決め事だけをここで固定する。
  printList(): void {
    this.printListRec("");
  }

  // Java版 protected abstract printList(String prefix) 相当。
  abstract printListRec(prefix: string): void;

  // Java版 toString() 相当。
  toString(): string {
    return `${this.getName()} (${this.getSize()})`;
  }
}
file.ts
// File層 (Java版の File.java 相当)
// 単体では実行不可。エントリポイントは main.ts (npx tsx main.ts)。

import { Entry } from "./entry";

// File: EntryのConcreteComponent(Leaf = 子を持てない末端)。
export class File extends Entry {
  constructor(
    private readonly name: string,
    private readonly size: number,
  ) {
    super();
  }

  getName(): string {
    return this.name;
  }

  getSize(): number {
    return this.size;
  }

  // add()はoverrideしない = Entryのデフォルト実装(常に例外)をそのまま使う。
  // Java版で「Fileがadd()をオーバーライドしていない」のと同じ意図。

  printListRec(prefix: string): void {
    console.log(`${prefix}/${this}`);
  }
}
directory.ts
// Directory層 (Java版の Directory.java 相当)
// 単体では実行不可。エントリポイントは main.ts (npx tsx main.ts)。

import { Entry } from "./entry";

// Directory: EntryのConcreteComponent(Composite = 子Entryの集合を持てる)。
export class Directory extends Entry {
  private readonly entries: Entry[] = [];

  constructor(private readonly name: string) {
    super();
  }

  getName(): string {
    return this.name;
  }

  // 自分が抱える全エントリのサイズ合計(再帰的に集計される)。
  getSize(): number {
    return this.entries.reduce((total, entry) => total + entry.getSize(), 0);
  }

  // エントリの追加。Java版と同様、追加後は自分自身を返す。
  add(entry: Entry): Entry {
    this.entries.push(entry);
    return this;
  }

  printListRec(prefix: string): void {
    console.log(`${prefix}/${this}`);
    for (const entry of this.entries) {
      entry.printListRec(`${prefix}/${this.name}`);
    }
  }
}
main.ts
// Composite パターン: ファイルシステムのツリー構造 (Java版Main.javaと同じお題)
//
// 実行: npx tsx GoF/patterns/Composite/typescript/main.ts
//
// Java版と同じツリー(root/bin,tmp,usr以下)を組み立てて2回表示する。
// 末尾に「Fileにadd()するとエラーになる」ことの確認を追加している
// (Java版はtry/catchで全体を囲むだけで、実際にはサンプル中で例外を踏まない)。

import { Directory } from "./directory";
import { FileTreatmentError } from "./entry";
import { File } from "./file";

function main(): void {
  console.log("Making root entries...");
  const rootdir = new Directory("root");
  const bindir = new Directory("bin");
  const tmpdir = new Directory("tmp");
  const usrdir = new Directory("usr");
  rootdir.add(bindir);
  rootdir.add(tmpdir);
  rootdir.add(usrdir);
  const vi = new File("vi", 10000);
  bindir.add(vi);
  bindir.add(new File("latex", 20000));
  rootdir.printList();

  console.log("");
  console.log("Making user entries...");
  const yuki = new Directory("yuki");
  const hanako = new Directory("hanako");
  const tomura = new Directory("tomura");
  usrdir.add(yuki);
  usrdir.add(hanako);
  usrdir.add(tomura);
  yuki.add(new File("diary.html", 100));
  yuki.add(new File("Composite.java", 200));
  hanako.add(new File("memo.tex", 300));
  tomura.add(new File("game.doc", 400));
  tomura.add(new File("junk.mail", 500));
  rootdir.printList();

  console.log("");
  console.log("Trying to add a file under a file (should fail)...");
  try {
    vi.add(new File("evil", 1));
  } catch (e) {
    if (e instanceof FileTreatmentError) {
      console.log(`error: ${e.message}`);
    } else {
      throw e;
    }
  }
}

main();
Python

Entry(ABC)にJava版と同じ形で共通実装を持たせる。

実行: python3 GoF/patterns/Composite/python/main.py

$ python3 GoF/patterns/Composite/python/main.py
entry.py
"""Composite パターン: エントリ層 (Java版の Entry.java / FileTreatmentException.java 相当)

単体では実行不可。エントリポイントは main.py (python3 main.py)。

Java版のEntryは抽象クラスで、add()とprintList()(引数なし)に共通のデフォルト実装を持つ。
PythonにはJavaのinterfaceに相当する言語機能はないため、FactoryMethod版Python実装と
同じ方針で抽象基底クラス(ABC, abcモジュール)を使う。

Java版はprintList()(引数なし・公開)とprintList(String prefix)(protected)を同名の
オーバーロードで表現しているが、Pythonのクラスは同名メソッドを1つしか持てないため、
prefix付きの方は_print_list_recという別名にし、先頭アンダースコアでprotected相当を表す
(FactoryMethod版の_create_product等と同じ慣習)。
"""

from __future__ import annotations

from abc import ABC, abstractmethod


class FileTreatmentError(Exception):
    """Java版 FileTreatmentException 相当。"""

    def __init__(self, message: str = "this entry cannot contain other entries") -> None:
        super().__init__(message)


class Entry(ABC):
    @abstractmethod
    def get_name(self) -> str: ...

    @abstractmethod
    def get_size(self) -> int: ...

    def add(self, entry: Entry) -> Entry:
        """Java版Entry.add()のデフォルト実装(常に例外)相当。Directoryだけがoverrideする。"""
        raise FileTreatmentError()

    def print_list(self) -> None:
        """Java版Entry.printList()(引数なし・公開)相当のテンプレートメソッド。

        「先頭のprefixは空文字から始める」という決め事だけをここで固定する。
        """
        self._print_list_rec("")

    @abstractmethod
    def _print_list_rec(self, prefix: str) -> None: ...

    def __str__(self) -> str:
        """Java版 toString() 相当。"""
        return f"{self.get_name()} ({self.get_size()})"
file.py
"""File層 (Java版の File.java 相当)

単体では実行不可。エントリポイントは main.py (python3 main.py)。
"""

from __future__ import annotations

from entry import Entry


class File(Entry):
    """File: EntryのConcreteComponent(Leaf = 子を持てない末端)。"""

    def __init__(self, name: str, size: int) -> None:
        self._name = name
        self._size = size

    def get_name(self) -> str:
        return self._name

    def get_size(self) -> int:
        return self._size

    # add()はoverrideしない = Entryのデフォルト実装(常に例外)をそのまま使う。
    # Java版で「Fileがadd()をオーバーライドしていない」のと同じ意図。

    def _print_list_rec(self, prefix: str) -> None:
        print(f"{prefix}/{self}")
directory.py
"""Directory層 (Java版の Directory.java 相当)

単体では実行不可。エントリポイントは main.py (python3 main.py)。
"""

from __future__ import annotations

from entry import Entry


class Directory(Entry):
    """Directory: EntryのConcreteComponent(Composite = 子Entryの集合を持てる)。"""

    def __init__(self, name: str) -> None:
        self._name = name
        self._entries: list[Entry] = []

    def get_name(self) -> str:
        return self._name

    def get_size(self) -> int:
        """自分が抱える全エントリのサイズ合計(再帰的に集計される)。"""
        return sum(entry.get_size() for entry in self._entries)

    def add(self, entry: Entry) -> Entry:
        """エントリの追加。Java版と同様、追加後は自分自身を返す。"""
        self._entries.append(entry)
        return self

    def _print_list_rec(self, prefix: str) -> None:
        print(f"{prefix}/{self}")
        for entry in self._entries:
            entry._print_list_rec(f"{prefix}/{self._name}")
main.py
"""Composite パターン: ファイルシステムのツリー構造 (Java版Main.javaと同じお題)

実行: python3 main.py
      (もしくはリポジトリルートから python3 GoF/patterns/Composite/python/main.py)

Java版と同じツリー(root/bin,tmp,usr以下)を組み立てて2回表示する。末尾に
「Fileにadd()するとエラーになる」ことの確認を追加している(Java版はtry/exceptで
全体を囲むだけで、実際にはサンプル中で例外を踏まない)。
"""

from __future__ import annotations

from directory import Directory
from entry import FileTreatmentError
from file import File


def main() -> None:
    print("Making root entries...")
    rootdir = Directory("root")
    bindir = Directory("bin")
    tmpdir = Directory("tmp")
    usrdir = Directory("usr")
    rootdir.add(bindir)
    rootdir.add(tmpdir)
    rootdir.add(usrdir)
    vi = File("vi", 10000)
    bindir.add(vi)
    bindir.add(File("latex", 20000))
    rootdir.print_list()

    print()
    print("Making user entries...")
    yuki = Directory("yuki")
    hanako = Directory("hanako")
    tomura = Directory("tomura")
    usrdir.add(yuki)
    usrdir.add(hanako)
    usrdir.add(tomura)
    yuki.add(File("diary.html", 100))
    yuki.add(File("Composite.java", 200))
    hanako.add(File("memo.tex", 300))
    tomura.add(File("game.doc", 400))
    tomura.add(File("junk.mail", 500))
    rootdir.print_list()

    print()
    print("Trying to add a file under a file (should fail)...")
    try:
        vi.add(File("evil", 1))
    except FileTreatmentError as e:
        print(f"error: {e}")


if __name__ == "__main__":
    main()