ポリモーフィズム
同じインターフェースで異なる型を扱い、呼び出し側を変えずに型ごとの処理へ振り分ける仕組み
ポリモーフィズムとは
ポリモーフィズム (多態性) は、同じインターフェースで異なる型を扱い、呼び出し側を書き換えずに型ごとの処理へ振り分ける仕組みである。「何をするか」を統一し、「どうするか」を型ごとに変える。
振り分けがいつ決まるかは種類によって違う。インターフェースや継承による振り分けは実行時に対象のオブジェクトを見て決まるが、ジェネリクスや関数オーバーロードはコンパイル時に決まり、実行時には分岐が残らない。「ポリモーフィズムは実行時に処理を選ぶ仕組み」という説明は、次に挙げる 3 種類のうちサブタイプにだけ当てはまる。
ポリモーフィズムの種類
アドホックとパラメトリックという区別は、Christopher Strachey が 1967 年の講義録「Fundamental Concepts in Programming Languages」で示したもので、引数の型から結果の型が体系的に決まるかどうかが分かれ目になる。算術演算子のように型ごとの規則を個別に並べるしかないものがアドホック、リストの写像関数のように型を変数として扱えるものがパラメトリックである。ここに継承・インターフェースによるサブタイプを加えた 3 種類で、実務で出会う形はほぼ収まる。
| 種類 | 説明 | 振り分けが決まる時点 | 例 |
|---|---|---|---|
| サブタイプ | インターフェースを実装 | 実行時 (対象のオブジェクトで決まる) | Notification を実装する Email, Slack |
| パラメトリック | ジェネリクス | コンパイル時 (型引数で決まる) | Array<T>, Promise<T> |
| アドホック | 関数オーバーロード | コンパイル時 (引数の型で決まる) | 同名関数で異なる引数 |
TypeScript のジェネリクスは型引数がコンパイル後に消えるため、T の中身で実行時に処理を変えることはできない。型ごとに違う処理をさせたいなら、値そのものに種類を持たせる (サブタイプか、後述する種類タグ付きの合併型) 必要がある。
TypeScript でのサブタイプポリモーフィズム
呼び出し側の notify は Notification という約束だけを知っていて、実体が電子メールかチャットかを知らない。送信手段が増えても notify は 1 文字も変わらないことが、サブタイプポリモーフィズムの実利である。
interface Notification {
send(message: string): Promise<void>;
}
class EmailNotification implements Notification {
constructor(private readonly endpoint: string) {}
async send(message: string) {
await fetch(this.endpoint, { method: 'POST', body: message });
}
}
class SlackNotification implements Notification {
constructor(private readonly webhookUrl: string) {}
async send(message: string) {
await fetch(this.webhookUrl, { method: 'POST', body: JSON.stringify({ text: message }) });
}
}
// 呼び出し側は具体的な型を知らない
async function notify(notifier: Notification, message: string) {
await notifier.send(message); // 実行時に適切な send() が呼ばれる
}
await notify(new EmailNotification('https://email.example.com/send'), 'Hello');
await notify(new SlackNotification('https://hooks.slack.example.com/T000/B000'), 'Hello');
Discriminated Union (TypeScript 的ポリモーフィズム)
クラスを作らず、種類を表すタグを持つ型の合併で同じことをする書き方もある。振る舞いを型ごとのクラスに分散させる代わりに、処理を 1 か所の分岐へ集めるのが特徴で、種類の数が動かない場合はこちらが読みやすい。
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'rectangle': return shape.width * shape.height;
default: {
// 未処理の種類が残っていると never への代入が通らずコンパイルエラーになる
const unreachable: never = shape;
return unreachable;
}
}
}
クラス方式との違いは拡張の方向にある。種類を増やすことが多いならクラス方式が向き (追加は新しいクラス 1 個)、操作を増やすことが多いなら合併型が向く (追加は新しい関数 1 個)。合併型に種類を足すとその型を扱う全ての関数に手を入れる必要があるが、直し漏れは実行時の不具合ではなくコンパイルエラーとして表に出る。
ポリモーフィズム vs if-else
文字列で種類を持ち回る分岐は、種類を増やしたときに直すべき場所がコンパイラから見えない。種類を型として表現しておけば、実装漏れは「動かしてみたら何も起きない」ではなくコンパイルエラーとして検出できる。
// ❌ if-else: 新しい型を追加するたびに分岐を追加
function process(type: string) {
if (type === 'email') { /* ... */ }
else if (type === 'slack') { /* ... */ }
else if (type === 'sms') { /* ... */ } // 追加
}
// ✅ ポリモーフィズム: 新しい型を追加するだけ
class SmsNotification implements Notification {
async send(message: string) { /* ... */ }
}
// 既存のコードを変更せずに拡張 (開放閉鎖原則)
ポリモーフィズムのメリット
ポリモーフィズムにより、既存コードを変更せずに新しい型を追加できる (開放閉鎖原則)。テスト時にはモックに差し替えが容易で、条件分岐が型ごとのメソッドに分散されるためコードの可読性も向上する。
使いどころを間違えるとき
分岐を見ると反射的にポリモーフィズムへ置き換えたくなるが、実装が 1 つしかないインターフェース、当面 2 つで増える見込みのない分岐、条件が型の違いではなく値の範囲 (金額の閾値など) にすぎないものは、抽象化しても追いにくくなるだけである。同じオブジェクトの状態遷移で振る舞いが変わるならステートパターン、同じ入出力で計算方法だけを差し替えるならストラテジーパターンのように、目的に名前が付いた形を選んだほうが意図が伝わる。継承で振る舞いを共有した場合は親クラスの変更が全ての子に波及するため、インターフェースの実装や部品の組み合わせを先に検討するのが安全である。
この記事は役に立ちましたか?
関連用語
抽象クラス
直接インスタンス化できず、サブクラスに共通のインターフェースと部分的な実装を提供するクラス
ジェネリクス
型をパラメータ化し、型安全性を保ちながら汎用的なコードを書く仕組み
ストラテジーパターン
処理のやり方を差し替え可能な部品として取り出し、呼び出す側を変えずに実行時に選べるようにするデザインパターン
オブジェクト指向
データと操作をオブジェクトとしてまとめ、現実の概念に近づけて設計する考え方
開放閉鎖の原則
SOLID の O - ソフトウェアの構成要素は拡張に対して開かれ、修正に対して閉じているべきという設計原則
アダプターパターン
互換性のないインターフェースを変換し、既存のコードを変更せずに連携させるデザインパターン