/note/tech

翻訳: Parse, don’t validate (バリデーションせずパースせよ)

要約:

■ 1. 型駆動設計を表すスローガン

  • 型駆動設計の説明の難しさ:
    • 型駆動設計の実践が何を意味するのか、簡潔に説明できずにいた
    • 突然のひらめきではなく反復的な設計プロセスがあるが、それを他人に伝えられなかった
  • スローガンの発見:
    • JSONを静的型付け言語と動的型付け言語でパースした際の違いを振り返る中で見つけた
    • そのスローガンは「Parse, don't validate(バリデーションせずパースせよ)」の3語で表せる

■ 2. 型で関数の実装可能性を判断する

  • 静的型システムの美点:
    • 「この関数を書くことはあり得るか」という問いに容易に答えられる
  • 実装不可能な関数の例:
    • foo :: Integer -> Void は実装できない
    • Voidは値を持たない型であり、どんな関数もVoid型の値を生成できないため
  • head :: [a] -> a の問題:
    • リストの最初の要素を返す関数だが、コンパイラは非網羅的パターンとして警告を出す
    • 空リストに対して返す要素がなく、すべての入力に対して定義されない部分関数となる
    • したがって、この型の関数も実装不可能である
  • 脚注: ボトムの扱い:
    • Haskellのボトム(無限ループや例外)は他言語のnullとは違い「実際の」値ではない
    • 慣用的なHaskellではボトムを避けるため、存在しないと仮定した推論にも価値がある
    • 論文「Fast and Loose Reasoning is Morally Correct」がその根拠を示す

■ 3. 戻り値を弱めて全域関数にする方法

  • 部分関数を全域関数にする方法は2つある
  • 約束を弱める方法:
    • 戻り値を Maybe a にし、要素がない場合はNothingを返す権利を確保する
    • これにより head :: [a] -> Maybe a は実装可能になる
  • 隠れたコスト:
    • 実装は便利になるが、利用側ではNothingに対処する負債を負うため著しく不便になる
  • 具体例: getConfigurationDirectories:
    • 環境変数CONFIG_DIRSから取得したリストが空でないことを事前にチェックする
    • それでもmainでheadを使う際、あり得ないとわかっているNothingに対処しなければならない
  • この方法が悪い理由:
    • すでにチェック済みのリストに冗長なチェックを書く必要があり、単純に面倒である
    • タイトループ内などでは冗長なチェックが積み重なり、パフォーマンスコストが生じうる
    • 事前チェックが削除されても呼び出し側が更新されず、「あり得ない」エラーが起こりうる
  • 型システムの穴:
    • 冗長なチェックの必要性は型システムに穴を開ける
    • Nothingにならないことを静的に証明できれば、チェック削除時にコンパイルエラーとなる
    • この書き方ではテストや手動検査でバグを見つけるしかない

■ 4. 引数を強めて全域関数にする方法

  • 入力の型を強める:
    • 戻り値の型を弱める代わりに、引数の型を強めて空リストで呼ばれる可能性を排除する
  • NonEmpty型:
    • Data.List.NonEmptyの data NonEmpty a = a :| [a] が空でないリストを表す
    • 最初の要素と残りの要素を分けて保管することで、空でないリストをモデリングする
    • head :: NonEmpty a -> a はGHCに文句なく受け入れられる全域関数となる
    • 脚注: Data.List.NonEmptyはheadを既に提供しているが、説明のために実装している
  • 改善後の実装:
    • getConfigurationDirectoriesが IO (NonEmpty FilePath) を返すようにする
    • チェックは nonEmpty :: [a] -> Maybe (NonEmpty a) を使い、1回だけ行われる
    • mainでは冗長なチェックが一切不要になる
  • Maybeの位置の変化:
    • Maybeは残るが、Nothingの処理は入力値をバリデーションする場所に明確に集約される
    • チェックを通過すれば、リストが空でないという知識が型システムによって保持される
  • この方法の利点:
    • 冗長なチェックがなく、パフォーマンスオーバーヘッドもない
    • 事前チェックを止めると戻り値の型が変わり、mainの型検査が失敗して実行前に問題がわかる
  • 第2のアプローチの優位性:
    • 古いheadは fmap head . nonEmpty で新しいheadから再現できる
    • 逆に新しいheadを古いheadから得ることはできない
    • 第2のアプローチはすべての点で優れている

■ 5. バリデーションとパースの違い

  • 違いの本質:
    • バリデーションとパースの違いは、ほぼすべて情報がどのように保持されるかにある
  • validateNonEmptyとparseNonEmpty:
    • どちらもリストが空でないことをチェックし、空ならエラーで中断する
    • validateNonEmptyは何の情報も持たない()を返し、獲得した情報を捨てる
    • parseNonEmptyは入力の改良版であるNonEmpty aを返し、知識を型システムに保持する
    • 型検査には両者とも従うが、型システムの恩恵を最大限受けるのはparseNonEmptyのみである
  • parseNonEmptyは正真正銘のパーサー:
    • パースとバリデーションの正確な定義には議論があるが、parseNonEmptyは単純なパーサーである
  • パーサーの定義:
    • より構造化されていない入力を消費し、より構造化された出力を生み出す関数である
    • 原理的に部分関数であり、すべてのパーサーは失敗の概念を持つ
    • 入力はテキストであることが多いが、必ずしもテキストである必要はない

■ 6. パースの力

  • 境界でのチェック:
    • 柔軟な定義によりパーサーは強力なツールとなる
    • 入力値をプログラムと外界の境界で1度チェックすれば、再チェックが不要になる
  • Haskellで日常的に使われるパーサー:
    • aesonはJSONをドメインの型にパースする
    • optparse-applicativeはコマンドライン引数のパーサーを組み合わせられる
    • persistentやpostgresql-simpleは外部データストアのデータをパースする
    • servantはパスコンポーネント、クエリパラメーター、HTTPヘッダーなどからパースする
  • 共通するテーマ:
    • いずれも外界とHaskellアプリケーションの境界に位置する
    • 外界のデータは和型や積型ではなくバイトのストリームであるため、パースが必要となる
    • 処理前にパースすることで、重大なセキュリティ問題を含む多くのバグを回避できる
  • 最初にパースするアプローチの欠点:
    • 値がパースされるのが実際に使われるより大幅に前になることが多い
    • 動的型付け言語ではパースと処理のロジックの一貫性を保つのが難しい
    • 膨大なテストで防げるが、その保守には非常に手間がかかる
    • 静的型システムでは、一貫性がなければコンパイル自体が失敗するため問題がシンプルになる

■ 7. バリデーションの危険性

  • 想定される反論:
    • 型システムが必要なチェックを強制するなら、少しの冗長なチェックは許容できるのではないか
  • ショットガンパーシング:
    • その場その場のバリデーションは、言語理論的セキュリティにおけるショットガンパーシングにつながる
    • 2016年の論文「The Seven Turrets of Babel」で定義されたアンチパターンである
    • パースと入力値バリデーションのコードを処理コードと混ぜて全体に分散させるものを指す
    • 体系的な根拠なく、大量のチェックのいずれかが「悪い」ケースを捕まえることを期待する
  • ショットガンパーシングの問題:
    • 不正な入力を処理せずに拒否する能力をプログラムから奪う
    • 遅れて発見されたエラーにより、不正な入力の一部が処理済みとなり、状態の予測が困難になる
  • 巻き戻しのリスク:
    • 最初にすべての入力をパースしないと、処理途中で不正を発見し巻き戻しが必要になりうる
    • RDBMSのトランザクションなどでは可能だが、一般的には巻き戻しはできない
  • バリデーションベースの限界:
    • すべてのバリデーションを最初に行えばショットガンパーシングのリスクは軽減できる
    • しかし、本当にすべてが最初に検証されたか確かめるのは極めて困難または不可能である
    • 「起こり得ない」ケースが実際に起こるかどうかも確かめられない
    • プログラム全体が、例外がどこでも発生しうると想定しなければならない
  • パースによる回避:
    • パースは処理をパースと実行の2フェーズに階層化することで問題を回避する
    • 不正な入力による失敗は最初のフェーズでしか起こらない
    • 実行中の残りの失敗は最小限であり、最大限の注意を払って扱える

■ 8. 実践のための方針

  • 基本のアドバイス:
    • 「何を」「なぜ」を理解しても「どうやって」に自信がない場合、データ型に集中すべきである
  • 具体例: 重複キーの扱い:
    • キーバリューペアのリストを受ける関数で、重複キーの扱いに迷う場面を想定する
    • 重複がないと表明する checkNoDuplicateKeys :: ... => [(k, v)] -> m () は危うい
    • 戻り値が使われないため、削除しても型検査が通り、簡単に忘れられる
  • 改善の手順:
    • 重複キーを許さないMapのようなデータ構造を選び、関数がMapを受け取るようにする
    • 呼び出し側の型検査が失敗するので、呼び出しの連鎖を辿ってリストからMapへの変換を続ける
    • 値が作られた最初の場所か、重複が本当に許される場所に辿り着く
    • その場所に checkNoDuplicateKeys :: ... => [(k, v)] -> m (Map k v) を差し込む
    • 重複チェックは処理に必要な処理となり、もはや削除できない
  • 不正な状態を表現できないデータ構造:
    • 可能な限り最も厳格なデータ構造でモデリングする
    • 現在のエンコーディングで可能性の排除が難しければ、別のエンコーディングを検討する
    • リファクタリングを恐れるべきではない
  • 証明の責任を可能な限り押し上げる:
    • システムの境界で、データに対して処理を行う前に、最も厳密な表現にする
    • あるコードの分岐がより厳格な表現を求めるなら、その分岐が選ばれた時点ですぐにパースする
    • 直和型を賢明に使い、データ型が制御フローを反映し適応するようにする
  • 脚注: 認可の扱い:
    • DoS攻撃を避けるため、パースより前に認可を行う必要がある場合もあるが問題ない
    • 認可は比較的小さな範囲で行われ、システムの状態に大きな変更を引き起こさないはずである
  • 欲しいデータの表現から考える:
    • 関数を書く際は、与えられたデータではなく欲しいデータの表現を基準にする
    • 設計は両側から働きかけ、中間で落ち合うまでギャップを埋める活動になる
    • 設計の部品をインタラクティブに調整し、リファクタリングの過程から学ぶべきである

■ 9. 追加のアドバイス

  • データ型にコードを導かせる:
    • コードがデータ型を支配してはならない
    • 今の関数が必要とするからといって、レコードにBoolを追加する誘惑を避ける
    • 正しいデータ表現へのリファクタリングを恐れず、変更箇所の検知は型システムに任せる
  • m()を返す関数を疑う:
    • 意味のある結果を返さず命令型的な副作用を起こす場合は本当に必要なこともある
    • 主目的が例外を投げることなら、より良い方法があるはずである
  • 複数パスでのパースを恐れない:
    • ショットガンパーシングの回避とは、パースされていないデータを処理しないことに過ぎない
    • 入力の一部が他の入力のパース方法を変えることは禁止されない
    • 多くの有用なパーサーは文脈依存である
  • 非正規化されたデータ型を避ける:
    • 特にミュータブルな場合に避けるべきである
    • データの複製は、複数箇所の同期が外れるという表現可能な不正状態を生む
    • Single Source of Truthを全力で追求する
    • 非正規化表現は抽象境界の後ろにとどめる
    • どうしても必要なら、カプセル化した小さな信頼できるモジュールに一貫性の責任を持たせる
  • バリデーターをパーサーに偽装する:
    • 整数が特定範囲にあることの保証など、Haskellのツールでは不正状態の排除が非現実的な場合がある
    • その場合はスマートコンストラクタを持つ抽象newtypeを使い、バリデーターをパーサーに見せる
  • 最善の判断をする:
    • error "impossible"を1つ消すためにsingletonsを持ち出してアプリ全体を書き換えるのは割に合わない
    • そうした箇所は放射性物質のように扱い、適切に処理する
    • 他がすべて失敗しても、次にコードを修正する人のために不変条件をコメントで残す

■ 10. 振り返りと関連資料

  • 本記事の意図:
    • Haskellの型システムを活かすのに、PhDもGHCの最新言語拡張も不要であることを示したい
    • Haskellを最大限に使う最大の障害は、何ができるかを認識することにある
    • Haskellの小さなコミュニティには、暗黙知化した設計パターンや技法を文書化する資源が不足する
  • アイデアの新しさ:
    • 核心は「全域関数を書く」という概念的に単純なものであり、新しくはない
    • それでもHaskellの書き方の実践的な詳細を伝えるのは驚くほど難しい
    • 抽象的な概念は語りやすいが、プロセスに関する有用な情報は伝えにくい
    • 本記事がその方向への小さな一歩になることを望む
  • 関連資料:
    • 別の視点や具体例には「Type Safety Back and Forth」を勧める
    • より高度な内容にはMatt Noonanの2018年の論文「Ghosts of Departed Proofs」を勧める
    • 同論文は、より複雑な不変条件を型システムで表現する技法を概説する
  • 実践上の心構え:
    • 現実のリファクタリングは示した例ほど単純ではない
    • 型駆動設計に慣れた人でも、特定の不変条件を型で表現するのは本当に難しいことがある
    • 思い通りにできなくても個人的な失敗とみなす必要はない
    • 本記事の原則は目指すべき理想であり、絶対に守るべき厳格な要件ではない
    • 大切なのは挑戦し続けることである

■ 11. 翻訳に関する議論

  • スローガンの訳:
    • 原文の意味は「パースしろ、バリデーションはするな」ではないかという指摘がある
    • 語順は変えたが意味は変わらず、スローガンとしての日本語の語感を優先して訳した
  • 用語の訳の補足:
    • "imperative effect" はHaskellの用語で「命令型的な副作用」を意味する
    • "multiple passes" はデータに複数回処理を行うことを指す言語処理系の用語である
    • "context-sensitive" は文脈依存文法に由来する「文脈依存」を意味する
    • "break out" は道具を持ち出すという意味であり、singletonsの導入を指す
  • 指摘された誤訳は修正して記事に反映した

MEMO: