cv.cm

コーディング · デザインパターン

SOLID原則を例でわかりやすく解説

約10分で読めます

結論

SOLIDは、オブジェクト指向設計の5つの原則、つまり単一責任(Single responsibility)、開放閉鎖(Open/closed)、リスコフの置換(Liskov substitution)、インターフェイス分離(Interface segregation)、依存性逆転(Dependency inversion)の頭文字を並べた略語です。Robert C. Martinがまとめ、略語はMichael Feathersが考案しました。目指すのはコードを変更しやすくすることで、各クラスの変更理由は1つだけにし、古いコードを編集せずに新しい振る舞いを追加し、サブタイプは安全に置き換えられるようにし、インターフェイスは小さく保ち、上位のコードは抽象に依存させます。

SOLIDとは

頭文字原則ひとことで言うと
S単一責任の原則(SRP)クラスを変更する理由は1つだけであるべきです。
O開放閉鎖の原則(OCP)ソフトウェアは拡張に対して開かれ、修正に対して閉じているべきです。
Lリスコフの置換原則(LSP)基底型が期待される場所では、どのサブタイプも使えなければなりません。
Iインターフェイス分離の原則(ISP)クライアントに、使わないメソッドへの依存を強制してはいけません。
D依存性逆転の原則(DIP)具体的な詳細ではなく、抽象に依存しましょう。

考え方自体は略語より古くからあります。開放閉鎖の原則はBertrand Meyerの著書『Object-Oriented Software Construction』(1988年)に、置換原則はBarbara Liskovの1987年の基調講演「Data Abstraction and Hierarchy」に由来します。Robert C. Martinは2000年ごろの著作で5つすべてを論じ、のちにMichael Feathersがそれを「SOLID」という語に並べ替えました。

S:単一責任の原則

クラスの変更理由は1つだけ、つまり1人のアクターまたは1つの関心事にだけ仕えるべきです。クラスがビジネスルール、書式整形、保存を一緒に抱えていると、どれか1つを変更したときにほかを壊すおそれがあります。

改善前:1つのクラスがレポートの計算、整形、保存をすべて行っています。

class Report {
  constructor(private sales: number[]) {}
  total(): number { return this.sales.reduce((a, b) => a + b, 0); }
  toHtml(): string { return `<p>Total: ${this.total()}</p>`; }
  saveToFile(path: string): void { /* write this.toHtml() to disk */ }
}

改善後:関心事ごとに小さな単位に分けたので、新しい出力形式を追加しても計算部分には手を触れません。

class SalesReport {
  constructor(private sales: number[]) {}
  total(): number { return this.sales.reduce((a, b) => a + b, 0); }
}
class HtmlFormatter {
  format(r: SalesReport): string { return `<p>Total: ${r.total()}</p>`; }
}
class ReportWriter {
  save(content: string, path: string): void { /* write to disk */ }
}
const html = new HtmlFormatter().format(new SalesReport([10, 20, 5]));
console.log(html); // <p>Total: 35</p>

O:開放閉鎖の原則

新しい振る舞いは、すでに動作確認とテストが済んだコードを編集するのではなく、新しいコードを追加して加えられるべきです。一般的な手段は、インターフェイスとポリモーフィズムです。

改善前:図形が増えるたびに area 関数を編集することになります。

type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(s: Shape): number {
  if (s.kind === "circle") return Math.PI * s.r ** 2;
  if (s.kind === "square") return s.side ** 2;
  throw new Error("unknown shape");
}

改善後:新しい図形は新しいクラスにすればよく、既存のコードは変わりません。

interface Shape { area(): number; }
class Circle implements Shape {
  constructor(private r: number) {}
  area(): number { return Math.PI * this.r ** 2; }
}
class Square implements Shape {
  constructor(private side: number) {}
  area(): number { return this.side ** 2; }
}
const totalArea = (shapes: Shape[]) => shapes.reduce((sum, s) => sum + s.area(), 0);
console.log(totalArea([new Square(2), new Square(3)])); // 13

L:リスコフの置換原則

コードが基底型で動作するなら、どのサブタイプを渡しても動作し続けなければなりません。サブタイプは、事前条件を強めたり、保証を弱めたり、基底型が投げない場面で例外を投げたりしてはいけません。

改善前:古典的な正方形と長方形の問題です。Square は Rectangle を継承しますが、幅と高さが独立しているという期待を壊してしまいます。

class Rectangle {
  constructor(public width: number, public height: number) {}
  setWidth(w: number): void { this.width = w; }
  setHeight(h: number): void { this.height = h; }
  area(): number { return this.width * this.height; }
}
class Square extends Rectangle {
  setWidth(w: number): void { this.width = w; this.height = w; }
  setHeight(h: number): void { this.width = h; this.height = h; }
}
function stretch(r: Rectangle): number {
  r.setWidth(5);
  r.setHeight(4);
  return r.area(); // caller expects 20
}
console.log(stretch(new Rectangle(1, 1))); // 20
console.log(stretch(new Square(1, 1)));    // 16, surprise

改善後:両者の関係を継承でモデル化しません。両方の型が読み取り専用の共通インターフェイスを実装し、正方形の辺を独立に変更できると期待する人はいません。

interface Shape { area(): number; }
class Rectangle implements Shape {
  constructor(readonly width: number, readonly height: number) {}
  area(): number { return this.width * this.height; }
}
class Square implements Shape {
  constructor(readonly side: number) {}
  area(): number { return this.side ** 2; }
}
console.log([new Rectangle(5, 4), new Square(4)].map((s) => s.area())); // [ 20, 16 ]

I:インターフェイス分離の原則

大きなインターフェイスは、クラスにサポートできないメソッドの実装を強いることになり、たいていはエラーを投げることになります。小さく焦点を絞ったインターフェイスに分割し、各クラスには本当にできることだけを実装させましょう。

改善前:単純なプリンターが、スキャンやファックスもできるふりをしなければなりません。

interface Machine {
  print(doc: string): void;
  scan(): string;
  fax(doc: string): void;
}
class BasicPrinter implements Machine {
  print(doc: string): void { console.log("print", doc); }
  scan(): string { throw new Error("not supported"); }
  fax(doc: string): void { throw new Error("not supported"); }
}

改善後:組み合わせて使える、小さな役割ごとのインターフェイスにします。

interface Printer { print(doc: string): void; }
interface Scanner { scan(): string; }
class BasicPrinter implements Printer {
  print(doc: string): void { console.log("print", doc); }
}
class OfficeMachine implements Printer, Scanner {
  print(doc: string): void { console.log("print", doc); }
  scan(): string { return "scanned page"; }
}
function printAll(p: Printer, docs: string[]): void { docs.forEach((d) => p.print(d)); }
printAll(new BasicPrinter(), ["a.pdf"]); // print a.pdf

D:依存性逆転の原則

上位のポリシーは、特定のデータベースやメールサービスのような下位の詳細に依存すべきではありません。両者は抽象に依存し、その抽象は通常、上位のモジュールが所有します。実際には、依存先を内部で作らず、外から渡します(依存性の注入)。

改善前:サービスが具体的なデータベースクライアントを自分で作るので、テストも切り替えもできません。

class MySqlUserStore {
  find(id: string): string { return "user " + id; }
}
class UserService {
  private store = new MySqlUserStore(); // hard-wired detail
  greet(id: string): string { return "Hello, " + this.store.find(id); }
}

改善後:サービスはインターフェイスに依存し、テスト用のフェイクを含め、どんな実装でも注入できます。

interface UserStore { find(id: string): string; }
class UserService {
  constructor(private store: UserStore) {}
  greet(id: string): string { return "Hello, " + this.store.find(id); }
}
class InMemoryUserStore implements UserStore {
  constructor(private users: Record<string, string>) {}
  find(id: string): string { return this.users[id] ?? "unknown"; }
}
const service = new UserService(new InMemoryUserStore({ u1: "Ada" }));
console.log(service.greet("u1")); // Hello, Ada

SOLIDを適用すべきとき、しないとき

SOLID原則は、頻繁に変更されるコードのための指針であり、法律ではありません。盲目的に適用すると、小さなクラスや実装が1つしかないインターフェイスが大量にでき、かえってコードが追いにくくなります。

  • 原則が解決する痛みを感じたときに適用しましょう。例えば、何かが変わるたびに壊れるクラス、本物のデータベースが必要なテスト、多くの箇所にコピーされたswitch文などです。
  • すべてのクラスのために先回りしてインターフェイスを用意するのはやめましょう。2つ目の実装やテスト用の代役が現れた時点で抽出します。
  • 小さなスクリプトやプロトタイプでは、単純で直接的なコードのほうが通常は優れています。
  • 多くのデザインパターンはSOLIDの具体的な応用です。Strategy パターンとDecorator パターンは開放閉鎖を支え、Adapter パターンとFacade パターンは詳細の隔離に役立ち、Abstract Factory パターンは依存性逆転を支えます。

よくある質問

SOLID原則は誰が考えたのですか?

Robert C. Martinが5つの原則をまとめて説明しました。その土台には、Bertrand Meyer(開放閉鎖)とBarbara Liskov(置換)による先行研究があります。SOLIDという略語はMichael Feathersが考案しました。

SOLIDは関数型プログラミングにも当てはまりますか?

考え方は置き換えられます。目的を1つに絞った小さな関数、振る舞いを編集せず合成すること、依存先を引数として渡すことです。クラスを前提にした表現は、オブジェクト指向のコードに固有のものです。

依存性逆転と依存性の注入の違いは何ですか?

依存性逆転は、上位のコードが抽象に依存すべきだという原則です。依存性の注入は、その依存先を、たとえばコンストラクタを通じて外から与える手法です。

SOLIDの中で最も重要な原則はどれですか?

公式な優先順位はありません。単一責任と依存性逆転は、コードを理解しやすくテストしやすくするので、通常は最も早く効果が出ます。

SOLID原則を破るのは悪いことですか?

必ずしもそうではありません。どの原則にも、コードが増えるというコストがあります。単純な設計のほうが明快で、その原則が守ろうとする変更が起こりそうにないときは、意図的に破ってかまいません。

コーディング講座の一覧