先週の金曜日、無事LeopardことMac OS X 10.5が発売された。もちろん目玉は、この連載の視点からは、Objective-C 2.0だ。これについては、この連載でも徹底的に解剖する予定だ。

では、Mementoパターンの続きに移ろう。今回は、CocoaでのMementoパターンということになるので、Cocoaのアンドゥ機構を紹介しよう。だが、先に言っておくが、これはGoF本でいうところのMementoでは、ない。

Mementoではない、NSUndoManager

Cocoaでアンドゥをサポートするのは、NSUndoManagerというクラスだ。このクラスは、アウンドゥのための汎用クラスだ。ほとんどのCocoaアプリケーションが、このクラスを用いてアンドゥを実現している。

NSUndoManagerの考え方を一言で説明すれば、直前の操作を「打ち消す操作」を登録する、というものだ。GoF本によるMementoが、変更前の状態を保存しておく、というものに対して、操作を登録するという行為は、大きく異なる。もっとも、GoF本でもこの種のアンドゥの実現方法についても言及はしている。だが、実現が難しい、カプセル化ができない、という理由で否定している。

NSUndoManagerを使ってプログラミングをしている経験から言うと、カプセル化ができないというのは、その通りである。操作を登録するタイプでのアンドゥの実現は、如何に登録する操作を抽出するかということが大きなポイントになり、それは様々なクラスに分散されて実装されることになる。NSUndoManagerや付随するクラスでカプセル化する、ということはできない。

実装が難しいという点に関しては、少し語弊があるかもしれない。難しいというよりは、トリッキーというべきか。どういう意味かというと、NSUndoManagerを使うときのコツは、アンドゥ操作として登録するメソッドをあらかじめ見当をつけておき、それに即した実装、たとえば無闇に無駄な作業をさせない、を行うことである。アンドゥのことを意識しないで作成したソースコードに、NSUndoManagerを適用するのは難しい。だが、一度仕組みを理解してしまえば、すんなりと馴染ませることができる。

MementoパターンとNSUndoManagerのどちらが優れているかという比較は、Mementoの実装もないしあまり意味はないが、少なくともNSUndoManagerを使っていて困ったことはない、とは言えるだろう。

NSUndoManagerの使い方

前置きはこのくらいにして、実際にNSUndoManagerを使ってみよう。

NSUndoManagerには操作を登録するのだが、これにはターゲットとアクションが使える。たとえば、次のようなクラスを考えてみよう。

@interface PersonGroup : NSObject
{
    NSMutableArray* _persons;
}

- (void)addPersons:(NSArray*)persons;
- (void)removePersons:(NSArray*)persons;
@end

Personというオブジェクトの集合を管理する、PersonGroupというクラスだ。インスタンス変数として、_personsという配列を保持している。ここにオブジェクトを追加または削除するために、addPersons:とremovePersons:というメソッドがあるわけだ。

このaddPersons:とremovePersons:を呼び出したときに、アンドゥを行えるようにしよう。ポイントは、打ち消す操作とは何か、を考えることである。addPersons:を打ち消す操作は、追加したpersonsを削除する操作になる。つまり、removePersons:を呼び出すことだ。逆に、removePersonsを打ち消すということは、addPersons:を呼ぶものになる。

この考え方をもとに、実装してみよう。次のようになる。

- (void)addPersons:(NSArray*)persons
{
    // Get NSUndoManager
    NSUndoManager* undoManager;
    ...

    // Register undo
    [undoManager registerUndoWithTarget:self 
        selector:@selector(removePersons:) 
        object:[NSArray arrayWithArray:persons]];

    // Add persons
    [_persons addObjectsFromArray:persons];
}

- (void)removePersons:(NSArray*)persons
{
    // Get NSUndoManager
    NSUndoManager* undoManager;
    ...

    // Register undo
    [undoManager registerUndoWithTarget:self 
        selector:@selector(addPersons:) 
        object:[NSArray arrayWithArray:persons]];

    // Remove persons
    [_persons removeObjectsInArray:persons];
}

まず、NSUndoManagerのインスタンスを取得する。これは、NSDocumentなどから取得することができる。そして、registerUndoWithTarget:selector:object:というメソッドを呼んでいる。これが、アウンドゥのための操作の登録だ。

このメソッドを詳しく見てみよう。引数は3つある。第1の引数は、アンドゥ操作のターゲット。第2の引数は、アンドゥ操作のアクションとなる。最後の引数は、そのアクションを呼び出すときの引数だ。この例では、NSArrayをコピーしてから渡している。

つまり、addPersons:を呼び出すときはremovePersons:をアンドゥとして登録し、removePersons:の呼び出しのときはaddPersonsが登録されるのだ。これで、メニューからアンドゥを選択すると、それぞれ登録されたアクションが呼び出されて、取り消し操作が行われる。

だが、もう少し気をつけて見てみよう。まず、addPersons:を呼び出したとする。removePersons:が登録される。そしてアンドゥを行うと、removePersons:が呼び出される。ここまではいい。だが、その呼び出されたremovePersons:の中でもアンドゥの登録が行われてしまっている。これはどうなるのか?

実は、アンドゥコンテキストの中で行われた登録は、「リドゥ」として登録されることになる。NSUndoManagerは、アンドゥを行うと同時に、リドゥの管理も行っているのだ。これが、NSUndoManagerの優れた点であり、いささかトリッキーなところでもある。

NSUndoManagerを使うときの注意点

これが基本的なNSUndoManagerの使い方である。

使う際には、注意しなくてはいけない点もある。上の例では、_personsというインスタンス変数があるが、この変数の操作をaddPersons:とremovePersons:以外では、行わないようにするべきである。なぜなら、_personsに含まれるオブジェクトは、NSUndoManagerのアンドゥスタックの中に、呼び出されるときに渡される引数として保持されているからだ。アンドゥスタックを無視して_personsの中のオブジェクを削除してしまえば、アンドゥ操作が破綻してしまうことになる。

ファイルからの読み込みなど、どうしても変更が必要なときは、NSUndoManagerのメソッドを使って、アンドゥスタックを空にしておくことが必要である。