Featured image of post コードガーデナー【Iterator】区画ごとに違う内部構造、巡回コードまで書き直してきた年末点検〜歩き方は、中身の形を知らなくていい〜

コードガーデナー【Iterator】区画ごとに違う内部構造、巡回コードまで書き直してきた年末点検〜歩き方は、中身の形を知らなくていい〜

区画ごとに内部構造(配列かハッシュか)が違うせいで、巡回コードまで書き直す羽目になっていた。庭師が教えるのは、巡回の手段を内部構造から切り離すIteratorだ。

冬の夕暮れ、自転車で最後の一軒へ

精密機器メーカーで、長らく組み込みソフトウェアの品質検証をしていた。定年を迎えて二年、今は地域の貸し出し区画型の市民農園「ひだまり農園」の運営委員会で、ボランティアとして事務と会計を手伝っている。今年の春、長く農園を切り盛りしてきた運営委員長が体調を崩して退任し、私が後任を引き受けることになった。

前の運営委員長は元ITエンジニアで、在任中に区画管理用の簡単なRust製ツール一式を書き残してくれていた。年に一度、全区画を巡って「水やり状況」と「越冬準備」をチェックリストにまとめる年末点検レポートも、そのツールの一つだ。急な退任だったので、詳しい話を聞く時間はほとんどなかった。

今朝、そのレポートを初めて自分の手で実行した。ターミナルに流れる結果を、手元の紙の台帳——今年登録した区画の一覧を、手書きで写しておいたもの——と、一行ずつ指でなぞりながら照らし合わせた。数が合わない。今年新しく開放した区画の作付けが、一件もレポートに出てこない。品質検証の仕事では、こういう食い違いを見つけたら、まず自分の入力から疑う癖がついている。今回もそうした。

てっきり自分の登録の仕方が間違っていたのだと思い、引き継ぎノートの手順を何度も読み返した。同じ手順を、二度、三度となぞってみたが、登録そのものに誤りは見つからない。原因が分からないまま、私は予定していた年末点検の巡回に出た。一区画ずつ、水やりの跡と防寒対策を確認して回るだけの、いつもの仕事だ。自転車のペダルを漕ぐたびに、頭の片隅ではさっきの数字のことが引っかかっていた。全区画を回り終える頃には陽が傾き始めていて、その最後の立ち寄り先として、引き継ぎノートの隅にあった走り書きを頼ることにした。「行き詰まったら、駅裏の工房の庭師さんに相談するとよい」

凍える夕方だった。手袋越しにハンドルを握っていても、指先の感覚が鈍っていく。入口の一角を通り過ぎた記憶はあるはずなのに、そこに古い木があったかどうかさえ、あとになって思い出せなかった。それくらい、レポートの欠落のことで、頭の中がほとんど占められていた。

戸を叩くと、庭師さんは短く会釈しただけで、すぐに私のノートパソコンの画面へ目を落とした。

「たぶん、私の登録の仕方が間違っていたんだと思います」

そう切り出してから、経緯を説明した。年末点検レポートの出力件数が、紙の台帳より明らかに少ないこと。今年新設した区画の作付けが、一件も現れていないこと。登録の手順は何度も見直したが、誤りが見つからないこと。

ノートパソコンには、年末点検で使っているレポート生成コードが表示されたままになっていた。

 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
use std::collections::HashMap;

struct Plant {
    name: String,
    watered_recently: bool,
    needs_winter_cover: bool,
}

struct CheckItem {
    plant_name: String,
    note: String,
}

fn inspect(plant: &Plant) -> CheckItem {
    let note = match (plant.watered_recently, plant.needs_winter_cover) {
        (false, true) => "水やり未確認・防寒未着手".to_string(),
        (false, false) => "水やり未確認".to_string(),
        (true, true) => "防寒の確認が必要".to_string(),
        (true, false) => "異常なし".to_string(),
    };
    CheckItem {
        plant_name: plant.name.clone(),
        note,
    }
}

struct NewPlot {
    plants: Vec<Plant>,
}

struct LegacyPlot {
    // 作付け位置番号 → Plant。長年の退会・統合で欠番がある
    plants: HashMap<u32, Plant>,
}

今年新設した区画はNewPlotという形で、連番のままVecに登録するよう、引き継ぎノートに指示があった。古くからある区画はLegacyPlotという形で、区画内の作付け位置を番号で管理している。長年のあいだに退会や区画の統合があったせいで、番号は歯抜けだ。前の運営委員長は、これをHashMapで持つことにしたのだろう。

レポート本体のコードも見せた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn legacy_checklist(plots: &[LegacyPlot]) -> Vec<CheckItem> {
    let mut items = Vec::new();
    for plot in plots {
        for plant in plot.plants.values() {
            items.push(inspect(plant));
        }
    }
    items
}

fn year_end_report(legacy: &[LegacyPlot]) -> Vec<CheckItem> {
    legacy_checklist(legacy)
}

一目見ただけでは、特におかしなところはないように思えた。inspectが水やりと越冬準備の状態を見て、チェック項目を組み立てる。legacy_checklistがそれを区画ぶんまとめる。year_end_reportがそれを呼ぶ。ごく普通の流れに見える。

症状観察 ── 土を踏みしめる足に、力がこもる

庭師さんは、しばらく無言のまま、legacy_checklistの中身とyear_end_reportの引数のあいだで、目だけを何度も往復させた。工房の奥には、防寒布をかぶせられた鉢がいくつも並んでいる。窓の外では、隣家の風鈴が北風に小さく鳴っていた。長い一日の終わりに、この静けさだけが妙に際立って感じられた。

私は、今年登録した新設区画のデータも別の画面で見せた。前の運営委員長の手順どおりにNewPlotとして登録した、レタスやキャベツの作付け情報が、確かにそこにある。

「登録は、ちゃんとできてると思うんですが……」

庭師さんは、それ以上何も答えなかった。土を踏みしめていた足の裏に、わずかに力が集まる。それだけだった。

「これ、私の登録が悪かったんでしょうか」

我慢できずに尋ねると、少し間があってから、庭師さんがようやく答えた。

「登録は、間違っていない」

それだけ言って、また画面に視線を戻す。肝心の原因については、その先をまだ聞かせてもらえなかった。

見立てと翻訳 ── 二つの画面を、並べて見る

ヒバリさんは、いつものように言葉を継ぐ前に、二つの画面を私の前に並べた。左にyear_end_reportのコード、右に私が登録したNewPlotのデータ。

「何が違うか、見比べてみてください」

言われるままに、左右を交互に見た。しばらくして、気づいた。

「……あ。新しい区画の方、このレポートの関数の中に、一度も出てきてないです」

year_end_reportの引数はlegacy: &[LegacyPlot]だけだ。今年登録したNewPlotのリストを渡す場所が、そもそも存在しない。登録は正しくできているのに、レポート側にそれを受け取る手がない。

ヒバリさんが頷く。「それです。登録は正しくても、レポート側に新設区画を巡る道が、最初から無いんです」

年末点検レポートの通り道はLegacyPlotのところで完結しており、正しく登録されたNewPlotへは渡す引数が存在しないため線が繋がっていないことを示す図

ヒバリさんが指し示した図を、私は食い入るように見た。左に描かれた点検レポートの通り道は、LegacyPlotのところで完結している。右に置かれたNewPlotの四角には、そこへつながる線が一本も伸びていなかった。

「登録された作付けは、ちゃんとそこにあるんです。ただ、レポート側から見ると、そこへ辿り着く道が最初から引かれていないだけで」

庭師さんは、画面の空白の部分を指先で一度だけ叩いた。それだけだった。

前の運営委員長がこの関数を書いた頃は、区画はまだHashMapの形のものしかなかったのだろう。区画が増えることは見越してNewPlotという受け皿は用意してくれていたようだが、レポート本体をそこまで対応させる作業には、体調を崩しての急な退任で、手が回らなかったに違いない。誰の落ち度でもない、というのが、コードを見ながら私の実感になった。

「じゃあ、year_end_reportnewを渡す引数を足すだけでも、今回は直りますよね」

品質検証の癖で、まず一番手っ取り早い直し方を口にしてみる。庭師さんが頷いた。それで今回は直る、ということらしい。ただ、とヒバリさんが付け加える。「それだけだと、来年また別の形の区画が増えたとき、また同じように書き直すことになります。今日みたいに、書き忘れたまま気づかない年が来るかもしれません」

再発を防ぐ側の直し方を、今度は自分なりに提案してみる。

「じゃあ、区画から中身を全部Vecでコピーして返すようにすれば、良かったんじゃないですか」

ヒバリさんが、眉を寄せて苦笑した。「それだと、古参区画の方は一度Vecへ集め直す手間が増えますし、区画によって呼び方——.iter().values()か——が違うこと自体は、結局どこかに残ります」

同じコピーの発想で、集め直す手間を省いて参照だけ返せないか——庭師さんが、黙ってキーボードを叩いた。

1
2
3
4
5
6
7
// 却下: 参照をそのまま返す案
fn plants_of_legacy(plot: &LegacyPlot) -> &Vec<Plant> {
    // LegacyPlot は HashMap<u32, Plant> を持っているだけで、
    // Vec<Plant> はそもそも存在しない。新しく作った Vec への
    // 参照を返そうとしても、その Vec は関数を抜けた瞬間に消える。
    unimplemented!()
}

「これは、そもそも書けない形になっています」

庭師さんは、そう一言だけ告げた。NewPlot側でならコピーを返す案は動くかもしれないが、LegacyPlot側では、参照を返すという案自体が成立しない。「今は困らないが、いつか困る」という将来の話ではなく、今この場で、コードとして書けるか書けないかの話だった。

「複数の異なる形をした区画から、中身を同じやり方で一つずつ受け取れるようにする定石があります。歩き方の手段そのものを、区画の型の後ろに隠してしまうんです。Iteratorといいます」

ヒバリさんがそう続けた。庭師さんは、ただ小さく頷いただけだった。

区画の中身がVecかHashMapかを、こちらが知らなくていい、ということらしい。話だけ聞くと単純に思えたが、本当にそんなことができるのか、まだ半信半疑だった。

まず、今朝と同じ抜け落ちが起きることを、テストコードで裏づけてみることにした。

 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
#[test]
fn year_end_report_omits_registered_new_plot_because_it_has_no_way_to_receive_it() {
    let mut legacy_plants = HashMap::new();
    legacy_plants.insert(
        3,
        Plant {
            name: "トマト".to_string(),
            watered_recently: true,
            needs_winter_cover: true,
        },
    );
    let legacy = vec![LegacyPlot {
        plants: legacy_plants,
    }];

    // 新設区画は引き継ぎノートの手順どおり正しく登録されている
    let new = vec![NewPlot {
        plants: vec![Plant {
            name: "レタス".to_string(),
            watered_recently: false,
            needs_winter_cover: false,
        }],
    }];

    // year_end_reportにnewを渡す場所が無いため、legacyしか渡せない
    let items = year_end_report(&legacy);

    assert_eq!(
        items.len(),
        1,
        "legacy分の1件しか返らず、newの登録内容は反映されない"
    );
    assert!(
        items.iter().all(|item| item.plant_name != "レタス"),
        "登録は正しいのに、レタスがレポートに一件も現れない"
    );
}

newにはレタスがちゃんと登録されている。それでもyear_end_reportにはそれを渡す場所がなく、結果には一件も現れない。今朝、私が自分の目で見た抜け落ちが、そのままコードの上で再現された。

「じゃあ、区画の中身がVecかHashMapかは、こっちが知らなくてよくなるってことですか」

手入れ ── 歩き方を、区画の外に出す

庭師さんが実際にコードへ手を入れ始めた。まず、Plotという言葉の輪郭だけを決める。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
trait Plot {
    fn walk(&self) -> impl Iterator<Item = &Plant>;
}

impl Plot for LegacyPlot {
    fn walk(&self) -> impl Iterator<Item = &Plant> {
        self.plants.values()
    }
}

impl Plot for NewPlot {
    fn walk(&self) -> impl Iterator<Item = &Plant> {
        self.plants.iter()
    }
}

walkが実際に返す型は、LegacyPlotなら.values()の型、NewPlotなら.iter()の型と、実装ごとに違う。それでも呼び出す側からは、どちらも同じ「Iterator」としてしか見えない——型の名前を揃えたのではなく、見せ方だけを揃えた形だという。PlantCheckIteminspectも、そっくりそのまま残っている。

walkを実装した区画なら、中身がVecかHashMapか気にせず、同じ書き方で回せるようになります」

画面の中の変化を追いながら、ヒバリさんがぽつりぽつりと言葉にしていく。庭師さんはその間も、続けてコードを書き足していった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fn checklist(plot: &impl Plot) -> Vec<CheckItem> {
    plot.walk().map(inspect).collect()
}

fn year_end_report(legacy: &[LegacyPlot], new: &[NewPlot]) -> Vec<CheckItem> {
    let mut items = Vec::new();
    for plot in legacy {
        items.extend(checklist(plot));
    }
    for plot in new {
        items.extend(checklist(plot));
    }
    items
}

checklistがやってるのは、walkで受け取った中身を、inspectに渡すだけです。中身の点検ロジックは、Beforeと何も変わっていません」

そしてもう一つ、大事な変化があった。year_end_reportの引数に、new: &[NewPlot]が初めて現れている。

「登録は正しくても、レポート側に新設区画を巡る道が、最初から無い」——さっきヒバリさんが言ったその一言が、そのまま関数の形として直っていた。渡す場所が無いという状態自体が、もう書けなくなっている。

LegacyPlot(HashMap)とNewPlot(Vec)それぞれのwalkメソッドが、trait Plotのwalkという一点に収束し、checklistからyear_end_report(legacy, new)へつながる図

画面に並んだ二枚の区画タグと、その間に置かれたPlotという札を、私はしばらく見比べた。HashMapVec、中身の形はまるで違うのに、どちらのタグからも同じwalkという一本の蔓が伸びて、同じchecklistに辿り着いている。

「内側の形がどう違っても、外から見える歩き方の窓口は一つだけです。checklistは、その窓口としか話していません」

庭師さんは、二本の蔓が一点に集まる図を、指でなぞった。それだけだった。

今度は抜け落ちが直っているかどうか、同じやり方で見てみる。区画が四件と少し増えるので、先にPlantを組み立てる小さな補助関数を用意した。

 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
fn sample_plant(name: &str, watered_recently: bool, needs_winter_cover: bool) -> Plant {
    Plant {
        name: name.to_string(),
        watered_recently,
        needs_winter_cover,
    }
}

#[test]
fn year_end_report_includes_both_legacy_and_new_plots() {
    let mut legacy_plants = HashMap::new();
    legacy_plants.insert(3, sample_plant("トマト", true, true));
    legacy_plants.insert(9, sample_plant("ナス", false, true));
    let legacy = vec![LegacyPlot {
        plants: legacy_plants,
    }];
    let new = vec![NewPlot {
        plants: vec![
            sample_plant("レタス", false, false),
            sample_plant("キャベツ", true, false),
        ],
    }];

    let items = year_end_report(&legacy, &new);

    assert_eq!(items.len(), 4, "legacy 2件 + new 2件で計4件のはず");

    let names: std::collections::HashSet<&str> =
        items.iter().map(|item| item.plant_name.as_str()).collect();
    let expected: std::collections::HashSet<&str> =
        ["トマト", "ナス", "レタス", "キャベツ"].into_iter().collect();
    assert_eq!(
        names, expected,
        "新設区画(レタス・キャベツ)を含む全区画の作付けがレポートに現れるはず"
    );
}

古参区画二件、新設区画二件、合わせて四件。レタスとキャベツが、ちゃんと結果に含まれている。「HashMapの中身を取り出す順番は、実行するたびに変わることがあるので、件数と中身の集合だけを見るようにしてあります」とヒバリさんが横から補った。順番までは保証されていない、ということらしい。品質検証の仕事をしていた頃、同じような注意を後輩によくしていたのを思い出した。

「来年、区画の種類がもっと増えたら、これ全部まとめて一つのリストで回せますか」

一歩先が気になって聞いてみる。庭師さんが、短く首を振った。代わりにヒバリさんが、言葉を継いだ。

「今の形はそこまでは面倒を見ません。year_end_reportの中の、legacyを回すfornewを回すforのような並びを、区画の種類が増えるたびにもう一段足すことになります」

正直な答えだった。今回のwalkは、区画の型がそれぞれ何であっても同じ歩き方で回せるようにはしたが、違う型の区画を一つの入れ物にまとめて持つところまでは面倒を見ていない、ということらしい。品質検証の仕事でも、「ここまでは保証する、ここから先は別の話」という線引きを曖昧にしたまま出荷すると、後で必ずしっぺ返しが来た。今の答えは、その線を正直に引いてくれたのだと思うと、むしろ安心できた。それでも、来年また新しい形の区画が増えたときにやることは、はっきりしている。

「来年また新しい形の区画が増えても、walkさえ書けば、レポート側は直さなくていいってことですね」

頭の中で順を追って組み立て直してみて、初めてすとんと繋がった気がした。区画の中身がVecかHashMapか、あるいはその先の何かであっても、checklistにもyear_end_reportにも、一切手を入れずに済む。

見送り ── 見上げる庭師、長い沈黙

画面を閉じるのを黙って見届けたあと、庭師さんは声を発さないまま立ち上がった。私も帰り支度をして、自転車の鍵を手にする。

戸口の外まで、庭師さんが見送りに出てくれた。並んで、入口の一角の前を通った。冬で葉を落とした老木が、夕暮れの薄い光の中で、裸の枝だけを見せていた。昼間なら気にも留めなかったであろう細い影が、地面に長く伸びている。

今日の庭師さんは、木に触れなかった。根元から梢まで、視線をゆっくりと持ち上げていく。一箇所に留まらず、木全体をなぞるように。息を吸う音すら聞こえないほど、あたりが静かだった。

いつもならすぐに声をかけるところだったが、今日は声をかけそびれた。庭師さんが視線を戻すまで、私は自転車のハンドルに手をかけたまま、黙って待った。それがどれくらいの長さだったのか、うまく言えない。ただ、今日一日かけて自転車で回った区画の数よりも、この立ったままの数十秒のほうが、ずっと長い時間に感じられた。何を見ているのか、何が見えているのか、私に分かるはずもない。それでも、ただ見ている、というその事実だけは、はっきりと目の前にあった。

「ありがとうございました」

そう言葉にした私に、庭師さんは小さく頷き返しただけだった。私は凍える冬の道を、自転車でまた家路についた。ペダルを踏むたびに白い息が後ろへ流れていく。来年の年末点検では、この一年で覚えたことを、前の運営委員長への報告に少しは添えられるだろうか——そんなことを、ぼんやりと考えながら走った。老木がどれくらいの歳月そこに立っているのか、私は結局、今日も聞かずじまいだった。


手入れ記録

こんな症状が出たら入れるべき手入れ(パターン)まだ様子見でいい
区画(コレクション)ごとに内部構造(配列かハッシュか)が違い、巡回コードを型ごとに個別実装している✓ Iterator(巡回の手段をトレイトの後ろに隠す)
区画の型は一種類しかなく、今後も増える見込みがない✓ 現状のまま様子見

手入れの手順

  1. 巡回対象(区画)ごとに、内部でどんな構造(VecHashMapか等)を使っているかを洗い出す
  2. 巡回・点検のロジックのうち、「何を見るか」(点検内容)と「どう歩くか」(巡回の手段)を分けて考える
  3. 巡回対象の型が共通して実装するトレイトを定義し、fn walk(&self) -> impl Iterator<Item = &T>のような形で、歩き方だけを返すメソッドを持たせる
  4. それぞれの型にwalkを実装する。中身がVecなら.iter()HashMapなら.values()を返すだけでよい
  5. 呼び出し側はwalkが返す共通のイテレータだけを相手にし、区画の内部構造には一切触れない形に書き換える

庭師の一言

畑であれ、鉢であれ——回る足取りは変えない。

見習いの手控え

今日は、こちらから言葉にする前に、二つの画面を並べて置いておくだけにしました。それで見つけていただけて、何よりです。歩き方を一つに決めておけば、区画の中身がどんな形でも、迷わず回れるんですね。

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