はじめに
私は普段Javaをメインに使っている2年目のエンジニアです。この記事ではSOLID原則について「ぼんやりわかるけど、説明しろと言われると…」という状態の人に向けたものになっています。サンプルコード(Java)を使って具体的な例も記述しているので、参考にしていただければと思います。
S: 単一責任の原則 (Single Responsibility Principle)
【概要】
SRPとして知られる単一責任の原則は、その名前が示すように各クラスやモジュールが持つべきは「1つの責任」だけであるという原則です。具体的には、変更の理由が1つだけであるべきということを意味します。これにより、将来の変更や拡張が容易になり、バグの導入リスクが低減します。
【メリット】
- 変更の影響範囲が限定的
- 1つの責任だけを持つクラスを変更すると、その影響はそのクラス内に限定されることが多い
- 再利用性の向上
- 特定の責任に特化したクラスは、他の場所で再利用しやすくなる
- テストの容易性
- 責任が1つだけのクラスはテストもシンプルになり、ユニットテストが書きやすくなる
【サンプルコード】
ユーザの情報を管理し、DBへの保存や画面への出力を行う場合を考えます。
単一責任の原則を遵守していない例
class User {
private String name;
// ... 他の属性やメソッド ...
public void saveToDatabase() {
// データベースにユーザー情報を保存
}
public void displayOnScreen() {
// 画面にユーザー情報を表示
}
}
上記のUserクラスは、ユーザー情報の管理だけでなく、データベースへの保存や画面表示の責任も持っています。これは単一責任の原則に違反していると言えます。
単一責任の原則を遵守している例
class User {
private String name;
// ... 他の属性やメソッド ...
}
class DatabaseManager {
public void saveUser(User user) {
// データベースにユーザー情報を保存
}
}
class DisplayManager {
public void displayUser(User user) {
// 画面にユーザー情報を表示
}
}
こちらの場合は、各クラスが1つの責任だけを持っています。Userはユーザー情報の管理、DatabaseManagerはデータベースへのアクセス、DisplayManagerは画面表示の責任を持っています。
O: オープン/クローズドの原則 (Open/Closed Principle)
【概要】
オープン/クローズドの原則は、ソフトウェアのクラス、モジュール、関数などは拡張に対して開かれていて(Open)、既存のコードの変更に対しては閉じられている(Closed)べきであるという原則です。これは、新しい機能や要件が追加されたときに、既存のコードを変更するのではなく、新しいコードを追加することでその機能や要件を実現することを意味します。
【メリット】
- 安定性
- 既存のコードを変更せずに新しい機能を追加できるため、既存の機能に影響を与えるリスクが低減する
- 再利用性
- 既存のコードが変更されないため、再利用が容易になる
- 拡張性
- 新しい要件や機能を追加する際の柔軟性が向上する
【サンプルコード】
図形の面積を扱うプログラムについて考えます。
オープン/クローズドの原則を遵守していない例
class Rectangle {
public double width;
public double height;
}
class AreaCalculator {
public double calculateArea(Rectangle[] rectangles) {
double totalArea = 0;
for (Rectangle rectangle : rectangles) {
totalArea += rectangle.width * rectangle.height;
}
return totalArea;
}
}
このコードは現在長方形のみ扱っています。ここに円の機能を追加するためには、新たにクラスを作成するのに加えてAreaCalculator
クラスも修正する必要があります。
オープン/クローズドの原則を遵守している例
interface Shape {
double calculateArea();
}
class Rectangle implements Shape {
public double width;
public double height;
@Override
public double calculateArea() {
return width * height;
}
}
class Circle implements Shape {
public double radius;
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
class AreaCalculator {
public double calculateArea(Shape[] shapes) {
double totalArea = 0;
for (Shape shape : shapes) {
totalArea += shape.calculateArea();
}
return totalArea;
}
}
Shape
インターフェースを作成し、それぞれの図形はこれを継承させることで、図形を追加する際はインターフェースの実装クラスを作成するだけでよくなりました。
L: リスコフの置換原則 (Liskov Substitution Principle)
【概要】
リスコフの置換原則は、オブジェクト指向プログラミングにおける継承の使用方法に関する原則です。具体的には、サブクラスのインスタンスがスーパークラスのインスタンスとして置き換えられる場面で、その置き換えによってプログラムの正当性が侵害されないようにするべきであるという原則です。
【メリット】
- 安定性
- サブクラスの振る舞いが予測可能になる
- 再利用性
- スーパークラスの代わりにサブクラスを安全に使用できるため、コードの再利用が容易になる
- 拡張性
- 新しいサブクラスを追加する際の柔軟性が向上する
【サンプルコード】
鳥の動作についてのプログラムです。Ostrich
(ダチョウ)は飛ぶことができない前提です。
リスコフの置換原則を遵守していない例
class Bird {
public void fly() {
System.out.println("Flying...");
}
}
class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Ostrich can't fly");
}
}
上記の例では、Ostrich
(ダチョウ)はBird
を継承していますが、flyメソッドをオーバーライドして例外をスローしています。これはリスコフの置換原則に違反しています。
リスコフの置換原則を遵守している例
interface Bird {
void move();
}
class Sparrow implements Bird {
@Override
public void move() {
System.out.println("Flying...");
}
}
class Ostrich implements Bird {
@Override
public void move() {
System.out.println("Walking...");
}
}
この改善例では、Bird
をインターフェースとして定義し、move
メソッドを持たせました。Sparrow
(すずめ)は飛ぶことができるので「Flying」と表示し、Ostrich
(ダチョウ)は飛ぶことができないので「Walking」と表示します。
I: インターフェース分離の原則 (Interface Segregation Principle)
【概要】
インターフェース分離の原則は、大きくて多機能なインターフェースよりも、特定のクライアントに特化した複数のインターフェースを持つ方が良いという原則です。言い換えれば、クラスは不要なインターフェースを持たないようにすべきということになります。これにより、クラスが使用しないメソッドを持つインターフェースを実装することを避けることができます。
【メリット】
- 柔軟性
- 必要なメソッドだけを持つインターフェースを実装することで、クラスの設計がシンプルになる
- 再利用性
- インターフェースが特定の目的に特化しているため、再利用が容易になる
- メンテナンス性
- インターフェースが小さく、特定の目的に特化しているため、変更や拡張が容易になる
【サンプルコード】
以下は労働者が行う動作を表すプログラムです。人とロボットの機能が実装されています。
インターフェース分離の原則を遵守していない例
interface Worker {
void work();
void eat();
}
class Human implements Worker {
@Override
public void work() {
System.out.println("Human is working");
}
@Override
public void eat() {
System.out.println("Human is eating");
}
}
class Robot implements Worker {
@Override
public void work() {
System.out.println("Robot is working");
}
@Override
public void eat() {
// ロボットは食べることはできないが、継承しているインターフェースで定義されているため、実装しなくてはならない
throw new UnsupportedOperationException("Robot can't eat");
}
}
Robot
クラスでは本来eat
メソッドは不要ですよね。インターフェースが分離されていないことにより、このような無駄な定義が発生しています。
インターフェース分離の原則を遵守している例
interface Workable {
void work();
}
interface Eatable {
void eat();
}
class Human implements Workable, Eatable {
@Override
public void work() {
System.out.println("Human is working");
}
@Override
public void eat() {
System.out.println("Human is eating");
}
}
class Robot implements Workable {
@Override
public void work() {
System.out.println("Robot is working");
}
}
インターフェースをWorkable
とEatable
に分割することで、Human
とRobot
に必要なものだけを定義する形にできました。
D: 依存関係逆転の原則 (Dependency Inversion Principle)
【概要】
依存関係逆転の原則は、高レベルのモジュール(ビジネスロジックを持つモジュールなど)が低レベルのモジュール(データアクセス層やユーティリティなど)に直接依存するのではなく、両者が抽象に依存すべきであるという原則です。具体的には、実装の詳細が抽象に依存するように設計すべきであり、逆ではないということです。
【メリット】
- 柔軟性
- 依存性が抽象に向けられることで、具体的な実装を容易に変更できる
- 再利用性
- 抽象に依存することで、特定の実装に縛られずにモジュールを再利用できる
- 分離性
- ビジネスロジックと実装の詳細が分離されるため、各モジュールのテストやメンテナンスが容易になる
【サンプルコード】
DBへの保存処理について考えます。
依存関係逆転の原則を遵守していない例
class MySQLDatabase {
public void save(String data) {
System.out.println("Saving data to MySQL database: " + data);
}
}
class DataSaver {
private MySQLDatabase database;
public DataSaver(MySQLDatabase database) {
this.database = database;
}
public void saveData(String data) {
database.save(data);
}
}
上の例では、DataSaver
クラスがMySQLDatabase
に直接依存してしまっています。「DBに保存する」という動作はアプリケーションにおいて多くの箇所で利用されることが想定されます。もしMySQLをPostgreSQLに変えたいとなった場合、影響範囲が非常に広くなってしまうのです。
依存関係逆転の原則を遵守している例
interface Database {
void save(String data);
}
class MySQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("Saving data to MySQL database: " + data);
}
}
class PostgreSQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("Saving data to PostgreSQL database: " + data);
}
}
class DataSaver {
private Database database;
public DataSaver(Database database) {
this.database = database;
}
public void saveData(String data) {
database.save(data);
}
}
こちらではDatabase
というインターフェースを作成し、DataSaver
はこれに依存するようになっています。実際の保存処理はMySQLDatabase
やPostgreSQLDatabase
が担当します。DataSaver
はDatabase
インターフェースのみに依存するため、具体的な実装の詳細を気にせずに使用でき、結果的に疎結合になります。
まとめ
調べる中で私が感じたことは、例のような小さいプログラムであれば「そりゃそうだろ」と思うことが多いですが、実際のソースでこれを見抜いて実現するのは難しいんだろうなということです。それぞれの原則を理解し意識しながらプログラムを読むことで、少しずつよくない設計の匂いを嗅ぎ分けられるように努力する必要がありそうですね。
最後まで読んでいただきありがとうございました。