型システムとプログラミング原則
目次
- 概要
- 型システムの役割
- 静的型と動的型
- 構造的型と名前的型
- Genericsと抽象化
- 変性
- nullとoptional
- 型とruntime validation
- 不正な状態を表せなくする
- 型で業務ルールを表す
- 型の限界
- 原則
- DRYの注意
- YAGNIと抽象化
- 依存方向
- 原則同士の衝突
- 原則の使い方
- 型システムの実装パターン
- 型システムと性能の最適化
- 型安全性と実務
- 実務での型システムの活用
- 型システムの進化とトレンド
- 型安全と実行時性能
- 段階的な型導入戦略
- 型システム比較表
- 型安全性とテスト
- 型安全性の cost/benefit
- 型とドメイン駆動設計
- 言語別の型システム設計哲学
- 型システムの高度な概念
- 型と設計パターン
- 次のステップ
- 型システムの実装詳細と型推論
- 型クラスと制約
- 関数型言語と型
- パターンマッチングと型安全性
- まとめ
- 参考文献
概要
型は制約であり、設計の文書でもある
型システムはコンパイルエラーを出すためだけの仕組みではありません。どの値がどこへ流れるか、何を受け付けて何を返すかを明示し、誤用を減らすための設計言語でもあります。
型は「通るかどうか」より、「何を禁止し、何を許すか」を設計するための道具です。原則はその型と責務の境界を崩さないための補助線です。
この章で重視すること
型システムの役割
- 不正な状態を表しにくくする
- API契約を明示する
- リファクタリング時の安全網を増やす
一方で、型を複雑にしすぎると、理解コストが上がることもあります。表現力と可読性のバランスが大切です。
静的型と動的型
静的型は実行前に多くの誤りを検出できます。動的型は実行時の柔軟性が高く、探索的に書きやすいことがあります。
重要なのは優劣ではありません。大規模な変更、チーム開発、公開API、長期保守では静的型の利点が出やすく、小さなスクリプトや探索では動的型の軽さが効くことがあります。
構造的型と名前的型
TypeScriptのような構造的型では、形が合っていれば同じ型として扱いやすくなります。JavaやC# のような名前的型では、明示的な宣言にもとづいて型関係を扱います。
構造的型は柔軟ですが、偶然同じ形になった値を区別しにくいことがあります。名前的型は明示的ですが、変換やadapterが増えることがあります。
Genericsと抽象化
Genericsは、型を引数として受け取る抽象化です。コレクション、Repository、Result、Eventなどでよく使います。
type Page<T> = {
items: T[];
nextCursor?: string;
};
ただし、型引数を増やしすぎると読み手の負荷が上がります。公開APIでは、利用者が自然に推論できる範囲に留めるのが重要です。
変性
Genericsを使うと、型の親子関係がコンテナ型へどう伝わるかが問題になります。これを変性と呼びます。
- covariance
CatがAnimalの一種なら、List<Cat>をList<Animal>として扱える方向 - contravariance 関数引数のように、逆向きの代入が安全になる方向
- invariance 型引数が完全に一致しないと扱わない
変性は難しく見えますが、要点は「読み取りだけなら広げやすく、書き込みがあると危険になる」です。
nullとoptional
多くのバグは「値があると思ったらなかった」ことで起きます。nullを許すなら、それを型で表す方が安全です。
type User = {
name: string;
email?: string;
};
optionalな値は、使う側で存在確認が必要です。存在しない可能性を型へ出すことで、暗黙の前提を減らせます。
型とruntime validation
TypeScriptの型は実行時には消えます。API入力、JSON、DB、環境変数のように外から来る値は、型注釈だけでは守れません。
外部入力
-> runtime validation
-> 型付きの内部値
-> business logic
Zod、Pydantic、JSON Schemaのような仕組みは、外部境界で値を検証し、内部では型付きで扱うために使えます。
不正な状態を表せなくする
型の強力な使い方は、不正な状態をそもそも作れなくすることです。
悪い例:
type Payment = {
status: "paid" | "failed";
paidAt?: Date;
failureReason?: string;
};
改善例:
type Payment =
| { status: "paid"; paidAt: Date }
| { status: "failed"; failureReason: string };
この形なら、失敗なのに paidAt がある、成功なのに failureReason がある、といった状態を作りにくくなります。
型で業務ルールを表す
型は低レベルなデータ形式だけでなく、業務上の制約も表せます。
type DraftOrder = { type: "draft"; items: Item[] };
type ConfirmedOrder = { type: "confirmed"; items: Item[]; confirmedAt: Date };
type ShippedOrder = { type: "shipped"; items: Item[]; shippedAt: Date };
type Order = DraftOrder | ConfirmedOrder | ShippedOrder;
この形では、未確定注文に shippedAt があるような状態を作りにくくなります。型は実装の都合ではなく、業務であり得る状態を表す道具になります。
型の限界
型システムは強力ですが、すべてを保証するわけではありません。
- 入力値が本当に存在するか
- DBの制約と一致しているか
- 権限があるか
- 時刻や外部APIが期待通りか
- 数式や統計的仮定が正しいか
型で表せること、テストで確認すること、実行時validationで守ることを分けます。
原則
SOLIDクラスやモジュールの責務と依存を整理するDRY知識の重複を避けるKISS不必要な複雑化を避けるYAGNIまだ必要ない抽象化を先回りで入れすぎない
DRYの注意
DRYは「同じ文字列を1回にする」ではなく、「同じ知識を重複させない」という原則です。たまたま似ている処理を早くまとめすぎると、別々に変わるものを無理に結合してしまいます。
重複を見つけたら、まず次を考えます。
- 同じ理由で変わるか
- 同じ概念を表しているか
- まとめた結果、呼び出し側が読みにくくならないか
YAGNIと抽象化
将来必要そうな抽象化を先に作ると、実際の要件とずれることがあります。抽象化は、3回目の重複や変更圧力が見えてから入れても遅くありません。
依存方向
プログラムが大きくなると、どのモジュールがどれに依存するかが重要になります。安定した抽象に依存し、変化しやすい具体実装を外側へ寄せると、変更の影響が小さくなります。
原則同士の衝突
設計原則はしばしば衝突します。
| 衝突 | 判断の軸 |
|---|---|
| DRY vs YAGNI | 同じ理由で変わる重複か |
| KISS vs 型安全 | 複雑な型が本当に誤用を減らすか |
| OCP vs 可読性 | 拡張点が現実の変更に合っているか |
| DIP vs 直接依存 | 抽象化でテストや差し替えが楽になるか |
原則は結論ではなく、議論のための語彙です。文脈に応じて、何を守るために何を捨てるかを説明できることが大切です。
原則の使い方
原則はレビューで相手を殴るための言葉ではありません。設計判断の理由を共有するための共通語です。たとえば「YAGNIなので今は抽象化しない」「DRYだが変更理由が違うので分ける」といった使い方が健全です。
型で表すと効果が大きい制約
すべてを型で表す必要はありません。効果が大きいのは、間違えると障害や事故につながり、かつコード上で繰り返し登場する制約です。
| 制約 | 型で表す例 | 効果 |
|---|---|---|
| 単位 | Meter、Byte、Yen | 単位混同を防ぐ |
| 状態 | Draft、Published | 不正な遷移を減らす |
| 権限 | AdminUser、VerifiedUser | 操作可能範囲を明示する |
| 入力検証後 | ValidatedEmail | 検証済みかを表す |
| 空でない値 | NonEmptyList | 境界条件を減らす |
一方で、短命なスクリプトや変化の激しい探索段階では、型を細かくしすぎると変更が遅くなります。型は安全性のための道具であり、設計の目的そのものではありません。
型システムの実装パターン
プログラミング言語における型システムの設計と実装は、言語の安全性と表現力のバランスを決定します。異なる言語がどのように型安全性を実現しているかを理解することで、各言語の設計思想が見えます。
静的型検査と動的型検査
静的型検査(Compile-time)
型エラーをコンパイル時に検出します。C、Java、Rust、Go、TypeScript などが採用。
let x: i32 = 42;
let y: String = "hello";
// let z = x + y; // コンパイルエラー:incompatible types
メリット:
- ランタイムでの型エラーが無い
- 最適化が容易(型情報がある)
- IDEによる補完・リファクタリング
デメリット:
- 開発速度が遅い可能性
- 複雑な型シグネチャ
動的型検査(Runtime)
型エラーを実行時に検出します。Python、JavaScript、Ruby などが採用。
x = 42
y = "hello"
z = x + y # 実行時エラー:unsupported operand type(s)
メリット:
- 開発速度が早い
- コードが簡潔
- プロトタイピングが容易
デメリット:
- ランタイムエラーの可能性
- 最適化が難しい
- デバッグが手間
段階的型付け(Gradual Typing)
動的言語に静的型情報を段階的に追加できます。TypeScript と Python(Type Hints)が代表的。
// TypeScript:段階的型付けの例
let x = 42; // 型推論:number
let y: string = "hello"; // 明示的型指定
function add(a: number, b: number): number {
return a + b;
}
// let z = add("hello", "world"); // 型エラー検出
型パラメータ(ジェネリクス)
型を抽象化して、複数の型に対して動作するコンポーネントを作成します。
Rust のジェネリクス:
fn largest<T: PartialOrd>(list: &[T]) -> T {
let mut largest = list[0].clone();
for &item in list {
if item > largest {
largest = item;
}
}
largest
}
let nums = vec![34, 50, 25, 100];
let max = largest(&nums); // T = i32 で単態化(monomorphization)
特徴:
- 単態化(Monomorphization):各型について別々のコード生成、ランタイムオーバーヘッド無し
- 型安全性を保ちながら再利用可能
Go のジェネリクス(Go 1.18以降):
func Largest[T constraints.Ordered](list []T) T {
if len(list) == 0 {
var zero T
return zero
}
largest := list[0]
for _, item := range list {
if item > largest {
largest = item
}
}
return largest
}
nums := []int{34, 50, 25, 100}
max := Largest(nums)
Trait / Interface と多態
異なる型が共通のメソッドセットを実装する仕組み。
Rust の Trait:
pub trait Drawable {
fn draw(&self);
}
pub struct Circle {
radius: f64,
}
impl Drawable for Circle {
fn draw(&self) {
println!("Drawing circle with radius {}", self.radius);
}
}
pub fn render<T: Drawable>(obj: &T) {
obj.draw();
}
Go の Interface:
type Drawable interface {
Draw()
}
type Circle struct {
radius float64
}
func (c Circle) Draw() {
fmt.Printf("Drawing circle with radius %f\n", c.radius)
}
func Render(obj Drawable) {
obj.Draw()
}
Go の interface は「構造的部分型(structural subtyping)」:型が明示的に implements を宣言しなくても、メソッドセットが一致すれば自動的に満たしたと見なされます。
型の制約(Bounds)
ジェネリクス使用時に、型パラメータが満たすべき要件を指定します。
// Bounded Generic
fn print_debug<T: std::fmt::Debug>(val: T) {
println!("{:?}", val); // Debug trait を必須
}
// Multiple Bounds
fn complex<T: Clone + std::fmt::Display>(val: T) {
let copy = val.clone();
println!("{}", copy);
}
// Trait Objects for Runtime Polymorphism
fn draw_all(shapes: Vec<Box<dyn Drawable>>) {
for shape in shapes {
shape.draw();
}
}
Null / None の型安全な扱い
Null 参照は「十億ドルの誤り」と呼ばれるほど、多くのバグの原因です。言語によって異なるアプローチがあります。
Java の Optional(Java 8+):
Optional<String> name = Optional.of("Alice");
String greeting = name
.map(n -> "Hello, " + n)
.orElse("Hello, Guest");
System.out.println(greeting); // "Hello, Alice"
Rust の Option
let name: Option<&str> = Some("Alice");
let greeting = name
.map(|n| format!("Hello, {}", n))
.unwrap_or_else(|| "Hello, Guest".to_string());
println!("{}", greeting); // "Hello, Alice"
Go の Error Handling:
func GetUser(id int) (*User, error) {
if id <= 0 {
return nil, errors.New("invalid user id")
}
// ...
return user, nil
}
user, err := GetUser(42)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("User: %v\n", user)
型推論
言語が型情報から変数の型を自動的に決定します。
Rust の型推論:
let x = 42; // i32 と推論
let y = 3.14; // f64 と推論
let z = vec![1, 2]; // Vec<i32> と推論
// 文脈から型が決定されることも
let mut v: Vec<_> = vec![]; // Vec<i32> に決定される
v.push(42);
型システムと性能の最適化
型情報は、コンパイラが多くの最適化を行うための基盤となります。
単態化(Monomorphization)による最適化
Rust のジェネリクス関数は、使用される型ごとにコード生成されます。
fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
a + b
}
let x = add(1, 2); // 実は add::<i32>(1, 2) に展開
let y = add(1.0, 2.0); // 実は add::<f64>(1.0, 2.0) に展開
結果:
- 各型に最適化されたコード
- ランタイムオーバーヘッド無し
- バイナリサイズ増加(code bloat)
Trait Object による動的ディスパッチ
// 静的ディスパッチ:コンパイル時に解決
fn work_static<T: MyTrait>(t: T) {
t.method(); // T の具体的な実装が呼ばれる
}
// 動的ディスパッチ:実行時に解決
fn work_dynamic(t: &dyn MyTrait) {
t.method(); // virtual method call
}
動的ディスパッチはやや遅いですが、異なる型を同じコレクションに入れられます。
型安全性と実務
実務では、完全な型安全性よりも生産性とのバランスが重要です。
言語選択の指針
| 特性 | 推奨言語 |
|---|---|
| 高い型安全性が必須(金融、医療) | Rust、TypeScript、Java |
| 迅速なプロトタイピング | Python、JavaScript、Ruby |
| パフォーマンス + 型安全 | Rust、Go、C++ |
| チームの学習曲線を優先 | Go、TypeScript、Python |
レガシーコードの型安全化
既存の動的言語プロジェクトに段階的に型情報を追加:
// 段階的に型を追加
// Before(JavaScript)
function processData(data) {
return data.map(x => x * 2);
}
// After(TypeScript)
function processData(data: number[]): number[] {
return data.map((x: number) => x * 2);
}
実務での型システムの活用
型安全性はレガシーシステムでも段階的に導入可能。TypeScript は JavaScript にオプショナルな型を加えることで、既存コードを保護しながら型チェックを実現。
型システムの進化とトレンド
構造的部分型(Structural Subtyping)
Go のように、型が interface を明示的に implement しなくても、メソッドセットが一致すれば自動的に満たしたと見なすアプローチ。
型 A が interface I を実装している
= A が I のすべてのメソッドを持つ
これは nominal typing(明示的な implements)より柔軟。
Union Types と Discriminated Unions
Rust の enum, TypeScript の union type は、複数の型の和を表現。
Result<T, E> = Success(T) | Failure(E)
パターンマッチで全ケースを処理することをコンパイラが強制。
Generics の限界
ジェネリクスは powerful ですが、すべての問題を解決しません。
例:関数型言語での高階多相(higher-rank polymorphism):
-- forall で量化される
f :: forall a. (forall b. b -> b) -> a -> a
通常の関数型では表現困難。
型安全と実行時性能
型情報の豊富さは最適化に直結:
- Rust: 型情報 → メモリレイアウト最適化 → C並みの性能
- Java: 型情報 → JIT 最適化 → マシンコード生成
- Python: 型情報薄い → インタプリタのオーバーヘッド
型チェックは単なる safety ではなく、性能向上の手段でもあります。
段階的な型導入戦略
既存の動的言語プロジェクトに型を段階的に追加:
- 新機能は TypeScript で(Python → Py, JS → TS)
- レガシー部分は型hints で annotate
- mypy などの静的チェッカーを CI に統合
- 段階的にカバレッジ向上
型システム比較表
| 項目 | Rust | Go | Java | Python | TypeScript |
|---|---|---|---|---|---|
| 型強度 | 非常に強 | 中程度 | 強 | 弱 | 中程度 |
| 型推論 | 強力 | 弱い | 弱い | N/A | 強力 |
| Null安全性 | Option | nil pointer | NPE | None | union type |
| エラーハンドリング | Result | error interface | Exception | Exception | union type |
| 汎用性(Generics) | 強力 | 基本的 | 強力 | duck typing | 強力 |
| 学習曲線 | 急 | 緩 | 中 | 非常に緩 | 中 |
型安全性とテスト
型が強いほど、テスト負荷が下がります:
Rust:
- コンパイル時に多くのバグを検出
- ユニットテスト:ビジネスロジックに集中
Python:
- 実行時にエラーが発生
- テストが comprehensive である必要
- coverage 100% を目指す
Type system はテストの一種と考えられます。
型安全性の cost/benefit
Cost:
- 開発時間増加(型アノテーション)
- 学習曲線(型システムの理解)
- 柔軟性の制限(subtyping etc)
Benefit:
- バグ削減(特に refactoring)
- IDE サポート(自動補完、リファクタリング)
- Documentation(型が interface を説明)
- Performance(type information → optimization)
プロジェクト特性に応じて選択:
- 長期保守が必要 → 型強い言語
- 迅速なプロトタイピング → 型弱い言語
型とドメイン駆動設計
型を使ってドメインの制約をエンコード:
// UserId と ProductId は異なる意味だが、両方 u64
struct UserId(u64);
struct ProductId(u64);
// 型により、誤った使用をコンパイルエラーにできる
fn get_user(user_id: UserId) -> User { }
fn get_product(product_id: ProductId) -> Product { }
// get_user(product_id) // コンパイルエラー!
この手法(newtype pattern)で business logic を型に embed。
Type-Driven Development Flow
- Domain を理解
- 型を設計(data structure)
- 型を満たす実装を書く
- テストで business logic を確認
型がスケルトンになり、実装が明確に。
型システムの未来
型システムは進化し続けています。dependent types、refinement types、session types など、より表現力のある型システムが研究されています。
実務では、言語の型システムを十分に理解し、それを design に活かすことが品質向上に繋がります。型はドキュメント、テスト、最適化の基盤。
言語別の型システム設計哲学
Rust の型システム:安全性と表現力
Rust は所有権(ownership)と型システムを統合し、メモリ安全とスレッド安全を同時に達成。
ジェネリック型の制約(Trait Bounds)
// T はどんな型でも OK
fn process<T>(item: T) { }
// T は Clone を実装している型に限定
fn process<T: Clone>(item: T) {
let copy = item.clone();
}
// 複数の制約
fn process<T: Clone + Debug>(item: T) {
println!("{:?}", item);
}
// Where 句で複雑な制約を記述
fn process<T>(item: T) where T: Clone + Debug + 'static {
// T はクローン可能で、デバッグ出力でき、スタティックライフタイムを持つ
}
ライフタイム(Lifetime)の明示
// 参照のライフタイムを明示的に指定
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
// 戻り値のライフタイムが入力参照のライフタイムと同じことを型で保証
ライフタイムの 3 つのルール:
- 各参照は自身のライフタイムパラメータを持つ
- ただ 1 つの入力ライフタイムがある場合、それは出力ライフタイムにコピーされる
- メソッドの場合、
&selfのライフタイムが出力ライフタイムに割り当てられる
Go の型システム:シンプルさとインターフェース
Go は明示的なジェネリクスを長く持たず(Go 1.18 以前)、インターフェースベースのポリモーフィズムを重視。
// 名前ベースではなく、メソッドを持つかで型が一致するかを判定
type Writer interface {
Write([]byte) (int, error)
}
type File struct {}
func (f File) Write(b []byte) (int, error) {
// 実装
return len(b), nil
}
// File は Writer を明示的に実装と宣言していない(implements キーワードなし)
// しかし Writer メソッドを持つので、Writer として使用可能
func SaveData(w Writer, data string) error {
w.Write([]byte(data))
return nil
}
var f File
SaveData(f, "hello") // OK
ジェネリックの追加(Go 1.18+)
// 型パラメータを使ったジェネリック関数
func Filter[T any](items []T, predicate func(T) bool) []T {
result := make([]T, 0)
for _, item := range items {
if predicate(item) {
result = append(result, item)
}
}
return result
}
// 使用例
nums := []int{1, 2, 3, 4, 5}
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
Python の型システム:段階的型付け(Gradual Typing)
Python 3.5+ は静的型ヒント(type hints)を導入。実行時には無視されるが、mypy などの type checker で検査可能。
型ヒントの構文
# 変数の型ヒント
x: int = 5
y: str = "hello"
names: list[str] = ["Alice", "Bob"]
# 関数シグネチャ
def greet(name: str) -> str:
return f"Hello, {name}"
# ジェネリック
from typing import TypeVar, Generic
T = TypeVar('T')
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self.value: T = value
def get(self) -> T:
return self.value
# Union(複数型の可能性)
def process(data: int | str) -> None:
if isinstance(data, int):
print(f"Number: {data}")
else:
print(f"String: {data}")
mypy による型チェック
# 静的に型エラーを検出(実行時エラーではなく)
mypy script.py
# Output:
# script.py:5: error: Incompatible types in assignment
# script.py:5: (expression has type "str", variable has type "int")
型システムの高度な概念
依存型(Dependent Types)
通常の型システムでは、値に依存する型を表現できません。依存型はこれを可能に。
-- Agda での例(疑似コード)
-- ベクトルの型に長さを含める
data Vec (A : Set) : ℕ → Set where
[] : Vec A zero
_∷_ : ∀ {n} → A → Vec A n → Vec A (suc n)
-- 長さ 3 のベクトルと長さ 2 のベクトルを足すと長さ 5 になる
_++_ : ∀ {A : Set} {m n : ℕ} → Vec A m → Vec A n → Vec A (m + n)
実務では Idris, Agda などが依存型を提供。
精密型(Refinement Types)
値の範囲を型で表現。
-- 疑似記法
type PositiveInt = {x : int | x > 0}
type ValidEmail = {s : string | contains(s, '@') && contains(s, '.')}
実務では Liquid Haskell、F* など。
型と設計パターン
Builder パターンと型の統合
従来の Builder:
class ConfigBuilder {
private String host;
private int port;
public ConfigBuilder host(String host) {
this.host = host;
return this;
}
public ConfigBuilder port(int port) {
this.port = port;
return this;
}
public Config build() {
if (host == null) throw new IllegalStateException("host required");
return new Config(host, port);
}
}
Rust での型ベース Builder:
struct ConfigBuilder {
host: Option<String>,
port: u16,
}
struct HasHost;
struct NoHost;
impl ConfigBuilder<NoHost> {
fn host(self, host: String) -> ConfigBuilder<HasHost> {
ConfigBuilder { host: Some(host), port: self.port }
}
}
impl ConfigBuilder<HasHost> {
fn build(self) -> Config {
Config { host: self.host.unwrap(), port: self.port }
}
}
// コンパイル時に host が設定されていることを保証
Rust の型システムを使うと、ビルドパターンを型レベルで強制できます。
State Machine パターンと型
状態遷移を型で表現:
// 状態を型で表現
struct Draft;
struct Approved;
struct Published;
struct Article<State> {
title: String,
content: String,
_state: std::marker::PhantomData<State>,
}
impl Article<Draft> {
fn new(title: String, content: String) -> Self {
Article { title, content, _state: std::marker::PhantomData }
}
fn approve(self) -> Article<Approved> {
Article { title: self.title, content: self.content, _state: std::marker::PhantomData }
}
}
impl Article<Approved> {
fn publish(self) -> Article<Published> {
Article { title: self.title, content: self.content, _state: std::marker::PhantomData }
}
}
// 使用例
let article = Article::new("Title".to_string(), "Content".to_string());
let approved = article.approve(); // Draft -> Approved
let published = approved.publish(); // Approved -> Published
// Draft 状態では publish() メソッドが呼べない(コンパイルエラー)
次のステップ
型システムの理解を深めるには、型を有効に活用する言語(Rust, Haskell, Scala)で実装してみることです。型と design を統合させるプロセスを体験することで、その価値が実感できます。
実務での応用:
- 型を使ったドメイン設計 → Invalid State を型で排除
- 新しい言語機能 → Rust の lifetime、Go の interface、Python の Union type
- 型チェック → mypy, Rust の厳格なチェックを活用
- エラーハンドリング → Option/Result パターンで例外なしに
型システムの実装詳細と型推論
Hindley-Milner型推論アルゴリズム
Haskellなどで採用されるHindley-Milner(HM)型推論は、プログラマが型注釈を書かなくても型を自動判定します。
-- 型注釈なしで推論される
map f xs = case xs of
[] -> []
(x:xs) -> f x : map f xs
-- HMアルゴリズムが以下の型を推論
-- map :: (a -> b) -> [a] -> [b]
推論アルゴリズムの流れ:
- 型変数の生成: 各式に型変数を割り当て
- 制約の生成: 式の構造から型の制約を抽出
- 単一化(Unification): 制約を解くため型変数を置き換え
- 最般型の計算: 最も一般的な型を出力
例: map f [1, 2, 3]
f :: α -> β
[1, 2, 3] :: [Int]
制約: α = Int, β = γ (返り値)
統一化: α := Int, β := γ
結果: map :: (Int -> γ) -> [Int] -> [γ]
型のランク(Rank)と多態性
Rank-1多態(単純多態)では、forall 量指子は最上レベルでのみ現れます。Rank-2以上では、関数の引数に forall が現れることで、より高度な多態性を表現できます。
-- Rank-1: 標準的な多態関数
polymorphic :: forall a. a -> a
polymorphic x = x
-- Rank-2: 関数の引数が多態的
applyTwice :: (forall a. a -> a) -> Int -> Int
applyTwice f n = f (f n)
-- この場合、f はすべての型に対して機能する必要がある
-- Rank-1では許可されない
badRank1 = applyTwice id 5 -- エラー: id はランク1
-- Rank-2を活用
goodRank2 = applyTwice (\x -> x) 5 -- OK
Rank-2多態は、型安全性を保ちながら、より柔軟な高階関数を定義できます。
依存型(Dependent Types)
依存型はPiおよびSigma型として表現されます。値に依存する型を持つことで、より強い不変条件を型で強制できます。
-- Agda での依存型の例
Vec : ℕ → Set → Set
Vec zero A = ⊤
Vec (suc n) A = A × Vec n A
-- 長さが保証されたベクトル型
-- Vec 3 Nat は要素数3のNatリスト
head : {n : ℕ} {A : Set} → Vec (suc n) A → A
head (x , _) = x
-- ベクトルの連結(型で長さを保証)
_++_ : {m n : ℕ} {A : Set} → Vec m A → Vec n A → Vec (m + n) A
依存型により、配列のインデックスアウトオブバウンズなどのエラーを型チェック段階で排除できます。
型クラスと制約
Rust のトレイト(Trait)
Rustではトレイトが型クラスの役割を果たします。トレイト境界により、ジェネリック型が満たすべき条件を指定します。
// トレイト定義
trait Drawable {
fn draw(&self);
}
// 実装
struct Circle {
radius: f64,
}
impl Drawable for Circle {
fn draw(&self) {
println!("Drawing circle with radius {}", self.radius);
}
}
// トレイト境界を使用
fn render<T: Drawable>(shape: &T) {
shape.draw();
}
// 複数トレイト境界
fn process<T: Clone + Debug>(item: T) {
println!("{:?}", item.clone());
}
Rust のトレイトオブジェクト(dyn Trait)を使うことで、異なる型を統一されたインターフェースで扱えます。
let shapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Rectangle { width: 10.0, height: 20.0 }),
];
for shape in shapes {
shape.draw();
}
Protocol Buffers(protobuf)とスキーマ進化
Protocol Buffersは言語非依存なシリアライゼーション形式です。バージョン間の互換性を維持しながらスキーマを進化させられます。
syntax = "proto3";
message Person {
int32 id = 1;
string name = 2;
string email = 3;
// フィールド4を削除してもデシリアライズは可能
// reserved 4; // 将来の使用を予約
}
message AddressBook {
repeated Person people = 1;
}
protobuf のバージョン互換性ルール:
- 新しいフィールドの追加は許可(デフォルト値で補完)
- フィールド番号の再割り当ては禁止(reserved で保護)
- 型の変更は制限あり(int32 ↔ sint32は可、int32 ↔ string は不可)
関数型言語と型
Scala の型システム
Scalaは静的型付けと動的型付けの特性を組み合わせます。
// 型推論
val x = 5 // Int に推論
// 型変数を使ったジェネリック
def identity[A](x: A): A = x
// 上界と下界
def maximum[T <: Comparable[T]](list: List[T]): T = {
list.maxBy(_.compareTo(_))
}
// 変位(Variance)
// 共変(Covariance)
class Container[+A](val item: A)
val intContainer: Container[Int] = Container(5)
val anyContainer: Container[Any] = intContainer // OK
// 反変(Contravariance)
trait Consumer[-A] {
def consume(item: A): Unit
}
val anyConsumer: Consumer[Any] = new Consumer[Any] {
def consume(item: Any) = println(item)
}
val intConsumer: Consumer[Int] = anyConsumer // OK
パターンマッチングと型安全性
完全性チェック
コンパイラがパターンマッチングの完全性を検証し、未処理のケースを検出します。
enum Result<T, E> {
Ok(T),
Err(E),
}
fn process(result: Result<i32, String>) {
match result {
Ok(value) => println!("Value: {}", value),
Err(error) => println!("Error: {}", error),
// すべてのケースを処理 → コンパイルエラーなし
}
// 不完全なマッチ
match result {
Ok(value) => println!("Value: {}", value),
// Err ケースを処理しない → コンパイルエラー!
}
}
完全性チェックにより、手動ですべてのケースを処理する手間が減り、バグを防げます。
ガード条件
パターンマッチにガード条件を加えることで、より複雑な条件判定が可能になります。
fn check_number(n: i32) {
match n {
x if x < 0 => println!("Negative"),
x if x == 0 => println!("Zero"),
x if x > 0 && x <= 10 => println!("Small positive"),
_ => println!("Large"),
}
}
ガード条件はパターンマッチの後に実行され、マッチの細かい制御が可能になります。
まとめ
型システムとプログラミング原則は別々の話ではなく、どちらも誤用しにくい設計を作るための道具です。型で制約を表し、原則で責務と依存を整えると、変更の安全性が高まります。