第9章:増殖したFSMを解体する

同じ「温まっているかどうか」を、四つのファイルがばらばらに覚えていた
GJI(Google Japanese Input、Google 日本語入力)が入力を受け付けられる状態(warm)か、まだ準備中の状態(cold)かは、awaseの出力層にとって特に重要な判定でした。ところがこの判定は、最初から一つの場所にまとまっていたわけではありません。2026年6月時点で、WarmthContext.warmというbool値、CompositionStateというenumの値、gji_long_idleというbool値、gji_last_io_msという時刻の記録という、四つの別々のフィールドが、それぞれ別のファイルに散らばって存在していました。
この散らばりには理由がありました。GJIが本当にwarmになったかどうかは、一度の確認だけでは決まりません。フォーカスが移った瞬間の様子、その少し後の様子、さらに時間を置いた後の様子――同じ対象について、時間を置いて何度も観測を重ね、そのたびに得られる新しい情報を足していく必要があったのです。1回ごとの観測の呼び出しを「tick」と呼ぶなら、この判定は1tickでは終わらない、複数のtickにまたがる判定でした。単純なbool値は「いまこの瞬間はどちらか」を一つ答えるのには向いていますが、「複数回の観測を経て、いまどの段階まで進んだか」を覚えておくのには向いていません。新しい観測が来るたびに、そのつど別々の場所を書き換える必要があったため、更新が一部だけで止まってしまう不整合が起きやすくなっていました。
この不整合は、実際に繰り返しバグとして現れました。2026年6月時点で、warm/cold判定に起因するバグは少なくとも7件記録されています。中には、WarmthContext.warmのように、値が書き込まれるだけで、どこからも参照されていなかったフィールドさえありました。書いた本人でさえ、後から読み返さなければ、その値が実際には使われていないことに気づけなかったのです。
idle時間の分類も、同じ形の限界を抱えていました。当初はgji_long_idleというbool一つだけで、「長時間放置された後かどうか」を表現していました。ところが後になって、7〜10秒の「中程度のidle」と、10秒を超える「長いidle」を区別する必要が生じました。boolを1個増やすだけでは足りず、それを参照するすべての箇所を洗い出して書き換える必要があり、変更の範囲が広がっていきました。
2026年6月20日から22日にかけて、この四つの断片を一つの状態機械(FSM、finite state machine)に統合しました(GjiFsm、ADR-046)。状態機械とは、プログラムが取りうる状態をあらかじめ書き出し、次に何が起きたらどの状態へ移るかを表にしておく設計です。信号機が分かりやすい例で、赤・黄・青という決まった状態があり、時間が来ると赤→青→黄→赤の順に必ず切り替わります。GjiFsmは、OffCold(未活性)・OnCold(活性化待ち)・OnWarm(準備完了)・OnComposing(変換中)という状態と、その間の遷移をあらかじめすべて書き出す形にしました。idle時間の分類もColdKind(Short/Medium/Long)という一つの型にまとめ、待機時間などの値をそこから導出するようにしました。四つに散らばっていたbool・enumを一つの状態機械に集約したことで、「一部だけ更新される」という種類の不整合は、構造的に起こらなくなりました。
移行は一度に切り替えたわけではありません。まず新しいGjiFsmを追加し、旧来の判定結果と新しい判定結果が一致するかを、実行のたびに突き合わせて確認する期間を置きました。両者が一致し続けることを確かめてから、はじめて新しい方を正式な判定基準に切り替えています。動いているものをいきなり置き換えず、しばらく両方を並走させてから切り替えるという慎重さは、状態管理そのものを作り直すという、後戻りの利きにくい変更だったことの表れです。
こうしてGJIという一つの観測対象については、状態機械による一元管理が実現しました。しかしawaseが観測しなければならない対象は、GJIだけではありませんでした。Chrome向けの確認処理、TSFの初期化待ち、リテラル化の検出――どれも同じように、複数回のtickにまたがる状態を必要としていました。2026年6月22日から23日にかけて、この共通の形をTickableFsmという一つの取り決め(トレイト、複数の型に共通の振る舞いを約束する仕組み)として抜き出しました(ADR-047)。「時刻を受け取って内部状態を進める」「イベントを受け取って内部状態を進める」という二つの操作さえ用意すれば、どんな観測対象でも同じ枠組みで、tick駆動の状態機械として扱えるようになったのです。
この抽象化は、狙いどおりに機能しました。GJI用のGjiWarmupFsm、Chrome用のChromeProbe、リテラル検出用のLiteralDetectFsmなど、観測対象ごとに専用の状態機械が、次々と同じ枠組みの上に追加されていきました。ばらばらのbool・enumを一つにまとめるという最初の判断は、正しい判断でした。ところがこの成功が、少し違う形の問題を生み出すことになります。増えたのはFSMの型の数だけではありませんでした。フェーズをまたいで持ち越す値も、enumのバリアントも、FSMが増えるたびに一緒に増えていったのです。
「-1055行」は一つのコミットの数字だった
2026年06月27日07時29分35秒、f01d401というコミットが7個のファイルにまたがる差分を残しました。差分の内訳は「25 insertions, 1055 deletions」です。単日のdiffstatとしては、awaseの開発の中でも際立って大きな数字でした。
ただし、この-1055行という数字を「大規模な整理」の証拠としてそのまま読むのは早計です。削除された行の大半、969行はtsf/gji_warmup_fsm.rsというファイル1本の全削除によるものでした。つまりこの数字は、あちこちを少しずつ削った結果ではなく、一つの型を丸ごと消したことで生まれています。減った行数そのものよりも、何が起きて1本のファイルが丸ごと不要になったのかのほうが、この章の主題です。先に答えを言うと、これは単なる整理ではなく、状態機械という表現方法をさらに選び直した結果でした。
前節で見たTickableFsmは、増殖そのものを防ぐ仕組みではありませんでした。散らばったbool・enumを一元管理するという問題は解いていましたが、観測対象が増えるたびにFSMの型も増えるという設計だったからです。この章が扱うのは、IMEをコールドスタートさせる手続きという、awaseの中でもとりわけフェーズの多い処理です。フェーズが多いということは、それだけ「次に何をすべきか」を管理する状態表現が複雑になりやすいということでもあります。だからこそ、この日の変更は単発の掃除では終わらず、状態機械とコルーチンという2つの表現方法を並べて比較する材料になりました。
コルーチンとは、関数の実行を途中で一時停止し、あとで続きから再開できる仕組みです。普通の関数は、呼ばれたら最後まで一気に実行されます。コルーチンは違い、途中まで進んだら制御をいったん呼び出し元へ返し、次に呼ばれたときにその続きから動くという書き方ができます。しおりを挟んで本を読むことに近く、最後まで一気に読まなくても、しおりの場所から続きを読めます。この章に登場するStepCoroも、この意味でのコルーチンとして実装された、awase独自の仕組みです。
執筆準備の初期には、この変更を「三つのFSMの統合」と整理していました。コミットログとADR-053を突き合わせて確認し直すと、経緯は違っていました。正確には五種類のFSM型を棚卸しし、手続き的な処理を三本のStepCoroへ移す一方で、LiteralDetectFsmは独立したFSMとして残していたのです。三つではなく五つを対象にした整理であり、しかも五つすべてがコルーチンに置き換わったわけではありません。この訂正を踏まえると、この章の問いは「FSMをコルーチンに置き換える話」ではなく、次のように立てるべきものになります。状態機械とコルーチンを、何を基準に使い分けるべきか。
五つのFSMが、SwitchMachineで機械を乗り換えていた
awaseのTSF(Text Services Framework)には、IMEをコールドスタートさせるための一連の手続きがありました。GJI(Google Japanese Input、Google 日本語入力)状態のprobe確認・FreshF2キーの送信・名前変更の待機・変換文字列の送信・リテラル(未変換文字列)の検出という、順番の決まった5フェーズです。
2026年06月27日時点で、この手続きは次の5個の独立したFSM型として実装されていました。
| FSM名 | ファイル | 行数 | 生成コミット |
|---|---|---|---|
GjiWarmupFsm | tsf/gji_warmup_fsm.rs | 969行 | fcd1b82(06-22) |
LiteralDetectFsm | tsf/literal_detect_fsm.rs | 132行 | 8608062(06-23) |
SacrificialWarmupFsm | tsf/sacr_warmup_fsm.rs | 299行 | 06-14〜06-24の累積 |
ChromeGjiReinitFsm | tsf/chrome_gji_reinit_fsm.rs | 95行 | 2c8d647(06-24) |
TsfProbeMachine | tsf/probe_fsm.rs | 827行 | 05月来の累積 |
生成コミットの日付を見ると分かるように、これらは一度に設計されたものではありません。SacrificialWarmupFsmは06-14から06-24までの間に何度も手が入り、その途中でChrome固有の再初期化問題が見つかったことでChromeGjiReinitFsmという別の型が切り出されました。TsfProbeMachineにいたっては05月から改修が積み重なり、06-27の時点で827行にまで育っていました。個々のFSMは、それぞれが生まれた時点で発生していた個々の問題に対する、その時点では妥当な回答でした。増えすぎたのは、後から振り返って初めて分かることです。
これらのFSMは互いに独立してはいましたが、一つのフェーズが終わると次のFSMに切り替えるSwitchMachineというアクションで連鎖していました。GjiWarmupFsmからLiteralDetectFsmへ、SacrificialWarmupFsmからChromeGjiReinitFsmへ、というようにです。呼び出す側から見れば、複数のフェーズが一つの手続きとして流れているように見えても、実装の内部では別々の型を渡り歩く構造になっていました。
この連鎖の構造には、増えるたびに重くなる代償がありました。フェーズを一つ追加するたびに、enumのバリアントとmatchのアームが線形に増えます。さらに厄介だったのは、フェーズをまたいで持ち越す情報の扱いです。ローマ字文字列やバックスペースの回数は、その手続きの間だけ意味を持つ値です。これを次のFSMに引き継ぐには、structのフィールドとして保持するか、SwitchMachineのペイロードとして手渡すしかありませんでした。一時的にしか使わない値のために、恒久的なフィールドを用意し続ける構造になっていたのです。
このフィールドは、フェーズが進むたびに「もう使わないが、まだstructには残っている」という状態を経由します。読む側は、あるフィールドがいまのフェーズで意味を持つのか、前のフェーズの残骸なのかを、コード全体を追わないと判断できません。フェーズの数だけmatchアームが増える負担と、フィールドの寿命が読み取りにくくなる負担は、別々の問題ではなく、同じ設計判断から生じた一対の症状でした。
一つの関数に直線で書いたら、969行が消えた
06月27日07時22分02秒、e548e63でtsf/step_coro.rsという新しい基盤が追加されました。StepCoroと呼ばれるこの仕組みは、unsafe(安全性チェックを一部省く書き方)も使わず、nightly(正式リリース前の実験的機能)にも外部クレートにも依存せず、標準ライブラリだけで動く軽量なコルーチンでした。
StepCoroの考え方はこうです。フェーズをまたぐ一時的な値は、structのフィールドに昇格させる必要はありません。1本のasync関数の中に、順番通りに書けばよいのです。関数の途中でyield_stepを呼ぶと、その時点までの出力を返しつつ、次のtickの入力を受け取って続きから再開します。
async fn phase_body(ch: Channel) {
loop { // フェーズ1: probeループ
let input = yield_step(&ch, vec![]).await;
if probe.check_outcome(total_max_ms).is_some() { break; }
}
yield_step(&ch, vec![SendFreshF2]).await; // フェーズ2
loop { /* フェーズ3: NameChangeWait、同じ関数内で続けて書ける */ }
}
このコルーチンを、既存のtick駆動の呼び出し側から見て以前とまったく同じ形に見せるには、もう一段の橋渡しが要ります。TickableFsmというトレイトへの適合を、薄いラッパーstructが引き受けました。
impl TickableFsm for GjiWarmupCoro {
fn tick(&mut self, env: &TsfEnvSnapshot) -> Vec<ProbeAction> {
let input = TickInput { env: *env, .. };
match self.coro.step(input) {
CoroStep::Yielded(actions) => actions,
CoroStep::Complete => vec![ProbeAction::Done],
}
}
}
呼び出し側は相変わらずtickを呼ぶだけで、内部がFSMかコルーチンかを意識する必要がありません。変わったのは実装の内側だけで、外側の契約は保たれています。
StepCoro本体は、unsafeもOSへの呼び出しも持たない、入力を受け取って次の出力を返すだけの型です。フェーズがいくつあっても、実際にGJIやTSFを動かさなくても、TickInputを順番に与えてCoroStepの列を確認するだけで、フェーズ間の遷移をテストコードの中だけで再現できます。ADR-053には、この構造によってChromeProbeやLiteralDetectFsmが独立してユニットテスト可能になったと記録されています。5個のファイルをまたいでいた手続きが1本の関数にまとまったことは、読みやすさだけでなく、実機を用意せずに検証できる範囲を広げたことにもつながっています。
10分後の07時22分12秒、ccd4711が最初の適用例を追加しました。GjiWarmupFsmとLiteralDetectFsmのチェーンを、GjiWarmupCoroという1本のコルーチンにまとめたのです。このコミットのメッセージには「inline LiteralDetect」という語があります。apply_transmit_doneがfalseを返すContinueパスによって、StartLiteralDetect → SwitchMachine → LiteralDetectFsmという機械切り替えが不要になった、という意味です。リテラル検出の処理は、独立したFSMを呼び出すのではなく、コルーチン本体の中にそのままインライン記述されました。同じメッセージには「GjiWarmupFsmは削除せず残置(Chromeパスのコメント参照元として)」とも書かれており、この時点ではまだ古いFSMを消す踏ん切りがついていません。
7分後の07時29分35秒、f01d401がGjiWarmupFsm本体を削除し、ProbeAction::StartLiteralDetectバリアントも合わせて撤去しました。これが冒頭の-1055行です。コミットメッセージには「GjiWarmupCoroへの移行完了に伴い不要になったコードを削除する」とあります。フェーズ間の情報を局所変数として持ち運べるようになった結果、恒久的なフィールドとenumバリアントの大半が不要になり、削除できる状態になっていました。さらに5分ほど後のb077c3dでは、この置き換えに伴って残っていた警告を修正する小さな後始末も行われています。
この書き換えで変わったのは行数だけではありません。以前は、GJI probeからLiteralDetectまでの手続きを追うには、5個のファイルをまたいでmatchアームとenumバリアントを行き来する必要がありました。書き換え後は、同じ手続きが1本の関数の中で、上から下へ読める順序で並んでいます。フェーズの数が増えても、増えるのは関数の行数であって、ファイルをまたぐmatchアームの数ではありません。
ここまでの経過だけを見ると、答えは単純に思えます。FSMの連鎖をコルーチンに書き直せば、行数は減り、フェーズ間の受け渡しも楽になる。5つのFSMをすべて同じ手順でコルーチン化すればよい、という結論に飛びつきたくなります。
消えなかった一つ
ところが、LiteralDetectFsmというファイルは、f01d401のあとも削除されませんでした。crates/awase-windows/src/tsf/literal_detect_fsm.rsは現在も333行のFSMとして存在し続けています。
削除されなかったLiteralDetectFsmは、06月27日の変更で対象から外れた古い実装ではありません。コールドパスでの役割はコルーチンにインライン化されて消えましたが、ウォームパス(変換後にIME側の確定表示を待つ経路)では、同じ名前の型が独立したFSMとして今も使われています。tsf/tickable_fsm.rsには「LiteralDetectFsm | warmパスのpost-transmit composition確認 | tick, cold_seq_hintのみ」という注記が残っています。
同じ「リテラル検出」という処理でありながら、コールドパスでは関数の中の一節としてインライン化され、ウォームパスでは独立したFSMのまま残された。ここに、前節で立てた「全部コルーチン化すればよい」という単純な結論では説明できない境界線があります。行数を減らすこと自体を目的にしていたなら、この333行も削除の対象になっていたはずです。そうならなかったのは、削除できるかどうかとは別の判断基準が働いていたからです。
整理の前後を図にすると、五つのうち一つだけが独立したFSMのまま残ったことが分かります。

仮に、この基準を意識しないままウォームパスのLiteralDetectFsmもコルーチンに書き直していたらどうなったでしょうか。IME側の確定表示は、いつ届くか呼び出し側には分かりません。コルーチンの内部で待ち続けさせるだけでは、外部から「いまどの段階か」を問い合わせる手段が失われます。結局、その問い合わせを可能にする状態を、コルーチンの外側に別途持たせる必要が生じたはずです。複雑さを消したのではなく、別の場所へ移しただけになっていたことになります。
残す基準は、順番と生存期間だった
コールドパスのLiteralDetectFsmとウォームパスのLiteralDetectFsmは、同じ処理内容でありながら扱いが違いました。両者を分けたのは、処理の内容ではなく、処理の形そのものでした。
コールドパスでのリテラル検出は、GJI probeからFreshF2送信、NameChangeWaitを経て到達する、決まった順序の最後のフェーズです。開始から終了までの道筋があらかじめ決まっており、一度だけ実行されれば役目を終えます。これはStepCoroが得意とする形でした。
ウォームパスでのリテラル検出は違います。IME側がいつ確定表示を返すかは呼び出し側には分かりません。外部からのtickを待ち続け、条件を満たすまで生存し続けます。さらに、tickable_fsm.rsの登録が示すように、この状態は呼び出し側からtickを通じて問い合わせられる対象でもあります。状態そのものが、他のコードにとって意味を持つドメイン上の値になっていたのです。
同じ関数名・同じ処理内容であっても、「いつ終わるか」と「誰がその途中経過を必要とするか」が違えば、向いている表現方法も違ってきます。コールドパスのリテラル検出は、完了したかどうかさえ分かればよく、途中経過を他のコードが参照することはありません。ウォームパスのリテラル検出は、完了を待つ間ずっと、いまどの段階にあるかが外部から意味を持ちます。この2つの違いを一般化すると、次のような基準になります。
| 特徴 | StepCoroに向く | FSMとして残すべき |
|---|---|---|
| 開始から終了までの順序 | あらかじめ決まっている | 外部イベントに応じて分岐する |
| 生存期間 | 一回限りの手続きで完結する | 長期間、外部からの入力を待ち続ける |
| キャンセル・タイムアウト | 手続きの一部として組み込める | 状態自体がタイムアウトの基準になることがある |
| 状態の意味 | 呼び出し側には見えない内部の実装詳細 | 呼び出し側が問い合わせて判断材料にする |
ウォームパスのLiteralDetectFsmは、右列の条件をすべて満たしていました。だから残りました。左列の条件を満たす手続きだけが、コルーチンへの書き換えの対象になったのです。
残り二系統も、同じ基準で割り切れた
この基準が単なる後付けの説明ではないことは、同じ日に行われたもう2つの統合を見ると分かります。
08時09分55秒の2c756ddは、SacrificialWarmupFsm(299行)とChromeGjiReinitFsm(95行)をSacrificialWarmupCoro(269行)にまとめました。コミットメッセージには「StartChromeGjiReinit(SwitchMachine) → SendChromeGjiReinit(Continue)に変更しFSM切り替えを撤去。コルーチン本体がそのままIME確認待機フェーズへ続く」とあります。同じメッセージには「deferred_vks / notify_start_compositionをTickInput経由で収集」ともあり、以前は別々のFSMがそれぞれ抱えていた保留中の値も、TickInputという一つの入力経路にまとめられています。Chrome再初期化前のウォームアップから再初期化完了までは、順序が決まった一回限りの手続きであり、基準の左列にそのまま当てはまりました。
17時03分17秒のd1d6d17は、TsfProbeMachine(827行)をTsfProbeCoroに置き換えました。ProbePhaseenum・WaitingFor・SendState・SystemClockといった内部型はすべて撤去されました。ChromeProbeからSacrificialWarmupまたはTransmitを経てLiteralDetectに至る3フェーズは、tsf_probe_coro_bodyという1本の関数に統合されています。この変更では237行が削減されました。
さらに同じ日の17時49分頃、9d08b66はStepCoroをこのプロジェクト専用の実装からtimed_fsm::coroという公開クレートへ昇格させ、直後の118b3cfはローカルのstep_coro.rsを撤去して、この共有クレートの利用に一本化しています。07時22分に一つのファイルとして生まれた仕組みが、同じ日の夕方には他の箇所からも再利用できる基盤に育っていたことになります。
07時22分のGjiWarmupCoroから17時03分のTsfProbeCoroまで、3系統の書き換えは同じ一日のうちに終わっています。これは急いで済ませたということではなく、最初の書き換えで基準がはっきりしたあとは、残り二系統への適用に迷いが要らなかったことの表れです。判断に時間がかかったのは、基準を見つけるまでの最初の一系統だけでした。
3件の変更と1件の非変更をまとめると、次の対応関係になります。
| 変更前 | 変更後 |
|---|---|
GjiWarmupFsm(969行) | GjiWarmupCoroへ統合、ファイルごと削除 |
LiteralDetectFsm(コールドパスでの利用) | GjiWarmupCoro内にインライン記述、型としての呼び出しは消滅 |
SacrificialWarmupFsm(299行) + ChromeGjiReinitFsm(95行) | SacrificialWarmupCoro(269行)へ統合 |
TsfProbeMachine(827行) | TsfProbeCoroへ統合、237行削減 |
LiteralDetectFsm(ウォームパスでの利用) | 変更なし。唯一残ったFSM |
5個あったFSM型のうち、順序が決まった一回限りの手続きを担っていた4個(実体としては3系統)はコルーチンへ移り、外部イベントを待ち続け状態そのものが問い合わせの対象になっていた1個だけがFSMのまま残りました。基準は3回とも同じでした。
適用限界
この基準は、StepCoroがFSMより優れているという主張ではありません。awaseのStepCoroはunsafeもnightly機能も使わず、標準ライブラリの範囲で動く軽量な実装であり、外部のasyncランタイムやジェネレータクレートの代わりに選ばれたものでした。ADR-053には、tokioのような外部ランタイムは単一スレッドのタイマー駆動モデルには過剰であり、generatorクレートやnightlyのコルーチン機能はnightly依存または外部クレート依存を新たに抱え込むことになる、という判断が記録されています。個人開発で長期にわたって保守する前提を考えれば、依存を増やさない選択にも理由がありました。
この基準が答えているのは、「手続きの形をした処理」と「状態そのものに意味がある処理」のどちらに当てはまるか、という一点だけです。ある処理が両方の性質を併せ持つ場合や、一回限りの手続きとして始まったものが後から外部に問い合わせられる対象へと変わっていく場合を、この基準だけでどう扱うかは、この章の範囲では答えが出ていません。境界線を引く基準は手に入りましたが、境界線そのものが動く場合の扱いは、また別の問題として残っています。
この基準そのものは、TSFのコールドスタート手続きに固有のものではありません。一回限りの手続きか、長期間生存して外部から問い合わせられる状態かを見分けるという判断は、awaseの他の箇所でも、フェーズを持つ処理を実装するたびに繰り返し現れる問いです。ただし、この章で確認できたのはあくまで一つの手続き群における3+1の内訳だけであり、他の箇所でも同じ比率になるとは限りません。
順番と生存期間を基準に、手続きと状態を分けられるようになりました。しかし、その手続きや状態が、そもそも何の遷移を表しているのかという問いには、まだ答えていませんでした。
他分野への転用
一直線の手続きと、問い合わせられる状態を分ける発想は、他の場所にもある
StepCoroが「フェーズをまたぐ一時的な値を、structのフィールドではなく関数の中の変数として書く」ためにしていたことは、実は多くのプログラミング言語がすでに、コンパイラの機能として提供しています。JavaScriptやPython、そしてRust自身が持つasync/awaitという構文は、見た目こそ普通の関数のように上から下へ書けますが、内部ではコンパイラが待機のたびに中断・再開できる状態機械へ自動的に変換しています。プログラマが手でenumのバリアントを書き並べる代わりに、コンパイラがその変換を肩代わりしているという違いはありますが、「一直線の手続きを、途中で止めて後で続きから再開できる形に変換する」という発想そのものは同じです。awaseのStepCoroは、この変換を外部クレートやnightly機能に頼らず、自前で小さく実装したものだと言えます。
一方、「長期間生存し、外部から問い合わせられる状態」の側にも、身近な対応物があります。フロントエンド開発でよく使われるRedux(状態管理ライブラリ)は、ボタンを押した瞬間だけ実行される一回限りの処理と、アプリ全体が常に持ち続け、どの画面からでも問い合わせられる状態とを、意図的に別の仕組みとして扱います。前者は関数呼び出しで済み、後者だけがstoreと呼ばれる専用の置き場所を持ちます。ウォームパスのLiteralDetectFsmが独立したFSMとして残ったのは、この「常に問い合わせられる置き場所」を必要としていたからであり、Reduxのstoreが一回限りの処理を素通りさせるのと、選んでいる基準は同じです。
この対応関係は、awaseがこの区別を発明したことを意味しません。一回限りの手続きと、長期間問い合わせられる状態という2つの性質は、プログラミング言語の機能としても、ライブラリの設計としても、繰り返し同じ形で発見されてきました。3回とも同じ基準で割り切れたという本章の経過は、この繰り返しの一例にすぎません。
設計原則
フェーズ間の一時的な値は、structフィールドではなく局所変数で表現できないか、まず検討する。
この原則が適用できるのは、処理の開始から終了までの順序があらかじめ決まっており、一回実行されれば役目を終える手続きに対してです。フェーズが増えるたびにenumバリアントとmatchアームが線形に増えていく設計は、この条件に当てはまる兆候です。
実装の形としては、1本のasync関数の中に手続きを直線的に記述し、フェーズの区切りごとにyield_stepで外部との入出力を受け渡します。フェーズ間で持ち越す値は、恒久的なフィールドではなく、関数スコープに閉じた局所変数として自然に表現できます。呼び出し側から見た契約は、薄いラッパーを介して以前と変えずに保つことができます。
ただし、外部イベントに応じて長期間分岐し続ける処理や、状態そのものを呼び出し側が問い合わせて判断材料にする処理には、この原則は当てはまりません。そのような処理は、局所変数に閉じ込めずに、FSMとして状態を外部から見える形のまま残すべきです。