世界を動かす技術を、日本語で。

解析せよ、検証するな (2019)

概要

  • 型駆動設計 の本質を「 Parse, don’t validate」というスローガンで表現
  • 部分関数を 全域関数 へ変換する手法の解説
  • 型システム による安全性と情報伝達の強化
  • バリデーションと パース の違いを明確化
  • NonEmpty型 の活用例を通じて実践的な設計方法を紹介

型駆動設計の本質:「Parse, don’t validate」

  • 型駆動設計 とは、静的型システムを活用して設計や実装を行う開発手法
  • 最も端的に表現するスローガンが「 Parse, don’t validate」(パースせよ、バリデーションするな)
  • このアプローチでは、 情報の消失を防ぎ、型で知識を伝搬 させることが重要
  • バリデーションは条件を満たすか確認するだけだが、 パースは構造化されたデータを生成 し、型安全性を高める
  • 型システムを活用することで、 実装ミスやバグの予防、保守性の向上 を実現

型システムが示す「実装可能性」

  • 静的型付き言語(例: Haskell)では、型シグネチャが実装可能性の有無を明確に示す
  • 例:foo :: Integer -> Voidは実装不可能。Void型には値が存在しないため
  • 例:head :: [a] -> aは一見単純だが、空リスト[]には対応できず、 部分関数 となる
  • コンパイラ警告により、 未定義ケース (例:空リスト)の存在を明示

部分関数を全域関数へ変換

  • 部分関数(例:head :: [a] -> a)は、すべての入力に対して定義されていない
  • 全域関数化の方法1: 戻り値を弱める (例:head :: [a] -> Maybe a
    • 空リスト時はNothingを返し、型安全性を確保
    • しかし、呼び出し側で毎回Nothingの考慮が必要となり、冗長かつバグの温床
  • 全域関数化の方法2: 引数型を強化 (例:head :: NonEmpty a -> a
    • NonEmpty型を利用し、 空リスト不可能性を型で保証
    • チェックは一度だけ、 以降は安全に利用可能

NonEmpty型による設計の実践

  • NonEmpty a型は、必ず1つ以上の要素を持つリストを表現
    • 構造:data NonEmpty a = a :| [a]
  • nonEmpty :: [a] -> Maybe (NonEmpty a)関数で、通常リストから変換
  • 例:設定ディレクトリの取得
    • 入力値のチェックとNonEmpty変換を 早期に一度だけ実施
    • 以降の処理(例:headでの利用)は 型による安全性 が保証される

型で知識を伝搬する設計の利点

  • 冗長なチェック不要、パフォーマンス劣化なし
  • 入力チェックを省略した場合、 型不整合で即座にコンパイルエラー
  • head' :: [a] -> Maybe a = fmap head . nonEmptyのように、 従来型の挙動も簡単に再現可能
  • 逆は不可:情報を失った型からは、強化型を安全に生成できない

「パース」と「バリデーション」の違い

  • バリデーション:条件を満たすか確認し、 情報は返さない (例:validateNonEmpty :: [a] -> IO ()
  • パース:条件を満たす場合、 構造化データ(型)を返す (例:parseNonEmpty :: [a] -> IO (NonEmpty a)
  • パースは、 入力の知識を型として呼び出し側に伝達 できる
  • 型システムの恩恵を最大限に活かすには、「 バリデーションではなくパースを選択」する設計が重要

まとめ:「Parse, don’t validate」の実践

  • 型駆動設計 は、情報を型で表現し、 安全性と保守性を高める手法
  • パースは、 情報の伝搬と型安全性 を両立
  • バリデーションのみでは、 知識が失われ型の恩恵を活かしきれない
  • NonEmpty型 のような型を活用し、 型システムを証明道具として利用 する設計が推奨

次のトピックや観点が必要な場合は指示してください。

Hackerたちの意見

もしかしたら何か見落としてるかもしれないけど、このアイデアに共感してくれて嬉しいよ。Javaが人気になって、ダイナミック言語が注目されるようになった後、プログラミングコミュニティの多くが強い静的型チェックがなぜ生まれたのかを忘れちゃって、今になって再発見しなきゃいけない状況になってる気がする。ほとんどの強い静的型付け言語では、文字列やジェネリック辞書を頻繁にやり取りすることはないんだ。むしろ、生データを型付きデータ構造にパース・変換する方向に自然と進むはず。そうすれば、どこでも防御的なコードを書く必要がなくなるからね。例えば、与えられた文字列が日付として有効でないときに例外を投げるDateオブジェクトみたいに。(編集: メールの検証はややこしいから、これをメールから変更したよ。)だから、「パースして、検証しない」ってのが普通で、特に広める必要のあるアイデアじゃないんだ。

私の経験では、これはかなり珍しいことだね。ほとんどの人は電話番号を文字列としてやり取りしていて、電話番号クラスを使うことは少ない。Javaだと面倒だから、ほとんどのコードがプリミティブにこだわっちゃうんだ。他の言語だと楽だけど、その言語や会社にこの文化がしっかり根付いてないと、やっぱりプリミティブにこだわることが多いね。

ほとんどの強い静的型付け言語では、文字列やジェネリック辞書を頻繁にやり取りすることはない。私がプロとして関わった99%のプロジェクトでは、人間からの入力はすべて文字列として扱われていて、ほとんどの場合、アプリケーションのすべての層でそのまま文字列のまま(経路に応じてチェックがあるかないかは別として)なんだ。あなたの具体的な例に関しては、「Emailオブジェクト」みたいなものを見たことがないと言えるよ。

この記事で説明されている方法論を実装する際に、強い静的型チェックは役立つけど、それが主な焦点ではないよね。やっぱり、最も制約の厳しい型を使う必要がある。例えば、負の値を除外したいときはintじゃなくてuintを使うとか、リストが空であってはいけないなら非空のリスト型を使うとか。型がもっと複雑な場合は、特定の制約を使うべきだよ。実際の例を挙げると、ホテル予約アプリの職業の型を設計したんだ。部屋の定員は正の数でなければならないし、子供は必ず大人が一人以上同伴しなきゃいけない。私のOccupants型には、Occupants(int adults, int children)というコンストラクタがあって、これを作成する際にその条件を確認するようになってる(最大値もチェックしてる)。

これはONかOFFってわけじゃないアイデアだね。型をどんどん厳しくしていくことができるから、狭い型に対して行う操作はさらに堅牢になる。動的言語でも100%可能だし、文化的なものなんだよね。

編集: メールバリデーションは厄介な例だから、これをメールから変更した。正直、メールは日付よりもずっと単純に見える…スウェーデンには1712年に2月30日があったし、ほとんどの国では存在しなかった日付範囲がいろいろある(例えば、アメリカの植民地は1752年に9月3日から13日を飛ばした)。

過去2年ほどで経験した問題のあるバグのうち、3つ中2つは静的型付け言語で、前の開発者が型システムをうまく使ってなかった。1つのバグは、Email型があったのに、実際にはメールの不変条件を強制していなかった。問題を引き起こしたのは、大文字小文字を区別しない比較を強制していなかったこと。修正は簡単だったけど、追跡が難しい層に埋もれてた。もう1つは、自家製のORMで、同じオプショナル/メイビー型を使って「このカラムをデフォルトのままにする」と「このカラムをnullにする」を表現してた。これがどう間違えるかは明らかだよね。修正は簡単だったけど、いくつかの本番データが壊れた。これらはどちらも「パースして、バリデートしない」の失敗だ。フォームは、データをパースしたはずの不変条件を強制していなかったし、後者は2つの異なるパースを区別していなかった。

Javaが人気になった後のことのように感じるけど、プログラミングコミュニティの大部分が強い静的型チェックがなぜ発明されたのかを忘れてしまって、今再発見しなきゃいけないってことだね。過去を美化してると思うよ。学術的な側面では静的型は証明のために意図されてたけど、産業的な側面では効率のためだった。Cはコードが正しいことを証明するために静的型を持ってたわけじゃなくて、メモリを管理して最適化するために静的型を持ってた。Javaも助けにならなかったし、すべての型が別々のファイルでなければならないと、個々の型のコストはものすごく大きくなるし、すべてのフィールドに2つのメソッドが必要になるとさらにひどくなる。> ほとんどの強い静的型付け言語では、文字列やジェネリック辞書を頻繁に渡し合うことはない。ほとんどの強い静的型付け言語ではそうだけど、ほとんどの静的型のコードベースではそうだよ。Windowsのインターフェースを見てみて。実際、Simonyiの元々の「アプリハンガリアン」は静的型の薄い影響があったけど、C++で広く使われてたシステムでは完全に洗い流されてしまったんだ。

自然に、生データを型付きデータ構造に変換する方向に進むよね。そうすれば、どこでも防御的なコードを書く必要がなくなるから。例えば、与えられた文字列が日付として有効でない場合、コンストラクタで例外を投げるDateオブジェクトみたいに。classはたくさんの意味的に異なるアイデアが混ざってるから難しいんだ。ある人は防御的なコードを書かないためにDateオブジェクトを作ってるかもしれないし(クラスは型だから)、でも…他の人は、日付関連のコードを一か所にまとめるためにDateオブジェクトを作ってるかもしれない(クラスはモジュールや名前空間だから、Javaではクラスがファイルに対応することもある)。また別の人は、実装をオーバーライドするためにDateオブジェクトを作ってるかもしれない(クラスはジャンプテーブルだから)。さらに他の人は、異なる入力に対してメソッドをオーバーロードするためにDateオブジェクトを作ってるかもしれない(クラスはタグだから)。コードがどこにあるか、実行がどう分岐するかが、こういった決定に対する影響は、安全性の懸念よりも大きいと思う。結局、「どこでも防御的なコードを書かない」最も人気のある方法は…危険で壊れやすいコードを書くことなんだよね :-(

自然に、生データを型付きデータ構造に変換する方向に進むよね。そうすれば、どこでも防御的なコードを書く必要がなくなるから。例えば、与えられた文字列が日付として有効でない場合、コンストラクタで例外を投げるDateオブジェクトみたいに。これには自然なところは全くないよ。良いオブジェクト指向設計を生まれつき知っているわけじゃないし、これは学ばなきゃいけないパターンなんだ。リンクされた記事は、多くの人がこのアイデアを理解するのに役立った有名なものの一つだよ。

自分の経験では、エンタープライズプログラマーたちはWSDLみたいなものに疲れ果てて、ちょうどRailsが使えるようになった頃に離れていった感じ。Railsはモデルのバリデーションに関して素晴らしい仕組みがあって、それがその後の全ての基盤になったんだよね。静的型の言語でも同じように。ASP.NET MVCは、あまりエンタープライズっぽくならないようにRailsのプログラマーを取り戻そうとした試みだった。だから、型システムに頼ってるように見える便利でフレームワークっぽい解決策があったけど、実際には全部リフレクションだったんだよね。それがどの言語でもスタンダードになって、重いフレームワークがその仕事をしてくれるから「パースしてバリデートしない」なんてことを覚えておく必要もなかった。なんでそうしないの?おしゃれな型付き言語では、実際に複数の(国際化された)バリデーションエラーをウェブページに表示するのに適したエラーや結果の型はほとんどないからね。プログラミング言語の苦い教訓は、どんなに賢くて速くて安全で低レベルな機能があっても、誰かがもっと生産的なフレームワークを、もっと劣悪な言語で作り出すってこと。注目すべきは、このフレームワーク、もしかしたら最後のものかもしれないけど、今は「AI」なんだよね。

HNの常連です。ヒント: タイトルの下にある「過去」のリンクをクリックすると(ページの上部にある「過去」リンクではなく)、過去の投稿を検索できます。https://hn.algolia.com/?query=Parse%2C%20Don%27t%20Validate&... でも、引用を混ぜる方が効果的で、誤検出を減らせるよ。https://hn.algolia.com/?dateRange=all&page=0&prefix=true&que...

再投稿する価値はあるよ。これとジョン・アウスターハウトの「ディープインターフェース」に関するトーク[1]は、私にとって革命的だった。Pythonでコーディングしてる私からの意見だから、学びの転用がたくさんあるよ。[1] https://www.youtube.com/watch?v=bmSAYlu0NcY

この記事は素晴らしいけど、タイトルに引っかかって変な結論を出す人が多いね。この記事のポイントは、システム内の検証ロジックのローカリティについてなんだ。この文脈でのパースは、受け取ったデータの構造と有効性を決定するロジックをプログラムの一箇所にまとめることだと考えられる。これによって、プログラムの他の部分では、検証ロジックを持たずに、既知の構造の有効なデータを信頼できるようになるんだ。関連して、protobuf用のprotovalidateやXML用のSchematronのように、構造/有効性のローカリティをさらに改善するツールを探す価値があるよ。これらは、既存のシリアル化フォーマットのために、全ての有効性チェックをライブラリコードにアウトソースできるんだ。

自分でこのアイデアに気づいたとき、私は「エッジでの翻訳」と呼んだ。でも、私にとってはデータ検証を中央集権化するだけじゃなく、プログラミング言語が持っているデータ操作のためのすべてのツールにアクセスできることも重要だった。私の主な例は、同僚と一緒に働いていたときのこと。彼のアプリケーションではいくつかのタイムスタンプを使っていて、それを文字列としてやり取りし、使用時にパースして計算していた。でも、入力を言語のタイムスタンプ表現にパースすることで、内部インターフェースがずっとクリーンになって、目的も明確になったんだ。計算が関数ロジックではなく呼び出し時に露出できるから、複雑な関数名を通さなくてもよくなるんだ。

それには同意できないな。重要な洞察は、あなたが「パース」する型の構造に証明を持ち運ぶことだと思う。

それは防御的なパースシステムを構築する素晴らしい方法だと思うけど…やっぱりそれを作ってから、バリデーターを前に置いて、よくあるチェックをたくさん実行して、ユーザーやサービスにわかりやすい(そしてボリュームのある)エラーを表示したいんだよね。20,000行のCSVファイルをシステムに読み込んで、「3行目の名前の値が無効です」って言われるのは、本当に辛い。多分、他にもたくさんの問題があるのを一つずつ見つけなきゃいけないって分かってるから。

現代の静的型付け言語や動的型付け言語は、ほとんどこのアイデアを採用してるみたいだけど、Goだけはゼロ値が常に(または大体)有効な状態を表すことに決めたみたい。Goプログラマーに真剣な質問なんだけど、「パースして、バリデートしない」についてどう思う?

すべてのGoプログラマーを代表しているわけじゃないけど、「ゼロを意味のある値にする」アイデアには多くの価値があると思う。ゼロは初期化(ZII)という哲学があって、このアイデアを使ってる。あと、Clojureの「nil-punning」も注目に値するよ。基本的に、すべての型に対して「ゼロ」を有効な状態にすると(数値0、空の配列、nullポインタなど)、Option型で値をラップする必要がなくなって、メモリがゼロで初期化される場合やゼロクリアされる場合にコードを設計できるんだ。

「Parse, Don't Validate」についてどう思う? それを常に目指してるよ。Goの慣習に置き換えると、コンストラクタは次のようなシグネチャを持つ必要があるね:func NewT() (T, error) { ... } こういうシグネチャは標準ライブラリに存在するよ、例えば https://cs.opensource.google/go/go/+/refs/tags/go1.25.7:src/... ただ、驚くべきことに、古参の人たちがこれに驚くこともある。大きなコードベースでは、T{}自体(NewTコンストラクタをバイパスして)が使えないことが多いから、コンストラクタは「parse, don't validate」をうまく強制することになる。非常に単純なT{}だけが、ポインタや関数、チャネルなどのnil可能なプライベートフィールドを持たないだろうね。「ゼロを意味のある値にする」っていうのは、コードベースが大きくなるとあまりスケールしないと思う。

関連情報。他にも? Parse, Don't Validate (2019) - https://news.ycombinator.com/item?id=41031585 - 2024年7月 (102件のコメント) Parse, don't validate (2019) - https://news.ycombinator.com/item?id=35053118 - 2023年3月 (219件のコメント) Parse, Don't Validate (2019) - https://news.ycombinator.com/item?id=27639890 - 2021年6月 (270件のコメント) Parse, Don’t Validate - https://news.ycombinator.com/item?id=21476261 - 2019年11月 (230件のコメント) Parse, Don't Validate - https://news.ycombinator.com/item?id=21471753 - 2019年11月 (4件のコメント)

一般的には、「効果を端に押しやる」という考え方があって、エラー報告やプログラムのクラッシュといった検証効果も含まれると思う。仮に、すべてのランタイムデータを大きな塊に保持して、作成時にその構造を検証したら、その塊を不透明な表現として渡すことができる。後でその塊をデシリアライズして使っても問題ないし、検証を前提条件として持ち運ぶだけで、別の表現を明示的に作る必要はない。ファントム型を使って前提条件の一部の意味を持ち運ぶこともできる。要するに、このルールはもう少し一般的だと思うけど、この説明の方が直感的かもしれないね。

システムは時間とともに変わるものだからね(分散ノードが一度に切り替わるわけじゃない)。だから、シリアライズしたときには有効だったものが、後でデシリアライズするときには有効でないかもしれない。

この話題は以前にもネットで回ってたよね。多分、人々に共感を呼ぶから(何度も再投稿される)。とにかく、私はこのアイデアに非常に賛同するよ。私の経験では、「テキスト」や「文字列」は型じゃない。技術的にはそうだけど、もっと適切な型が使える場面で良い使い方を見たことがほとんどない。要するに、これは最後の手段で、そこでもあまりうまくいかない。皮肉なことに、唯一の良い使い方は…パーサーへの入力として使うことなんだ。型理論を活用できるシステム内で、ユーザー定義型を提供することができるのに、URLが文字列として渡されるのをよく見る。場合によっては、そのURLはすでに一度パースされているのに、効果的に「未パース」にされて、システムの「接点」で毎回パースが必要なテキストとして送られ続ける。パースは避けるべき不吉なリタニーのように扱われて、正規表現で怠惰に実装されることが多いけど、正規表現では全然足りない。おそらく、私たちにはパーサーが足りないのか、少なくとも、一般の開発者が理解できて使いやすいパーサー生成器が不足しているからだと思う。ファイルパスやHTTPヘッダーの値など、単なるテキストとして片付けられがちなものも同様だ。もしそうでなければ、「テキスト」が壊れる様子を何度も見てこなかったら問題ではなかっただろう。例えば、クエリパラメータが不正なURL、どうして+ '?' + entries.map(([ name, value ]) => name + "=" + value).join("&")をやらないのか、そんなに難しいことじゃないのに。先頭のスラッシュを仮定したり、なかったりするパスなど。この記事は、まさに同じフラストレーションから生まれたものだと思う。だから、私はどこに行っても同じマントラを持ち歩いている。「stringという型は存在しない」。できるだけ早くパースして、言語が許すなら怠惰に(ほとんどの言語がそうだ) -- 先に進むために、ただテキストが漏れないようにすることが大事。経験から言ってるけど、あなたの状況は違うかもしれない。

もしかしたら私は反論的なのか、理解していないのかもしれないけど、入力を読み取るときは、パースした後に必ずその入力を検証するよ。特にユーザーからのものであれば。別々にすべきだとは理解しているけど、すごく近くにあるべきだと思う。

[遅延]

最近レキシは何してるんだろう?彼女のハスケルへの最後の大きな貢献は、区切られた継続のプライムオペレーションだったけど、それから突然姿を消しちゃったね。