コンテンツにスキップ

Visitor

個人的重要度と感想

★★★☆☆

多分オープン・クローズドの原則に従ったパターン。

新しい機能を追加する際に既存のクラスは書き換えたくない場合使える。使い道は結構難しそうである。

例を考える

前提

図形を表現するクラスが最初にあったとする。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ----------------------------------------
// 図形たち
// ----------------------------------------
interface IShape {
  getArea(): number;
}

class Circle implements IShape {
  constructor(public readonly radius: number) {}
  getArea() {
    return Math.PI * this.radius * this.radius;
  }
}

class Rectangle implements IShape {
  constructor(public readonly width: number, public readonly height: number) {}
  getArea() {
    return this.width * this.height;
  }
}

const shapes: IShape[] = [new Circle(5), new Rectangle(4, 6)];

このあと、図形の面積を画面に表示する機能を新たに追加したくなったとき、どうするかを考える。

新機能を追加する方法 1

IShapeインターフェースを拡張してみる。

 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
// ----------------------------------------
// 図形たち
// ----------------------------------------
interface IShape {
  getArea(): number;
  printArea(): void;
}

class Circle implements IShape {
  constructor(public readonly radius: number) {}
  getArea() {
    return Math.PI * this.radius * this.radius;
  }
  printArea(): void {
    console.log(`円です。面積は${this.getArea()}です。`);
  }
}

class Rectangle implements IShape {
  constructor(public readonly width: number, public readonly height: number) {}
  getArea() {
    return this.width * this.height;
  }
  printArea(): void {
    console.log(`四角です。面積は${this.getArea()}です。`);
  }
}

const shapes: IShape[] = [new Circle(5), new Rectangle(4, 6)];
shapes.forEach((shape) => {
  shape.printArea();
});
1
2
円です。面積は78.53981633974483です。
四角です。面積は24です。

インターフェースにprintArea()というメソッドを追加し、CircleRectangleprintArea()を実装した。

このやり方は 2 つの点でイケてない。

  1. 図形が表示の責任まで負っている点で単一責任の原則に反している。(これはこのページの論点とは違うが触れておく。)
  2. クラスで見たときの影響範囲が大きい。

今回インターフェースを書き換えたあと実装の 2 クラスに追記した。このあとさらに半径や辺を表示する機能を追加することになったら、また同じようにこれらのクラスをすべて書き換えないといけない。

既存の成果物を変更せず拡張できるようにすべきだというオープン・クローズドの原則に反するのである。

新機能を追加する方法 2

Shapeインターフェースを利用する側に実装してみる。

 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
// ----------------------------------------
// 図形たち
// ----------------------------------------
interface IShape {
  getArea(): number;
}

class Circle implements IShape {
  constructor(public readonly radius: number) {}
  getArea() {
    return Math.PI * this.radius * this.radius;
  }
}

class Rectangle implements IShape {
  constructor(public readonly width: number, public readonly height: number) {}
  getArea() {
    return this.width * this.height;
  }
}

const shapes: IShape[] = [new Circle(5), new Rectangle(4, 6)];
// 面積の画面表示処理
shapes.forEach((shape) => {
  if (shape instanceof Circle) {
    console.log(`円です。面積は${shape.getArea()}です。`);
  } else if (shape instanceof Rectangle) {
    console.log(`四角です。面積は${shape.getArea()}です。`);
  }
});
1
2
円です。面積は78.53981633974483です。
四角です。面積は24です。

これもやりがちだが一度こういうコードを書いてしまうと、図形の種類が増えるたびに分岐を追加しないといけなくなる。実装漏れがでそうでやはりイマイチである。

Visitor パターンを適用しておく

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
// ----------------------------------------
// 図形たち
// ----------------------------------------
interface IShape {
  getArea(): number;
  printAccept(printVisitor: IPrintVisitor): void; // 抽象に依存しているのでセーフ理論
}

class Circle implements IShape {
  constructor(public readonly radius: number) {}
  getArea() {
    return Math.PI * this.radius * this.radius;
  }
  printAccept(printVisitor: IPrintVisitor): void {
    printVisitor.visitCircle(this); // ダブルディスパッチというテクニックらしい
  }
}

class Rectangle implements IShape {
  constructor(public readonly width: number, public readonly height: number) {}
  getArea() {
    return this.width * this.height;
  }
  printAccept(printVisitor: IPrintVisitor): void {
    printVisitor.visitRectangle(this);
  }
}

// ----------------------------------------
// 画面表示
// ----------------------------------------
interface IPrintVisitor {
  visitCircle(shape: IShape): void;
  visitRectangle(shape: IShape): void;
}

class PrintAreaVisitor implements IPrintVisitor {
  visitCircle(shape: Circle): void {
    console.log(`円です。面積は${shape.getArea()}です。`);
  }
  visitRectangle(shape: Rectangle): void {
    console.log(`四角です。面積は${shape.getArea()}です。`);
  }
}

const shapes: IShape[] = [new Circle(5), new Rectangle(4, 6)];

const printAreaVisitor = new PrintAreaVisitor();
shapes.forEach((shape) => {
  shape.printAccept(printAreaVisitor);
});
1
2
円です。面積は78.53981633974483です。
四角です。面積は24です。

Note

printAccept()はどうやって描画するかの実装を持たず表示処理を外部に任せるので、単一責任の原則に反しない。

表示する処理が表示専用のクラスに実装されているという点でいい感じだが、これだけだとあまり恩恵を感じられない。

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
// ----------------------------------------
// 図形たち
// ----------------------------------------
// (変更ないので省略)

// ----------------------------------------
// 画面表示
// ----------------------------------------
interface IPrintVisitor {
  visitCircle(shape: Circle): void;
  visitRectangle(shape: Rectangle): void;
}

class PrintAreaVisitor implements IPrintVisitor {
  visitCircle(shape: Circle): void {
    console.log(`円です。面積は${shape.getArea()}です。`);
  }
  visitRectangle(shape: Rectangle): void {
    console.log(`四角です。面積は${shape.getArea()}です。`);
  }
}

class PrintPropertyVisitor implements IPrintVisitor {
  visitCircle(shape: Circle): void {
    console.log(`円です。半径は${shape.radius}です。`);
  }
  visitRectangle(shape: Rectangle): void {
    console.log(`四角です。幅は${shape.width}、高さは${shape.height}です。`);
  }
}

const shapes: IShape[] = [new Circle(5), new Rectangle(4, 6)];

const printAreaVisitor = new PrintAreaVisitor();
const printPropertyVisitor = new PrintPropertyVisitor();
shapes.forEach((shape) => {
  shape.printAccept(printAreaVisitor);
  shape.printAccept(printPropertyVisitor);
});

クラスの変更はPrintPropertyVisitorクラスの追加だけになった。機能追加時に既存クラスの追記が不要になるのが Visitor パターンのメリットである。