🧊

【SOLID原則(ISP)】なぜ巨大な万能インターフェースが「無能」なのか?レゴブロックのように「役割」を組み合わせる設計術

に公開

はじめに:クラスは「SRP」、ではインターフェースは?

前回の記事では SRP(単一責任の原則) について学びました。

SRPの本質は、クラス(構造体)の責務を「変更の理由(アクター)」ごとに整理することでした。「混ぜるな、危険」。これを守ることで、私たちはスパゲッティコードの密結合から解放されました。

いくらクラスの中身を整理したとしても、そのクラスの利用するインターフェースが整理されていなければ、あっという間に無駄に手間のかかるバグの巣窟になります。

何故でしょうか?

この記事を通して、ISPの原則に違反したインターフェースが招く災難や正しいインターフェースで実現できる高い拡張性を体験してもらいます。

ISPとは:その注文、面倒すぎませんか?

ISP(Interface Segregation Principle): 「クライアントに、利用しないメソッドへの依存を強制してはならない」

この定義だけだと少し堅苦しいので、レストランで例えてみましょう。

あなたはカフェに入り、「ホットコーヒー」を一杯注文しようとしました。

しかし、店員はオーダーを通すために、以下の質問すべてに答えるようあなたに要求します。

👨:「(メニュー多いな...)コーヒー1つください」
🤵‍♀️:「畏まりました。ちなみにアレルギーはありますか?」
👨:「いいえ。」
🤵‍♀️:「サラダはいかがですか?4つのサラダオプションがあります。」
👨:「結構です。」
🤵‍♀️:「お好みのステーキの焼き加減はありますか?レア・ミディアム・ウェルダンとあります。」
👨:(コーヒーだけでいいんだけど…)

あなたはただコーヒーが飲みたいだけなのに、自分には関係のないステーキやサラダの仕様まで気にしなければなりません。

これが ISP違反(巨大なインターフェース) の状態です。

本来あるべき姿は、「ドリンクメニュー」だけを渡されること。

これなら、あなたはステーキのことは一切気にせず、コーヒーを注文できます。

これがISP(インターフェース分離) の状態です。

現場の災難:開発者を不幸にする万能インターフェース

では、この「レストランの理不尽」がコードの世界で起きると、どのような災難を招くのでしょうか?

まずは 「プリンタ複合機(Multi-Function Printer)」 の実装例を見てみましょう。

これは印刷機能、スキャン機能、FAX機能と、さまざまな機能を持ち合わせている多機能プリンターです。

package multifunctionalprinter

import "fmt"

// プリンタ複合機
type MultiFunctionalPrinter struct {}

func (p MultiFunctionalPrinter) Print() {
    fmt.Println("印刷します")
}

func (p MultiFunctionalPrinter) Scan() {
    panic("スキャンします")
}

func (p MultiFunctionalPrinter) Fax() {
    panic("ファックスします")
}

この多機能プリンターの振る舞いを実装するために、どのようなインターフェースが考えられるでしょうか?

まとめすぎた万能インターフェース

「複合機を作るんだから、インターフェースも1つでいいだろう」という安易な考えで、全ての機能を詰め込んだMachineインターフェースを定義しました。

// 巨大なインターフェース(Fat Interface)
type Machine interface {
    Print()
    Scan()
    Fax()
}

しかし、あるときに、シンプルに印刷の機能を持ったプリンター(SimplePrinter)も実装するよう要求がありました。

ここで問題が発生します。

SimplePrinterは、Printというシンプルな機能のみを持ちたいはずです。

しかし、万能インターフェースにより、印刷機能だけを使いたくても、全ての機能を実装しなければなりません。

type SimplePrinter struct {}

func (p SimplePrinter) Print() {
    fmt.Println("印刷します")
}

// ★ここが災難!
// 実装したくないのに、インターフェースの制約で強制される
func (p SimplePrinter) Scan() {
    panic("この機能は使えません!")
}

func (p SimplePrinter) Fax() {
    panic("この機能は使えません!")
}

この実装では、コンパイラは問題を検知しません。だからこその、致命的な問題が存在します。

1. パニック&クラッシュの頻発リスク:

もし誰かが誤ってSimplePrinterでScan()を呼んだ瞬間、プログラムはパニックでクラッシュします。「インターフェースを満たしているのに動かない」という、最も恐ろしいバグです(まさにLSP違反)。

https://zenn.dev/hashidev/articles/004f245f1f038b

2. 無駄な修正・再テストの増加:

もし「FAXの仕様変更」でMachineインターフェースに変更が生じたら、FAX機能を持たないSimplePrinterまで修正と再テストを強いられます。

無関係な変更の影響によって開発者の負担がどんどん重くなっていきます。

ISPの本質:「実体」と「概念」は 1:1 ではない

なぜ、私たちはついインターフェースを巨大にしてしまうのでしょうか?

それは、無意識のうちに 「データの実体(クラス)と概念型(Interface)を 1:1 で考えてしまう」 からです。

😥間違い: 複合機というクラスがあるから、「複合機の振る舞い」を作る

😃正解: 複合機というクラスは、「プリンターの振る舞い」と「スキャナーの振る舞い」を持ち合わせている」

ISPの本質は、1つのクラス(実体)に対して、役割ごとに複数のインターフェース(概念)を用意してあげることです。

インターフェースとは、「そのクラスの全機能リスト」ではありません。 「そのクラスを『どう使いたいか』という役割(Role)の証明書」 なのです。

解決策:インターフェースを「レゴブロック」にする

この本質を踏まえて、先ほどの複合機の例を修正しましょう。 1つの巨大なインターフェースを解体し、役割ごとの小さなインターフェースに分割します。

インターフェースの分離

まず、実体が必要とする役割に最低限の機能を持たせるようにインターフェースを設計していきます。

// 役割ごとに小さく分ける
type Printer interface { Print() }
type Scanner interface { Scan() }
type Faxer   interface { Fax() }

##必要なものだけを実装

この上で、定義したインターフェースに基づいてクラスを実装していきます。
これによって、実装が不要な機能を誤って使うことによるパニックのリスクが無くなり、不要なテストや変更への対応も軽減しました。

// シンプルなプリンター
// Printerインターフェースだけを実装すればOK(panicは不要!)
type SimplePrinter struct {}
func (p SimplePrinter) Print() { fmt.Println("印刷中...") }

// プリンタ複合機(MultiFunctionalPrinter)
// 全部のインターフェースを実装することで、最強のマシンになれる
type MultiFunctionalPrinter struct {}
func (s MultiFunctionalPrinter) Print() { fmt.Println("高速印刷!") }
func (s MultiFunctionalPrinter) Scan()  { fmt.Println("スキャン!") }
func (s MultiFunctionalPrinter) Fax()   { fmt.Println("FAX送信!") }

使う側(クライアント)の自由

印刷をしたい人にとって、そのプリンターがファックスなど他の機能を持っているかどうかなどに関心はありません。ただ「印刷したい」という目的のために、Printerインターフェースとして扱えば良いのです。

func DoPrint(p Printer) {
    p.Print()
}

func main() {
    simple := SimplePrinter{}
    multi := MultiFunctionalPrinter{}

    // どちらも「Printer」として扱える!
    DoPrint(simple)
    DoPrint(multi)
}

結論:クレバーな設計とは「組み合わせ」である

ISPが教えてくれるのは 「大は小を兼ねないことは悪である」ということです。

ソフトウェア設計において、巨大な万能インターフェースは「足かせ」でしかありません。

インターフェースを 「必要最小限の単位」 まで分解しておけば、私たちはそれらをレゴブロックのように自由に組み合わせることができます。

印刷機能だけ欲しいなら、Printerブロックを使う。

全部入りが欲しいなら、全てのブロックを組み合わせる。

このように、「データの実体」に縛られず、「役割(Interface)」を柔軟に使い分けること。

これこそが、密結合を防ぎ、拡張性の高いシステム(OCP)を実現するための、最もクレバーな設計なのです。

Discussion