Featured image of post コードガーデナー【State】フラグを増やすたび、ありえない組み合わせも増えていく〜生育段階は、型で一つに決める〜

コードガーデナー【State】フラグを増やすたび、ありえない組み合わせも増えていく〜生育段階は、型で一つに決める〜

boolフラグ3つで生育段階を管理していた盆栽記録アプリが「休眠中なのに開花中」という矛盾状態を作ってしまう。RustのState(enum+match)で不正な組み合わせを型として排除する手順を、庭師と見習いのやり取りで追う。

秋の日、駅前のロータリーを抜けて

祖父の盆栽園を引き継いでから、もうすぐ二年になる。私は総務職の会社員で、平日は盆栽に触れる時間なんてほとんどない。週末だけ盆栽教室に通って、実家の片隅に残った五鉢——黒松、五葉松、楓、梅、つつじ——を細々と世話している。祖父が生きていた頃はもっと多かったはずだが、私が引き継げたのはこの五鉢だけだった。

その日は教室帰りだった。十月の陽は思ったより早く傾いていて、教室の先生に「そろそろ落葉樹の水やり間隔も変えないと」と言われたことが、まだ頭の隅に引っかかっていた。電車を一駅乗って、駅前のロータリーを抜けて歩く。手には、自分で書いたコードを手書きで写したメモがある。昨夜、寝る前に見つけた矛盾に対して、自分なりに直したつもりの一手を書き足した紙だった。「これで直したはず」という小さな自信があった――なにしろ、直したはずの経路では、ちゃんと矛盾が消えていることをこの目で確認したのだから。

道すがら、庭師さんの工房の入口にある一角を通りかかる。大きな古い木が一本、そこに立っている。急いでいたせいで、今日は「変わらずそこにあるな」くらいの印象しか持たなかった。教室帰りでまだ頭が盆栽用語でいっぱいだったせいもあると思う。

工房に着くと、庭師さんは挨拶もそこそこに、作業台に置いた私のノートパソコンへ、腰も下ろさないまま視線だけをよこした。急かされないのはいつものことだけれど、今日はこちらが先に切り出したい気持ちが勝っていた。

「これなんですけど……梅の鉢、休眠中って表示されてるのに、開花中のフラグも立ったままになってて。しかも一度直したはずなのに、また出てきたんです」

画面には、自分で書いた記録アプリのコードが出ている。

1
2
3
4
5
6
struct Bonsai {
    name: String,
    is_dormant: bool,
    is_growing: bool,
    is_blooming: bool,
}

祖父は生育段階(休眠・成長・開花)を紙の記録用紙にチェックボックスで書いていた。三つの升目が横に並んでいるだけの、ごく素朴な様式だった。鉢が増えて紙では追いきれなくなり、独学で覚えたRustでこの記録アプリを作ったのは去年の春だ。紙の様式をそのまま三つのbool型フィールドへ移植しただけのつもりだった。祖父のやり方を裏切りたくなかったから、あえて構造を変えず、そのまま置き換えた――そのつもりだった。

実は来月、教室の生徒が持ち寄る小さな展示会があって、梅の鉢もそこに出す予定だった。休眠期に入る前の最後の手入れ記録を、正確に残しておきたかった。記録がおかしいままだと、いつ何をしたのかが自分でも信用できなくなる。だからこそ、昨夜見つけた矛盾を放っておけなかった。

「祖父の紙だと、こんな矛盾は一度も起きなかったんです。なのに、コードにしたらこうなって。しかも、直したつもりなのに、また出てくる」

沈黙のまま、コードを追う指

庭師さんは何も言わなかった。画面をスクロールする指だけが動く。工房の奥から、乾いた土と葉の匂いが薄く漂ってくる。秋のこの時期は、鉢の入れ替えで工房全体が少し落ち着かない空気になるらしいが、庭師さんの手つきにはそんな気配は一切なかった。

画面の外、作業台の隅には、庭師さんが手入れ中らしい鉢がいくつか並んでいた。私が来る前からそこにあったものだろう。誰の手も入っていないその静けさと、いま画面の上で私が抱えている矛盾との落差が、なんだか妙に対照的に思えた。

start_dormancy、私が足したfix_conflicting_flags、それから一覧画面側の更新処理——庭師さんの視線は順番にそこを通り過ぎていく。私は横で、その視線がどこで止まるのかを、息を詰めるようにして目で追った。

「昨日の夜、これで直ったの確認したんですけど」

私は食い下がったけれど、庭師さんは何も答えない。一覧画面側の更新処理のコードの前で、瞬きが一拍だけ止まった。ほんの一瞬のことで、見ていなければ気づかなかったと思う。

「……何か、おかしいところありました?」

「もう一つ、ある」

それだけだった。何がもう一つあるのか、私には見当もつかなかった。昨夜、詳細画面側の処理を直したときは、たしかにテストで矛盾が消えたことを確認した。なのに「もう一つ」と言われて、足元が少しぐらついたような気がした。

見習いヒバリの一言

庭師さんは、また何も言わないまま、一覧画面側の更新処理の行を指でなぞった。その動きだけを見て、ヒバリさんが庭師さんより先に言い当てた。

「あ——これ、fix_conflicting_flagsが呼ばれてない経路がありますね」

庭師さんは小さく頷いただけだった。

ヒバリさんが私の方に向き直る。

「フラグ、3つありますよね。休眠・成長・開花、それぞれ○か×かで、2×2×2で8通り作れちゃうんです。でも本当にありえる状態は3つだけ。矛盾を直す関数を足しても、呼び忘れた場所ではまた同じ矛盾が起きます」

ヒバリさんはそう言うと、作業台の隅から木札を三枚取り出して並べた。休眠、成長、開花――それぞれに○×の紐が結んである。「これ、三本バラバラに結ぶと、どっちもどっちも選べちゃうんです。でも札を一枚にして、どれか一つだけ選べるようにすれば……」言いながら、三枚の木札を重ねて、一枚の新しい札に描き直してみせた。

荒れ札(Before)は独立した3枚のフラグ札で8通りを作れてしまうが、剪定札(After)は1枚のenum札に統合され3通りだけになる対比図

私が足したfix_conflicting_flagsは、こういうものだった。

1
2
3
4
5
6
// 依頼人が自力で追加した応急処置。呼び出す経路にしか効かない
fn fix_conflicting_flags(bonsai: &mut Bonsai) {
    if bonsai.is_dormant && bonsai.is_blooming {
        bonsai.is_blooming = false;
    }
}

私は詳細画面側の更新処理にだけ、この呼び出しを足していた。一覧画面からもクイックで生育段階を変えられることを、正直あまり意識していなかった。急いでいたし、詳細画面のバグさえ直れば十分だと思っていた。

「じゃあ、休眠フラグと開花フラグが同時に立たないよう、どこかで弾けば直りますか」

私は一段浅い理解で聞き返した。チェックをもう一つ、別の場所にも足せばいいのだろう、というつもりだった。

「近いですけど、チェックを増やすんじゃなくて——これ、Stateっていう定石があって、生育段階そのものを1つの型として表現するんです」

State。初めて聞く名前だった。

ヒバリさんが続ける。is_dormantis_growingis_bloomingという3つのboolは、それぞれ○か×かの2通りを持てるから、組み合わせとしては2×2×2=8通り作れてしまう。でも実際に意味のある生育段階は「休眠」「成長」「開花」の3通りだけ。残りの5通り——「休眠かつ開花」「全部×」のような組み合わせは、紙の運用では起きなかったのに、コードの上ではフィールドとして存在してしまう。

「じゃあ、私がチェックを足しても足しても、また別のところで同じことが起きるのって……」

「はい。呼び出せる場所が増えるたびに、fix_conflicting_flagsを呼び忘れる場所も一緒に増えます。今回は一覧画面でしたけど、来月アプリに機能を足したら、また別の画面で同じ矛盾が起きるかもしれません」

言われてみれば、その通りだった。詳細画面と一覧画面の二つを塞いだところで、次に自分が三つ目の更新経路を足せば、また同じ穴が開く。応急処置は、穴を塞ぐたびに新しい穴の場所を増やしているだけだったのかもしれない。私は昨夜、詳細画面側のテストが通った時点で「直った」と思い込んでいた。でも実際には、直したのは一箇所の症状であって、症状が生まれる仕組みそのものではなかった。

「最初は3つのチェックボックスをそのまま持ってきただけだったんです。まさかこんなことになるとは思わなくて」

祖父の紙は、鉢が2つだけの頃は矛盾なんて一度も起きなかった。鉢が5つに増えるまでは、それで十分足りていた。紙の升目は、指でチェックを入れる場所であって、システムが勝手に何通りも組み合わせを作れる場所ではなかったのだ、と今になって思う。

手入れ ── フラグを、型に置き換える

庭師さんが実際にコードへ手を入れ始めた。キーボードを叩く手つきは急がず、けれど迷いもなかった。3つのboolフィールドを、1つのenumに置き換える。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
enum GrowthStage {
    Dormant,
    Growing,
    Blooming,
}

struct Bonsai {
    name: String,
    stage: GrowthStage,
}

enumは、いくつかの決まった選択肢から1つだけを選ぶための型だ。Bonsaiが持つstageは、常にDormantGrowingBloomingのどれか1つでしかありえない。「休眠かつ開花」に対応する値は、そもそもこの型の中に存在しない。

更新関数も書き換わる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn start_dormancy(bonsai: &mut Bonsai) {
    bonsai.stage = GrowthStage::Dormant;
}

fn start_growing(bonsai: &mut Bonsai) {
    bonsai.stage = GrowthStage::Growing;
}

fn start_blooming(bonsai: &mut Bonsai) {
    bonsai.stage = GrowthStage::Blooming;
}

start_dormancyを呼べば、詳細画面側からでも一覧画面側からでも、stageDormant一つに決まる。以前のように「is_bloomingのリセットを忘れる」というミスの入り込む隙間が、書き方のレベルで塞がっている。事後補正のfix_conflicting_flagsはもう要らない。将来、三つ目の更新経路をどれだけ足しても、同じstart_dormancyを呼ぶ限りは同じことが起きる。呼び忘れる対象そのものが存在しない。

――もっとも、新しい画面を作ったときにstart_dormancyの呼び出し自体を書き忘れれば、stageは古い値のまま更新されない。それは今回と別種の不具合で、Stateパターンが解決するのはそこではない。防げるのは「更新したのに、休眠かつ開花のような、ありえない組み合わせになる」方だけだ。更新そのものを忘れることと、更新した結果が矛盾することは、切り分けて考える必要がある。

「これだけで、いいんですか……? チェックとか、もう書かなくて平気なんですか」

自分がここまで身構えていたわりに、拍子抜けするくらい簡潔だった。

「フィールドを一つにしただけです。矛盾が起きないようにしたんじゃなくて、矛盾を表せないようにしました」

生育段階を表示する関数も変わる。

1
2
3
4
5
6
7
fn describe(bonsai: &Bonsai) -> String {
    match bonsai.stage {
        GrowthStage::Dormant => format!("{}は休眠中", bonsai.name),
        GrowthStage::Growing => format!("{}は成長期", bonsai.name),
        GrowthStage::Blooming => format!("{}は開花中", bonsai.name),
    }
}

matchは、enumが取りうる値のパターンごとに処理を書く仕組みだ。書き換わったコードを指さして、ヒバリさんが説明を続けた。

「フィールドが1つになったので、そもそも矛盾する値を作れません。それに、あとで生育段階を増やしたときも——ちょっと見せますね」

ヒバリさんは、念のため確かめるような手つきで、describematchからBloomingの腕をわざと外してビルドしてみせた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
error[E0004]: non-exhaustive patterns: `&GrowthStage::Blooming` not covered
 --> state_exhaust.rs:8:11
  |
8 |     match stage {
  |           ^^^^^ pattern `&GrowthStage::Blooming` not covered
  |
note: `GrowthStage` defined here
 --> state_exhaust.rs:1:6
  |
1 | enum GrowthStage {
  |      ^^^^^^^^^^^
...
4 |     Blooming,
  |     -------- not covered
  = note: the matched value is of type `&GrowthStage`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard
      pattern or an explicit pattern as shown

「これ、直すの忘れてたら教えてくれるってことですか」

「はい。enumのバリアントをmatchで一つでも書き漏らすと、コンパイラがそこを黙って通しません。網羅していないと、こうやってビルドの時点でエラーになります。将来、例えば結実期みたいな新しい段階を増やしたときも、このdescribeを直し忘れていたら、実行時に気づく前にビルドが止まります」

これを網羅性チェックというのだと、ヒバリさんは付け加えた。enumのバリアントを1つでも書き漏らしたままmatchを書くと、コンパイラがそれを見逃さず教えてくれる、という仕組みらしい。フィールドをenumに統合したことで「存在しない値を作らせない」、この網羅性チェックで「存在する値への対応漏れを作らせない」――どちらも、人間の注意力ではなくコンパイラに保証させている点は同じなのだと、私は後から理解した。

「じゃあ、これでもうフラグの矛盾は、金輪際起きなくなるんですか」

「今回みたいな『どの生育段階か』を表すだけなら、これで十分です。もっと厳しく、状態ごとに"呼べる操作"自体を変えたいような、間違えたら困る場面だと、状態ごとに別の型を用意するようなやり方もあるんですけど――梅の生育段階は、表示や判定が変わるだけで、呼べる操作自体は増減しないので、今回はそこまでしなくていいと思います」

ヒバリさんはそう言って、それ以上は深追いしなかった。私も、まずは目の前の矛盾が消えることの方が大事だった。

私はしばらく画面を見つめてから、口を開いた。

「つまりこれ……直したつもりが、実は直せてなかった、ってことですよね。fix_conflicting_flagsを足したときは、矛盾を"見つけて直す"ことしか考えてなくて。そもそも矛盾を"作れなくする"っていう発想が無かった」

ヒバリさんが頷く。

「事後チェックだと、呼び忘れた場所が必ず抜け穴になります。enumだと、その抜け穴自体が最初から無いんです」

ヒバリさんの言葉を、私はもう一度自分の中で並べ直した。塞いだはずの穴と、そもそも穴の空きようがない形――同じ「直す」でも、たどっている道筋がまるで違う。

事後補正は呼び忘れた経路が抜け穴になり矛盾したままになるが、型で排除する設計はどの経路を通っても矛盾する値が存在しないことを示す対比図

その一言が、妙に腑に落ちた。私は昨夜、詳細画面側の穴を一つ塞いだつもりで、実は塞いだ場所を数えていただけだったのだと思う。

見送り ── もう一度、老木の前で

試しにstart_dormancyを何度呼んでも、stageDormant一つのまま動かなかった。詳細画面の経路を通しても、一覧画面の経路を模して呼んでも、同じだった。祖父の紙のチェックボックスが、結局は一度に一つしか塗りつぶせなかったのと同じことが、コードの上でも成り立っている。それを目で確かめてから、ようやく肩の力が抜けた。

「祖父の紙は3つのチェックボックスだったのに、なんで矛盾しなかったんだろうって、さっきまで不思議だったんです。多分、紙には"両方同時にチェックを入れる"っていう発想自体が最初から無かったんですよね。私のコードだけ、フィールドを3つ独立に持たせたせいで、その余地を自分で作ってしまってた」

私は自分の言葉で、そう確かめた。祖父のやり方を裏切らないように、そのまま移植したつもりだったのに、実は移植のしかたそのものが違っていたのだ。

工房を出るとき、もう一度あの入口の一角を通りかかった。来たときは足早に通り過ぎただけだったのに、今度は少し、足を止めたくなった。夕方に近い光が、幹の低いところにだけ長く差し込んでいる。

「あの……庭師さんは、この木がいつからここにあるか、知ってるんですか」

前に、見習いのヒバリさんから「実は師匠も、全部は知らないらしい」と聞いたことを思い出しながら、今度は本人に尋ねてみた。ダメ元のつもりだった。

庭師さんは、肯定も否定もしなかった。一拍、老木の方へ視線をやってから、短く一言だけ返した。

「さて」

それ以上は、何も。でも、答えなかっただけで、無視もされなかった。そのことが、なぜか少しだけ印象に残った。

駅前のロータリーへ戻る道すがら、私はその「さて」の一言を、何度か頭の中で繰り返していた。来月の展示会には、直った記録アプリで管理した梅の鉢を出すことになる。今度は、矛盾を見つけて慌てて塞ぐのではなく、最初から矛盾を作れない形で記録を続けられる。そのことだけは、はっきりと持ち帰れる気がした。


手入れ記録

こんな症状が出たら入れるべき手入れ(パターン)まだ様子見でいい
複数のboolフラグの組み合わせで、本来ありえない状態が生まれる✓ State(enummatch
状態が矛盾したときに、事後チェックのif文で補正している✓ State(enummatch
フラグは複数あるが、独立して切り替わってよく、組み合わせに矛盾が生まれない✓ 現状のまま様子見

手入れの手順

  1. 対象の値が取りうる「意味のある状態」を、まず日本語で数え上げる(今回なら「休眠」「成長」「開花」の3つ)
  2. 現状のboolフィールドの組み合わせ数(2^n通り)と、意味のある状態の数を比べ、余剰がどれだけあるかを確認する
  3. 意味のある状態それぞれに対応するenumのバリアントを定義し、boolフィールド群を1つのenumフィールドへ置き換える
  4. 各状態更新関数を、フィールドへの代入からenumバリアントの代入へ書き換える
  5. 状態を分岐処理する箇所(表示・判定など)をmatchで書き直し、事後補正のための条件分岐が不要になったことを確認する

庭師の一言

札を掛け直すより、掛けられる場所を一つにする方が早い。

見習いの手控え

事後チェックは、矛盾が起きた後でしか気づけません。enumは、矛盾そのものを型の外に置く手入れです。“直す"を増やすんじゃなくて、“間違えられない"を作るんですね——今日はそれがすとんと腑に落ちました。

comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。