@nqounetです。
前回は、変換処理をConverterクラスに分離しました。でも、convertメソッド内にはまだif/elseが残っています。今回は、このif/elseを完全に排除する方法を考えてみましょう。
このシリーズについて
このシリーズは「Mooで覚えるオブジェクト指向プログラミング」シリーズを読了した方を対象に、実践的なドキュメント変換ツールを作りながらオブジェクト指向設計を深く学ぶシリーズです。
発想の転換

今までのアプローチでは、Converter側が「この要素はどのタイプか?」を判断していました。
1
2
3
4
5
6
| # Converter側が判断している
sub convert ($self, $element) {
my $type = $element->type;
if ($type eq 'heading') { ... } # Converterが判断
elsif ($type eq 'paragraph') { ... }
}
|
これを逆にして、要素側が「私はこうやって変換してね」とConverter側に伝えるようにしたらどうでしょう?
acceptメソッドを導入する
各要素クラスにacceptメソッドを追加します。このメソッドは、渡されたConverterに「私を訪問して(visit)」と伝えます。
1
2
3
4
5
6
7
8
9
10
11
12
13
| 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";
}
}
|
そして、各サブクラスでacceptを実装します。
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
| package Paragraph {
use Moo;
use experimental qw(signatures);
extends 'Element';
sub accept ($self, $visitor) {
return $visitor->visit_paragraph($self);
}
}
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);
}
}
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);
}
}
|
各クラスが「私を訪問するときはvisit_paragraphを呼んで」「私はvisit_headingを呼んで」と自分で決めています。
HtmlConverterにvisit_*メソッドを追加
Converter側では、各要素タイプに対応するvisit_*メソッドを用意します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| 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>";
}
}
|
if/elseがなくなりました!
処理の流れを理解する
この仕組みがどう動くか、図で確認しましょう。
sequenceDiagram
participant Main as メイン処理
participant Heading as Heading要素
participant Converter as HtmlConverter
Main->>Heading: accept(converter)
Heading->>Converter: visit_heading(self)
Converter-->>Heading: "<h1>タイトル</h1>"
Heading-->>Main: 結果を返す
- メイン処理が要素のacceptメソッドを呼ぶ
- 要素が「私はHeadingなので、visit_headingを呼んで」とConverterに伝える
- Converterのvisit_headingが実行される
これを「Double Dispatch(二重ディスパッチ)」と呼びます。処理の決定が2回行われるからです。
- 最初のディスパッチ: element.acceptの呼び出し(どの要素か)
- 2番目のディスパッチ: visitor.visit_xxxの呼び出し(どの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
| #!/usr/bin/env perl
use v5.36;
use lib '.';
# (クラス定義は省略)
my $markdown = <<'MARKDOWN';
# タイトル
これは段落です。
```perl
my $x = 1;
```
MARKDOWN
my $parser = Parser->new();
my @elements = $parser->parse($markdown);
my $converter = HtmlConverter->new();
say "=== HTML出力(acceptを使用) ===";
for my $elem (@elements) {
say $elem->accept($converter); # 要素にconverterを渡す
}
|
実行結果:
1
2
3
4
5
| === HTML出力(acceptを使用) ===
<h1>タイトル</h1>
<p>これは段落です。</p>
<pre><code class="language-perl">my $x = 1;
</code></pre>
|
前回と同じ結果が得られました。
何が嬉しいのか
この設計の最大のメリットは、新しい要素を追加してもConverterのconvertメソッドを修正する必要がないことです。
例えば、Blockquote要素を追加する場合:
1
2
3
4
5
6
7
8
| package Blockquote {
use Moo;
extends 'Element';
sub accept ($self, $visitor) {
return $visitor->visit_blockquote($self);
}
}
|
そしてHtmlConverterにvisit_blockquoteを追加するだけ:
1
2
3
| sub visit_blockquote ($self, $element) {
return "<blockquote>" . $element->content . "</blockquote>";
}
|
if/elseの塊を修正する必要がありません。
今回のポイント
今回は以下のことを学びました。
- acceptメソッドで要素が「訪問者」を受け入れる
- visit_*メソッドで各要素タイプの処理を実装
- Double Dispatch(二重ディスパッチ)の仕組み
- 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
| #!/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>";
}
}
# === メイン処理 ===
package main {
my $markdown = <<'MARKDOWN';
# タイトル
これは段落です。
```perl
my $x = 1;
```
MARKDOWN
my $parser = Parser->new();
my @elements = $parser->parse($markdown);
my $converter = HtmlConverter->new();
say "=== HTML出力(acceptを使用) ===";
for my $elem (@elements) {
say $elem->accept($converter);
}
}
|
次回予告
次回は、この仕組みの真価を発揮します。HtmlConverterだけでなく、TextConverterを追加してみましょう。既存のコードをほとんど変更せずに、新しい出力形式を追加できることを体験します。これがOCP(開放閉鎖の原則)の実践です。
お楽しみに!