2_AIで下駄を履いてなんかすごそうなゲームやらツールを作るのではなく、まずは再学習も兼ねて身の丈に合った開発から始めた話
前回↑は、Zenn向けの記事自動整形ツールとして、
H2を検知する
↓
前後へ<br>を追加する
↓
ファイルへ出力する
↓
pytestで確認する
ところまで作った。
その最後に、
- 連続した空行を
<br>へ変換する - 引用ブロックを扱えるようにする
という二つを、次に作るものとして挙げていた。
今回は、その続きを実装していく。
1. 今回形にしたかったもの
今回追加したかった機能は二つ。
一つ目は、連続する空行の変換。
自分が文章を書くときの空行を、
空行2個
→ <br> 1個
空行4個
→ <br> 2個
のように変換する。
奇数個だった場合については、
2 → 1
3 → 1
4 → 2
5 → 2
とすることにした。
つまり、
blank_count // 2
で<br>の数を求めればよさそうだった。
もう一つは引用ブロック。
入力が、
> 引用の最初
引用の続き
さらに続く
通常文章
だった場合、
> 引用の最初
> 引用の続き
> さらに続く
通常文章
へ変換したい。
前回考えていた通り、
> を検知
↓
引用状態に入る
↓
後続行も引用として処理
↓
空行を検知
↓
引用状態を解除
という形で実装してみることにした。
2. まずは、空行を数える
これまでの処理は基本的に、
1行読む
↓
その1行を判定する
↓
その1行を変換する
というものだった。
H2であれば、
if line.startswith(h2_candidates):
と現在の行を見るだけで判定できる。
ところが空行の場合はそうはいかない。
空行を一つ見つけただけでは、
空行が1個なのか
2個なのか
3個なのか
4個なのか
がまだ分からない。
後続の行を見る必要がある。
そこで、process_all_lines()の中に、
blank_count = 0
を持たせることにした。
空行だった場合はすぐに出力せず、
if line == "":
blank_count += 1
continue
として、とりあえず数だけ記録する。
そして次の文章が来たところで、
if blank_count == 1:
formatted_text.append("")
for ct in range(0, blank_count // 2):
formatted_text.append("<br>")
blank_count = 0
としてまとめて処理する。
空行が一つだけなら、普通の空行として残す。
二つ以上続いていれば、
blank_count // 2
の数だけ<br>を追加する。
前回なんとなく考えていた、
2 → 1
3 → 1
4 → 2
5 → 2
という仕様が、そのままコードになった。
3. 引用ブロックには「状態」が必要だった
次は引用。
こちらは、
quote_block = False
というフラグを持つことにした。
行頭の>を見つけたら、
if line.startswith(quote_candidates):
quote_block = True
として引用状態へ入る。
その状態をformat_all()へ渡す。
formatted_text.append(
format_all(line, quote_block)
)
format_all()側では、
def format_all(line: str, quote_block: bool = False) -> str:
if quote_block:
return format_quote_block(line)
if line.startswith(h2_candidates):
return format_h2(line)
else:
return line
という形にした。
引用中であれば、まずformat_quote_block()へ渡す。
def format_quote_block(line: str) -> str:
quote = line.removeprefix(">").lstrip(" ")
return "> " + quote
最初の行にすでに>が付いていれば一度取り除き、
最終的には、
> 文章
という形へ揃える。
そして空行が来たら、
if line == "":
quote_block = False
として引用状態を終了する。
これで、
> 色々考えた結果
目標を捨てた方が
うまくいくような気がした
という入力を、
> 色々考えた結果
> 目標を捨てた方が
> うまくいくような気がした
へ変換できるようになった。
4. 今回一番悩んだのは、実装そのものではなかった
今回の作業自体は、1〜2時間ほどで一段落した。
ただ、自分が想像していたほど綺麗なコードにはならなかった。
なんとなく、
もっとベストプラクティス的な書き方があるのでは?
という感覚が残っている。
特に悩んだのが、
「空行を変換する処理を、どの関数へ置くのか」
だった。
5. 本当はformat_all()の下へ置きたかった
当初、自分の中では、
process_all_lines()
↓
format_all()
↓
個別のformat系関数
という依存関係を考えていた。
H2であれば、
process_all_lines
↓
format_all
↓
format_h2
となっている。
引用についても、
process_all_lines
↓
format_all
↓
format_quote_block
という形にできた。
なので空行についても、
process_all_lines
↓
format_all
↓
format_blank_line的な何か
として処理できれば、構造として綺麗になるような気がしていた。
ただ、実際に書こうとするとそう簡単ではなかった。
6. 一行だけを受け取る関数では、空行の数が分からない
format_all()が受け取っているのは、その時点の一行だけ。
例えば、
format_all("")
と渡されたとしても、
その空行が、
1個だけ存在する空行
なのか、
4個連続する空行の1個目
なのかは分からない。
つまり、空行変換は、
一行だけ見れば判断できる処理ではない。
いったん空行を発見したら、
空行
↓
まだ空行
↓
まだ空行
↓
文章が来た
↓
ここで初めて空行の数が確定
という流れになる。
そう考えると、
ファイルを一行ずつ読みながら全体の状態を持っているprocess_all_lines()側にblank_countがあるのも、そこまで不自然ではない気がしてきた。
7. 関数として切り出そうともした
もちろん、
def format_blank_lines(blank_count):
...
のような関数として切り分けることも考えた。
ただ、そうしたところで、
blank_countを増やす
↓
いつ出力するかを判断する
↓
関数へblank_countを渡す
↓
戻り値を受け取る
↓
blank_countを初期化する
といった処理は、結局process_all_lines()側に残る。
関数を一つ増やしたことで劇的に読みやすくなるわけでもない。
むしろ今回の規模だと、
関数を分けるための処理が増えて、逆に追いにくくなる
ように感じた。
なので今回は、無理に分離するのをやめた。
綺麗に見せるために関数を増やすより、
この関数がファイル全体の状態を管理している
ということを優先して、process_all_lines()の中へ残すことにした。
8. 前回とは少し違う種類の処理になった
前回作ったH2変換は、
現在の一行だけを見る処理
だった。
今回追加した二つは違う。
引用ブロックでは、
quote_block
を使って、
前の行で始まった状態を次の行へ引き継ぐ。
空行変換では、
blank_count
を使って、
複数行を読み終わるまで結果を確定しない。
同じ「文章整形」でも、
一行だけ見れば判断できる処理
と、
複数行にまたがって状態を持つ処理
では、コードの置き場所も考え方も変わってくる。
前回の記事の最後に、
次の引用処理では、前の行から状態を引き継ぐ処理が必要になる。
と書いていた。
実際にやってみると、引用だけではなく空行についても同じ問題にぶつかることになった。
9. 実ファイルを通して確認する
今回も最後は、単体の文字列だけではなく実際のMarkdownファイルを通した。
入力ファイルには、
## 見出し
だけではなく、
通常文章、複数の空行、引用文などを混在させている。
それを、
formatted_text = process_all_lines(input_path)
save_formatted_text(output_path, formatted_text)
と実際の処理へ通し、
期待しているMarkdownファイルと比較する。
これで、
H2整形
+
空行変換
+
引用ブロック変換
+
ファイル読み込み
+
ファイル保存
までをまとめて通した状態を確認できる。
前回作ったパイプラインテストが、
新しい機能を追加した後もそのまま使えるのは少し嬉しかった。
機能を足すたびに、
最終的に欲しいMarkdownが出力されるか
を一つの基準として残せる。
10. 今日得た理解
今回一番残ったのは、
関数を細かく分ければ、それだけで綺麗な設計になるわけではない
という感覚だった。
最初は、
process_all_lines
↓
format_all
↓
それぞれのformat関数
と綺麗に並べたかった。
ただ、処理の性質そのものが違えば、
無理に同じ階層へ押し込める必要もない。
今の自分なりに整理すると、
format_all()
→ その一行をどう変換するか
process_all_lines()
→ 複数行を読みながら、今どんな状態なのかを管理する
という分け方になった。
これが最適なのかはまだ分からない。
もっと良いやり方はある気がする。
ただ、
なぜ今この場所にこの処理を書いているのか
については、最初より説明できるようになった。
前回は、
一行を見る処理と、ファイル全体を見る処理は別物
というところまでだった。
今回はそこから少し進んで、
一行単位の変換と、複数行にまたがる状態管理も別物
ということを実装しながら理解した気がする。
11. 今日できたもの
今日の終了時点では、
- 連続する空行のカウント
- 空行2個を
<br>1個へ変換する処理 - 奇数個の空行を切り捨てる処理
- 引用開始の検知
- 引用状態の保持
- 引用中の行へ
>を付ける処理 - 空行による引用状態の終了
- H2整形を含めた既存処理との統合
- 実Markdownを使ったパイプラインテスト
までが残った。
前回はH2一つだけだった文章変換処理に、
複数行をまたいで判断する機能
を二つ追加できたことになる。
12. 残っているもの
一方で、コードそのものにはまだ少しモヤモヤが残っている。
特にprocess_all_lines()は、
ファイルを読む
空行を数える
引用状態を管理する
変換処理へ渡す
という複数の役割を持ち始めた。
今の規模なら追える。
ただ、ここからさらに機能を足していけば、
いつか本当に分ける必要が出てくるかもしれない。
今回は、
分けられるから分ける
のではなく、
分けた方が理解しやすくなった時に分ける
ことにした。
13. 終わりに
今回は1〜2時間程度の作業だった。
やろうとしていた二つの機能も、一応形にはなった。
ただ、前回より「綺麗にできた」という感覚は薄い。
むしろ、
もっと綺麗に書けそうなのに、どう分ければいいのか分からない
という感覚の方が強かった。
以前なら、その状態を単に
「設計が下手だった」
で終わらせていたかもしれない。
今回は少しだけ踏み込んで、
なぜ分けにくいのか
↓
一行だけで完結する処理ではない
↓
複数行の状態をどこかで持つ必要がある
↓
今はprocess_all_linesに置く
というところまでは考えられた。
最初の記事で、
「自分が何を書いているのか理解しながら、小さな機能を一つずつ積み上げる」
と書いた。
綺麗なコードを書けることだけが、
「理解している」ということではないのかもしれない。
少なくとも今は、
なぜこのコードがここにあるのかを、自分なりに説明できること。
そこから始めればいい気がしている。
次の記事↓
Discussion