コンストラクタを利用した保証テクニック


はじめに

今回は私が愛用する 保証テクニックを紹介します.

(2021/09/26 追記)どうやら,この記事の内容は完全コンストラクタパターン(Complete Constructor パターン)とバリデーションを含めたものらしい.

まとめ

  • リソースは初期化時に確定させよう
  • 不安定な状態を持つリソースを確保してはならない
  • 関数の使用者は,関数への入力の生成順序を知る必要はない

テクニック概要

アイデアは単純で,リソースの一連の初期化手続きをコンストラクタにまとめ,
コンストラクタの時点でリソースが確保できたか,失敗したかの 2 択に追いやります.

例えば,API サーバを作っていた場合に,下記のような入力結果が妥当かを判断する
PrizeForm を設計したとします.

non-raii.rs
#[derive(Debug, Validate)]
pub struct PrizeForm{
    #[validate(length(min_length=5, max_length=100))]
    name: String,
}

let form = PrizeForm::new("sample_prize");
// 正しい入力が来たかな?
form.validate()?;

このテクニックでは,PrizeForm のようなフォーム層は不要です.
代わりに,入力パラメータ PrizeParams のコンストラクタでバリデーションを行います.

raii.rs
#[derive(Debug, Validate)]
pub struct PrizeParams{
    #[validate(length(min_length=5, max_length=100))]
    name: String,
}

// 正しい入力が来たかな?
let params = PrizeParams::try_new("sample_prize")?;

これは,外から見ると何も変わっていません.例外の投げられるタイミングが
コンストラクタの後か,コンストラクタの最中かの違いだけです.

反論もあるでしょう.Form は検証することを責務としているので,
初期化で誤った値であろうとを正しく確保しているのだと.

一体,なぜこんなことを言うのでしょうか?

言葉遊びのようなテクニックですが,これにより明確な利点が生まれます.
後者の場合,PrizeParams を引数とする関数はバリデーションが成功していると確信できます.

fn get_prize(params: &PrizeParams) -> Result<Prize> {
    // あなたがこのアイデアを使うべきかどうかは,この関数を書いているときに,
    // 関数の呼び出し前に validate() を実行したのかを頭の片隅で考えたいかどうかです.
    ...
}

一方,最初の例では,PrizeForm を使う全ての関数は form.validate() を実施していたかを
常に関数の外へ行き確認する必要があります
(この get_prize()が 100 回呼ばれていたら,長い冒険の始まりかもしれません ).

不安定なリソース・安定なリソース

このテクニックは 不安定なリソースを定義してはならない という個人思想からきています.

不安定なリソースとは何か

それは先程の PrizeForm のような,こちらが期待しているものかもしれないし,
そうでないかもしれないリソースのことです.
(PrizeForm は,form.validate() が通るかもしれないし,通らないかもしれない)

言い換えれば,私は 安定したリソース として,下記の条件を求めています.

  • リソースはライフタイムを通じて,その不変条件が守られていなければならない
  • リソースは自身を利用する全ての関数に対して,関数が求める事前条件を守らなければならない

この条件に外れたデータが入力されたとき,リソースのコンストラクタは例外を出します.

ね,簡単でしょ

一部の人は,2 番目が難しいと感じるかもしれません.
リソースは自身を利用する全ての関数の要望を知ることはできず,
よって,リソースは全ての関数の要望は叶えることはできないはずだと.

しかし,それは考えすぎです.もし,特別な要求をする関数があるなら,
その関数が求めているリソースは別のリソースである可能性が高いです.
(PrizeParams は空であってもいいなら, Option<PrizeParams> とか...
 成功していることが前提なら Response ではなく SuccessResponse とか...
 私は異なる型は異なるリソースを指していると見做しています)

このテクニックで重要なことは関数を定義するときに,その関数が利用される文脈を捉え,
入力リソースを正確に定義することです.

そのために必要なことは,あなたが作っている対象を理解することです.

私は,製品作りというものは定義作りと考えています.
ニーズと製品を理解しましょう.そうすれば,必要なリソースをより明確に定義できます.

型で定義できれば,後は型チェッカーが全数点検で頑張ってくれます

無知のベールに包まれた使用者を想定する

安定したリソースという概念は関数の中,関数の設計者に視線を向けたときの考え方です.
逆に,関数を利用する側の観点を考えるとき,私は「無知のベール」に近い考え方をとります.

すなわち,関数の利用者は,どのような手順で入力を準備するか知る必要はありません.
入力を生成できたなら,その生成手段によらず関数は入力に対して正しく動かなければなりません.
(誤ったデータが投入されたら必ず例外を出さなければならないということです.
 validate() を実行していない入力を与えてもOkのデータを返すようでは問題です)

要するに,関数の使用者は ではなくて, ってことです.

最後に

私は基本的に,心構えで品質を保証できるとは考えていません.

例えば,職人らしきプログラマが

私はいつも,form.validate や Null チェックができているかチェックすることを肝に銘じています

と言ってきたとしても,それで保証できているとは考えていません
精神論は重要ですが,私自身が精神論でチェック漏れという反例を作った実績から
保証とは認めません.

コードレビューで変更点に対して Null チェックできていることをチェックしました

と言っても,私は保証できているとは考えていません
チェックとは観測に基づいたものであり,観測していない所は何も保証してくれません.
コードが変更していない箇所へ変化点の影響が波及していることを想定できていません.
プロジェクトの規模が大きくなるほど開発速度が低下し、
品質が悪化する原因はここにあると考えています。

保証とは,もっと厳格なものです.

つまり,上の回答は次の疑問に答えてくれません.

もし確実に保証できているのなら,なぜチェックするんだい?

保証というのは,ノーチェックで信頼できるものでなくてはなりません.

そうは思いませんか?

常に安定したリソースを確保し続けることは、型による保証を得るために
重要なファクターであると私は考えています。


社外勉強会の質疑応答を踏まえての修正と補足

A::new(a)?A::try_new(a)? の方が良いかも

発表中にコメントに気づけませんでした.その通りですね.いい表現をいただきました.
今後は try_new と書きます.

Formが不要というのはやりすぎでは?

確かに極端な思想かもしれません.
例えば,Form#validateのインターフェースが下記なら安定なリソースを生成できます.

impl PrizeForm {
    fn validate(&self) -> PrizeParams;
}

let form = PrizeForm::new("sample_prize");
// 正しい入力が来たかな?
let params = form.validate()?;

発表の際にはここまでしか話せませんでしたが,後で考えればFromを無くしたのには,いくつかの理由がありました.

  • Formがなくても成り立つので,不要なレイヤーは作りたくない
  • Paramsに求められる制約をParamsではなくFromに書いている.Paramsを使用している関数からエディタでParamsの型定義へ飛んだとき,制約を確認できない(Paramsを生成する対象を探す旅が始まる.Formがいると知っていれば).
  • Paramsを生成できる複数のFormを用意できる事になるが,その場合AのForm検証を通ってきたのか?BのForm検証を通ってきたのか?が不明になり,話が元に戻る.

よくよく考えると,Form不要派であることに変わりありませんね
定義は近ければ近いほどいいというのが私の信条です.

また,チラリと話に出た気がしますが,コンストラクタを制限することは下記のようなコードで可能です.

/// 公開しない構造体
#[derive(Debug)]
struct InnerA {
    a: i32,
}

/// 公開する構造体
#[derive(Debug)]
pub struct A {
    inner: InnerA,
}

impl A {
    /// 公開しない構造体を内部的に生成する
    pub fn new(a: i32) -> Self {
        Self {
            inner: InnerA{a},
        }
    }
}

こうすれば,別のファイルからはimplで定義されたコンストラクタしか使えません.

use crate::A;

// 公開されたインターフェースを経由してのみ生成できる.
let a = A::new(12);

// どうやっても公開されていない InnerA を用意できないので他の方法で生成できない.
let ?? = A{inner: InnerA{12}};

まぁ全ての構造体で使うにはコストのかかる手法ですが,それは他の言語でも変わらないかな

参考になれば幸いです