Featured image of post 第4回-変換処理を別クラスに分けよう - PerlとMooでドキュメント変換ツールを作ってみよう

第4回-変換処理を別クラスに分けよう - PerlとMooでドキュメント変換ツールを作ってみよう

if/elseの地獄を脱出するため、変換処理を別のクラスに分離します。ConverterクラスとHtmlConverterを作り、処理を委譲する設計を学びましょう。

@nqounetです。

前回は、if/elseによる変換処理が要素と出力形式の組み合わせで爆発的に増えてしまう問題を体験しました。今回は、この問題を解決するために変換処理を別のクラスに分離していきます。

このシリーズについて

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

変換処理をクラスにする

ElementモジュールからConverterモジュールへの処理委譲

まず、変換処理を担当する「Converter」クラスを作成しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package Converter;
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo

use Moo;
use experimental qw(signatures);

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

1;

これが基底クラスです。実際の変換処理はサブクラスで実装します。

HtmlConverterを作る

HTML変換を担当するクラスを作成しましょう。

 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
package HtmlConverter;
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo, Converter

use Moo;
use experimental qw(signatures);
extends 'Converter';

sub convert ($self, $element) {
    my $type = $element->type;
    
    if ($type eq 'heading') {
        return $self->convert_heading($element);
    }
    elsif ($type eq 'paragraph') {
        return $self->convert_paragraph($element);
    }
    elsif ($type eq 'code_block') {
        return $self->convert_code_block($element);
    }
    else {
        return $element->content;
    }
}

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

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

sub convert_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>";
}

1;

変換処理がHtmlConverterクラスに集約されました。

使ってみよう

 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 '.';
use Parser;
use HtmlConverter;

my $markdown = <<'MARKDOWN';
# タイトル

これは段落です。

```perl
my $x = 1;
```
MARKDOWN

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

my $converter = HtmlConverter->new();

say "=== HTML出力 ===";
for my $elem (@elements) {
    say $converter->convert($elem);
}

実行結果:

1
2
3
4
5
=== HTML出力 ===
<h1>タイトル</h1>
<p>これは段落です。</p>
<pre><code class="language-perl">my $x = 1;
</code></pre>

前回と同じ結果が得られました。

何が変わった?

クラス構造を図にしてみましょう。

	classDiagram
    class Converter {
        +convert(element)*
    }
    class HtmlConverter {
        +convert(element)
        +convert_heading(element)
        +convert_paragraph(element)
        +convert_code_block(element)
    }
    
    Converter <|-- HtmlConverter : extends

変換処理が独立したクラスになりました。これにより、以下のメリットがあります。

  • 変換処理の追加が容易(新しいConverterサブクラスを追加するだけ)
  • HTML変換のロジックがHtmlConverterに集約されている
  • テストが書きやすい(HtmlConverterだけをテストできる)

でもまだ問題がある

よく見ると、HtmlConverterのconvertメソッド内でまだif/elseを使っています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
sub convert ($self, $element) {
    my $type = $element->type;
    
    if ($type eq 'heading') {
        return $self->convert_heading($element);
    }
    elsif ($type eq 'paragraph') {
        # ...
    }
    # まだif/elseがある!
}

新しい要素を追加するたびに、HtmlConverterのconvertメソッドを修正する必要があります。OCPの問題は依然として残っています。

今回のポイント

今回は以下のことを学びました。

  • 変換処理を専用のクラス(Converter)に分離
  • HtmlConverterで具体的な変換処理を実装
  • クラスに分離することで責任が明確になる

ただし、convertメソッド内の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
#!/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 type ($self) {
        return 'element';
    }
}

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

    sub type ($self) {
        return 'paragraph';
    }
}

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

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

    sub type ($self) {
        return 'heading';
    }
}

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

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

    sub type ($self) {
        return 'code_block';
    }
}

# === 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;
    }
}

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

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

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

    sub convert ($self, $element) {
        my $type = $element->type;
        
        if ($type eq 'heading') {
            return $self->convert_heading($element);
        }
        elsif ($type eq 'paragraph') {
            return $self->convert_paragraph($element);
        }
        elsif ($type eq 'code_block') {
            return $self->convert_code_block($element);
        }
        else {
            return $element->content;
        }
    }

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

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

    sub convert_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出力 ===";
    for my $elem (@elements) {
        say $converter->convert($elem);
    }
}

次回予告

次回は、残っているif/elseを完全に排除する方法を探ります。「要素自身が変換方法を決める」という発想で、acceptメソッドとvisit_*メソッドの仕組みを導入しましょう。この仕組みは「Double Dispatch(二重ディスパッチ)」と呼ばれるテクニックです。

お楽しみに!

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