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

型システムを活用する

概要

  • 異なる意味 を持つ値に 型を分ける 重要性を解説
  • intやstring など汎用型の乱用による バグ例 を紹介
  • Go言語libwxライブラリ での具体例
  • 型ごとの 変換メソッド型安全性 の利点を説明
  • GitHub でサンプルコードを公開

単純な値に独自型を定義するテクニック

  • 多くのプログラムで、 intやstring、UUID などのシンプルな型をそのまま利用するケースが多い
  • しかし、これらの型を 異なる意味 で使い回すと、 IDの取り違え引数の順序ミス などのバグが発生しやすい
  • 例えば、 UserIDAccountID がどちらもstringやUUID型の場合、 意図しない代入や関数呼び出し がコンパイル時に検出できない
  • 解決策として、 用途ごとに新しい型 を定義し、 意味づけ を明確にすることが有効
  • 型を分けることで、 コンパイラによる型チェック が働き、 人為的ミス を未然に防止

Go言語での例

  • 例えば、Goでは次のようにIDごとに型を定義可能

    type AccountID uuid.UUID
    type UserID uuid.UUID
    
  • このように型を分けると、 UserIDを受け取る関数にAccountIDを渡すとコンパイルエラー となる

  • 逆に、 型を分けずにuuid.UUID型のまま使うと、引数の取り違えがコンパイル時に検出できず、ランタイムでバグ発生

libwxライブラリでの型安全性

  • Go用の libwxライブラリ では、 気象や大気の計算 に使う値ごとに型を定義
  • 例: 温度はTempF型やTempC型、湿度はRelHumidity型 など
  • 型ごとに 変換メソッド(例:Km.Miles()) も実装
  • これにより、 float64だけで値を扱う場合に起きやすい単位や意味の取り違え を防止
  • 関数の引数に 間違った型 を渡すと、 コンパイルエラー で即座に検出

コード例

```go
temp := libwx.TempF(84)         // 華氏温度
humidity := libwx.RelHumidity(67) // 相対湿度(%)

// 誤って摂氏温度が必要な関数に華氏温度を渡すとエラー
fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointC(temp, humidity))
// => エラー: 'temp' (libwx.TempF型)をTempC型として使えない

// 引数の順序を間違えてもエラー
fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointF(humidity, temp))
// => エラー: 引数の型が一致しない
```

型システムの活用のすすめ

  • 型システムはバグ防止の強力な味方
  • モデルごとにID型を分ける、関数の引数に floatやintだけを使わない 設計が推奨
  • intやstring、UUID を使い回すことで、 実際のシステムでも多くのバグが発生
  • Goのような 比較的シンプルな型システム でも、 型定義による安全性向上 は十分に実現可能
  • このテクニックは簡単なのに広く使われていない ため、ぜひ積極的に導入を推奨

サンプルコードとリソース

  • 本記事で紹介した コード例や詳細 はGitHubで公開
  • cdzombak/libwx_types_lab リポジトリにて参照・貢献が可能
  • URL: https://github.com/cdzombak/libwx_types_lab

Hackerたちの意見

これ、いいね。まさに「悪い状態を表現できなくする」って感じだ。だけど、このアプローチには問題もあって、開発者が最初のタイプ実装の段階で止まっちゃうと、すべてがタイプになって、うまく組み合わさってないことが多い。タイプが微妙に変わっただけのものがたくさんあって、理由を考えるのが難しくなる。そんなシステムなら、むしろJSみたいな弱い型の動的言語か、Elixirみたいな強い型の動的言語を書いてる方がいいかな。でも、開発者が論理を型制御のフローに押し込んでいくと、例えば条件付きロジックをパターンマッチのあるユニオンタイプに移動させたり、デリゲーションを活用したりすると、また楽しくなるよね。例えば「DewPoint」関数は、どちらのタイプでも受け入れてそのまま動くようにできるかも。

ちなみに、Rubyは強い型付けだよ、緩いわけじゃない。 > 1 + "1" (irb):1:in 'Integer#+': String can't be coerced into Integer (TypeError) from (irb):1:in '' from :168:in 'Kernel#loop' from /Users/george/.rvm/rubies/ruby-3.4.2/lib/ruby/gems/3.4.0/gems/irb-1.14.3/exe/irb:9:in '' from /Users/george/.rvm/rubies/ruby-3.4.2/bin/irb:25:in 'Kernel#load' from /Users/george/.rvm/rubies/ruby-3.4.2/bin/irb:25:in ''

そうだね。この理由から、もっと多くの言語がバウンド整数をサポートしてほしいな。例えば、x: u32って言う代わりに、型システムを使ってxを[0, 10)の範囲に制約できるようにしたい。これによって、いくつかの良い特性が得られるし、今の言語ではできない小さな最適化も可能になる。例えば、配列の範囲内に収まる整数を作れるようになる。そうすれば、配列にインデックスを付けるときに範囲チェックをする必要がなくなる。Optionを使った時にも、もっと多くのピープホール最適化ができるようになる。奇妙なことに、RustはLLVMの魔法のおかげで、関数内ではこれをある程度サポートしてるんだけど、関数間で渡される変数にはサポートしてないんだよね。

最近、赤-緑-リファクタリングをやってるんだけど、失敗したテストの代わりに型システムを厳しくして、プロダクションで報告されたバグが型チェッカーを失敗させるようにしてから、そのバグを直して緑にするようにしてる。新しい機能やエッジケース、型システムを変えても失敗を引き起こせないバグには、TDD-with-a-testを続けてる。でも、型システムを使った赤-緑-リファクタリングは通常すぐに終わるし、バグのクラス全体に対して強い保証を提供できるんだ。

「悪い状態を実験できないようにする」とも言われてるね。

「型実装の最初のレベルで止まる」ってところで、コードベースが失敗するのをよく見るよ。「このintを構造体でラップしてUUIDと呼ぶ」っていう例は、すごく良いスタートだし、だいたいそこから始めるんだけど、必ず誰かがその安全性を回避しちゃうんだ。UUIDを受け取る関数があって、intを持っていると、無思考で自分のintをUUIDでラップして進んじゃう。そうすると、そのUUIDが実際にユニークでない可能性があるから、そういう前提に依存しているコードが壊れちゃう。ここで「構造によって正しい」という概念が重要になってくる。もしあなたのコードにUUIDが実際にユニークであるという前提条件があるなら、それがユニークでないものを作るのはできるだけ難しくすべきだよ。コンストラクタが例外を投げたり、初期化がErrを返したり、あなたの好きな言語のイディオムで、誰かがその不変条件が証明されていないUUIDを取得できる唯一の方法は、本当に本当に自分が何をしているかを知っている時だけだよ。(UUIDをサブして、ユニーク性の不変条件を他の型や不変条件に当てはめても、同じことが言える)

ユニオンタイプ!!すべてが型で、何も一緒に動かないなら、インターフェースでラップして、すべてを一度にユニオンする超型を定義しよう。TypeScriptへようこそ。ここでは、ジェネリクスが私たちのジェネリックなジェネリクスの中心にあって、ボブが8年前に書いた何かのジェネリックなジェネリックを投げてる。彼らが作ったアーキテクチャで論理的に考えられないから、型システムに投げて整合性を保ってるんだ。大体はうまくいくけど、Rustは間違ってるって教えてくれるのが美しい。結局、私たちが増え続ける複雑さの中で柔軟性を設計できてないだけなんだよね。「コンポーネント」が「コントロール」だった頃、あれが十数個しかなかったのを覚えてる?NNが数十万のパラメータしかなかった頃を覚えてる?計算能力が増すにつれて、私たちのメンタルモデルもそれに合わせて理解を深めていかないといけない。でも、そのメンタルモデルをしっかり持っておかないと。もしタイプするなら、やるべきだし。厳密なテストが必要なら、テストを書こう。シミュレーションが必要なら、友達、実行しよう。結局、私たち全員が望んでるのは、予期しない方法で壊れない高品質なソフトウェアなんだ。

これは通常、名義型ではなく構造型で緩和できるよ。本当に必要なら名義型を強制することもできるけどね。

一般的な考えには同意せざるを得ないけど、すべてがユニークなタイプだったシステムでのひどい体験を無視するのも難しい。基本的に、バイトを移動させたいコードと、特定のドメインでの計算をしたいコードが混ざると、うまくいかないことが多いと思う。今のところ、これをうまく言い換える方法は思いつかないな。 :(

なんとなく言いたいことがわかるかも。データは手元にあるのに、タイプを作成したりインスタンス化したりする方法を探さなきゃいけない状況ってあるよね。クックブックやチートシートがないと、ドキュメントの中で宝探ししてる気分になる。例えば、{ x, y, z }があるのに、createVector(x, y, z): Vectorを使わなきゃいけない場合とか。で、vertices: Vector[]を使ってcreateFace(vertices): Faceを作ることができるけど、Faceはただの{ vertices }なんだよね。これも、Faceに法線を反転させるメソッドがあるからなんだ。もう一つの例は、JavaのBouncyCastleみたいなライブラリで、必要なバイト配列はあるのに、8種類の異なるタイプをインスタンス化して、それぞれのメソッドを使わないと、hash(data, "sha256")ができないっていう。

理想的には、コンパイラがドメイン固有のロジックをシンプルなバイト移動に変換して、タイプが合ってるか確認した後に処理する感じかな。でも、もしかして誤解してた?

型システムは、他のツールと同じように80/20の法則があるよね。型をやりすぎると、ライブラリを使うのがすごく面倒になって、ほとんどメリットがないか、逆にマイナスになっちゃうことが簡単にある。UUID(またはString)が何かは知ってるけど、AccountIDやUserIDが何かは知らない。だから、あなたのソフトウェアを使うためには、それらが何なのか(どうやって作るのかも)を知る必要がある。 elaborateな型システムが価値あるかもしれないけど、そうじゃないかもしれない(特に良いテストがあるなら)。

まぁ、そういうことは知っておく必要があったんじゃない?そうじゃないと、無効なデータを関数に渡しちゃうことになるし。

「UUID(またはString)が何かは知ってるけど、AccountIDやUserIDが何かは知らない。あなたのソフトウェアを使うためには、それらが何か(どう作るかなど)を知る必要がある。」 その通りだね。AccountIDを取得する方法がわからないなら、ランダムな文字列やUUIDをAccountIDを受け取る関数に渡してもうまくいくとは限らない。AccountIDを発行するソースから取得しておくべきだよ!

この例はあまり役に立たないと思う。計算的な違いじゃなくて、ドメインの分離を示してるから、ほとんどいつも間違ったアプローチなんだよね。でも、UUID型を返すのは有用だし、[16]byteの代わりにHTMLNodeを返すのもいい。これらは実際の計算上の違いを区別してる。例えば、UUIDの文字列表現を返すメソッドは、それが使われる周囲のドメインを気にしない。UUIDとAccountID、UserIDを区別するのは文脈に依存するから、俺はそれを集約で伝えたい。摂氏と華氏も同じだね。すべてのタイムゾーンで日付時刻用の特殊な型を使うことはないだろう。

UUID(またはString)が何かは知ってるよ。 でも「UUID」がGUIDv1やUUIDv4/UUIDv7として保存されているかどうかは、結局わからないんだ。 128ビットだけって言われてるけど、古いJavaサーブレット+古いJava永続化+古いMS SQLスタックを使ってるときに、時々「java.util.UUID」をMS SQLのTransact-SQLのuniqueidentifierに「変換」する時に、エンディアンをひっくり返すのが「賢い」とされることがあったんだ。 そのせいで、エンドポイントは手動でエンディアンを「修正」して、元の識別子と修正された識別子の両方で挿入・選択・更新・削除をしないと、期待した結果が得られなかった。 (俺の推測では、永続化スタックが「賢すぎて」、データベースに保存しているタイムスタンプの「タイムゾーンを修正」しようとして、うまくいかないことがあるのと似たような問題だと思う。)

アカウントIDやユーザーIDが何か分からない。あなたのソフトウェアを使うためには、それらが何か(どうやって作るかも)知る必要がある。最初にそのソフトウェアを使うには、アカウントとユーザーが何かを理解している必要があると思う。UUID型の引数を一つ取るgetAccountById関数は理解できるけど、AccountId型の引数を一つ取るgetAccountById関数が理解できない合理的な人を想像できない。

foo(UUID, UUID); foo(AccountId, UserId); 最初のバージョンよりも、2番目のバージョンの方がずっと扱いやすい。自己文書化されてるし、「foo(userId, accountId)」みたいなエラーを防げるから、コンパイラがそういうケースをチェックしてくれる。さらに、別のタイプを作らなくても、より複雑なデータ構造にも対応できるし。Map> Map>

君のコメントの最初の段落を読み終える前に、「これ、グラグの人だな」って思ったよ。ちなみに、ハイパーメディアシステム大好き!2018年に大学でJavaの授業を受けて、すごく楽しかったんだ。教授にプログラミングの仕事をどうやって得るか聞いたら、「知らんけど、JavaScriptフレームワークを学べ」って言われた。で、「JavaScriptフレームワークって何?」ってググって、UdemyでReactのコースを見て、ReactとNodeの仕事をゲットした。実際、他のことはあんまり学ばなかったな。君の本は、正直言って、僕の開発に対するシニシズムをかなり減らしてくれたよ。HTML大好き!自分の好きなツールで、好きなことができるんだ!もうバンドラーなんていらない、ベイビー。ただし仕事では使ってるけど、仕事のはなんか微妙で、設定しなくてもいいしね。

これやってたんだけど、https://github.com/bbkane/warg/で1年間使ってた。でも、Goが関数呼び出しで基礎となる型を派生型に自動キャストしちゃうから、取り除いたんだ。Type userID int64 func Work(u userID) {...} Work(1) // Goはこれを受け入れると思う。これが正しく思い出せてればいいけど。そんな感じのことがほとんどだったから、安全性のメリットを感じられなかったけど、他のところでは型をキャストすることを覚えておかなきゃいけなかった(確か、構造体のフィールドに手動で保存する時とか)。

そうだね、var u userID = 1が使えるのと同じように、Work(1)も使えるってことだね。var u userID = userID(1)Work(userID(1))にこだわらなくてもいい。年に数回Goを教えてるけど、これが話題になることがあるんだ。なんでこんなに明示的な言語なのに、こういう一貫性があるのか、いい答えが見つからないんだよね。

ちょっと誤解を招く表現だね。Goは数値リテラルを、自動的に代入先の変数の型に変換するんだ。コンパイル時のアイデアで、実行時には表現されないからね。でも、Goは一つの型の変数を別の型に自動でキャストすることはしない。これには明示的にやらなきゃいけない。

func main() {
    var x int64 = 1
    Func(SpecialInt64(x)) // これは動く
    Func(x) // これは動かない
}

type SpecialInt64 int64

func Func(x SpecialInt64) { }

これはリテラル値だけに当てはまるよ。異なる型の変数を混ぜると、型エラーになるからね。Goで42を書くと、それはint32でもint64でも、もっと具体的な型でもないんだ。自動的に正しい型に推論されるんだよ。ユーザー定義の数値型でも同じことが言える。

近い話として、チェック例外を使って、その型に応じて適切に処理することがあるよね。Javaのチェック例外がそんなに悪く言われる理由がわからない。プロジェクトで強制的に使わせたおかげで、すごく助かったんだ。技術リードだったからね。みんな最初は嫌がってたけど、コードフローの例外ケースを考えるリズムに慣れたら、逆に好きになってくれた。プロジェクトはすごく堅牢だったし、ユニットテストに特に厳格ではなかったけどね。

Javaでは、チェック型に関して使い勝手の問題が多いよね。例えば、データを処理するストリームは、マップやフィルター関数がチェック例外を投げると、うまく動かない。さらに、異なるサービスを呼び出すとき、それぞれにチェック例外があると、一般的なExceptionを捕まえるか、コメディのように大きな例外リストを抱えることになる。

一般的に例外に対する異論を置いておくとして、チェック例外は、アンチェック例外とは対照的に、呼び出しスタックの深いところにある関数やメソッドが例外を投げるように変更されると、多くの関数を変更しなきゃいけなくなることを意味するんだ(少なくともその例外を投げることを示さなきゃいけない)。これはシステムを変更する際の使い勝手に対する異論だよね。非同期の関数の色付けに関する不満を思い出してみて、どう「伝染する」か。チェック例外も同じ関数の色の問題を抱えてる。潜在的な例外を投げる関数をtry/catchの中から呼ぶか、呼び出し側が例外を投げることを宣言するかのどちらかだね。

チェック例外が悪く言われるのは、使いすぎたからだと思う。Javaがチェック例外とアンチェック例外の両方をサポートしているのはいいことだよね。でも、個人的にはチェック例外はエリック・リパートが言う「外因性」の例外にだけ使うべきだと思う。しかも、ライブラリコードを出たらほとんどはアンチェック例外に変換されるべきだと思う。例えば、DBがいつでもオフラインになる可能性はあるけど、「throws SQLException」が呼び出しスタックの型シグネチャを汚すのは避けたいよね。SQL文が成功する前提でコードを書いて、失敗したらトップレベルのcatch-allでログを取ってHTTP 500を返す方がいい。

Javaのチェック例外に関する不満は、結局は例外処理がどれだけ冗長かに集約されると思う。言語が本当に必要ない時に例外を処理させると、ちょっと嫌になっちゃうよね。まず、ライブラリの作者が自分の公開APIで何がチェック例外で何がそうでないかを合理的に定義するのは難しい。結局、それはクライアントの判断に委ねられるんだ。でも、例外処理がこんなに冗長じゃなければ、そんなに大きな問題にはならないはず。例外を別の型に簡単に変換できたり、ランタイム例外として宣言できれば、モジュールやアプリケーションレベルで、こんな風に処理を強制されることもないだろう。次に、シグネチャの脆弱性について、標準的なアドバイスはドメイン特有の例外を作ることだよね。たぶん、あなたのコードはIOExceptionsを投げるべきじゃない。でも、Javaは例外の変換を不必要に冗長にしてるんだ…上で言った通り。結局、チェック例外は好きなんだけど、Javaの例外周りの使い勝手が嫌いなんだ。デザイナーがその改善にもっと注力してほしいな。

だから、リッチエラーがKotlinに来るのが嬉しいんだ。これは、ハッピーパスでプログラミングしながら、エラーをデストラクチャリングするためのちょっとした文法的な甘さを持って、可能なエラーステートをうまく表現してる。

チェック例外が使いにくいと思ってる人には朗報だよ。モダンなJavaでは、sealedインターフェースを使ってカスタムのResultっぽい型を作れるんだ。

アプリケーションでtry..catchブロックが5つ以上になることはめったにないよ。これらは、一時的な失敗の場合に再試行できる操作をラップするか、現在の操作をログメッセージと共に中止するためのもの。チェック例外は、エラーの返却と色付き関数の悪いミックスに感じる。

C#は、適切に型付けされたがチェックされていない例外を採用したんだ。個人的には、あまり問題なくクリーンなエラースタックが得られると思う。さらに、各レベルで特別な処理をするより、きれいにパターンマッチしたハンドラーブロックを持つ方が少しクリーンだと思う。ただ、アンラップされたエラー結果がしっかりしたレイアウトを持っているなら、たぶん同じくらいのものだと思う。

C#では、よくこんな型を使うよ:readonly struct Id32 { public readonly int Value { get; } } で、こういうふうに使える:public sealed class MFoo { } public sealed class MBar { } そして、Id32 x; Id32 y; これで、混同されない整数IDが得られるんだ。IdGuidIdStringに拡張できて、新しいMプレフィックスの「マーカー」タイプを作るだけで新しいユニークなユースケースをサポートできる。これは一行でできるよ。TypeScriptやRustでもこのバリエーションをやったことがある。

それに関しては、Vogenみたいなライブラリがあるよ。 https://github.com/SteveDunn/Vogen 名前の意味は「Value Object Generator」で、ソース生成を使って「Value object」型を生成するんだ。そのREADMEには、似たようなライブラリやさらに読むべきリンクも載ってるよ。

俺も似たようなことをやったことがあるよ。IDが整数のとき、enumの方がさらに摩擦が少ない(2014年当時はね)って気づいたけど、このパターンを実際のコードに入れるのは混乱を招くかもと思ってやらなかったんだ。 https://softwareengineering.stackexchange.com/questions/3090...

この技術は「ファントム型」と呼ばれているんだ。なぜなら、MFooやMBarの値はランタイムでは存在しないから。

Swiftにはtypealiasキーワードがあるけど、実際にはあまり役に立たないよね。同じ基盤の型を持つ2つの異なるエイリアス型が自由に入れ替えられるから。間違ったコードは見た目はおかしいけど、コンパイルは通る。ラッパー構造体がこれを実現するためのイディオマティックな方法で、ExpressibleByStringLiteralを使うとかなり使いやすいけど、「強い」typealias(「typecopy」とか?)の必要性があるかどうか気になるな。例えば「これはただのStringだけど、特定の種類のStringで、他のStringと混ぜるべきじゃない」って示すような。

うん、私が使ったほとんどの言語はこんな感じだよ。例えば、RustやC、C++とか。TFAの例はGo言語かな?ラッパー型を定義しなくていいのはちょっといいよね、確かにそれがあるとちょっと面倒になることもあるけど。C++ではラッパークラスでも特に注意が必要で、型はデフォルトで暗黙的に変換されるから。だから、Fooが単一のint引数を取るコンストラクタを持っていたら、Fooが期待される場所にはどこでもintを渡せるんだ。コンストラクタをexplicitとしてマークするのを忘れなければ大丈夫だけど。

具体的に、これがラッパー構造体とどう違うと想像してるの?

理論的には優雅に聞こえるけど、実際にはかなり厄介だよね。特にC++では、標準が変わったとしても。たとえば、std::cout << your_different_strの挙動をどうしたいの?サードパーティの関数や、以前は文字列を受け取ってた拡張ポイントはどうなるの?

Haskellにはこれがあって、newtypeって呼ばれてる。OOP言語では、特化したいタイプがfinalじゃなければ、サブクラスを作るだけで済むからね。コストも安いし(追加のラッパーやボックスもいらない)、簡単だし、特化した挙動も実装できる。残念ながら、いろんな理由でJavaはStringをfinalにしてるから、Stringは特化するのに最も便利なタイプの一つなんだよね。

これ、物理量に焦点を当ててこの問題を解決しようとしているmp-unitsライブラリを思い出すな。強い量を使うことで、安全性と複雑な変換ロジックを自動で処理できるし、特定の単位に縛られない汎用コードも書ける。これをプロログの世界に持ち込もうとしたけど、仲間のプロログプログラマーたちはあまり受け入れてくれないみたい^^。 [1] https://mpusz.github.io/mp-units/latest/ [2] https://github.com/kwon-young/units

ずっと前、距離、速度、温度、圧力、面積、体積など、さまざまな物理量を扱うプロジェクトに取り組んでたのを覚えてる。でも、全部「float」として渡されてたから、距離が必要なところに速度が渡されてバグが出たり、コンパイルは通るけど微妙なランタイムエラーが発生したりしてた。APIが速度をkm/hで要求してるのに、miles/hを渡すと同じ結果になるし。こういう問題を開発中にキャッチできるように、異なるタイプで厳密にしたいと思ってたけど、当時はジュニアだったからうまく説明できなかったし、エンジニアリングの努力を正当化できなかった。みんなも、数値を操作するためにプリミティブタイプに明示的に変換するのを面倒がってたし。

物理単位の複雑さから、タイプを使うのは諦めてたけど、これを見てみるよ!一番の問題は、みんなが単位を指定しないことなんだ。自分たちのコードでは、常に変数に単位を付けるように言ってるけど、クライアントからのデータや標準ライブラリの関数などでは、単位が指定されてないことが多いんだよね。

最近、うちのチームは混合数値を使っているC++コードにこれをやったんだ。最初はバグを見つけるために始まったんだけど、バグは修正されたけど、修正者は将来のバグを避けるために安全な型を追加したいと言ってた。で、追加したら、意図せず間違った値が使われているバグが3つ見つかったよ。

プロジェクトでUUIDに関して説明されているアプローチを使ったことがあって、気に入ったよ。TypeScriptを使っていたから、テンプレートリテラル型を使ってさらに進めたんだ。[1] type UserId = user:${uuid}; type OrgId = org:${uuid}; これによって、バリデーション(基本的には始まりのロジック)を追加できて、視覚的に見たときに明らかだったよ(例えば、ログやデバッグでね)。1. https://www.typescriptlang.org/docs/handbook/2/template-lite...

これ、リレーショナルデータベースに対して使ったんだよね?そのIDをプレフィックス付きでコミットしたの?それとも.split()[1]みたいなことしたの?結構いいアイデアだと思うよ。他のシステムにどう適用されたのか気になるな。