コーディング · デザインパターン
Flyweight パターンとは?例でわかりやすく解説
結論
Flyweight は構造に関するデザインパターンで、共通部分を共有することで、非常に多くの似たオブジェクトをプログラムで扱えるようにします。各オブジェクトの状態は、内部状態(共有され、不変で、Flyweight に一度だけ保存される)と外部状態(利用ごとに異なり、呼び出し側が渡す)に分けられます。ファクトリが Flyweight をキャッシュするので、同じ内部状態が二重に保存されることはありません。
目的と解決する問題
目的:共有を使って、粒度の細かい大量のオブジェクトを効率よく扱うことです。このパターンは Gang of Four(GoF)の書籍に由来し、構造パターンに分類されます。
100 万本の木を描画する地図を考えてみましょう。各木は位置(x, y)と種類(樹種名、色、数キロバイトのテクスチャ画像)を持ちます。すべての木オブジェクトが自分専用のテクスチャのコピーを持つと、メモリ使用量は木の本数に比例して増え、メモリ不足に陥るおそれがあります。しかし樹種は数えるほどしかありません。テクスチャ、名前、色はどのオークでも同じです。
Flyweight は、繰り返されるデータを少数の共有オブジェクトにまとめます。木は自分の位置と、共有された木の種類への参照だけを持ちます。100 万本の木でも樹種が 5 種類なら、テクスチャのコピーは 100 万個ではなく 5 個で済みます。
内部状態と外部状態
重要なのは、各オブジェクトのデータを次の 2 種類に分けることです。
| 状態 | 意味 | 木の例 |
|---|---|---|
| 内部状態(Intrinsic) | 文脈に依存せず、多数のオブジェクトで同一。Flyweight の内部に保存され、変更されない | 樹種名、色、テクスチャ |
| 外部状態(Extrinsic) | 文脈に依存し、利用ごとに異なる。クライアントが保持し、Flyweight のメソッドに渡す | 位置 x と y |
Flyweight は共有されるため、不変でなければなりません。もしあるクライアントが共有されたオークの色を変えられるなら、地図上のすべてのオークが変わってしまいます。
構造と登場する役割
| 役割 | 責務 |
|---|---|
| Flyweight | 内部状態を保持し、外部状態を引数として受け取る操作を公開する |
| Flyweight ファクトリ | Flyweight を生成してキャッシュする。同じ内部状態が再び要求されたら、既存のものを返す |
| Context(コンテキスト) | 外部状態と Flyweight への参照を保持する(現実のオブジェクト 1 つにつき 1 つの Context) |
| Client(クライアント) | ファクトリに Flyweight を要求し、外部状態を保持または計算する |
TypeScript での実装
ファクトリは、内部状態の値をキーにした Map を使います。TreeType が Flyweight、Tree が Context、Forest がクライアントです。
class TreeType {
constructor(
readonly name: string,
readonly color: string,
readonly texture: Uint8Array, // large, shared data
) {}
draw(x: number, y: number): string {
return this.name + " (" + this.color + ") at " + x + "," + y;
}
}
class TreeTypeFactory {
private static cache = new Map<string, TreeType>();
static get(name: string, color: string): TreeType {
const key = name + "|" + color;
let type = this.cache.get(key);
if (!type) {
type = new TreeType(name, color, new Uint8Array(4096));
this.cache.set(key, type);
}
return type;
}
}
class Tree {
constructor(readonly x: number, readonly y: number, readonly type: TreeType) {}
draw(): string {
return this.type.draw(this.x, this.y);
}
}
const forest: Tree[] = [];
for (let i = 0; i < 100_000; i++) {
const [name, color] = i % 2 === 0 ? ["Oak", "green"] : ["Birch", "white"];
forest.push(new Tree(i, i * 2, TreeTypeFactory.get(name, color)));
}
const types = new Set(forest.map((t) => t.type));
console.log(forest.length, "trees,", types.size, "tree types");
console.log(forest[3].draw());
// 100000 trees, 2 tree types
// Birch (white) at 3,6ファクトリがなければ、プログラムは 100,000 個のテクスチャバッファを確保することになります。ファクトリを使えば 2 個で済みます。Tree オブジェクトは引き続き存在しますが、それぞれ持つのは 2 つの数値と 1 つの参照だけです。
使うべき場面と避けるべき場面
次の条件がすべて当てはまるときに Flyweight を使います。
- プログラムが、似たオブジェクトを非常に大量に生成する。
- メモリ使用量が、推測ではなく実測された問題になっている。
- 各オブジェクトの状態の大部分を、外部状態にするか共有にできる。
- プログラムがオブジェクトの同一性に依存していない(同じ Flyweight を持つ 2 つの Context が、それを共有してよい)。
オブジェクト数が少ない場合、オブジェクトが共有データを個別に変更する必要がある場合、あるいは外部状態を渡し回す複雑さのせいで、節約できるメモリ以上にコードが読みにくくなる場合は避けましょう。多くの場合、もっと単純な方法で足ります。たとえばデータを並列配列や型付き配列に格納する、繰り返される文字列を Map でインターンする、といった方法です。
トレードオフ:Flyweight はメモリと CPU 時間を引き換えにします。メソッドを呼ぶたびに、外部状態の再計算や検索が必要になることがあります。
実際の利用例
- Java:
Integer.valueOf(int)は -128 から 127 の値に対してキャッシュされたIntegerオブジェクトを返すので、小さな数値のボクシングのたびに新しいオブジェクトが生成されることはありません。 - Java:
String.intern()は、文字列プールにある正規の共有コピーを返します。 - テキストエディタやブラウザは、共有のフォントキャッシュからグリフを描画します。画面上の各文字は位置だけを保持し、グリフの形状はフォントとサイズごとに 1 回だけ保存されます。
- ゲームエンジンはインスタンシング描画を使います。メッシュとマテリアルは 1 つを共有し、位置や回転のようなインスタンスごとのデータだけを各コピー分送ります。
関連するパターン
| パターン | Flyweight パターンとの違い |
|---|---|
| Singleton パターン | Singleton はクラスのインスタンスが 1 つだけであることを保証します。Flyweight は異なる内部状態ごとに 1 つのインスタンスを保持するので、複数存在することがあります。 |
| Factory Method パターン | Flyweight ファクトリはキャッシュ付きのファクトリです。Factory Method は、サブクラスにどのクラスを生成するかを選ばせるためのものです。 |
| Composite パターン | Composite ツリーの葉ノードは、繰り返し現れる葉がメモリを共有できるよう、Flyweight として実装されることがよくあります。 |
| Prototype パターン | Prototype はオブジェクトをコピーして新しいオブジェクトを作ります。Flyweight はコピーを避け、1 つのオブジェクトを共有します。 |
よくある質問
内部状態と外部状態の違いは何ですか?
内部状態は多数のオブジェクトで共有され同一なので、Flyweight の内部に置かれ、変更されません。外部状態は利用ごとに異なるため、クライアントが保持し、Flyweight のメソッドに渡します。
Flyweight はキャッシュと同じですか?
キャッシュは使いますが、目的が違います。キャッシュは結果の再計算を避けるためのものですが、Flyweight は同じ不変データを何度も保存しないよう、1 つのオブジェクトを多数の利用者で共有します。
Flyweight はなぜ不変でなければならないのですか?
多くの Context が同じ Flyweight への参照を持っています。そのうち 1 つが変更すると、その Flyweight を共有しているほかのすべての Context に変更が現れてしまうからです。
Flyweight は JavaScript や TypeScript でも役に立ちますか?
はい。パーティクル、地図のタイル、グリッドのセルなど、数十万個のオブジェクトを作る場合に役立ちます。JavaScript のオブジェクトにはオブジェクトごとのオーバーヘッドがあるため、繰り返されるデータを共有すると、メモリ使用量とガベージコレクションの負荷を減らせます。