Visitor - Guide パターン

デザインパターンの中でも複雑な Visitor パターンについていくつかの改善を実践しましたので紹介します。TypeScript で実装しました。

Visitor パターンを使おうとした動機

ドメインモデルにおいて、エンティティがプロパティとして別のエンティティや別のエンティティを要素に持つリストを持つことは、よくある構造ではないかと思います。これらは再帰構造を持ちます。

再帰構造を持つエンティティを扱うユティリティとして次のような機能を作ろうとしました。

  • コピー
  • シリアライズ
  • デシリアライズ
  • 構造のパスに基づいてエラーをエンティティやプロパティに紐づけ
  • オブジェクト構造のどこかでエラーが発生していないか検査

これらの機能を作るとき、再帰構造を巡回するコードを書く必要がありますが、すべて類似の処理になりますので共通化したいと考えました。

過去に試した経緯

構造を巡回することから Visitor パターンが利用できるのではないかと考えて、過去に何度か試したことがあるのですが、今までうまくいっていませんでした。その理由を改めて整理すると下記の通りです。

オブジェクト構造のたどり方が微妙に異なる
コピー、シリアライズでは構造を末端までたどりますが、それ以外の処理では必ずしもすべてたどりません。 エラーがあるかないかだけの検査であれば、エラーが一つ見つかればその時点で残りの要素を巡回する必要がなくなりまが、Visitor パターンでは巡回を中断するのが困難です。
処理に付加的な情報が必要
例えば、コピーではコピー先のオブジェクト、親からとの関連(ルートなのか、親のプロパティなのか、リスト要素なのか)が必要になります。これらを共通化するのか難しかったです。
オブジェクト構造側に Visitor を受け入れる仕組みが必要
Visitor を受け入れるために、エンティティやリストに Accept メソッドが必要になります。エンティティのプロパティも巡回するとなると各言語で用意されている文字列、数値などのプリミティブな型が使えなくなります。

改善内容

そこで今回改めて次の条件を満たす仕組みを考えてみました。

  • 巡回方法を柔軟に選択できること
  • 途中で巡回を中断できること
  • 求める機能に応じて付加的な情報を柔軟に追加できること
  • オブジェクト構造側は修正しないで導入できること

以上を実現するため、次のように改善しました。

  • Visitor - Guide パターンを適用
  • Visitor に渡す付加的なパラメーターを追加
  • Visitメソッドの戻り値をGenerator(C#ならEnumerable)にする

Visitor - Guide パターン

VisitorパターンにはVisitor側で巡回経路を決める場合と、オブジェクト要素側で巡回経路を決める場合があるようです。

Visitor 側で巡回経路を決める場合、Visitor が実現する機能に従って巡回方法を変えられるメリットがあります。Visitor は巡回と具体的な処理の両方を受け持つことになります。
それに対して、オブジェクト要素側で巡回経路を決める場合、Visitor 側は巡回を受け持つ必要はなくなりますが、巡回経路が固定化されます。

そこで、これらを解決するため第3の要素として Guide を考えました。

Guide は巡回方法を決めて Visitor を案内します。Visitor は Guide の案内に従ってオブジェクト要素を訪問します。 VisitorとGuideは1対1で機能します。VisitorがGuideの guide メソッドを呼び、guide はオブジェクト構造の下位要素を巡回しVisitorのオブジェクト要素に対応する visit メソッドを呼びます。末端の文字列、数値などの要素に達した場合、Visitor は Guide をそれ以上呼び出しません。 つまり、VisitorがGuideを呼び出すのは再帰構造のルートまたは中間ノードを訪問するときだけです。

上記のような方法を Visitor - Guide パターンと名づけました。

このパターンによって責務が明確化しました。巡回方法は Guide が、実現する機能は Visitor が受け持ちます。異なる巡回方法が必要になった時は Guide を変えます。

但し、中間ノードで guide を呼ばない、visit が呼び出されても処理をしないと言った選択肢が Visitor 側にありますので、今のところ異なる Guide が必要になるケースは出てきていません。

中間ノードで guide を呼ぶ前に処理をすれば幅優先探索に、guide を呼んだあとに処理をすれば深さ優先探索になります。

また、オブジェクト構造側に手を入れる必要がありません。

Visitor に渡すパラメーターの追加

Visitor でもっとも単純なパターンではVisit メソッドにはオブジェクト要素のみを渡しますが、実現しようとする機能によっては、情報が不足します。そこで、下記の2つのパラメーターを設けました。

index
上位のオブジェクトからプロパティとしてアクセスされるならプロパティの情報、リストの要素であれば index
param
付加的な情報を表すパラメーター、コピーであればコピー先オブジェクト。木構造を dump 表示する Visitor であればインデントの深さなど

戻り値の Generator 化

Visitor の戻り値を Generator にした直接の理由は、巡回処理を途中で中断できるようにするためです。

コピーでは Visitor は処理した中間結果を処理が進むにしたがってコピーした要素を Generator に積み上げていきます。guide 呼び出しから戻った時点で積み上げた結果をコピー先オブジェクトに反映します。

一方、オブジェクト要素をたどってエラーがあるかどうか検査するケースでは、Visitor は訪問した要素が正常であれば Generator に要素を追加せず、エラーが見つかれば Generator に要素を追加します。呼び出しルーチンは巡回を開始して一つだけ要素を待ち、要素が返ればエラーありと判定し2個目の要素を待たないことで巡回を中断できます。すべて巡回が終わっても Generator が要素が返さなければエラーなしと判断できます。

欠点

オブジェクト構造側を修正しないで実現したため、Visitor パターンのメリットの一つであるダブルディスパッチによる型判定の除外はできません。

型判定をなくす理由は、型判定がプログラムのあちらこちらに散らばっていると、オブジェクト構造に新しい種類が加わった時、改修が困難となるためだと理解しています。

現状では Guide 内に型判定が残っています。もともとの動機は巡回を繰り返し記述しないことでした。それは実現できていますのでこの欠点はこれ以上対処しないことにします。

サンプル

下記は Visitor - Guide パターンによりオブジェクトを巡回する基本的なコードです。

IObjectNodesVisitor
Visitor が実装すべきインターフェースを表します。
IObjectNodesGuide
Guide が実装すべきインターフェースを表します。
ObjectNodesGuide
Guide の基本実装をカプセル化します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
export type ObjectNode = object | any[];
export type ObjectNodeIndex = string | number;

export interface IObjectNodesVisitor<TResult, TParam = undefined> {
visitObject(target: object, index?: ObjectNodeIndex, param?: TParam): Generator<TResult>;
visitArray(target: any[], index?: ObjectNodeIndex, param?: TParam): Generator<TResult>;
visitValue(target: any, index?: ObjectNodeIndex, param?: TParam): Generator<TResult>;
}

export interface IObjectNodesGuide<TResult, TParam = undefined> {
guideObject(target: object, param?: TParam): Generator<TResult>;
guideArray(target: any[], param?: TParam): Generator<TResult>;
}

export class ObjectNodesGuide<TResult, TParam = undefined> implements IObjectNodesGuide<TResult, TParam> {
private visitor: IObjectNodesVisitor<TResult, TParam>;

private* callVisit(index: string | number, value: any, param?: TParam): Generator<TResult> {
if (value instanceof Array) {
yield* this.visitor.visitArray(value, index, param);
} else if (typeof value !== 'number' && typeof value !== 'string' && !(value instanceof Date)) {
yield* this.visitor.visitObject(value, index, param);
} else {
yield* this.visitor.visitValue(value, index, param);
}
}

public* guideObject(target: object, param?: TParam): Generator<TResult> {
for (const [name, value] of Object.entries(target)) {
yield* this.callVisit(name, value, param);
}
}

public* guideArray(target: any[], param: TParam): Generator<TResult> {
for (const [index, item] of target.entries()) {
yield* this.callVisit(index, item, param);
}
}

constructor(visitor: IObjectNodesVisitor<TResult, TParam>) {
this.visitor = visitor;
}
}

下記は オブジェクトをダンプする機能を持つ Visitor を表します。

ObjectDumpVisitorParamはインデントの深さを格納するパラメーターです。各 Visitor はジェネレーターを返しています。これらの戻り値をつなぎ合わせるとダンプ結果を表す文字列を取得できます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import {IObjectNodesVisitor, ObjectNodeIndex, ObjectNodesGuide} from "./ObjectNodesVisitor";

class ObjectDumpVisitorParam {
public depth: number;
public getNestedParam(): ObjectDumpVisitorParam {
return new ObjectDumpVisitorParam(this.depth + 2);
}
constructor(depth: number = 0) {
this.depth = depth;
}
}

class ObjectDumpVisitor implements IObjectNodesVisitor<string, ObjectDumpVisitorParam> {
public guide = new ObjectNodesGuide<any, ObjectDumpVisitorParam>(this);

private buildRow(param: ObjectDumpVisitorParam, index: ObjectNodeIndex | undefined, body: string): string {
const indent = ' '.repeat(param!.depth);
const path = index !== undefined ? `${index}: ` : '';
return `${indent}${path}${body}` + '\n';
}

public* visitObject(target: object, index?: ObjectNodeIndex, param?: ObjectDumpVisitorParam): Generator<string> {
if (!param) param = new ObjectDumpVisitorParam();
yield this.buildRow(param, index, '{')
yield* this.guide.guideObject(target, param.getNestedParam());
yield this.buildRow(param, undefined, '}');
}

public* visitArray(target: any[], index?: ObjectNodeIndex, param?: ObjectDumpVisitorParam): Generator<string> {
if (!param) param = new ObjectDumpVisitorParam();
yield this.buildRow(param, index, '[')
yield* this.guide.guideObject(target, param!.getNestedParam());
yield this.buildRow(param, undefined, ']');
}

public* visitValue(target: any, index?: ObjectNodeIndex, param?: ObjectDumpVisitorParam): Generator<string> {
if (!param) param = new ObjectDumpVisitorParam();
yield this.buildRow(param, index, target);
}

public static dumpObject(target: Record<string, any>): string {
return Array.from(new ObjectDumpVisitor().visitObject(target)).join('');
}

public static dumpArray(target: any[]): string {
return Array.from(new ObjectDumpVisitor().visitArray(target)).join('');
}
}

const target = {
id: 1,
name: 'test',
children: [
{
detailId: 1,
detailName: 'detail1',
},
{
detailId: 2,
detailName: 'detail2',
},
]
}

const result = ObjectDumpVisitor.dumpObject(target);
console.log(result);
参照