🔍

if文を用いた状態の分岐のかわりに「ちょクワガタ(Tagged union)」を活用しよう!

2022/12/24に公開

READYFOR 2022 アドカレカバー画像

READYFOR でプロダクトエンジニアとして働いている pxfnc です。

この記事は READYFOR Advent Calendar 2022 の 24 日目の記事です。

クリスマスイブにクワガタの話を徹夜で書いている残念エンジニアなわけですが、自分のプログラミング経験の中でも大いに役に立った「ちょクワガタ」という生物について書いていこうと思います。

はじめに

プログラミングにおける if 文とは、変数の次に学ぶ基礎中の基礎の構文であり、プログラム中の至るところに出現する構文です。

この if 文は誰でも理解できる反面、プログラムを複雑にする要因でもあります。一番わかり易いプログラムとは、上から下まで一行ずつ実行されるようなプログラムのことです。逆に、一番わかりづらいプログラムというのは、いつ満たされるかわからない条件で構成された if 文がたくさんあるプログラムです。

そこで、if による複雑な条件分岐をわかりやすい形で置き換えることができるのが、このちょクワガタ(Tagged union)という概念です。

これがどのように嬉しいのかを説明するために、前半はモデルがどんどん複雑になって開発が崩壊するストーリーを語った後、後半で Tagged union のパワーによって前半の辛かった部分が解消されるといった構成にしてます。

前半: 「旅行予定メモアプリ」を拡張しまくってモデルを崩壊させる

プログラミング言語は Rust 言語でのサンプルを記述しますが、コードの意味については手厚い説明を書くので、お好きな言語で読み替えながら雰囲気を感じ取ってください。

サンプルとして、旅行の予定をを管理するアプリを考えてみて、その上で「旅行の予定」を表現するデータを考えていきます。

最初は単純だったはずのアプリケーションが、気づかぬうちにバグを埋め込んでいき、「旅行の予定」の概念が壊れていく様を味わっていただけると幸いです 😈

第一話: 旅行を計画するぞ!

皆さん旅行は好きですか?自分は特段旅行が好きなわけでもないです。

旅行に行きたい人は、特殊な人間を除き、多少なりとも予定は考えてから行動に移すかと思います。その旅行の予定をプログラムで扱うのにはどんなデータを持っているのが良いのでしょうか?

例えば「2023-01-07 に一泊のスキー旅行行くかぁ」なんて考えたら、データ構造としては「旅行の名前」」、「いつから旅行するか」、「どのくらいの期間旅行するか」を持てるようなデータ構造を考えますよね。

/// 旅行の予定データ。
pub struct TripPlan {

    /// 旅行の名前
    plan_name: String,

    /// 旅行開始日。時刻は含まない
    start: NaiveDate, // NaiveDateは2022-12-24のような年月日を持つオブジェクト

    /// u32は32ビット符号なし整数。4,294,967,295日旅行ができるね!
    days: u32,
}

第二話: その予定はまだ決まってないから

ところで、旅行の予定の中には、「新幹線や宿も確保しててもう寝て待つだけ!」という状況の予定や、「一応考えてはいるけどまだ確定じゃないなぁ」という予定もあるかと思います。

このような旅行の予定たちを区別するべく、確定済みの予定かどうかを情報として持つようにしてみましょう。日付や旅行期間も、確定してない場合は入力しない場合もあるので、オプショナルな値に変更します。

  /// 旅行の予定データ
  pub struct TripPlan {

+     // 確定している予定かどうかを決めるbool値
+     scheduled: bool,
+
      // 旅行の名前
      plan_name: String,

      // 旅行開始日。時刻は含まない
-     start: NaiveDate,
+     start: Option<NaiveDate>,

      // u32は32ビット符号なし整数。4,294,967,295日旅行ができるね!
-     days: u32,
+     days: Option<u32>,

}

第三話: 旅行楽しかった〜

さて、旅行が無事終わったとき、良かった景色のことや地元の美味しいお店などの情報を書き留めたくなりますよね。感想も残せるようにデータを追加してみます。

  /// 旅行の予定データ
  pub struct TripPlan {

      // 確定している予定かどうかを決めるbool値
      scheduled: bool,

      // 旅行の名前
      plan_name: String,

+     // 旅行時のノート
+     plan_note: String,
+
      // 旅行開始日。時刻は含まない
      start: Option<NaiveDate>,

      // u32は32ビット符号なし整数。4,294,967,295日旅行ができるね!
      days: Option<u32>,

}

第四話: あれ、なんか新幹線の予定と出発日ずれてるんだけど

すっかり忘れていましたが、一度予定が確定した場合、旅行の開始と日数は変更できないようになっていてほしいですね。setter と getter を定義してあげて、そのようなプロジェクトはstartdaysが上書きできないようにしましょう。

  // `impl TripPlan { ... }`は、他言語で言うところのメソッド実装にあたる機能です。

+ impl TripPlan {
+      /// startのgetter
+      pub fn start(&self) -> Option<&NaiveDate> {
+          self.start.as_ref()
+      }
+
+      /// daysのgetter
+      pub fn days(&self) -> Option<u32> {
+          self.days
+      }
+
+      /// startのsetter
+      pub fn set_start(&mut self, d: NaiveDate) {
+          if self.scheduled { // scheduledがtrueならば何もしない
+              return;
+          }
+          self.start = Some(d); // そうでなければ引数をstartに入れる
+      }
+
+      /// daysのsetter
+      pub fn set_days(&mut self, days: u32) {
+          if self.scheduled { // scheduledがtrueならば何もしない
+              return;
+          };
+          self.days = Some(days); // そうでなければ引数をdaysに入れる
+      }
+  }

第五話: 夢の海外旅行

改めて旅行の記録を振り返って見ると、お金が無くて旅行の計画を断念したことを思い出しました。ただ、scheduled でないTripPlanを検索してみても、中途半端に作成しただけのメモのような計画ばかりヒットしてしまい、目的の旅行計画をを探せませんでした。

確定していない旅行の予定のうちには、「一旦計画を止めるけど後でもう一度計画し直したいと思って断念した計画」と「単に気持ちがアガらなくて放置してしまった計画」があるみたいです。

更にデータ型の方に変更を加えて、明示的に断念したものをわかるようにします。

  /// 旅行の予定データ
  pub struct TripPlan {

      // 確定している予定かどうかを決めるbool値
      scheduled: bool,

+     // 一旦計画は止めるけど、後で見直したい予定
+     reconsidered: bool,
+
      // 旅行の名前
      plan_name: String,

      // 旅行時のノート
      plan_note: String,

      // 旅行開始日。時刻は含まない
      start: Option<NaiveDate>,

      // u32は32ビット符号なし整数。4,294,967,295日旅行ができるね!
      days: Option<u32>,

}

第六話: 只今メンテナンス中です

経験や人にもよりますが、ここまでのあらすじに違和感がない人もいれば、その変更する前にちょっと考え直せよとすぐに気づいた方もいるかもしれません。

それぞれの機能開発の理由ははまったくもって妥当であり、それ自体が悪いことではありません。一つの機能のみ着目すれば正しく動いてはいるものの、いろんな概念同士の組み合わせに開発者が意図していない不整合な状態がいくつもあります。

私が考えた範囲ではこういうところがおかしいかなと思っています。(もっと致命的なバグはコメント欄でこっそり教えて下さい)

  • scheduled かつ reconsidered な旅行の予定って変だし、どちらかしか on にできないようにしようよ
  • scheduled にするタイミングでは start と days は常にデータが入っているようにするべきじゃなきゃいけないのにそのチェック無いね
  • set_daysみたいな更新系で変更できなかった場合にエラー出したい。てか変更できるかどうかって何で確認するの?scheduled のフィールド確認しても setter の中身がその if 文で制御しているかどうかなんて実行時はわからないよね。
  • plan_note は scheduled でないときの旅行ルートのメモが入ってたりしない? 旅行後のみに使うノートだから旅行後のみ取得できるようにしたいんだけど。
  • plan_noteとは別に reconsidered になった理由をテキストとして持つべきだと思う。

最終話: あれ、旅行予定ってなんなんだろう。

issue を片っ端からやっつけましょう。最終的なコードがこちらです。

出来上がったすごいコード。長いので折りたたんだ。
use chrono::NaiveDate;

/// 旅行の予定の状態
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TripPlanState {
    /// 旅行の予定が日程も確定していない状態
    Draft,
    /// 日程も決まって確実に行く旅行計画
    Scheduled,
    /// 何かしらの理由で練り直しを余儀なくされた計画
    Reconsidered,
    // やった!これでreconsideredかつscheduledな状態がなくなった!
}

/// 旅行の予定データ
pub struct TripPlan {
    /// 現在の状態
    state: TripPlanState,

    /// 旅行の名前
    pub plan_name: String, // これだけいつでも編集できる

    /// 旅行時のノート
    plan_note: Option<String>,

    /// 再計画しなきゃいけない理由
    reason_to_reconsider: Option<String>,

    /// 旅行開始日。時刻は含まない
    start: Option<NaiveDate>,

    /// u32は32ビット符号なし整数。4,294,967,295日旅行ができるね!
    days: Option<u32>,
}

// TripPlanの操作時のエラーをまとめたenum
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TripPlanError {
    SetDaysError,
    SetStartError,
    SetPlanNoteError,
    SetReasonToReconsiderError,
    StartOrDaysEmptyError,
}
type TripPlanResult<T> = Result<T, TripPlanError>;

impl TripPlan {
    /// draftな旅行のプランを作成する
    pub fn new(plan_name: String) -> TripPlan {
        return TripPlan {
            state: TripPlanState::Draft,
            plan_name,
            plan_note: None,
            reason_to_reconsider: None,
            start: None,
            days: None,
        };
    }

    /// 現在の旅行プランの状態を返す
    pub fn state(&self) -> TripPlanState {
        self.state
    }

    /// 旅行プランの状態を変更する
    /// Scheduledな状態にする場合、startとdaysが空だった場合エラーになる。
    pub fn set_state(&mut self, new_state: TripPlanState) -> TripPlanResult<()> {
        if new_state == TripPlanState::Scheduled
            && (self.start().is_none() || self.days().is_none())
        {
            // scheduledな状態でいずれかの項目が入っていなかった場合はエラーにする
            return Err(TripPlanError::StartOrDaysEmptyError);
        }

        self.state = new_state;
        Ok(())
    }

    /// 旅行の感想を返す
    pub fn plan_note(&self) -> Option<&String> {
        self.plan_note.as_ref()
    }

    /// 旅行の感想を書く
    /// Scheduled以外の場合はエラーになる
    pub fn set_plan_note(&mut self, note: String) -> TripPlanResult<()> {
        if self.state != TripPlanState::Scheduled {
            return Err(TripPlanError::SetPlanNoteError);
        }
        self.plan_note = Some(note);
        Ok(())
    }

    /// 旅行の再計画の理由を返す
    pub fn reason_to_reconsider(&self) -> Option<&String> {
        self.reason_to_reconsider.as_ref()
    }

    /// 旅行の再計画の理由をセットする
    /// Reconsidered以外の場合はエラーになる
    pub fn set_reason_to_reconsider(&mut self, reason: String) -> TripPlanResult<()> {
        if self.state != TripPlanState::Reconsidered {
            // DraftとかScheduledなときはエラーを出すよ
            return Err(TripPlanError::SetReasonToReconsiderError);
        }
        self.plan_note = Some(reason);
        Ok(())
    }

    /// 旅行の開始日を返す
    pub fn start(&self) -> Option<&NaiveDate> {
        self.start.as_ref()
    }

    /// 旅行の開始日を書く
    /// Scheduledな場合は設定できないため、エラーになる
    pub fn set_start(&mut self, d: NaiveDate) -> TripPlanResult<()> {
        if self.state == TripPlanState::Scheduled {
            // スケジュール中にセットしたらエラーにするよ!
            return Err(TripPlanError::SetStartError);
        }
        self.start = Some(d);
        Ok(())
    }

    /// 旅行の日数を返す
    pub fn days(&self) -> Option<u32> {
        self.days
    }

    /// 旅行の日数を書く
    /// Scheduledな場合は設定できないため、エラーになる
    pub fn set_days(&mut self, days: u32) -> TripPlanResult<()> {
        if self.state == TripPlanState::Scheduled {
            // スケジュール中にセットしたらエラーにするよ!
            return Err(TripPlanError::SetDaysError);
        }
        self.days = Some(days);
        Ok(())
    }
}

生成されたドキュメントの screenshot

cargo docで生成されたTripPlanモジュールのドキュメント

正直、これ以上TripPlanの状態を考えたくありません。一つ状態を追加するだけでどこが壊れるかも見当がつきません。どうすればTripPlanを拡張するときに既存の機能が壊れない事を保証できるのでしょうか?

自動テストは、テストが書ける範囲でしか保証ができません。予測しづらいバグを見つけるためにテストを信頼しているのではなく、既存の動いてた部分を保証する程度でしかありません。状態の組み合わせの複雑さはテストでカバーするのは限界があります。

...

と、ここまでのすべてが前段でした。本題はここからです。ちょクワガタにこのプログラムをリファクタしてもらいましょう。

後半: ちょクワガタにプログラムを分割してもらう

ちょクワガタ
画像は@kazu_yamamoto(twitter)さんが作成したちょクワガタのイメージです[1]

Tagged union という言葉は Tagged_Union(Wikipedia en)からの引用ですが、いろいろな呼び方がされているようです。

  • variant(ヴァリアント), Rust とか OCaml とか Elm とかがこう呼んでる
  • choice type
  • disjoin union
  • sum type, Haskell とかがこう呼んでる
  • discriminated union (判別可能な Union 型), TypeScript とかはこう呼んでる

これらはすべて同じ事を言っているのですが、Tagged union 特に重要な性質が以下の性質です。

  • Tagged union 型の値はタグ付けした型の集まりのうちどれか一つの型の値であることを保証している。
  • Tagged union 型の値を使う際にはすべてのタグに対しての場合分けを実装しなければならない[2]

その結果、新たにタグを追加した場合に以下のようなことが起こります。

  • 既存の場合分けでパターンマッチの漏れが発生するため、追加されたパターンに対するマッチングができていないとエラーが出る

Tagged union の簡単な例

まずは簡単な例から紹介します。

Tagged union の説明のために、まずは旅行の予定として有効なものを「開始の日付が決まっている」または「開始の日付と期間が決まっている」ものという仕様を考えてみます。

日付型がDate、日数型がDaysだとした場合、DateOnlyタグとDateAndDaysタグを持った Tagged unionValidScheduleは、それぞれの言語上で以下のリストの文法で実装します。

各言語での Tagged union の例

Tagged union の定義を行うと、そのタグの値(関数)が作成されます。今回は 2 種類のタグがそれぞれ 1 つや 2 つの値を持っているので、タグの値もそれぞれの値の数だけ引数を取ります。

// rustの例
enum ValidSchedule {
    DateOnly(Date),
    DateAndDays(Date, Days),
}
// DateOnly(Date) -> ValidSchedule
// DateAndDays(Date, Days) -> ValidSchedule
-- Haskellの例
data ValidSchedule
  = DateOnly Date
  | DateAndDays Date Days
-- DateOnly :: Date -> ValidSchedule
-- DateAndDays :: Date -> Days -> ValidSchedule
(* OCaml の 例。型名が小文字から始まる必要があるので型は小文字 *)
type valid_schedule
  = DateOnly of date
  | DateAndDays of date * days;;
(* DateOnly : date -> valid_schedule *)
(* DateAndDays : date * days -> valid_schedule *)
// Swiftの例
enum ValidSchedule {
    case DateOnly(Date)
    case DateAndDays(Date, Days)
}
// ValidSchedule.DateOnly(Date) -> ValidSchedule
// ValidSchedule.DateAndDays(Date, Days) -> ValidSchedule

各言語でのパターンマッチの例

Tagged union の定義では、その Tagged union 型に変換するためのタグが作られていました。パターンマッチでは、Tagged union にどのタグで埋め込まれたの分岐を書くことになります。

ValidateScheduleDateOnlyDateAndDaysの 2 種類のタグで構成されているので、パターンマッチをするときは Tagged union にあるすべてのタグに対して分岐をしなければなりません。

// Rust での Tagged union のパターンマッチ
let v: ValidSchedule = ...
match v {
  ValidSchedule::DateOnly(date) => { ... }
  ValidSchedule::DateAndDays(date, days) => { ... }
}
-- Haskell での Tagged union のパターンマッチ
v :: ValidSchedule
v = ...
case v of
  DateOnly date -> ...
  DateAndDays date days -> ...
(* OCaml での Tagged union のパターンマッチ *)
let v = DateOnly(Date);;
match v with
  | DateOnly(date) -> ...
  | DateAndDays(date, days) -> ...;;

// Swift での Tagged union のパターンマッチ
let v: ValidSchedule = ...
switch v {
  case .DateOnly(let date):
    ...
  case .DateAndDays(let date, let days):
    ...
}

そして、この Tagged union に対しての分岐を書く上で重要なのが、一つの値でパターンマッチをする際、いずれかのブランチが必ず実行され、どこかのブランチが実行されたらその他のブランチが実行されることはありません。

この性質こそがちょクワガタ(直和型)の名前の由来と関係する部分になります。(やっとタイトル回収)

Tagged union 型の値は、その型が持つタグのどれか一つだけを持っており、同時に 2 つ以上のタグを持ったり、どのタグにも対応しないということがありません。 常に排他的に 1 つだけの値を持っています。

このやたら都合のいい性質、いろいろなデータモデルの設計に活用できそうですよね。そう、例えばTripPlanとか...

TripPlan再考

TripPlanは、Draft | Scheduled | Reconsidered の3種類の状態があり、しかもその状態によって特定のプロパティが編集できたりできなかったり、要素があったりなかったりしました。

これらの概念は全部、旅行の予定にこそ関係していたものの、データとしての振る舞いに「共通の概念」としてまとめなきゃいけないロジックがあったわけではありません。それを無理やり一つのTripPlanに実装してしまったがゆえにぐちゃぐちゃになってしまったのです。

では、それぞれ別のクラスに実装してしまって良いのでしょうか。すると Draft から Scheduled にするといった遷移についてはどう扱えばよいのでしょう...そうです。ここで Tagged union が活躍してくれるわけです。

ちょクワガタによって生まれ変わったTripPlan

Tagged union を用いることで、全く関係ないデータたちを寄せ集めてオブジェクト間の関係を定義することができます。

pub enum TripPlan {
    Draft(DraftTripPlan),
    Scheduled(ScheduledTripPlan),
    Reconsidered(ReconsideredTripPlan),
}

この表現の何が良いかを詳しく掘り下げていきます。

Tagged union の定義と性質由来の、存在するだけのありがたみ

まず、enum TripPlanがどんな状態を取りうるかが定義から読めます。今までのように状態を尋ねる関数実装は読む必要がありません。Tagged union の性質から、TripPlanが取りうる状態は 3 つに 1 つです。

もし x: TripPlanDraft(DraftTripPlan) -> TripPlanから作られな値なら、xScheduledTripPlanでもReconsideredTripPlanでも無いことは定義から明らかです。 同様に、何かしらの状態を取っているうちはその他の状態を取らないことがわかります。

状態分岐の知識がモデルから消える

そして、今はもうDraftTripPlanScheduledTripPlanReconsideredTripPlanはお互いの実装に依存する必要がありません。今までは一つのTripPlanがあらゆる状態を表現できる豊かすぎる程に機能を持っていたため、プロパティ一つがもつ意味が多すぎたのです。しかし今は予定が確定した旅行の計画はScheduledTripPlan、そうでないまだ決まってないものはDraftTripPlanと実装が別れてます。

元の実装のstart: Option<NaiveDate>は scheduled な場合は setter を呼び出しても処理されない場合がありました。しかし、今の実装は Tagged union によって分岐しているので、「スケジュール中なら書き込めない」仕様は Tagged union 型に対しての実装になり、ScheduledTripPlanDraftTripPlanの実装はお互いの仕様を気にすることなく自身の知識の表現に徹底することができます。

use chrono::NaiveDate;

/// 予定中の旅行の計画
pub struct DraftTripPlan {
    pub plan_name: String,
    /// 旅行開始日。
    /// Draftなのでない場合も有るし、pubで公開してるので外からmutableにいじれる
    pub start: Option<NaiveDate>,
    /// 旅行日数
    /// Draftなのでない場合も有るし、pubで公開してるので外からmutableにいじれる
    pub days: Option<u32>,
    //  ↑ 今まではここのフィールドの更新はScheduledのときに誤って更新しないように
    //    プログラマが配慮しながら気をつけて自分の状態を見ながら更新していた。
    //    今は`DraftTripPlan`ということで、未確定の状況のみ考えて実装すれば良い
}

/// 予定が確定した旅行の計画
pub struct ScheduledTripPlan {
    pub plan_name: String,
    pub plan_note: Option<String>,
    /// 確定した旅行開始日。
    /// pubでないので外から書き換えられない。getterからアクセスする
    start: NaiveDate,
    /// 確定した旅行日数。
    /// pubでないので外から書き換えられない。getterからアクセスする
    days: u32,
    // ↑ 今まではscheduledなときは必ず値が入っている規約を信じで恐る恐る
    //   Option<u32>から値を取り出さなければならなかったが、
    //   今は`ScheduledTripPlan`なので必ず有ることが保証されているし、
    //   想定外のフローで書き換えられる心配もない。
}

/// 再計画することにした予定
pub struct ReconsideredTripPlan {
    pub plan_name: String,

    /// 再計画しなきゃいけない理由
    pub reason_to_reconsider: Option<String>,

    /// 旅行開始日。
    /// Draftなのでない場合も有る。reconsideredなときは編集させない。
    start: Option<NaiveDate>,
    // ↑ 他の状態を黄にせず実装できるのがよき
    /// 旅行日数
    /// Draftなのでない場合も有るし、pubで公開してるので外からmutableにいじれる
    days: Option<u32>,
}

impl ScheduledTripPlan {
    pub fn new(
        plan_name: String,
        plan_note: Option<String>,
        start: NaiveDate,
        days: u32,
    ) -> ScheduledTripPlan {
        ScheduledTripPlan {
            plan_name,
            plan_note,
            start,
            days,
        }
    }
    pub fn start(&self) -> NaiveDate {
        self.start
    }
    pub fn days(&self) -> u32 {
        self.days
    }
}
pub enum TripPlan {
    Draft(DraftTripPlan),
    Scheduled(ScheduledTripPlan),
    Reconsidered(ReconsideredTripPlan),
}

impl TripPlan {
    pub fn set_days(&mut self, days: u32) {
        // パターンマッチ。取りうる状態すべてに対して網羅的に定義している
        match self {
            TripPlan::Draft(draft) => draft.days = Some(days),
            TripPlan::Scheduled(_) => (),
            TripPlan::Reconsidered(_) => (),
        }
    }

    pub fn schedule(&mut self) -> Result<(), String> {
        // Rustのパターンマッチもすごいぞ
        // ネストしたtagged unionにダイレクトにマッチしたり、複数のタグに対してもマッチできる
        match self {
            TripPlan::Draft(DraftTripPlan {
                start: Some(start),
                days: Some(days),
                plan_name,
            })
            | TripPlan::Reconsidered(ReconsideredTripPlan {
                start: Some(start),
                days: Some(days),
                plan_name,
                ..
            }) => {
                *self = TripPlan::Scheduled(ScheduledTripPlan::new(
                    plan_name.clone(),
                    None,
                    *start,
                    *days,
                ));
                Ok(())
            }
            TripPlan::Draft(_) | TripPlan::Reconsidered(_) => {
                Err("旅行開始日と旅行期間がセットされてないよ!".to_string())
            }
            TripPlan::Scheduled(_) => Ok(()),
        }
    }
}

新しい状態の追加が if より安全

それぞれの状態ごとの制約はそれ専用のデータが担い、専用のデータ間の受け渡しが Tagged union の実装として記述でき、うまく関心を分離させることができました。

ここで、もし旅行の予定が取りうる状態が更に 3 つ増えるとしたら、今までの if 文のアプローチと Tagged union でのアプローチはどう変わるでしょうか?

if で状態を列挙しようとする今までのアプローチの場合、新しく増えた状態に対する bool を返すメソッドやプロパティが増えるだけでは既存の状態による分岐の対応が終わったとは言えません。あらゆる状態による分岐のコードをチェックし、自分の追加した状態についての処理が必要かどうか逐一確認する必要があります。そしてモデルの内部状態は今までにまして複雑度が上がり、既存のコードと新しいコードの修正はより神経を使うことになるはずです。また、新規実装の際は追加されたこの状態関数の存在を意識した上で分岐を実装しなければなりません。更に特定の状態であるかの判定は、排他的であるかどうかもわかりません。

それと比べて Tagged union のアプローチはどうでしょうか?タグを追加して取り込みたいデータを指定するだけです。この新しいタグに対してのパターンマッチがないすべての箇所ですぐにエラーが発生して、実行するまでもなく「このままではすべてのシチュエーションに対応できてません!」とコンパイラが伝えてくれます。また、既存のパターンマッチに新しく追加したデータが間違って渡ってしまうことは発生せず、安全に状態を追加するということが可能です。

まとめ

状態に応じて分岐するのに if 文のみだと

  • 取りうる状態の種類が見えづらい
  • 新しい状態が追加されてたときに対応を漏らしやすい
  • 状態に依存するため、モデルがどんどん複雑になってしまう

といったことが発生してしまいますが、ちょクワガタ(Tagged union)を用いると

  • 取りうる状態を列挙できる
  • 必ずどれかのうちの 1 つであることが保証される
  • 状態の分岐の知識と、その状態でのできることを分離できる
  • 状態を追加したときに、実装漏れにすぐ気づくことができる

といった恩恵を受けることができます。

この際にちょクワガタを飼育してみてはいかがでしょうか?

最後までお読みいただき、ありがとうございます!

脚注
  1. https://twitter.com/kazu_yamamoto/status/781339553697116164?s=20&t=TNe5iZFJ8kIgSpWtOoObJw ↩︎

  2. すべてにマッチするワイルドカードパターンを書くと、新しく追加したタグについてのマッチングが定義されているので、タグを追加しても処理をし忘れることはあります。 ↩︎

Discussion