前回の振り返り
前回は、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">...
|
条件分岐がさらに増えます。
破綻の兆候
コードを見返してみると、以下のような問題が見えてきます:
- 条件分岐の爆発: レベル差、前後関係、特殊ケースごとに
if/elsif/elseが増える - 状態管理の複雑化:
$prev_levelだけでなく、「開いている<ul>の履歴」も追跡が必要に - 拡張性の欠如: 新しい出力形式(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パターンを導入します。