メインコンテンツまでスキップ

インターフェース分離の原則 — 動機の半分は言語に依存する

ISP は「クライアントが使わないメソッドへの依存を強制されるべきではない」という原則です。定義は分かりやすく、違反も見つけやすい。それでも実務で迷うのは、どの単位で分ければよいかが定義から出てこないからです。

さらに、この原則には言語によって消える動機が含まれています。C++ の事情から生まれた部分と、言語を問わず生きている部分を分けないと、TypeScript で何をすべきかが決まりません。

原典は何を言ったか — 動機は 2 つある

Martin の 2000 年の文書は、ISP をこう定義します1

Many client specific interfaces are better than one general purpose interface (クライアントごとに用意された多数のインターフェースの方が、汎用のインターフェース 1 つよりも良い)

「多数の方が良い」と書かれている点に注意してください。 この定義だけを読むと、細かく切るほど良いように見えます。後で見るとおり、原典は同じ節でその読みを退けています。

続く説明は、大きなインターフェースの問題を図で示します。3 つのクライアントが 1 つの太いサービスに依存している状況について、こう書きます。

Note that whenever a change is made to one of the methods that ClientA calls, ClientB and ClientC may be affected. It may be necessary to recompile and redeploy them. (ClientA が呼ぶメソッドのいずれかに変更が加えられると、ClientB と ClientC が影響を受けうることに注意してほしい。それらを再コンパイルし再デプロイする必要が生じるかもしれない。)

ここに動機が 2 つ含まれています。

  1. 再コンパイルと再デプロイのコスト。 C++ では、依存するヘッダが変わると再ビルドが走ります。1990 年代の大規模プロジェクトでは、これが実際の待ち時間として重くのしかかりました
  2. 影響範囲が広がること。 ClientB は ClientA 向けのメソッドを使っていないのに、変更の影響を受けます

TypeScript で残るのは 2 つめだけです。型の変更でビルドが走ることはありますが、C++ のヘッダ依存のような単位ではありません。「ISP は業界標準だから守る」と言うとき、消えた側の動機まで引き継いでいないかを確認する必要があります。

分ける単位はクライアントの種類

原典は分割の単位も明示しています。ここが実務で最も効く記述です。

The ISP does not recommend that every class that uses a service have its own special interface class that the service must inherit from. If that were the case, the service would depend upon each and every client in a bizarre and unhealthy way. Rather, clients should be categorized by their type, and interfaces for each type of client should be created. (ISP は、サービスを使うすべてのクラスが専用のインターフェースを持ち、サービスがそれを継承すべきだと勧めているのではない。もしそうなら、サービスは 1 つ 1 つのクライアントに奇妙で不健全な形で依存することになる。そうではなく、クライアントはその種類によって分類され、種類ごとにインターフェースを作るべきである。)

さらに、重複を許すことも明記されています。

If two or more different client types need the same method, the method should be added to both of their interfaces. This is neither harmful nor confusing to the client. (2 つ以上の異なる種類のクライアントが同じメソッドを必要とするなら、そのメソッドは両方のインターフェースに追加すべきである。これはクライアントにとって有害でも紛らわしくもない。)

分割の基準はクライアント側の使い方であって、サービス側の都合ではありません。 そして種類が同じメソッドを求めるなら、重複させてよい。ここは第 2 章の SRPと同じ発想です。まとめる基準は「同じコードかどうか」ではなく「同じ相手が使うかどうか」です。

なぜ守るのか — 使えないものを実装させない

ISP 違反が実害になるのは、実装側が使えないメソッドを埋めることを強制されるときです。

図書館の資料管理で見ます。

❌ Bad: 1 つのインターフェースにすべての操作が乗っている
interface CopyRegistry {
findById(copyId: string): string | undefined;
listByShelf(shelf: string): readonly string[];
register(copyId: string, shelf: string): void;
discard(copyId: string): void;
}

// 閲覧端末は検索しかしないのに、更新系まで実装させられる
class ReadOnlyCopyRegistry implements CopyRegistry {
findById(copyId: string): string | undefined {
return undefined;
}
listByShelf(shelf: string): readonly string[] {
return [];
}
register(copyId: string, shelf: string): void {
throw new Error("閲覧専用です");
}
discard(copyId: string): void {
throw new Error("閲覧専用です");
}
}

例外を投げる実装が並んだ時点で、ISP 違反が起きています。そしてこれは同時に LSP 違反でもあります。 CopyRegistry 型で受け取ったコードは register が動くと信じますが、この実装は裏切ります。前章の言葉で言えば、事前条件を強めています。

**使えないメソッドをそもそも持たせなければ、契約を破りようがありません。**ISP を守ることが LSP 違反を構造的に防ぐというこの結びつけは、原典の ISP 節にはなく、本連載の整理です。

どう誤用されるか — メソッド単位に切る

誤用は「細かく切るほどよい」という方向に出ます。

❌ Bad: メソッド 1 つごとにインターフェースを切る
interface CopyFinder {
findById(copyId: string): string | undefined;
}
interface ShelfLister {
listByShelf(shelf: string): readonly string[];
}
interface CopyRegistrar {
register(copyId: string, shelf: string): void;
}
interface CopyDiscarder {
discard(copyId: string): void;
}

class DatabaseCopyRegistry
implements CopyFinder, ShelfLister, CopyRegistrar, CopyDiscarder
{
// ...
}

Martin 自身がこの状態を警告しています。

As with all principles, care must be taken not to overdo it. The specter of a class with hundreds of different interfaces, some segregated by client and other segregated by version, would be frightening indeed. (すべての原則がそうであるように、やりすぎないよう注意しなければならない。何百もの異なるインターフェースを持つクラスという幻影は、実に恐ろしい。)

メソッド単位の分割が誤りである理由は、分割の基準がクライアントから来ていないからです。原典が言う単位はクライアントの種類でした。閲覧端末とカウンター業務という 2 種類しかクライアントがいないなら、インターフェースも 2 つです。

✅ Good: クライアントの種類で切る
// 閲覧端末が使う操作
interface CopyLookup {
findById(copyId: string): string | undefined;
listByShelf(shelf: string): readonly string[];
}

// カウンター業務が使う操作
interface CopyMaintenance {
findById(copyId: string): string | undefined; // 重複してよい
register(copyId: string, shelf: string): void;
discard(copyId: string): void;
}

findById が両方に現れていますが、原典が明記するとおりこれは問題ありません。クライアントごとに必要なものが揃っていることの方が、重複を避けることより優先します。

いつ破っていいのか

ISP を適用しない判断が妥当になるのは、次のときです。

クライアントの種類が 1 つしかないとき。 分けても、どちらのインターフェースも同じ相手が使います。得るものがありません。

実装が常に全メソッドを提供できるとき。 ISP の実害は、使えないメソッドを実装させられることでした。すべての実装がすべてのメソッドを正しく提供できるなら、その害は発生しません。分けるかどうかは読みやすさの問題に落ちます。

クライアントの分類がまだ確定していないとき。 種類ごとに切るには種類が分かっている必要があります。分からないうちに切ると、第 3 章の予測的抽象化と同じ失敗になります。

TypeScript での現在地 — インターフェースを切らずに満たす

ここが TypeScript で最も変わる部分です。構造的型システムでは、名前付きインターフェースを宣言しなくても ISP を満たせます。

関数の引数型に、必要なメンバだけを直接書けばよいのです。

✅ Good: 引数型に必要な操作だけを書く
function renderShelfView(
registry: { listByShelf(shelf: string): readonly string[] },
shelf: string,
): readonly string[] {
return registry.listByShelf(shelf);
}

この関数は listByShelf しか要求しません。CopyLookupCopyMaintenance も宣言せずに、クライアント固有の最小の契約を表現しています。呼び出し側は、そのメソッドを持つ任意のオブジェクトを渡せます。

既存の型から切り出すこともできます。

✅ Good: Pick で用途ごとの視点を作る
type FullRegistry = {
findById(copyId: string): string | undefined;
listByShelf(shelf: string): readonly string[];
register(copyId: string, shelf: string): void;
discard(copyId: string): void;
};

type LookupView = Pick<FullRegistry, "findById" | "listByShelf">;
type MaintenanceView = Pick<FullRegistry, "findById" | "register" | "discard">;

Pick で作った型は、元の型が変わると自動的に追随します。手で書いた 2 つのインターフェースを同期させる手間がありません。

公称型の言語との違い

PHP や Java のような公称型の言語では、この書き方ができません。型の一致は名前で判定されるので、インターフェースを宣言し、実装クラスに implements を書く必要があります。分割は宣言の作業になり、その分だけコストがかかります。

TypeScript では宣言が要らない分、ISP を満たすことのコストがほぼゼロです。引数型を必要最小限に書くだけで済みます。裏を返せば、わざわざインターフェースを宣言する理由は別に必要だということです。

目的名前付きインターフェースが要るか
クライアントが使うものを最小にする不要。引数型に直接書けばよい
実装側に「この契約を満たす」と明示させる要る。implements で漏れを検出できる
契約に名前を付けて語彙にする要る。読み手が概念として扱えるようになる
複数箇所で同じ形を再利用する型エイリアスで足りることが多い

TypeScript における ISP は「インターフェースを分割する」ではなく「引数型を必要最小限に書く」として実現されます。 インターフェースの宣言は、別の目的のために選ぶものです。

構造的部分型が implements を不要にする範囲と、private / protected を持つクラスで例外になる条件は、TypeScript デザインパターン — Adapter が詳しく扱っています。

まとめ

  • ISP の動機は 2 つあり、再コンパイルのコストは TypeScript にそのままの形では存在しない。残るのは影響範囲の問題
  • 分割の単位はクライアントの種類。原典は「クライアントごとに専用インターフェース」を明確に否定している
  • 種類が違うクライアントが同じメソッドを求めるなら、重複させてよいと原典が明記している
  • ISP 違反は LSP 違反を招く。使えないメソッドを持たせなければ、契約を破りようがない
  • 誤用はメソッド単位への分割。Martin 自身が「やりすぎないよう注意せよ」と書いている
  • **TypeScript では引数型に必要なメンバを直接書くだけで ISP を満たせる。**インターフェースの宣言は別の目的で選ぶ

次に読む

次章では依存性逆転の原則を扱います。この章で作った「クライアント側が必要な形を決める」という発想が、依存の向きの話につながります。

練習問題

次のインターフェース分割はクライアント単位でしょうか、メソッド単位でしょうか。それが問題になるのはどんなときですか
interface Openable {
open(): void;
}
interface Closable {
close(): void;
}
interface Lockable {
lock(): void;
}
interface Unlockable {
unlock(): void;
}

class LoanDesk implements Openable, Closable, Lockable, Unlockable {
open(): void {}
close(): void {}
lock(): void {}
unlock(): void {}
}

解答例

メソッド単位です。 分割の根拠がクライアントから来ていません。

判定の手がかりは、インターフェース名が操作名とほぼ同じという点です。Openableopen を持つという以上のことを語りません。クライアント単位で切られていれば、名前は「誰が何のために使うか」を表すはずです。たとえば「開館業務」「施錠管理」のような単位です。

問題になるのは、クライアントが複数の操作を組にして使うときです。

開館業務を担当するコードは、ふつう unlockopen を続けて呼びます。閉館業務は closelock を呼びます。この使われ方なら、切るべき単位は次の 2 つです。

interface DeskOpening {
unlock(): void;
open(): void;
}
interface DeskClosing {
close(): void;
lock(): void;
}

メソッド単位のままだと、開館業務を書くコードは 2 つのインターフェースを受け取ることになります。引数が増え、しかも「この 2 つは組で使う」という情報がどこにも表現されません。

逆に問題にならない場合もあります。 4 つの操作が本当に独立に使われ、unlock だけを使うクライアントと open だけを使うクライアントが実在するなら、この分割はクライアント単位と一致します。判断はクライアントの実際の呼び方を見て決めます。

なお TypeScript なら、そもそもこれらを宣言せずに引数型へ直接書けます。function openDesk(desk: { unlock(): void; open(): void }): void と書けば、組で使うことが引数の形に現れます。


Footnotes

  1. 出典: Robert C. Martin, "Design Principles and Design Patterns" (2000)、14-16 ページのインターフェース分離の原則の節。本章の引用はすべてこの節からです。参照した PDF については第 1 章の脚注を参照してください。