型・値


まとめ

  • 型は値を知りつつある
  • 型を事前条件として捉えるというアプローチ

はじめに

最近は静的型チェックを行う言語が増えてきました.
私の職場でも Rust や TypeScript を使ったプロジェクトが増えています.

型チェックのありがたみを感じた人は,次の疑問を持った方がいるかもしれません.

型チェックをすればテストは不要になるのか?

残念ながら,テストは必要です. Rust や TypeScript にもテストフレームワークが存在します.

そこで,問いを変えましょう.本記事は下記の疑問に対する私の回答です.

型チェックはどこまで保証してくれるのか?

ぼつぼつ語りますか...

型と値

昔,型は値を保証できないと考えていました.

例を挙げましょう.次のようなコードはテストしなければわかりません.

fn get_something(value: i32) -> Result<(), Error> {
    if value > 10 {
        Ok(())
    }
    else {
        Err(Error::SomeError)
    }
}

assert!(get_something(15).is_ok());

いやいや,真面目に言ってます.実行しないと正しいことを保証できません.
実際にテストコードを書くかはともかく,理屈的にはテストが必要です.

型は値を知らないので,値に関することは全てテストしなければなりません.

逆に,これはテストしなくてもいい例です( panic! を使わないことを前提としていますが).

fn print_hello_workd() -> () {
   println!("hello world")
}

// 失敗するの?
assert_eq!(print_hello_workd(), ());

このassert_eqが失敗する気がしません.
print_hello_world のインターフェースに値がないのでテストする理由がないです.
テストが必要なのは標準出力した文字が期待通りかなどでしょう(副作用怖い).

文字列は値なので,下の場合はテストが必要でしょう.

fn get_hello_workd() -> String {
    "hello world".to_string()
}

// 文字列は値なのでテストしなければならない.
assert_eq!(get_hello_world(), "hello world".to_string());

でも,型で保証したいですねぇ...

データと共にロジックを型に格納する「カプセル化」

ここで一旦,型で保証できる昔ながらの例を挙げます.
私は不具合対応をした時に,このようなコードを見たことがありました.

fn get_winner_prizes(campaign: Campaign, sorted_user_array: Vec<User>) -> Vec<Prize> {
    ...
}

sorted_user_array は気になる変数名です.ソート済みであることが重要なようです.
このコードは厄介でした.ソートされた場所と,ソートする必要があった箇所が遠すぎました.
質問をしたりコードを読んだりした結果.ソートは探索速度向上のための最適化とわかりました.

でも,いつか私は別のIssue対応でソート不要と誤認し Vec<User> を投入してしまいそうです
型チェックは私のソート忘れのミスに気づいてくれません

計算量を意識した設計は素晴らしいですが,事前にソートしているかを毎回目視確認したくないです.
今回のケースでは探索キーに対する要求は特になく,線形探索が嫌だっただけのようなので,
よかった
ソートしているかどうかを気にする必要はありません.

以下のようなコードで懸念を払拭できます.

fn get_winner_prizes(campaign: Campaign, users: BTreeMap<UserName, User>) -> Vec<Prize> {
    ...
}

この場合,ソートされているかどうかを忘れていいです.BTreeMapはソートを型として保証します.
BTreeMapを投入しなければ型チェックが教えてくれます.

私が特定のユーザを知りたい場合, users.get(&user_name) をするだけです.
(まさにやりたかったこと)
型は探索の細部をカプセル化によって隠してくれているのです.

ロジックを型によって保証できるのは素晴らしいことです.
オブジェクト指向プログラミングの輝かしい成果ですね.

型は値を知りつつある

ただのデータの集まりは構造体...
それにロジックをメソッドとして包んだのがクラス...
素晴らしい!!

それで満足していた時期がありました.でも型は進化していました.

Option

Option型は悪名高きヌルポを撲滅可能にした偉大な型でした.
( 本来必要ない箇所まで Null チェックを書きまくってしまった人は私だけではあるまい )

この型は,初期化が完了していない可能性があることを型として保証してくれます.
(つまり, Optional でなければ初期化は完了しているのです!!

struct Model {
    /* fields omitted */
}

impl Model {
    fn do_something(&self) {
        ...
    }
}

/// もしかしたら,まだ未初期化かもしれないシチュエーションで呼ぶ関数
fn do_something_if_model_exists(model: Option<Model>) {
    // Rust 流の Null チェック
    match model {
        Some(m) => m.do_something(),
        None => (),
    }
}

/// 初期化していることが前提のシチュエーションで呼ぶ関数.
fn do_something_when_model_existed(model: Model) {
    // None でないことを前提にできてる!!さようならNull チェック
    model.do_something()
}

OptionはModelが生成された(=Some)か,生成されていない(=None)かを知っているのです.
ポインタとして考えた場合,型は下記の2種類のアドレス値を区別できます.

  • Null Pointer( = 0 )
  • Resource Pointer ( != 0 )

似たようなものに,NonZeroI32 とかがあります.

そう,型は値を知りつつあるのです.

Enum による値の型保証

まだあります.
次のようなAPIのレスポンス型を考えてみましょう.

#[derive(Debug, Deserialize, Serialize)]
struct Response<D> {
    result_code: String,
    data: Option<D>,
    error_message: Option<String>
}

impl<D> Response<D> {
    fn is_success(&self) -> bool {
        self.result_code == "SUCCESS"
    }
}

// 賞品に変換したい.
let response: Response<Prize> = api::get_prize()?;

// 危ない危ない成功したか確認しておかないと...
assert!(response.is_success());

この場合,文字列は値なので成功したかどうか毎回チェックしなければなりません.
忘れてしまいそうですね.

でも,次のように書いたらどうでしょうか?

#[derive(Debug, Deserialize, Serialize)]
struct Response<R, D, E> {
    result_code: R,
    data: D,
    error_message: E
}

/// 成功であること保証させる型
#[derive(Debug, Deserialize, Serialize)]
enum Success {
    /// 成功した
    #[serde(rename = "SUCCESS")]
    Success
}

/// 失敗であることを保証させる型
#[derive(Debug, Deserialize, Serialize)]
enum Failed {
    /// 見つからなかった
    #[serde(rename = "NOT_FOUND")]
    NotFound

    /* other fields omitted */
}

type SuccessResponse<D> = Response<Success, D, ()>;
type FailedResponse = Response<Failed, (), String>;

// 賞品に変換したい.
let response: SuccessResponse<Prize> = api::get_prize()?;

// 確認する意味ある?
assert!(response.is_success());

SuccessResponse は成功していることを型で保証しているので,
成功していることを念頭に以降の全てのコードを記述できます.

確認する必要なんてないです

※ ここでは response 変数は成功した結果のみ格納することを保証しているに過ぎません.
response に上手く変換できずに失敗する可能性があるので,その意味でテストは必要です.

事前条件としての型

c++などのヘンテコな言語では,型で保証できることはもっと多いですが,今回はここまでにします.
そもそも,型は値を分類するために生まれたものですが,分類の体系化はかなり進んだと思います.
(今回の話は,その中でも列挙体の発展でした)

型の進化に合わせて,私は次のような思想に至りました.

もう事前条件も型でいいのでは?同じコメントやcheck文を何箇所も書きたくないんだけど

(あるいは型定義は強力なドキュメントの一つということかもしれません)

型は不変条件の定義に向いていると思っていましたが,
値を限定できることで事前条件も兼ねつつあるように思います.

型に事前条件があれば,問題となっている関数の引数にある関数定義から,事前条件が何かは
エディタの「定義へ移動」ショートカットで一発です.

型で保証していれば,型としての事前条件を満たさないデータは関数の入力に成り得ないので,
不正なデータがシステムの深くまで侵入することはないです.
デバックは問題が起こった付近から開始することができます(攻撃的プログラミングの一種).

最初の問いはなんでしたっけ?

型チェックはどこまで保証してくれるのか?

私の答え) 型の定義を読みなさい.型が君に教えてくれる


おまけ)今回話した型の保証度合い

私の型の品質レベルのランク付は下記のようなイメージです.

例1) 賞品の集合の取り扱い

品質 型の例 コメント
serde_json::Value データということだけはわかります
Vec<Prize> 賞品の集合だと分かります.投入順の保証で十分なら完成.変数名が sorted_prizes とかなら下に続く
BTreeMap<String, Prize> 何かの文字列でソートが保証されていることは分かります
BTreeMap<PrizeName, Prize> 賞品名でソートされているんだと分かります
Prizes 賞品の取り出し方法の実装が変わっても増えても大丈夫そうです.あなたのやりたいことはソートではなく,賞品を取り出すことなはずです.でも,やりすぎかも.

例2) APIレスポンスの取り扱い

品質 型の例 コメント
serde_json::Value データということだけはわかります
Response<serde_json::Value> APIレスポンスだと,ここで気づきます
Response<Prize> 賞品を返すのだと分かります.失敗してなければね
SuccessResponse<Prize> 約束された成功

いかがでしょうか.良きコーティングライフを!!

参考

というよりもおすすめの記事.
- 和田卓人さん,PHPで堅牢なコードを書く—例外処理,表明プログラミング,契約による設計 〜PHPカンファレンス2016
- 契約による設計から見た例外