Featured image of post 第8回-これがVisitorパターンだ! - PerlとMooでドキュメント変換ツールを作ってみよう

第8回-これがVisitorパターンだ! - PerlとMooでドキュメント変換ツールを作ってみよう

シリーズで作り上げた設計がVisitorパターンだったことを明かし、パターンの正式な定義と構成要素を解説します。Double Dispatchの本質、パターンが威力を発揮する場面、そしてSOLID原則との関係を理解しましょう。

@nqounetです。

いよいよ最終回です。このシリーズで私たちが作り上げてきた設計の正体を明かしましょう。

このシリーズについて

このシリーズは「Mooで覚えるオブジェクト指向プログラミング」シリーズを読了した方を対象に、実践的なドキュメント変換ツールを作りながらオブジェクト指向設計を深く学ぶシリーズです。

振り返り:何を作ってきたか

Visitorパターンの全体構造

シリーズを通して、以下のような構造を作ってきました。

	classDiagram
    class Element {
        +content
        +accept(visitor)
    }
    class Paragraph {
        +accept(visitor)
    }
    class Heading {
        +level
        +accept(visitor)
    }
    class CodeBlock {
        +language
        +accept(visitor)
    }
    
    Element <|-- Paragraph
    Element <|-- Heading
    Element <|-- CodeBlock

    class Visitor {
        +visit_heading(element)
        +visit_paragraph(element)
        +visit_code_block(element)
    }
    class HtmlConverter {
        +visit_xxx methods
    }
    class TextConverter {
        +visit_xxx methods
    }
    class WordCounter {
        +visit_xxx methods
    }
    class LinkExtractor {
        +visit_xxx methods
    }
    
    Visitor <|.. HtmlConverter
    Visitor <|.. TextConverter
    Visitor <|.. WordCounter
    Visitor <|.. LinkExtractor
    
    Element ..> Visitor : accept

これは「Visitorパターン」と呼ばれるデザインパターンです。

Visitorパターンとは

GoF(Gang of Four)による定義:

オブジェクト構造の要素に対して実行する操作を表現する。Visitorを使うことで、操作対象のクラスを変更することなく、新しい操作を定義できる。

私たちの実装に当てはめると:

パターンの構成要素私たちの実装
Element(要素)Element, Paragraph, Heading, CodeBlock
Visitor(訪問者)HtmlConverter, TextConverter, WordCounter, LinkExtractor
accept()各要素クラスのacceptメソッド
visit()visit_heading, visit_paragraph, visit_code_block

なぜVisitorパターンなのか

このパターンの本質は「操作の分離」です。

従来のアプローチ

1
2
3
4
5
6
7
# 要素クラス内に操作を定義
package Heading {
    sub to_html { ... }
    sub to_text { ... }
    sub count_chars { ... }
    # 操作が増えるたびにクラスを修正
}

要素に操作が密結合している:

	classDiagram
    class Heading {
        +to_html()
        +to_text()
        +count_chars()
        +extract_links()
    }
    note for Heading "操作が増えるたびに\nクラスを変更する必要あり"

Visitorパターンのアプローチ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 要素クラスはacceptのみ
package Heading {
    sub accept ($self, $visitor) {
        return $visitor->visit_heading($self);
    }
}

# 操作は別クラスで定義
package HtmlConverter {
    sub visit_heading { ... }
}

要素と操作が分離されている:

	classDiagram
    class Heading {
        +accept(visitor)
    }
    class HtmlConverter {
        +visit_heading()
    }
    class TextConverter {
        +visit_heading()
    }
    
    Heading ..> HtmlConverter
    Heading ..> TextConverter
    
    note for Heading "操作が増えても\nHeadingは変更不要"

Double Dispatch(二重ディスパッチ)

Visitorパターンの核心は「Double Dispatch」です。

1
2
3
4
5
6
$element->accept($visitor);
# 1回目のディスパッチ: どのacceptを呼ぶか(Heading? Paragraph?)

# accept内部で
$visitor->visit_heading($self);
# 2回目のディスパッチ: どのvisit_xxxを呼ぶか(HtmlConverter? TextConverter?)

2回のメソッド呼び出しにより、「要素の種類」と「操作の種類」の組み合わせで処理が決定されます。

SOLID原則との関係

Visitorパターンは、SOLID原則のうち特に以下の2つを実現しています。

SRP(単一責任の原則)

各Visitorクラスは1つの操作だけを担当します。

  • HtmlConverter: HTML変換のみ
  • WordCounter: 文字数カウントのみ
  • LinkExtractor: リンク抽出のみ

OCP(開放閉鎖の原則)

新しい操作を追加する場合、既存のコードを変更せずに拡張できます。

1
2
3
4
5
6
# 新しいVisitorを追加するだけ
package JsonExporter {
    sub visit_heading { ... }
    sub visit_paragraph { ... }
    sub visit_code_block { ... }
}

Element、Paragraph、Heading、CodeBlockは一切変更不要です。

Visitorパターンが有効な場面

このパターンが力を発揮するのは:

  1. オブジェクト構造(要素の種類)が安定している
  2. 操作の種類が頻繁に追加される
  3. 操作ごとにコードをまとめたい

今回のドキュメント変換ツールはまさにこの条件に当てはまります。

  • 要素の種類(見出し、段落、コードブロック)は比較的安定
  • 出力形式や分析処理は後からいくらでも追加したい

Visitorパターンの注意点

逆に、このパターンが不向きな場面もあります。

  1. 要素の種類が頻繁に変更される場合
    • 新しい要素を追加すると、全てのVisitorにvisit_xxxメソッドを追加する必要がある
  2. オブジェクト構造が複雑でない場合
    • シンプルな構造なら、if/elseで十分な場合もある

完成したドキュメント変換ツール

シリーズを通して作成した完成版です。

  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
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo

use v5.36;

# === Element(基底クラス) ===
package Element {
    use Moo;
    use experimental qw(signatures);

    has content => (
        is       => 'ro',
        required => 1,
    );

    sub accept ($self, $visitor) {
        die "accept must be implemented by subclass";
    }
}

# === Paragraph ===
package Paragraph {
    use Moo;
    use experimental qw(signatures);
    extends 'Element';

    sub accept ($self, $visitor) {
        return $visitor->visit_paragraph($self);
    }
}

# === Heading ===
package Heading {
    use Moo;
    use experimental qw(signatures);
    extends 'Element';

    has level => (
        is      => 'ro',
        default => 1,
    );

    sub accept ($self, $visitor) {
        return $visitor->visit_heading($self);
    }
}

# === CodeBlock ===
package CodeBlock {
    use Moo;
    use experimental qw(signatures);
    extends 'Element';

    has language => (
        is      => 'ro',
        default => '',
    );

    sub accept ($self, $visitor) {
        return $visitor->visit_code_block($self);
    }
}

# === Parser ===
package Parser {
    use Moo;
    use experimental qw(signatures);

    sub parse ($self, $text) {
        my @elements;
        my @lines = split /\n/, $text;
        
        my $in_code_block = 0;
        my $code_content = '';
        my $code_lang = '';
        
        for my $line (@lines) {
            if ($line =~ /^```(\w*)/) {
                if ($in_code_block) {
                    push @elements, CodeBlock->new(
                        content  => $code_content,
                        language => $code_lang,
                    );
                    $code_content = '';
                    $code_lang = '';
                    $in_code_block = 0;
                } else {
                    $code_lang = $1 // '';
                    $in_code_block = 1;
                }
                next;
            }
            
            if ($in_code_block) {
                $code_content .= $line . "\n";
                next;
            }
            
            next if $line =~ /^\s*$/;
            
            if ($line =~ /^(#+)\s+(.+)/) {
                my $level = length($1);
                my $content = $2;
                push @elements, Heading->new(
                    content => $content,
                    level   => $level,
                );
                next;
            }
            
            push @elements, Paragraph->new(content => $line);
        }
        
        return @elements;
    }
}

# === HtmlConverter ===
package HtmlConverter {
    use Moo;
    use experimental qw(signatures);

    sub visit_heading ($self, $element) {
        my $level = $element->level;
        return "<h$level>" . $element->content . "</h$level>";
    }

    sub visit_paragraph ($self, $element) {
        return "<p>" . $element->content . "</p>";
    }

    sub visit_code_block ($self, $element) {
        my $lang = $element->language;
        if ($lang) {
            return "<pre><code class=\"language-$lang\">" 
                   . $element->content 
                   . "</code></pre>";
        }
        return "<pre><code>" . $element->content . "</code></pre>";
    }
}

# === TextConverter ===
package TextConverter {
    use Moo;
    use experimental qw(signatures);

    sub visit_heading ($self, $element) {
        my $level = $element->level;
        my $prefix = "=" x (7 - $level);
        return "$prefix " . $element->content;
    }

    sub visit_paragraph ($self, $element) {
        return $element->content;
    }

    sub visit_code_block ($self, $element) {
        my $content = $element->content;
        $content =~ s/\n$//;
        return "---\n" . $content . "\n---";
    }
}

# === WordCounter ===
package WordCounter {
    use Moo;
    use experimental qw(signatures);

    has total_chars => (
        is      => 'rw',
        default => 0,
    );

    sub visit_heading ($self, $element) {
        my $chars = length($element->content);
        $self->total_chars($self->total_chars + $chars);
        return $chars;
    }

    sub visit_paragraph ($self, $element) {
        my $chars = length($element->content);
        $self->total_chars($self->total_chars + $chars);
        return $chars;
    }

    sub visit_code_block ($self, $element) {
        return 0;
    }

    sub get_total ($self) {
        return $self->total_chars;
    }
}

# === LinkExtractor ===
package LinkExtractor {
    use Moo;
    use experimental qw(signatures);

    has links => (
        is      => 'rw',
        default => sub { [] },
    );

    sub visit_heading ($self, $element) {
        $self->_extract_links($element->content);
        return;
    }

    sub visit_paragraph ($self, $element) {
        $self->_extract_links($element->content);
        return;
    }

    sub visit_code_block ($self, $element) {
        return;
    }

    sub _extract_links ($self, $text) {
        while ($text =~ m{(https?://[^\s<>\"]+)}g) {
            push $self->links->@*, $1;
        }
    }

    sub get_links ($self) {
        return $self->links->@*;
    }
}

# === メイン処理 ===
package main {
    my $markdown = <<'MARKDOWN';
# ドキュメント変換ツール

このツールはMarkdownをHTMLに変換できます。
詳細は https://example.com/docs を参照してください。

## 使い方

公式サイト https://example.com からダウンロードできます。

```perl
my $parser = Parser->new();
my @elements = $parser->parse($markdown);
```

上記のコードで使えます。
MARKDOWN

    my $parser = Parser->new();
    my @elements = $parser->parse($markdown);

    say "=" x 60;
    say "  ドキュメント変換ツール - Visitorパターンのデモ";
    say "=" x 60;

    say "\n【HTML出力】";
    my $html_converter = HtmlConverter->new();
    for my $elem (@elements) {
        say $elem->accept($html_converter);
    }

    say "\n【テキスト出力】";
    my $text_converter = TextConverter->new();
    for my $elem (@elements) {
        say $elem->accept($text_converter);
    }

    say "\n【文字数カウント】";
    my $counter = WordCounter->new();
    for my $elem (@elements) {
        $elem->accept($counter);
    }
    say "総文字数: " . $counter->get_total . " 文字";

    say "\n【リンク抽出】";
    my $extractor = LinkExtractor->new();
    for my $elem (@elements) {
        $elem->accept($extractor);
    }
    for my $link ($extractor->get_links) {
        say "  - $link";
    }

    say "\n" . "=" x 60;
    say "  完了!";
    say "=" x 60;
}

シリーズのまとめ

このシリーズを通して、以下のことを学びました。

学んだこと
第1回基本のElementクラスとパース処理
第2回継承による要素クラスの分離
第3回if/else分岐の限界(SRP/OCP違反)
第4回Converterクラスへの処理の委譲
第5回accept/visitによるDouble Dispatch
第6回OCPの実践(TextConverter追加)
第7回複数Visitorの共存
第8回Visitorパターンの正体

Visitorパターンは、オブジェクト構造と操作を分離することで、拡張性の高い設計を実現します。ぜひ実務でも活用してみてください。

最後まで読んでいただき、ありがとうございました!

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。