SOLID 設計原則は、クリーンなコードを書くために知っておく必要がある最も重要な設計原則です。SOLID 原則をしっかりと理解することは、あらゆるプログラマーにとって不可欠なスキルです。これらは、他の設計パターンを開発するための基礎となります。
この記事では、実際の例を使用して SOLID 設計原則を取り上げ、その重要性を理解します。
多態性、抽象化、継承とともに、SOLID 原則は、目的指向プログラミングを習得する上で非常に重要です。
SOLID 原則は、次のような複数の理由で重要です。
それでは、実際の例を用いて、SOLID の原則をそれぞれ詳しく見ていきましょう。
SOLID 原則の S は、単一責任原則 (Single Responsibility Principle) を表します。単一責任原則では、クラスを変更する理由は 1 つだけであるべきであると規定されています。これにより、プロジェクトに追加の要件を組み込むときに変更が必要な場所の数が制限されます。
各クラスには変更する理由が 1 つだけ必要です。
たとえば、Java で銀行アプリケーションを設計しているとします。このアプリケーションには、debit、credit、 sendUpdates
などの基本的な操作が可能なSavingsAccount
クラスがありますsendUpdate
メソッドはNotificationMedium
(Email、SMS など) と呼ばれる列挙型を受け取り、適切なメディアで更新を送信します。そのためのコードを以下のように記述します。
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void sendNotification(NotificationMedium medium) { if (medium == NotificationMedium.SMS) { // Send SMS here } else if (medium == NotificationMedium.EMAIL) { // Send Email here } } }
public enum NotificationMedium { SMS, EMAIL }
さて、上記のSavingsAccount
クラスを見ると、複数の理由により変更される可能性があります。
SavingsAccount
クラスのコアロジック ( debit
、 credit
など) に変更がある場合。
銀行が新しい通知媒体(たとえば WhatsApp)を導入することを決定した場合。
これは、SOLID 原則の単一責任原則に違反しています。これを修正するには、通知を送信する別のクラスを作成します。
上記のコードをSOLID原則に従ってリファクタリングしてみましょう。
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void printBalance() { System.out.println("Name: " + name+ " Account Balance: " + balance); } public void sendNotification(Medium medium) { Sender.sendNotification(medium, this); } }
public enum NotificationMedium { SMS, EMAIL }
public class Sender { public static void sendNotification(NotificationMedium medium, SavingsAccount account) { // extract account data from the account object if (medium == NotificationMedium.SMS) { //logic to send SMS here } else if (medium == NotificationMedium.EMAIL) { // logic to send Email here } } }
コードをリファクタリングしたので、 NotificationMedium
やフォーマットに変更があった場合は、 Sender
クラスを変更します。ただし、 SavingsAccount
のコアロジックに変更があった場合は、 SavingsAccount
クラスに変更が加わります。
これにより、最初の例で確認された違反が修正されます。
オープン クローズ原則は、クラスを拡張 (追加機能の追加の場合) に対してオープンでありながら、変更に対してはクローズするように設計する必要があることを述べています。変更に対してクローズであることは、次の 2 つの点で役立ちます。
オープン クローズ原則の例を示すために、ショッピング カート (e コマース Web サイトに実装されているものなど) の例を見てみましょう。
Cart
というクラスを作成します。このクラスには、追加できるItem
のリストが含まれます。アイテムの種類と課税に応じて、 Cart
クラス内でカートの合計値を計算するメソッドを作成します。
クラスは拡張に対してはオープンで、変更に対してはクローズであるべきである
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart() { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { if (item.getItemType() == GIFT) { // 8% tax on gift + 2% gift wrap cost value += (item.getValue()*1.08) + item.getValue()*0.02 } else if (item.getItemType() == ItemType.ELECTRONIC_ITEM) { value += (item.getValue()*1.11); } else { value += item.getValue()*1.10; } } return value; } }
@Getter @Setter public abstract class Item { protected double price; private ItemType itemType; public double getValue() { return price; } }
public enum ItemType { ELECTRONIC, GIFT }
上記の例では、 calculateCartValue
メソッドはカート内のすべてのアイテムを反復処理し、アイテムの種類に基づいてロジックを呼び出すことでカートの値を計算します。
このコードは正しいように見えますが、SOLID 原則に違反しています。
カートの値を計算する際に、異なるタイプのアイテム (たとえば Grocery) に新しいルールを追加する必要があるとします。その場合、元のクラスCart
を変更し、その中にGrocery
タイプのアイテムをチェックする別のelse if
条件を記述する必要があります。
ただし、少しリファクタリングするだけで、コードをオープン/クローズ原則に準拠させることができます。その方法を見てみましょう。
まず、 Item
クラスを抽象化し、以下に示すように、さまざまな種類のItem
の具体的なクラスを作成します。
public abstract class Item { protected double price; public double getValue() { return price; } }
public class ElectronicItem extends Item { public ElectronicItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.11; } }
public class GiftItem extends Item { public GiftItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.08 + super.getValue()*0.02; } }
public class GroceryItem extends Item { public GroceryItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.03; } }
GroceryItem
、 GiftItem
、 ElectronicItem
の各具体的な Item クラス内では、課税と値の計算に関するビジネス ロジックを含むgetValue()
メソッドを実装します。
ここで、 Cart
クラスを抽象クラスItem
に依存させ、以下に示すように各アイテムに対してgetValue()
メソッドを呼び出します。
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } }
さて、このリファクタリングされたコードでは、新しいタイプのItem
が導入されても、 Cart
クラスは変更されません。ポリモーフィズムにより、 items
ArrayList
内のItem
の実際のタイプが何であっても、そのクラスのgetValue()
メソッドが呼び出されます。
リスコフの置換原則は、特定のコードでスーパークラスのオブジェクトを子クラスのオブジェクトに置き換えても、コードは壊れないというものです。つまり、サブクラスがスーパークラスを継承してそのメソッドをオーバーライドする場合、スーパークラスのメソッドの動作との一貫性が維持される必要があります。
たとえば、次のクラスVehicle
と 2 つのクラスCar
クラスおよびBicycle
クラスを作成します。ここで、Vehicle クラス内にstartEngine()
というメソッドを作成するとします。このメソッドはCar
クラスではオーバーライドできますが、 Bicycle
クラスではサポートされません。これは、 Bicycle
にはエンジンがないためです (以下のコード サンプルを参照)。
サブクラスは、メソッドをオーバーライドするときに、スーパークラスの動作との一貫性を維持する必要があります。
class Vehicle { public void startEngine() { // start engine of the vehicle } } class Car extends Vehicle { @Override public void startEngine() { // Start Engine } } class Bicycle extends Vehicle { @Override public void startEngine(){ throw new UnsupportedOperationException("Bicycle doesn't have engine"); } }
さて、vehicle 型のオブジェクトを期待し、 startEngine()
メソッドに依存するコードがあるとします。そのコードを呼び出すときに、 Vehicle
型のオブジェクトを渡す代わりにBicycle
オブジェクトを渡すと、コードに問題が発生します。startEngine startEngine()
メソッドが呼び出されると、 Bicycle
(s) クラスのメソッドが例外をスローするためです。これは、SOLID 原則 (Liskov の置換原則) に違反します。
この問題を解決するには、 MotorizedVehicle
とNonMotorizedVehicle
2つのクラスを作成し、 Car
MotorizedVehicle
クラスから継承し、 Bicycle
をNonMotorizedVehicle
から継承します。
class Vehicle { } class MotorizedVehicle extends Vehicle { public void startEngine() { // start engine here } } class Car extends MotorizedVehicle { @Override public void startEngine() { // Start Engine } } class NonMotorizedVehicle extends Vehicle { public void startRiding() { // Start without engine } } class Bicycle extends NonMotorizedVehicle { @Override public void startRiding(){ // Start riding without the engine. } }
SOLID 原則の「I」は、インターフェース分離原則を表します。
インターフェース分離の原則は、未使用のメソッドを実装クラスに強制する大きなインターフェースを持つのではなく、より小さなインターフェースを持ち、クラスを実装するべきであると述べています。この方法では、クラスは関連するメソッドのみを実装し、クリーンな状態を維持します。
インターフェースを 1 つの大きなインターフェースではなく、複数の小さなインターフェースに分割します。
例えば、Javaに組み込まれているCollectionsフレームワークを見てみましょう。Javaは他のデータ構造の中でも、 LinkedList
とArrayList
データ構造も提供しています。
ArrayList
クラスは、 Serializable
、 Cloneable
、 Iterable
、 Collection
、 List
、 RandomAccess
の各インターフェースを実装します。
LinkedList
クラスは、 Serializable
、 Cloneable
、 Iterable
、 Collection
、 Deque
、 List
、およびQueue
を実装します。
インターフェースがかなりたくさんありますね!
Java 開発者は、多数のインターフェースを持つ代わりに、 Serializable
、 Cloneable
、 Iterable
、 Collecton
、 List
、およびRandomAccess
を 1 つのインターフェース、つまりIList
インターフェースに組み合わせることができました。これで、 ArrayList
とLinkedList
クラスの両方がこの新しいIList
インターフェースを実装できるようになりました。
ただし、 LinkedList
ランダム アクセスをサポートしていないため、 RandomAccess
インターフェイスのメソッドを実装し、誰かがそれを呼び出そうとするとUnsupportedOperationException
がスローされる可能性があります。
ただし、これは SOLID 原則のインターフェイス分離原則に違反することになります。これは、必須ではないにもかかわらず、LinkedList クラスにRandomAccess
インターフェイス内のメソッドを実装するように「強制」することになるからです。
したがって、共通の動作に基づいてインターフェースを分割し、各クラスに 1 つの大きなインターフェースではなく多数のインターフェースを実装させる方が適切です。
依存性逆転の原則では、上位レベルのクラスは下位レベルのクラスに直接依存してはならないと規定されています。これにより、2 つのレベル間の密接な結合が生まれます。
その代わりに、下位クラスは上位クラスが依存するインターフェースを提供する必要があります。
クラスではなくインターフェースに依存する
たとえば、上で見たCart
の例を引き続き使用して、支払いオプションをいくつか追加して拡張してみましょう。 DebitCard
とPaypal
の 2 種類の支払いオプションがあると仮定します。ここで、 Cart
クラスで、カートの値を計算し、指定された支払い方法に基づいて支払いを開始するメソッドをplaceOrder
に追加します。
これを実現するには、 Cart
クラス内のフィールドとして 2 つの支払いオプションを追加することで、上記のCart
例に依存関係を追加できます。ただし、そうすると、 Cart
クラスがDebitCard
クラスおよびPaypal
クラスと密結合されます。
その代わりに、 Payment
インターフェースを作成し、 DebitCard
クラスとPaypal
クラスの両方にPayment
インターフェースを実装させます。これで、 Cart
クラスは、個々の支払いタイプではなく、 Payment
インターフェースに依存するようになります。これにより、クラスは疎結合のままになります。
以下のコードを参照してください。
public interface Payment { void doPayment(double amount); } public class PaypalPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate paypal payment } } public class DebitCardPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate payment via debit card } } import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; private Payment paymentOption; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); this.paymentOption = paymentOption; } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } public void placeOrder() { this.paymentOption.doPayment(calculateCartValue()); } }
オブジェクト指向プログラミング、GoFデザインパターン、低レベルデザインインタビューについてさらに詳しく知りたい場合は、私の高評価コース「オブジェクト指向プログラミング+Javaデザインパターン」をぜひチェックしてください。