型システムを活用しよう
(dzombak.com)- プログラミングでは 型システム を活用することで、異なるデータの意味を明確に区別できる
- 文字列や整数のような一般的な型をそのまま使うこと は文脈を失わせ、バグにつながる可能性がある
- 同じ基盤型であっても 目的に応じて新しい型を定義 すれば、コンパイル時エラーでミスを防げる
- Goライブラリ libwxでは、測定単位を明確に区別する型 を定義し、
float64の混用によるミスを防止 している - サンプルコードでは UUID型を
UserIDとAccountIDに分ける ことで、誤った使い方を コンパイラが遮断 する - Goのように型システムがそれほど強くない言語でも、簡単な型ラッピングでバグを予防できる
型システムを積極的に活用しよう
問題の出発点: 単純な型の混用
- プログラミングでは
string,int,UUIDのような 基本型 だけで多くの値を表現することが多い - しかしプロジェクトの規模が大きくなると、こうした単純な型が 区別なく混用されてしまうミス が増えがちになる
- 例: userID の文字列を誤って accountID として渡したり、
int引数が3つある関数で順番を間違えて渡したりする
- 例: userID の文字列を誤って accountID として渡したり、
解決策: 意図を表す型定義
intやstringは ビルディングブロック にすぎず、システム全体でそのまま受け渡すと 意味のある文脈が失われる- これを防ぐには、役割ごとに固有の型を定義 して使うべき
- 例:
type AccountID uuid.UUID type UserID uuid.UUID func UUIDTypeMixup() { { userID := UserID(uuid.New()) DeleteUser(userID) // エラーなし } { accountID := AccountID(uuid.New()) DeleteUser(accountID) // エラー: AccountID 型を UserID として使うことはできない } { accountID := uuid.New() DeleteUserUntyped(accountID) // コンパイル時エラーは出ないが、ランタイムで問題が起こる可能性が高い } }
- 例:
- こうすることで、誤った型の引数をコンパイル時にブロック できる
実際の適用例: libwx ライブラリ
- 筆者は自身のGoライブラリ libwx でこの手法を実践している
- すべての測定単位について 専用の型を定義 し、単位変換メソッドも型に結び付けている
- 例:
Km.Miles()メソッドで単位を明確に区別する
- 例:
- 以下は、誤った関数引数の順序や単位の取り違えを コンパイラが遮断 する例:
// 華氏温度を宣言 temp := libwx.TempF(84) // 相対湿度を宣言(パーセント) humidity := libwx.RelHumidity(67) // 華氏ではなく摂氏温度を要求する関数に誤って渡す fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointC(temp, humidity)) // コンパイラが型 mismatch エラーを即座に検出 // temp(TempF 型)は TempC として使えない // 関数に引数の順序を誤って渡す fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointF(humidity, temp)) // コンパイラが引数型エラーを防いでくれる - 単に
float64を使っていたなら起こり得たミスを まとめて防げる
結論: 型システムを積極的に活用しよう
- 型システムは単なる文法チェック用ではなく、バグ予防のための道具 である
- モデルごとに ID型を個別に定義 し、関数引数も float や int の代わりに明確な型でラップすべき だ
- この方法は、Goのように型システムがそれほど強くない言語でも 非常に効果的で実装も簡単 である
- 現実には UUID や文字列型の混用によるバグが本当に多い
- この簡単な方法が 本番コードであまり使われていない現実は驚きだ と筆者は強調する
関連コード
- 全体のサンプルは GitHub で確認可能:
https://github.com/cdzombak/libwx_types_lab
8件のコメント
Kotlinで使おうとすると、primitive が wrapper で包まれることになり、stack ではなく heap に格納されるため、パフォーマンス上の問題が生じる可能性があると理解しています。もちろん、ほとんどのユースケースでは保守性が優先されます。また、value class を利用することでパフォーマンス上の問題を最小限に抑えることもできます。
Ada言語は、この点で非常に優れた型システムを持っています。種類の異なる値は簡単に別の型として宣言でき、混在した場合もコンパイラがうまく検出してくれます。
興味があってお聞きします。ほかの一般的な型付き言語と比べて、異なる利点もあるのでしょうか?(kotlin, rust, typescript, ...)
Adaの利点は、おおむね「Cよりはましだ」という方向です。Cでは開発者を信頼して許容されるものが広く、その制約の少なさが大きいですね。暗黙的な型変換のようなものです。でも、ほとんどの開発者は慣れているからか、Cのほうを好むようです...
私が扱っているコードベースの特徴かもしれませんが、ほとんどすべてを別個の型として宣言して使っています。基本型を使うのは配列のインデックスくらいですね。
理解しました、ありがとうございます
Hacker Newsのコメント
私はこのアプローチが好きだ。いわゆる「悪い状態を表現不可能にする(make bad state unrepresentable)」というやり方だ。ただ、このパターンでよく起きる問題は、開発者が型実装の第一段階にとどまってしまうことだ。あらゆるものが型になり、相互運用性が低くなり、微妙に変形された型が大量に生まれて、コードの追跡や理解が難しくなる。そうなるくらいなら、むしろ弱く型付けされた動的言語(JS)や、強く型付けされた動的言語(Elixir)で書きたい。しかし開発者が条件分岐のロジックをパターンマッチ可能なユニオン型に押し込み、委譲をうまく活用するなど、型主導の流れをさらに押し進めれば、開発体験は再び快適になる。たとえば
DewPoint関数は、複数の型を受け取っても自然に動作するように作れるその意味でも、もっと多くの言語が bounded(Integer の範囲制限)型を標準サポートしてほしい。たとえば
x: u32ではなく、x が[0,10)の範囲だけを許可するよう型システムで強制できてほしい。そうすれば配列インデックスで境界チェックが不要になる。Optionのような場合でも peephole 最適化がずっとやりやすくなる。Rust では関数内では LLVM のおかげで一部こうした支援があるが、関数間で変数を渡すときにはサポートされないちなみに Ruby は弱い型付けではなく強い型付けだ。
1 + "1"のような演算をすると、TypeError: String can't be coerced into Integerのようなエラーになる「型実装の第一段階で止まること」こそが失敗の原因だ。たとえば int を struct で包んで UUID として使い始めるのは良い出発点だが、誰かが int さえあれば型でラップして渡せてしまい、本来一意であるべき UUID の性質が壊れてしまう。結局重要なのは
Correct by construction(構築時点で正しさを保証)であり、UUID のように一意でなければならない型は、関数やコンストラクタで例外を投げるにせよ何らかの方法にせよ、本当に証明されない限り生成できないようにすべきだ。この概念は UUID だけでなく、あらゆる型と不変条件に適用できる最近は Red-Green-Refactor パターンに従っているが、失敗するテストの代わりに型システムをより厳格にして、バグを型チェッカーに捕まえさせている。新機能やエッジケース、型でエラーを誘導できないバグは依然としてテストで扱うが、型システムを活用した red-green-refactor は一般に速く、バグの大きなカテゴリを丸ごと防げる
構造的型付け(structural types)で大半の問題は緩和できる。本当に必要なら名目的型付け(nominal types)で強制もできる
例外と型に近い話として、checked 例外をうまく活用して型ごとに適切に処理するのは良いことだと思う。Java の checked 例外がなぜ非難されるのか理解できない。自分が担当したプロジェクトで強制的に checked 例外を使わせたときは、最初はみんな嫌がっていたが、コードフローのあらゆる例外ケースを考えることに慣れると、皆むしろ好むようになった。単体テストではそこまで厳格ではなかったが、プロジェクトは非常に堅牢になった
Java の checked 例外に対する不満は、例外処理があまりにも煩雑だからだ。ライブラリ作成者は checked 例外を明確に決めきれず、クライアント側では関数を呼ぶたびに不要な例外処理をしなければならないので、嫌われるのも当然だ。例外を別の型やランタイム例外へ簡単に変換できたり、モジュール/アプリ単位で宣言するだけで済むなら、この問題はかなり減るはずだが、今は面倒すぎる。またシグネチャを壊しやすいのでドメイン別例外を使うべきだが、Java は例外変換もやりにくい。checked 例外自体は良いが、Java の例外処理の使い勝手が嫌いだ
checked 例外が非難されたのは乱用のせいだ。Java が checked と unchecked の両方をサポートしているのは良い選択だと思う。ただし Eric Lippert の言う
exogenous例外のようなものにだけ checked 例外を使い、それ以外の大半は unchecked に変換するのが望ましい。たとえば DB はいつでも接続が切れる可能性があるが、throws SQLExceptionをコールスタック上部まで延々と表示し続けるのはあまりに煩雑だ。最上位で catch-all して HTTP 500 を返せばよい。関連投稿checked 例外は(非 checked と比べて)、コールスタックの深い関数が例外を投げるように変わると、処理関数だけでなくその間にある関数をすべて変更しなければならない可能性がある。つまりシステム変更時の柔軟性が低い。async 関数の coloring 論争も似た文脈だ。例外を投げうるなら
try/catchで包むか、呼び出し側も例外を投げると宣言しなければならないC# は型は明確だが unchecked 例外を採用している。エラースタックがきれいに整理され、特に問題もない。レベルごとに bespoke な処理をするパターンマッチ済み例外ハンドラよりもすっきりしている。堅牢なアンラップ可能エラー結果があれば、それと似たようなものだと思う
Java では checked 型の使い勝手が悪い面もあり、たとえば stream API で
map/filter関数から checked 例外を投げると本当に厄介だ。複数サービス呼び出しがそれぞれ独自の checked 例外を持っていると、結局Exceptionを捕まえるか、ばかげて長い例外一覧を書くしかなくなる全体としては「固有の型を作る」という方針には賛成だが、何もかもが固有型になっているシステムで苦労した経験も多い。特に、ただバイトをあちこちに運ぶコードとドメイン計算コードが混ざると、本当に難しく感じる
その感覚は分かる。必要なデータはすでにあるのに、まず型を作る方法やインスタンス生成方法を探さないといけないので、レシピがなければドキュメントと格闘している気分になる。たとえば
{x, y, z}オブジェクトがあっても、まずcreateVector(x, y, z): Vector関数を使わなければならず、Faceを作るにはcreateFace(vertices: Vector[]): Faceのようにしなければならないので、手続きが無駄に長くなる。BouncyCastle のようにバイト配列がすでに用意されていても、複数の型を作って相互に methods を使わないと本当にやりたい機能にたどり着けないことがあるGo では型 alias を元の型(例:
AccountID → int)に戻すのがかなり簡単だ。適切に構造化すれば、ドメインロジックでは型 alias を使い、ドメインを気にしないライブラリ側では higher/lower 型に変換して処理するというクリーンアーキテクチャ風のやり方も可能だ。ただし変換コードは非常に多く必要になるPhantom types(ファントム型)はこういう場合に有用だ。型パラメータ(つまりジェネリクス)を追加するが、そのパラメータ自体はどこにも使わない。以前 Scala で暗号化コードを書いたとき、配列は全部バイトだったが、ファントム型で相互に混ざるのを防いだ。関連事例
理想を言えば、コンパイラが型だけ確認したら、残りのドメインロジックはすべて単なるバイトコピーに落としてくれるとよい。あなたの意図を正しく理解しているかは分からないが
型システムにも 80/20 の法則が当てはまると思う。やりすぎるとライブラリ利用の負担が重くなり、実利もほとんどない。UUID や String くらいなら慣れているが、AccountID や UserID は知らないので新たに学ぶ必要がありコストが高い。elaborate な型システムに価値がある場合もない場合もある(特にテストが十分なら)。参考
どうせソフトウェアを使うなら Account や User が何かは理解しなければならないので、
getAccountByIdのようにAccountIdを受け取る関数が、UUID を受け取る関数より理解しにくいとは思わない実際には String は単なるバイトの集合にすぎず、それ自体には何の意味もない。
AccountIDなら、たいていは「アカウントの ID」だと分かる。本当に内部表現が気になるなら型定義を見ればよいが、大半の文脈ではAccountIDが何か分かれば十分だ。型というのは結局、明確な名前が付いていれば使うときに混乱しにくい。grugbrain.dev のリンクは、むしろ基礎的すぎる。grug brain ならこの程度の型分離には賛成するはずだfoo(UUID, UUID)よりfoo(AccountId, UserId)の形のほうがはるかに望ましい。自己記述的で、誤って順番を入れ替えて呼び出したときもコンパイラが検出できる。複雑なデータ構造でも新しい型を作らずに明確に書ける「UUID や String ならすでに馴染みがある」という話に対して言うと、実際には UUID が GUIDv1、UUIDv4、UUIDv7 のどの形式なのか、どう保存・変換されるのかを正しく把握するのは難しい。経験上、Java+MS SQL の組み合わせで UUID と uniqueidentifier を変換する際、エンディアン変換の問題で手作業が必要になったことがある。データベースのタイムゾーン自動変換の罠に近い問題だと思う
実際のところ、こうした型を理解することはどうせ必要な作業だった。そうでなければ誤ったデータをそのまま関数に渡していたはずだ
最近うちのチームでも C++ コードで複数の数値が混用されている箇所に型を導入してみた。きっかけはバグを見つけて修正していたことで、安全な型を入れてみたら、同種の誤った値の使い方がさらに 3 か所見つかった
mp-units(mp-units 公式ドキュメント) ライブラリは、こうした物理単位の問題に焦点を当てた例として思い出される。強力な単位型を使えば安全性を確保でき、複雑な単位変換ロジックも自動化でき、ジェネリックコードで多様なユニットを扱える。これを Prolog の世界に持ち込もうとしたが、周囲の同僚はあまり乗り気ではなかった。Prolog 向けの例
以前、複数の物理量(距離、速度、温度、圧力など)を扱うプロジェクトをしたことがあるが、全部ただの float として渡していたので、距離の値を速度の場所に入れてもコンパイルでは問題なく、バグは実行時にしか表面化しなかった。単位(例: km/h vs miles/h)を誤って渡す問題も同じだ。型を増やして開発段階でこうした問題を捕まえたかったが、当時はジュニアで説得が難しかった
物理単位ごとの型導入は複雑すぎるのではと諦めていたが、mp-units を調べてみるつもりだ。特に、変数がどの単位なのかを明示していないため問題がよく起きる。外部データや標準関数などでは、単位が表示されないことが多い
C# では次のように型を作る
すると
このように、異なる integer ID を区別して使える。
IdGuidやIdStringへ拡張することもでき、新しいマーカー型(M)も 1 行追加するだけで済む。TypeScript や Rust でも似た変種を使う似たパターンを使ったことがある。int ID なら enum が最も friction が少ないが、混乱しすぎる気がして実際のコードには入れなかった。関連議論
このパターンは MFoo や MBar の値がランタイムに存在しないため、「phantom type」と呼ばれる
こうした用途向けには Vogen のようなライブラリもある。Vogen は Value Object Generator の略で、ソースコード生成によって Value object 型の追加を支援する。README に類似ライブラリやリンクもある
この方法は以前にも見たことがあったが、目的は分かっていなかった。今日も文字列引数を 3 つ受け取る関数を書きながら、型ごとのパースを強制すべきか、関数内でやるべきか悩んでいたが、実際にはパース済みの値は必要ない状況だったので、この方法こそまさに探していた答えだった。今年の自分のコーディングスタイルに最も大きな影響を与えそうだ
友人の Lukas がこのアイデアを「Safety Through Incompatibility」としてまとめている。私はこのパターンを golang コード全体に適用していて、とても有用だと感じている。誤った ID の受け渡しを根本から防いでくれる
関連投稿 1
関連投稿 2
Swift には
typealiasキーワードがあるが、基底型が同じなら相互に自由変換できるので、この目的には実質的に向いていない。wrapper struct は Swift ではかなり慣用的で、ExpressibleByStringLiteralまで使えばそれなりに便利だ。しかし「強い型エイリアス(strong typealias)」のような新しいキーワード(typecopyなど)があって、「これはただの String だが特別な意味を持つ String なので、ほかの String と混ぜるな」と明示できると良いと思う実際ほとんどの言語はこんな感じだ。たとえば rust/c/c++ もそうで、Go の例のようにラッパー型を作らずに済むと気分がいい。C++ ではコンストラクタを
explicitと宣言しないと、int を Foo 型の場所に自由に入れられてしまうので、より注意が必要だ理論上はエレガントに見えても、実運用では複雑になりうる。C++ で
std::coutに流し込むときや、既存で String を受け取るサードパーティ関数、あるいは拡張ポイントとの互換性など、実際にどう動かすかは悩ましいHaskell にはこれに対応する
newtypeという概念がある。OOP 言語では、型が final でなければサブクラスを簡単に作って望む振る舞いを追加・特化できる。追加のラッパーや boxing なしで安価かつ単純だ。ただし Java では String が final なのでこの方法は難しく、String 自体を specializaton するのは困難だ具体的には、struct ラッパーとどう違う動作をしてほしいのか気になる
Rustもこういう形で使っていますし、確かに良いと思います
型システムがしっかりしている言語を使っていれば、こういうことも防げたのではないでしょうか..
1999年9月 NASA火星気候オービター消失