PHP7でデザインパターン入門18/23 Mementoパターン

Memento パターンとは

オブジェクトを以前の状態に(ロールバックにより)戻す能力を提供する。

Memento パターン - Wikipedia

オブジェクト指向のプログラムでundo(やり直し)を実現するには、インスタンスの持っている情報を保存しておく必要がある。 ただし、保存しておくだけでは駄目で、保存しておいた情報からインスタンスを元の状態に戻せなければならない。

インスタンスを復元するためには、インスタンス内部の情報にアクセスできる必要がある。しかし、不用意にアクセスを許可してしまうと、そのクラスの内部構造に依存したコードがプログラムのあちこちに散らばり、クラスの修正がしにくくなってしまう。これをカプセル化の破壊と言う。

インスタンスの状態を表す役割を導入し、カプセル化の破壊に陥ることなく保存と復元を行うのが、Mementoパターンである。

Mementoパターンを用いると、プログラムに対して

  • undo(やり直し)
  • redo(再実行)
  • history(作業履歴の作成)
  • snapshot(現在の状態の保存)

example

Mementoパターンを使ったサンプルプログラム「フルーツを集めていくサイコロゲーム」を作成する。

このゲームのルールは以下の通り

  • このゲームは自動進行する
  • 主人公はサイコロを振り、その目が次の状態を決定する
  • サイコロの目によって、
    • お金が増える
    • お金が減る
    • フルーツを貰う
  • お金がなくなったら終了する

サンプルコード中では、お金が貯まったところで、将来のためにMementoクラスのインスタンスを作成し、「現在の状態」を保存する。 保存するのは現在持っているお金とフルーツ。もし負け続けてお金が減ってきたら、お金がなくなって終了しないように、以前保存しておいたMementoのインスタンスを使って、以前の状態を復元する。

Memento クラス

主人公(Gamer)の状態を表現するクラス。

<?php
class Memento
{
    public $money;
    public $fruits = [];

    public function __construct(int $money)
    {
        $this->money = $money;
    }

    public function getMoney(): int
    {
        return $this->money;
    }

    public function addFruit(string $fruit)
    {
        $this->fruits[] = $fruit;
    }

    public function getFruits(): array
    {
        return $this->fruits;
    }
}

Gamer クラス

ゲームを行う主人公を表現しているクラス。

  • createMementoメソッドは、現在の状態を保存する(snapshot)。このメソッドはMementoを作成する。フルーツについては、「おいしい」ものだけを保存している。
  • restoreMementoメソッドは、createMementoメソッドの逆で、undoを行う。与えられたMementoのインスタンスを元に、自身の状態を復元する。
<?php
class Gamer
{
    private $money;
    private $fruits = [];
    const FRUITS_NAME = ["リンゴ", "ぶどう", "バナナ", "みかん"];

    public function __construct(int $money)
    {
        $this->money = $money;
    }

    public function getMoney(): int
    {
        return $this->money;
    }

    public function bet()
    {
        switch (rand(1, 6)) {
            case 1:
                $this->money += 100;
                echo "所持金が増えました。" . PHP_EOL;
                break;
            case 2:
                $this->money /= 2;
                echo "所持金が半分になりました。" . PHP_EOL;
                break;
            case 6:
                $f = $this->getFruit();
                echo "フルーツ(" . $f . ")をもらいました。" . PHP_EOL;
                $this->fruits[] = $f;
                break;
            default:
                echo "何も起こりませんでした。" . PHP_EOL;
        }
    }

    public function createMemento(): Memento
    {
        $m = new Memento($this->money);
        foreach ($this->fruits as $v) {
            if (substr($v, 0, strlen("おいしい")) === "おいしい") {
                $m->addFruit($v);
            }
        }
        return $m;
    }

    public function restoreMemento(Memento $memento)
    {
        $this->money = $memento->money;
        $this->fruits = $memento->getFruits();
    }

    public function __toString(): string
    {
        return "[money = " . $this->money . ", fruits = " . join(', ', $this->fruits) . "]";
    }

    private function getFruit(): string
    {
        $suffix = boolval(rand(0, 1)) ? "おいしい" : "";
        return $suffix . self::FRUITS_NAME[rand(0, count(self::FRUITS_NAME)-1)];
    }
}

Main

Gamerのインスタンスを作成し、それを使ってゲームを行う。Gamerのbetメソッドを繰り返し呼び出し、その度に現在の状態を表示する。

Mementoパターンを導入し

  • 変数mementoに「ある時点のGamerの状態」を保存する。
  • お金が増えてきたら、createMementoを使って現在の状態を保存する。
  • お金が足りなくなってきたら、restoreMementoメソッドにこのmementoを与えて所持金を元に戻す。
<?php
$gamer = new Gamer(100);
$memento = $gamer->createMemento();
for ($i = 0; $i < 50; $i++) {
    echo "==== " . $i . PHP_EOL;
    echo "現状:" . $gamer . PHP_EOL;
    $gamer->bet();
    echo "所持金は" . $gamer->getMoney() . "円になりました。" . PHP_EOL;
    if ($gamer->getMoney() > $memento->getMoney()) {
        echo "    (だいぶ増えたので、現在の状態を保存しておこう)" . PHP_EOL;
        $memento = $gamer->createMemento();
    } elseif ($gamer->getMoney() < $memento->getMoney() / 2) {
        echo "    (だいぶ減ったので、以前の状態に復帰しよう)" . PHP_EOL;
        $gamer->restoreMemento($memento);
    }
    sleep(1);
    echo PHP_EOL;
}

実行結果

$ php main.php
==== 0
現状:[money = 100, fruits = ]
所持金が半分になりました。
所持金は50円になりました。

==== 1
現状:[money = 50, fruits = ]
所持金が半分になりました。
所持金は25円になりました。
    (だいぶ減ったので、以前の状態に復帰しよう)

==== 2
現状:[money = 100, fruits = ]
所持金が増えました。
所持金は200円になりました。
    (だいぶ増えたので、現在の状態を保存しておこう)

==== 3
現状:[money = 200, fruits = ]
所持金が増えました。
所持金は300円になりました。
    (だいぶ増えたので、現在の状態を保存しておこう)

==== 4
現状:[money = 300, fruits = ]
所持金が半分になりました。
所持金は150円になりました。

==== 5
現状:[money = 150, fruits = ]
フルーツ(リンゴ)をもらいました。
所持金は150円になりました。

==== 6
現状:[money = 150, fruits = リンゴ]
何も起こりませんでした。
所持金は150円になりました。

==== 7
現状:[money = 150, fruits = リンゴ]
何も起こりませんでした。
所持金は150円になりました。

==== 8
現状:[money = 150, fruits = リンゴ]
何も起こりませんでした。
所持金は150円になりました。

==== 9
現状:[money = 150, fruits = リンゴ]
何も起こりませんでした。
所持金は150円になりました。

==== 10
現状:[money = 150, fruits = リンゴ]
所持金が増えました。
所持金は250円になりました。

... 中略 ...

==== 40
現状:[money = 275, fruits = おいしいみかん, ぶどう, バナナ]
所持金が増えました。
所持金は375円になりました。

==== 41
現状:[money = 375, fruits = おいしいみかん, ぶどう, バナナ]
何も起こりませんでした。
所持金は375円になりました。

==== 42
現状:[money = 375, fruits = おいしいみかん, ぶどう, バナナ]
何も起こりませんでした。
所持金は375円になりました。

==== 43
現状:[money = 375, fruits = おいしいみかん, ぶどう, バナナ]
フルーツ(みかん)をもらいました。
所持金は375円になりました。

==== 44
現状:[money = 375, fruits = おいしいみかん, ぶどう, バナナ, みかん]
所持金が半分になりました。
所持金は187円になりました。
    (だいぶ減ったので、以前の状態に復帰しよう)

==== 45
現状:[money = 550, fruits = おいしいみかん]
何も起こりませんでした。
所持金は550円になりました。

==== 46
現状:[money = 550, fruits = おいしいみかん]
所持金が半分になりました。
所持金は275円になりました。

==== 47
現状:[money = 275, fruits = おいしいみかん]
所持金が半分になりました。
所持金は137円になりました。
    (だいぶ減ったので、以前の状態に復帰しよう)

==== 48
現状:[money = 550, fruits = おいしいみかん]
所持金が半分になりました。
所持金は275円になりました。

==== 49
現状:[money = 275, fruits = おいしいみかん]
所持金が半分になりました。
所持金は137円になりました。
    (だいぶ減ったので、以前の状態に復帰しよう)

ソースコード

github.com

役割

Originator役

この役は、自分の現在の状態を保存したいときにMemento役を作る。 Originator役はまた、以前のMemento役を渡されると、そのMemento役を作った時点の状態に戻る処理を行う。 サンプルコードにおけるGamerクラス。

Memento役

この役は、Originator役の内部情報をまとめる。Memento役はOriginator役の内部情報を持っているが、その情報を誰にでも公開するわけではない。Memento役は次の2種類のインターフェースを持っている。

  • wide interface オブジェクトの状態を元に戻すために必要な情報がすべて得られるメソッドの集合。これを使えるのはOriginator役のみ。

  • narrow interface 外部のCaretaker役に見せるもの。内部状態が外部に公開されるのを防ぐ。

この2種類のインターフェースを使い分けることでオブジェクトのカプセル化の破壊を防ぐことができる。 この役はサンプルコードにおけるMementoクラス。

Caretaker役

現在のOriginator役の状態を保存したいときに、そのことをOriginator役に伝える。Originator役はそれを受けてMementoを作り、Caretaker役に渡す。Caretaker役は将来の必要に備えて、そのMemento役を保存しておく。 サンプルコードにおけるMain.php

まとめ

  • サンプルコードでは1つのMementoしか保存しなかった(上書きで最新保存)が、Mementoのインスタンスを複数個持つようにすれば、インスタンスの様々な時点での状態を保存できる。
  • Memento役をCaretaker役が保存しておき、必要なときにはMemento役を取り出して、Originator役に渡すことで復元ができる。これがMementoパターン。

関連パターン

増補改訂版Java言語で学ぶデザインパターン入門

増補改訂版Java言語で学ぶデザインパターン入門