Featured image of post 第4回-階層構造を表現しようとして壁にぶつかる - PerlとMooで学ぶComposite

第4回-階層構造を表現しようとして壁にぶつかる - PerlとMooで学ぶComposite

PerlとMooでMarkdown目次生成器を作る第4回。親子関係を表現しようとif/else地獄に。条件分岐爆発の問題を体験し、デザインパターン導入の必要性を理解します。

前回の振り返り

前回は、FlatTOCRendererクラスを作成し、見出しをインデント付きで表示しました。しかし、見出しは「フラットな配列」として扱われており、親子関係が表現されていませんでした。

今回は、「真の階層構造」を表現しようとして、壁にぶつかる体験をします。

今回のゴール

HTML形式で入れ子の<ul><li>構造を生成するHierarchicalTOCRendererクラスを作ろうとして、なぜそれが難しいのかを体験します。

やりたいこと

以下のようなHTML出力を生成したいとします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<ul>
  <li>はじめに
    <ul>
      <li>第1章
        <ul>
          <li>セクション1.1</li>
        </ul>
      </li>
      <li>第2章</li>
    </ul>
  </li>
</ul>

これは「はじめに(H1)」の下に「第1章(H2)」と「第2章(H2)」があり、「第1章」の下に「セクション1.1(H3)」があるという入れ子構造です。

素朴なアプローチ

「前の見出しのレベルと比較して、深くなったら<ul>を開き、浅くなったら</ul>を閉じればいいのでは?」と考えるかもしれません。

やってみましょう。

 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
package HierarchicalTOCRenderer;
use v5.36;
use Moo;

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

sub render ($self) {
    my @output;
    my $prev_level = 0;
    
    for my $h ($self->headings->@*) {
        my $level = $h->{level};
        my $text  = $h->{text};
        
        if ($level > $prev_level) {
            # レベルが深くなった:<ul>を開く
            for my $i (1 .. ($level - $prev_level)) {
                push @output, "<ul>";
            }
        } elsif ($level < $prev_level) {
            # レベルが浅くなった:</li></ul>を閉じる
            for my $i (1 .. ($prev_level - $level)) {
                push @output, "</li>";
                push @output, "</ul>";
            }
        } else {
            # 同じレベル:</li>を閉じる(最初の要素以外)
            if ($prev_level > 0) {
                push @output, "</li>";
            }
        }
        
        push @output, "<li>$text";
        $prev_level = $level;
    }
    
    # 最後に残った</li></ul>を閉じる
    for my $i (1 .. $prev_level) {
        push @output, "</li>";
        push @output, "</ul>";
    }
    
    return join("\n", @output);
}

1;

実行してみる

1
2
3
4
my $renderer = HierarchicalTOCRenderer->new(
    headings => $extractor->headings,
);
say $renderer->render;

出力:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<ul>
<li>はじめに
<ul>
<li>第1章
<ul>
<li>セクション1.1
</li>
</ul>
</li>
<li>第2章
</li>
</ul>
</li>
</ul>

一見うまくいっているように見えます。しかし…

問題が発生するケース

次のようなMarkdownを考えてみましょう。

1
2
3
# はじめに
### いきなりH3
## 第1章

レベルが1から3に飛ぶケースです。

期待する構造:

H1の下にH3が直接来ることは本来ありませんが、「H2をスキップしてH3に」という場合にどう扱うべきでしょうか?

現在の実装では:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<ul>
<li>はじめに
<ul>
<ul>
<li>いきなりH3   <!-- 2段階開いてしまう -->
</li>
</ul>
</li>
</ul>
<li>第1章
</li>
</ul>

構造が壊れています。<ul>が2つ連続で開かれ、中間の<li>がありません。

条件分岐を増やして対処?

「じゃあ、レベルが2以上離れている場合の条件を追加しよう」と考えるかもしれません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
if ($level > $prev_level) {
    my $diff = $level - $prev_level;
    if ($diff == 1) {
        # 1段階深くなった
        push @output, "<ul>";
    } elsif ($diff == 2) {
        # 2段階深くなった:空のliを挟む?
        push @output, "<ul><li><ul>";
    } elsif ($diff >= 3) {
        # 3段階以上深くなった:さらに複雑...
        # ...
    }
}

さらなる複雑化

しかし、問題はこれだけではありません。

問題1:閉じる順序

レベルが3から1に戻る場合、閉じるべき</ul></li>の数は?中間に「開いたけど中身がない」ケースがあったら?

問題2:複数のルート

H1が複数ある場合、それぞれが独立したツリーになります。

1
2
3
4
# 第1部
## 章1
# 第2部
## 章2

問題3:ネストしたHTML属性

各レベルに異なるCSSクラスを付けたい場合:

1
2
<ul class="level-1">
  <li><ul class="level-2">...

条件分岐がさらに増えます。

破綻の兆候

コードを見返してみると、以下のような問題が見えてきます:

  1. 条件分岐の爆発: レベル差、前後関係、特殊ケースごとにif/elsif/elseが増える
  2. 状態管理の複雑化: $prev_levelだけでなく、「開いている<ul>の履歴」も追跡が必要に
  3. 拡張性の欠如: 新しい出力形式(JSON、Markdownなど)を追加するたびに、同様の複雑なロジックが必要

これは単一責任原則(SRP)違反の兆候です。1つのクラスが「レベル差の計算」「HTMLタグの開閉」「状態管理」を全て担当しており、責任が過多になっています。

本当に必要なものは?

ここで立ち止まって考えてみましょう。

私たちが本当に欲しいのは:

  • ツリー構造: 親→子の関係を持つデータ構造
  • 統一的な操作: ルート要素も葉要素も、同じインターフェースで扱いたい
  • 再帰的な処理: 子要素に対して同じ操作を適用したい

これはまさにCompositeパターンが解決する問題です。

次回予告

次回は、Compositeパターンを導入して、この問題を解決します。見出しを「ツリー構造」として表現し、再帰的に処理できるようにします。

ポイントは:

  • Heading Role(共通インターフェース)
  • LeafHeading(子を持たない葉要素)
  • SectionHeading(子を持つ複合要素)

という3つの構成要素でツリー構造を作ることです。

今回の「失敗」コード

今回作成したHierarchicalTOCRendererは、問題を体験するための「意図的な失敗」コードです。実際のプロジェクトでは使わないでください。

 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
package HierarchicalTOCRenderer;
use v5.36;
use Moo;

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

sub render ($self) {
    my @output;
    my $prev_level = 0;
    
    for my $h ($self->headings->@*) {
        my $level = $h->{level};
        my $text  = $h->{text};
        
        if ($level > $prev_level) {
            for my $i (1 .. ($level - $prev_level)) {
                push @output, "<ul>";
            }
        } elsif ($level < $prev_level) {
            for my $i (1 .. ($prev_level - $level)) {
                push @output, "</li>";
                push @output, "</ul>";
            }
        } else {
            if ($prev_level > 0) {
                push @output, "</li>";
            }
        }
        
        push @output, "<li>$text";
        $prev_level = $level;
    }
    
    for my $i (1 .. $prev_level) {
        push @output, "</li>";
        push @output, "</ul>";
    }
    
    return join("\n", @output);
}

1;

まとめ

今回は、階層構造をif/else条件分岐で表現しようとして、壁にぶつかる体験をしました。

学んだこと:

  • フラットな配列から階層構造を生成することの難しさ
  • 条件分岐の爆発(複雑なエッジケースへの対応)
  • 単一責任原則(SRP)違反の兆候
  • なぜデザインパターンが必要なのか

次回は、この問題を解決するCompositeパターンを導入します。

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