Featured image of post コードガーデナー【Strategy】剪定のやり方が、全部同じ関数に積み上がっていく〜品種ごとの流儀は、任せる場所を持たせる〜

コードガーデナー【Strategy】剪定のやり方が、全部同じ関数に積み上がっていく〜品種ごとの流儀は、任せる場所を持たせる〜

電気工事士から果樹園を継いだ俺が、品種を足すたび肥大化するmatch関数に苦しむ。系統で関数を分ける応急処置は一度効いたが、6品種目でまた同じ場所が詰まった。庭師が見せてくれたRustのStrategyパターンで、品種ごとに任せる場所を持たせる手入れ。

10月、収穫の合間。斜面のりんご畑には赤く色づいた実が並び、朝の冷え込みで息が白く見える日も増えてきた。俺は収穫作業の手を止め、軽トラの荷台に腰掛けてノートパソコンを開いた。今季から接ぎ木で試している6品種目——むつ——の剪定計画を組もうと、いつものコマンドを叩く。cargo clippy。画面に流れた文字の中に、見覚えのある一文があった。

1
warning: this function has too many lines [clippy::too_many_lines]

3年前、初めてこの警告を見たときと同じ文言だ。だが今回、警告が指しているのはpruning_plan_lateという関数だった。去年、警告を消すために自分で分けた3つの関数のうちの1つだ。

「系統で分けたのに」と、俺は独りごちる。りんご農家をやっていると、品種にはそれぞれ収穫時期がある。早生・中生・晩生——うちで育てているつがるは早生、紅玉は中生、ふじ・王林・シナノゴールドは晩生に分類される。品種が5つになった頃、初めてtoo_many_linesの警告に当たった。前職の電気工事の仕事なら、系統を分けて負荷を散らせば、それで大抵は解決する。剪定計画を出す関数を収穫時期ごとに3つへ分割したら、実際に警告は消えた。あれで直ったつもりでいた。

ところが今季、むつを晩生グループに足した途端、pruning_plan_lateだけがまた膨らんだ。系統を分けたのに、また同じ場所で詰まる——この感覚に納得がいかないまま、畑仕事の合間にこれ以上考え込む余裕もなく、軽トラのハンドルを握った。先月の農協の会合で、隣の畑の後継者が雑談ついでに漏らしていた一言を思い出す。「コードのことまで見てくれる庭師がいるらしいよ」。半信半疑だったが、他に当てもなく、工房へ向かうことにした。

訪れ ── 系統で分けたはずが、また同じ警告

収穫期の慌ただしさもあって、車を走らせながらもスマートフォンで警告画面のスクリーンショットを何度も見返してしまう。工房前に軽トラを停め、画面を見たまま降りた。敷地の入口に立つ古木の脇を通り過ぎる瞬間、落ち葉を踏む乾いた音がした。それで一瞬だけ顔を上げたが、すぐにまたスマートフォンへ視線を戻す。木の姿を確かめる余裕は、今はない。

戸を叩くと、庭師が黙って顔を出した。挨拶もそこそこに、俺は開いたノートパソコンをそのまま差し出す。

「品種が増えるたびに、コードのある関数がまた膨らむんです。一度、系統ごとに関数を分けて直したはずなんですけど」

庭師の後ろから若い女性がひょいと顔を出し、「まあ、座ってください」と作業台を示した。名乗られるより先に、「ヒバリです、見習いやってます」と自分から名乗る。俺は勧められるまま腰を下ろし、画面をそのまま見せた。

1
2
3
4
5
6
7
8
fn pruning_plan(variety: &str, age_years: u32) -> Vec<String> {
    match variety {
        "つがる" => pruning_plan_early(age_years),
        "紅玉" => pruning_plan_mid(age_years),
        "ふじ" | "王林" | "シナノゴールド" | "むつ" => pruning_plan_late(variety, age_years),
        _ => vec!["未登録の品種です。庭師に相談してください。".to_string()],
    }
}

「これが振り分け役の関数で、実際の判定は、収穫時期ごとの3つの関数に任せてます」と俺は説明した。「早生用、中生用、晩生用——去年、これで警告が消えたんです。でも今日また同じ警告が出て」

症状観察 ── 庭師の呼吸が、一瞬浅くなる

庭師は、差し出された画面に目を落とした。何も言わない。まずpruning_planの短いmatch——3つの関数へ振り分けるだけの部分——に目を通し、それからpruning_plan_lateの中身へ視線を移す。ふじ、王林、シナノゴールド、むつ——4つのmatchアームの間を、行きつ戻りつするように目が動いた。

 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
fn pruning_plan_late(variety: &str, age_years: u32) -> Vec<String> {
    match variety {
        "ふじ" => match classify_age(age_years) {
            TreeAge::Young => vec![
                "主幹を1本に定め、競合する枝は間引く".to_string(),
                "ふじは枝が強く伸びるので、誘引で角度を早めにつける".to_string(),
            ],
            // ...中木・成木も同様に続く
        },
        "王林" => match classify_age(age_years) {
            // ...ふじと同じ形で、王林固有の判定が続く
        },
        "シナノゴールド" => match classify_age(age_years) {
            // ...同上
        },
        "むつ" => match classify_age(age_years) {
            TreeAge::Young => vec![
                "主幹を1本に定め、競合する枝は間引く".to_string(),
                "むつは実が大きく枝が折れやすいので、支柱で誘引を補強する".to_string(),
            ],
            // ...中木・成木も同様に続く
        },
        _ => vec!["未登録の品種です。庭師に相談してください。".to_string()],
    }
}

俺は、その沈黙に落ち着かなくなって「系統を分けたのが、まずかったですか」と聞いてしまう。庭師は答えない。土を払っていた手も、画面へ向けた視線も動かないまま、呼吸だけが一瞬浅くなった。それが何のサインなのか、俺にはまだ分からない。

庭師は、pruning_plan_earlypruning_plan_midの短さと、pruning_plan_lateの長さを、目だけで往復するように見比べている——ように見えた。それから、4つのmatchアームの、冒頭の書き出し——「主幹を1本に定め、競合する枝は間引く」——が繰り返されている箇所で、視線の動きがわずかに緩やかになった。

見立てと翻訳 ── 「寄せ植え台帳」という名前

しばらくの沈黙のあと、庭師の口から、ぽつりと名前が漏れた。

「寄せ植え台帳だな」

ヒバリさんが俺の方を向く。「これ、前にも似たような形、見たことないですか?」

俺は少し考えて、「……いや、初めてです。系統は分けたはずなんですけど」と答えた。

「そうなんです」とヒバリさんが続ける。「性質の違う品種を、全部同じ一つの関数——台帳みたいなもの——に寄せ集めて書き込んでる状態のことを、こう呼んでるんです。花壇に性質の違う植物を無理に寄せ植えすると、世話の仕方がごちゃ混ぜになって扱いにくくなるのと、似た形です」

「早生・中生・晩生って分けた方向性、筋は通ってたんです」とヒバリさんが言う。「ただ、品種の数がその3つに均等に分かれるとは限りません。実際、つがると紅玉はそれぞれ1品種ずつなのに、晩生にはふじ・王林・シナノゴールド・むつの4品種が集まってます」

俺は自分のコードを見返した。「……確かに。分けた時点では、晩生が多くなることまでは考えてませんでした」

「これ、結局のところ」と俺は少し食い下がる。「関数を分けても、その中でまた品種ごとにmatch書いてるなら、意味なかったんじゃないですか」

庭師が初めて口を開いた。

「置き場所が変わっただけだ」

ヒバリさんが噛み砕く。「そうなんです。品種を追加するたびに『どの関数に入れるか』を決めて、さらにその関数の中のmatchにも手を入れる——追加のたびに触る場所が減ってないんです」

「最初は、matchひとつで足りてたんです」と俺は経緯を語った。「品種が5つになった頃、初めてclippyに怒られて。回路を系統ごとに分ければ負荷が散るのと同じ感覚で、早生・中生・晩生に分けました。実際、警告は消えたので、直った気になってました」

「その時点では、それで筋が通ってましたよ」とヒバリさんが言う。庭師も、小さく頷いた。

「じゃあ、どう書けば、品種を足しても既存の場所に触らずに済むんですか」と俺は結論を急いだ。

庭師が短く答える。

「品種ごとに、任せる場所を持たせる」

俺は「え」と聞き返す。ヒバリさんが続けた。「1つの関数に全品種の判定を寄せ植えするんじゃなくて、品種ごとに専用の置き場所を作って、そこに判定を任せるんです。それがStrategy——農事暦の定石でいう、品種ごとの流儀を切り分けて任せる手入れです」

手入れ ── 品種ごとに、任せる場所を持たせる

俺がキーボードから手を離すと、庭師が代わって座り、PruningStrategyというトレイトを書き始めた。メソッドはplan(&self, age_years: u32) -> Vec<String>ひとつだけ。横からヒバリさんが手元を覗き込みながら言葉を添える。

「トレイト——複数の型に共通のふるまいを約束させる仕組みですね——を1つ用意して、品種ごとに、この形を実装した専用の置き場所を作ります。TsugaruFuji……品種の数だけ、構造体が増えます」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
trait PruningStrategy {
    fn plan(&self, age_years: u32) -> Vec<String>;
}

struct Tsugaru;
impl PruningStrategy for Tsugaru {
    fn plan(&self, age_years: u32) -> Vec<String> {
        match classify_age(age_years) {
            TreeAge::Young => vec![
                "主幹を1本に定め、競合する枝は間引く".to_string(),
                "早生種は花芽がつきやすいので、誘引で骨格を先に作る".to_string(),
            ],
            TreeAge::Middle => vec![
                "主枝を3〜4本に整理する".to_string(),
                "短果枝を残し気味に間引く".to_string(),
            ],
            TreeAge::Mature => vec![
                "老化した主枝から更新剪定を始める".to_string(),
                "毎年の切り戻しで樹高を抑える".to_string(),
            ],
        }
    }
}

「構造体、増えるんですか」と俺が聞くと、ヒバリさんが頷く。「増えます。ただし、増えるのは品種の数だけです。1つの構造体は、1つの品種の判定ロジックしか持ちません」

庭師はFuji構造体も同じ形で書いた。中身は、さっきのpruning_plan_lateにあった「ふじ」のアームの判定を、そのままplanメソッドへ移しただけだ。

 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
struct Fuji;
impl PruningStrategy for Fuji {
    fn plan(&self, age_years: u32) -> Vec<String> {
        match classify_age(age_years) {
            TreeAge::Young => vec![
                "主幹を1本に定め、競合する枝は間引く".to_string(),
                "ふじは枝が強く伸びるので、誘引で角度を早めにつける".to_string(),
            ],
            TreeAge::Middle => vec![
                "主枝を3〜4本に整理する".to_string(),
                "徒長枝をやや多めに間引く".to_string(),
            ],
            TreeAge::Mature => vec![
                "老化した主枝から更新剪定を始める".to_string(),
                "実付きの良い短果枝を残しながら切り戻す".to_string(),
            ],
        }
    }
}

struct Mutsu;
impl PruningStrategy for Mutsu {
    fn plan(&self, age_years: u32) -> Vec<String> {
        match classify_age(age_years) {
            TreeAge::Young => vec![
                "主幹を1本に定め、競合する枝は間引く".to_string(),
                "むつは実が大きく枝が折れやすいので、支柱で誘引を補強する".to_string(),
            ],
            TreeAge::Middle => vec![
                "主枝を3〜4本に整理する".to_string(),
                "徒長枝をやや多めに間引く".to_string(),
            ],
            TreeAge::Mature => vec![
                "老化した主枝から更新剪定を始める".to_string(),
                "実付きの良い短果枝を残しながら切り戻す".to_string(),
            ],
        }
    }
}

// 紅玉(Kogyoku)・王林(Orin)・シナノゴールド(ShinanoGold)も
// 同じ形で、それぞれの判定をそのまま plan() へ移す

俺は画面を覗き込みながら「中身は……変わってない、ですよね」と確かめた。

「変わってない」と庭師が短く答える。

品種の数だけ構造体が並んだところで、庭師はbuild_registryという関数を書き始めた。品種名と、対応する構造体を並べていく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::collections::HashMap;

fn build_registry() -> HashMap<String, Box<dyn PruningStrategy>> {
    let mut registry: HashMap<String, Box<dyn PruningStrategy>> = HashMap::new();
    registry.insert("つがる".to_string(), Box::new(Tsugaru));
    registry.insert("紅玉".to_string(), Box::new(Kogyoku));
    registry.insert("ふじ".to_string(), Box::new(Fuji));
    registry.insert("王林".to_string(), Box::new(Orin));
    registry.insert("シナノゴールド".to_string(), Box::new(ShinanoGold));
    registry.insert("むつ".to_string(), Box::new(Mutsu));
    registry
}

fn pruning_plan(
    registry: &HashMap<String, Box<dyn PruningStrategy>>,
    variety: &str,
    age_years: u32,
) -> Vec<String> {
    match registry.get(variety) {
        Some(strategy) => strategy.plan(age_years),
        None => vec!["未登録の品種です。庭師に相談してください。".to_string()],
    }
}

「これは何ですか」と俺が聞くと、ヒバリさんが答える。「レジストリ——特定のキーと、それに対応する処理をまとめて管理する仕組みです。品種ごとの札を並べた道具棚だと思ってください。つがるの札にはTsugaru紅玉の札にはKogyoku——棚を見れば、どの品種にどの判定が対応してるか、一目で分かります」

俺は画面を見比べた。さっきまで一つの関数の中で身を寄せ合っていた4品種が、それぞれ別の札に収まって棚に並んでいる。

Beforeは寄せ植え台帳:pruning_planが収穫時期でpruning_plan_early・pruning_plan_mid・pruning_plan_lateに振り分けられ、pruning_plan_lateの中にふじ・王林・シナノゴールド・むつの4品種の判定が同居し新規追加はここへ割り込む。Afterは品種ごとの札を並べた道具棚:pruning_planがbuild_registry()を経由し、つがる・紅玉・ふじ・王林・シナノゴールド・むつそれぞれの専用札(Tsugaru・Kogyoku・Fuji・Orin・ShinanoGold・Mutsu)へ個別の紐で繋がる、という構造対比を木札と道具棚で示したインフォグラフィック

俺は少し引っかかって聞いた。

「でも、この棚にも、結局品種を全部並べて書いてますよね。これも一種のmatchと同じじゃないんですか」

ヒバリさんが頷く。「そこ、突いてくると思ってました。確かに、新しい品種を足すときはこの棚に1行足す必要があります。でも、この1行は『どの品種にどの構造体を対応させるか』という一覧への追記であって、品種ごとの判定ロジックそのものを書き換えてるわけじゃないんです」

俺はBeforeのpruning_plan_lateを思い浮かべた。「さっきの応急処置だと、むつを足すのに、樹齢3パターン分の判定を新しく書いた上で、既存のmatchの中に差し込む必要がありましたよね」

「そうです」とヒバリさんが頷く。「今度は、その樹齢3パターン分の判定を、新しい構造体の中に書くだけです。既存のmatchに差し込む作業自体が要らなくなります。棚に増えるのは品種名と構造体の対応、1行だけです」

俺は少し黙って、頭の中で整理した。「つまり……判定そのものを書き換えるのと、対応表に1行足すのとでは、触ってる対象が違う、ってことですか」

「そういうことです」とヒバリさんが言う。「FujiにもTsugaruにも、他のどの品種の実装にも、新しい構造体は一切触りません。pruning_plan関数の中身も——棚から取り出して呼ぶだけの形になってるので——触る必要がないんです」

俺は自分の中で、さっきの言葉を絵にしてみた。編集する版と、追記するだけの版——並べてみると、触っている場所がまるで違う。

新しい品種を1つ追加すると何を書き換えるかの対比図。Beforeは判定ロジックの編集:pruning_plan_lateの中のmatchに新アームを追記する→ふじ・王林・シナノゴールドの判定と同じ関数に手を入れる→既存の3品種のコードもまとめて見返すことになる、という3段の傷んだ木札。Afterは対応表への追記:新しい構造体を1つ書く(独立した置き場所)→build_registryに1行だけ追記する→Fuji・Orin・ShinanoGoldの構造体には触れない、という3段のきれいな木札を、道具棚のペグに掛けた様子で示したインフォグラフィック

「じゃあ、enumで品種を定義して、コンパイラの網羅性チェック——matchがありうる全てのパターンを網羅しているかを、コンパイラが確認してくれる仕組みです——に任せるのは、どうなんですか」と俺は聞いてみた。前職の検査工程の癖で、漏れをコンパイラに保証してもらえるなら、その方が安全な気もしたからだ。

「それも有力な選択肢です」とヒバリさんが答える。「ただし網羅性チェックは、新しい品種——新しいバリアント——を足したとき、そのenumを使っている全てのmatchをコンパイルエラーで洗い出してくれます。安全ではあるんですが、それは裏を返せば、新しい品種を足すたびに、既存のmatchという判定ロジックへ必ず手を入れさせられる、ということでもあるんです。今回は『既存の判定ロジックに触れずに増やしたい』のが目的なので、今のところは向きが逆なんです」

俺はその説明に、少し感心した。「安全さと、触らずに済む、っていうのが、逆に働くこともあるんですね」

「じゃあ、これで、もう二度と関数が膨らむことはないんですか」と俺は聞いた。

庭師は、俺の顔をちらりと見てから、静かに答えた。

「棚は増える」

思っていたより素っ気ない返事だった。ヒバリさんが補足する。「品種を足すたびに、棚への登録は増え続けます。そこは正直に言うと、ゼロにはなりません。ただ、増えるのは"対応表への1行"だけで、“判定ロジックの編集"は増えない——そこが、去年の系統分けとの違いなんです」

素っ気ない答えの分だけ、逆に信用できる気がした。「全部きれいさっぱり、とはいかないんですね」

俺は、今朝見た警告のことを思い出した。「じゃあ、build_registryは、品種が増えても1行ずつしか伸びないってことですよね。だとすると、あのtoo_many_linesの警告には、もう引っかからない……?」

「引っかかりません」とヒバリさんが頷く。「1行増えるだけの関数が、100行を超えることはまず無いので」

「もう一つだけ」とヒバリさんが付け加える。「棚の中身、以前も出てきたBox<dyn Trait>を使ってます。前は、1本の株を外側から包んで機能を積み重ねるのに使いましたよね。今回は用途が違って、品種ごとに形の違う構造体を、同じ棚にまとめて収めるために使ってます。品種が実行時にどんどん増えていく前提だから、色んな品種の構造体を同じ型の入れ物として並べられる形が要るんです。もし品種の集合がずっと固定されているなら、ジェネリクス——コンパイル時に型を1つに確定させる書き方——の方が向いていたかもしれません。でも、うちの果樹園みたいに毎年のように新しい品種を接ぎ木で試すなら、こっちの方が実情に合ってます」

「この棚は、いつ作るものなんですか。判定のたびに毎回作り直すんですか」と俺は聞いた。

「いいえ」とヒバリさんが答える。「起動時に一度build_registry()を呼んで作ったら、あとはそれを使い回します。品種が増えたときだけ、この関数に1行足して作り直せばいいんです」

見送り ── 帰り道、根元の土の色

画面のコードをノートパソコンへ書き写す手を止めずに、俺は口だけ動かした。

「むつのぶんも、もう心配しなくていいですよね」

庭師は頷く。「ああ」

ヒバリさんが「系統で分けようとした発想自体、間違ってはなかったんですよ。分け方の軸が、判定ロジックの中まで届いてなかっただけです」と言った。喉の奥に詰まっていたものが、少し軽くなった気がして、俺は荷物をまとめ始める。

腰を上げかけた俺に、庭師が戸口までついてきて、短く声をかけた。

「根は、一本ずつ、絡ませずに張らせておけ」

俺はその言葉を、頭の中でもう一度なぞった。

軽トラへ戻る道すがら、入口の脇のあの古い木の前を通りかかる。今度は足を止めて、木を見た。根元の土が、周りよりも心持ち黒っぽいことに気づく。長年、同じ場所に水をやり続けてきたせいなのかもしれないが、確かめるほどのことでもなく、そのままエンジンをかけた。

山道を下りながら、俺は独りごちる。

「系統を分けるんじゃなくて、品種ごとに置き場所を作る、か」

来季また新しい品種を試すことになったら——今度は、関数を分割する前に、その品種専用の置き場所を先に作ろう、と思った。収穫の続きに戻りながら、来季の植え付け計画のことを、少しだけ考え始めていた。


手入れ記録

こんな症状が出たら入れるべき手入れ(パターン)まだ様子見でいい
品種(種類)が増えるたびに、1つのmatch/if-elseが肥大化し続けている✓ Strategy(トレイト化+レジストリ登録)
カテゴリ分けで関数を分割したのに、特定のグループだけがまた肥大化する✓ Strategy(品種ごとに専用の置き場所を持たせる)
種類が2〜3個で、今後も増える予定が無い✓ そのままのmatchで様子見

手入れの手順

  1. 判定ロジックの中で、種類ごとに枝分かれしている箇所を洗い出す
  2. 種類ごとに専用の構造体を作り、共通のふるまい(トレイト)としてまとめる
  3. 判定ロジックの中身を、対応する構造体のメソッドへそのまま移す(ロジックは変えない)
  4. 種類→構造体の対応を、レジストリ(HashMap等)として呼び出し側にまとめる
  5. 新しい種類を追加するときは、構造体を1つ書いてレジストリに1行足すだけにする。既存の構造体には触れない

庭師の一言

根は、一本ずつ、絡ませずに張らせておけ。

見習いの手控え

師匠の言葉、要は「新しく増える前提で、触らずに済む場所を先に用意しておく」ってことです。今日は、系統で分けるっていう発想自体は悪くなかったんですよね。分け方の軸が、判定ロジックの中までは届いてなかった——それだけの違いでした。

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