コーディング · デザインパターン
SOLID原則を例でわかりやすく解説
結論
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)])); // 13L:リスコフの置換原則
コードが基底型で動作するなら、どのサブタイプを渡しても動作し続けなければなりません。サブタイプは、事前条件を強めたり、保証を弱めたり、基底型が投げない場面で例外を投げたりしてはいけません。
改善前:古典的な正方形と長方形の問題です。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.pdfD:依存性逆転の原則
上位のポリシーは、特定のデータベースやメールサービスのような下位の詳細に依存すべきではありません。両者は抽象に依存し、その抽象は通常、上位のモジュールが所有します。実際には、依存先を内部で作らず、外から渡します(依存性の注入)。
改善前:サービスが具体的なデータベースクライアントを自分で作るので、テストも切り替えもできません。
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, AdaSOLIDを適用すべきとき、しないとき
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原則を破るのは悪いことですか?
必ずしもそうではありません。どの原則にも、コードが増えるというコストがあります。単純な設計のほうが明快で、その原則が守ろうとする変更が起こりそうにないときは、意図的に破ってかまいません。