はじめに
SOLIDは、一般にUncle Bobとして知られるロバート・C・マーティン(Robert C. Martin)によって提唱された、オブジェクト指向設計の5つの原則の頭字語(アクロニム)です。これらの原則は、ソフトウェア設計者、アーキテクト、エンジニア、開発者が、より柔軟でメンテナンスしやすく、拡張性の高いソフトウェアを作成できるようにすることを目的としています。これらの原則に従うことで、テスト、リファクタリング、再利用、拡張が容易なクラスを設計できます。
SOLIDという頭字語は、以下を表しています。
S – Single Responsibility Principle
O – Open-Closed Principle
L – Liskov Substitution Principle
I – Interface Segregation Principle
D – Dependency Inversion Principle
この記事では、より優れたコードを書くために各原則がどのように役立つかを理解するために、それぞれの原則を個別に説明します。さらに、クリーンコードアーキテクチャにおいて避けるべきことだけでなく、コーディングの過程でこれらをどのように適用できるかを示すために、各原則のコードスニペットを追加します。概念を実証するために、JetBrainsとオープンソースの貢献者によって開発されたKotlin programming言語を使用します。
単一責任の原則
単一責任の原則(SRP: Single Responsibility Principle)は、プログラム内のすべてのクラスまたはモジュールが、単一の明確に定義された責任を持つべきであるとするソフトウェア設計の原則です。これは、クラスを変更する理由は1つだけでなければならず、プログラムの機能の単一の部分に対してのみ責任を負うべきであることを意味します。
この原則をKotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
Kotlinの dataキーワードは、このクラスがデータクラスであることを示しています。これは、データを保持することを目的としており、複雑な動作を持たないことを意味します。このデータクラスを使用して、以下に示すようにユーザーを管理するための UserServiceクラスを作成できます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // データベースにユーザーを作成するコード } fun deleteUser(user: User) { // データベースからユーザーを削除するコード } fun updateUser(user: User) { // データベースのユーザーを更新するコード } fun getUser(id: Int): User { // データベースからユーザーを取得するコード } } |
この例では、 UserServiceクラスは、データベース内のユーザーを管理するという単一の責任を持っています。各ユーザーは、以前に共有された data class Userコードによって表されます。 UserServiceクラスのすべてのメソッドはこの責任に関連しているため、凝集度が高くなります。これにより、クラス内のすべてのメソッドが単一の明確に定義されたタスクに関連していることが明らかになり、クラスの理解とメンテナンスが容易になります。
単一責任の原則(SRP)に違反する UserServiceクラスのメソッドは、データベース内のユーザー管理というクラスの主な責任に関連していないメソッドになります。たとえば、次の UserServiceクラスのバリエーションを考えてみましょう。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // データベースにユーザーを作成するコード } fun deleteUser(user: User) { // データベースからユーザーを削除するコード } fun updateUser(user: User) { // データベース内のユーザーを更新するコード } fun getUser(id: Int): User { // データベースからユーザーを取得するコード } fun sendEmail(user: User, subject: String, message: String) { // ユーザーにメールを送信するコード } } |
この例では、 sendEmail メソッドは、データベース内のユーザーを管理するという UserService クラスの主な責務とは関係ありません。このメソッドはメールの送信を担当していますが、これはユーザーの管理とは別の関心事です。その結果、このメソッドは UserService クラスが変更される2つ目の理由を導入することになり、SRP(単一責任の原則)に違反します。
単一責任の原則を遵守するためには、メール送信機能を例えば EmailService クラスのような別のクラスに分離する方が良いでしょう。これにより、 UserService クラスはユーザー管理という主な責務に集中でき、 EmailService クラスはメール送信という責務に集中できるようになります。
単一責任の原則とは、クラスが持つメソッドの数に関するものではなく、メソッドの凝集度やクラス内における責任の明確な分離に関するものであることに注意してください。
オープン・クローズドの原則
オープン・クローズドの原則(OCP)とは、ソフトウェアの構成要素(クラス、モジュール、関数など)は拡張に対して開いており、修正に対して閉じているべきであるというソフトウェア設計の原則です。これは、既存のコードを変更することなく、クラスやモジュールに新しい機能を追加できるようにすべきであることを意味します。
前の例の UserService クラスを考えてみましょう。メールアドレスでユーザーを検索できるようにする新機能を UserService クラスに追加したいとします。これを行う1つの方法は、以下のコードで強調表示されているように、 UserService クラスに新しいメソッドを追加することです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // データベースにユーザーを作成するコード } fun deleteUser(user: User) { // データベースからユーザーを削除するコード } fun updateUser(user: User) { // データベース内のユーザーを更新するコード } fun getUser(id: Int): User { // データベースからユーザーを取得するコード } fun searchUsersByEmail(email: String): List<User> { // メールアドレスでユーザーを検索するコード } } |
このアプローチは機能しますが、新しい機能を追加するために既存の UserService クラスを変更する必要があったため、オープン・クローズドの原則に違反しています。より良いアプローチは、継承またはコンポジションを使用して、 UserService クラスのコードを変更せずにその機能を拡張することです。
これを実現するために、 UserSearchService という新しいクラスを作成し、 UserService クラスを拡張してメール検索機能を追加することができます。
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // メールアドレスでユーザーを検索するコード } } |
この例では、 UserSearchService クラスは、 UserService クラス。同時に、 UserService クラスは、メール検索機能を追加するためにコードを変更する必要がなかったため、変更に対して閉じたままです。
リスコフの置換原則
リスコフの置換原則(LSP)は、プログラムの正確性に影響を与えることなく、スーパークラスのオブジェクトをサブクラスのオブジェクトで置き換えられるべきであるとするソフトウェア設計の原則です。これは、サブクラスがそのスーパークラスの有効な代替となり、同じコンテキストで使用されたときにスーパークラスと同じように動作する必要があることを意味します。
引き続き、前の例の User と UserService クラスを使用してデモンストレーションを続けます。User クラスを拡張できるようにするために、Kotlin の open キーワードを使用します。
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
以下は、元の UserService クラスです。これは上記の User データクラスを使用しています:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // データベースにユーザーを作成するコード } fun deleteUser(user: User) { // データベースからユーザーを削除するコード } fun updateUser(user: User) { // データベースのユーザーを更新するコード } fun getUser(id: Int): User { // データベースからユーザーを取得するコード } } |
ここで、 User のサブクラスで、管理者権限を持つユーザーを表す AdminUser を作成したいとします。これは次のように行うことができます:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
この例では、 AdminUser クラスは User クラスの有効な代替です。なぜなら、 User クラスと同じように動作し、 User オブジェクトが期待される任意の場所で使用できるからです。例えば、次のように AdminUser クラスを UserService クラスと一緒に使用できます:
|
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
このコードは正しく動作します。なぜなら、 AdminUser クラスは User クラスの有効な代替であり、 User オブジェクトと同じように使用できるからです。
リスコフの置換原則は、単なる継承以上の意味を持つことに注意することが重要です。これは、サブクラスがどのように実装されているかに関わらず、サブクラスのオブジェクトがスーパークラスのオブジェクトと同じように動作することを保証することについて述べています。例えば、もし AdminUser クラスが何らかの方法で User クラスとは異なる動作をする場合、それは User クラスの有効な代替とはならず、リスコフの置換原則に違反することになります。
インターフェース分離の原則
インターフェース分離の原則(ISP)は、クライアントが使用しない interfacesに依存することを強制されるべきではないとするソフトウェア設計の原則です。これは、一般的に、1つのことをうまく行う、小さく焦点を絞った interfacesを作成する方が、多くのことを行おうとする大規模で汎用的な interfacesを作成するよりも良いアイデアであることを意味します。
以下は、前の例の User および UserService クラスを書き直すことで、この原則をどのように適用できるかを示す例です:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // データベースにユーザーを作成するコード } override fun deleteUser(user: User) { // データベースからユーザーを削除するコード } override fun updateUser(user: User) { // データベースのユーザーを更新するコード } override fun getUser(id: Int): User { // データベースからユーザーを取得するコード } } |
この例では、 UserService インターフェースは、データベース内のユーザー管理に関連する4つのメソッドを定義しています。 DatabaseUserService クラスはこのインターフェースを実装し、これらのメソッドの具体的な実装を提供します。
たとえば、新しい機能を UserService インターフェースに追加して、メールアドレスでユーザーを検索できるようにしたいとします。これを行う1つの方法は、新しいメソッドを UserService インターフェースに追加することです:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
コードを実行するには、 UserService インターフェースを実装するすべてのクラスで、このメソッドも実装する必要があります:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // データベースにユーザーを作成するコード } override fun deleteUser(user: User) { // データベースからユーザーを削除するコード } override fun updateUser(user: User) { // データベースのユーザーを更新するコード } override fun getUser(id: Int): User { // データベースからユーザーを取得するコード } override fun searchUsersByEmail(email: String): List<User> { // メールアドレスでユーザーを検索するコード } } |
このアプローチは機能しますが、インターフェース分離の原則に違反しています。なぜなら、 DatabaseUserService クラスに対して、自身が必要としない、または使用しない可能性のあるメソッド( searchUsersByEmail )の実装を強制することになるからです。
より良いアプローチは、メール検索機能用に別の インターフェース を作成することです:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
これで、分割された、小さく、目的が明確なインターフェース(すなわち、 UserService と UserSearchService)になります。単一の責任を持つものです。これらのインターフェースのすべての機能を必要とするクラスは、以下のコードスニペットに示すようにそれらを実装できます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // データベースにユーザーを作成するコード } override fun deleteUser(user: User) { // データベースからユーザーを削除するコード } override fun updateUser(user: User) { // データベースのユーザーを更新するコード } override fun getUser(id: Int): User { // データベースからユーザーを取得するコード } override fun searchUsersByEmail(email: String): List<User> { // メールアドレスでユーザーを検索するコード } } |
これはインターフェース分離の原則に準拠しています。なぜなら、クライアント(例えば DatabaseUserService クラス)が、使用しないインターフェースへの依存を強制されないようにするためです。
この概念をよりよく理解するために、別のクラスとして MemoryUserService があり、これが UserService インターフェースを実装しているものの、メール検索機能を必要としないと仮定します。その場合、コードは以下のように記述できます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
この例では、 MemoryUserService クラスは、 UserService インターフェースで定義されたメソッドのみを実装すればよく、メール検索機能について心配する必要はありません。これにより、 MemoryUserService クラスは、無関係な機能の実装を強制されることなく、メモリ内でのユーザー管理という本来の責任に集中することができます。
依存性逆転の原則
依存性逆転の原則(DIP)とは、上位モジュールは下位モジュールに依存すべきではなく、両者は抽象に依存すべきであるというソフトウェア設計の原則です。これは一般的に、上位コンポーネントが下位コンポーネントの具体的な実装に縛られるのではなく、さまざまな方法で実装可能な抽象(インターフェースや抽象クラスなど)に依存するようにソフトウェアを設計することが望ましいということを意味します。
この原則が、以前のコードスニペットで使用された User および UserService クラスにどのように適用できるか、例を見てみましょう。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
この例では、 UserService クラスは、 UserRepository インターフェースという抽象化に依存しており、ユーザーリポジトリの具体的な実装には依存していません。これにより、 UserRepository インターフェースをデータベースやインメモリストレージなど、さまざまな方法で実装できるようになり、 UserService クラスに影響を与えることがなくなります。
例えば、以下は UserRepository インターフェースをデータベースを使って実装した例です:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // データベースにユーザーを作成するコード } override fun delete(user: User) { // データベースからユーザーを削除するコード } override fun update(user: User) { // データベースのユーザーを更新するコード } override fun get(id: Int): User { // データベースからユーザーを取得するコード } } |
以下は、 UserRepository インターフェースをインメモリストレージを使って実装したもう一つの例です:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
これにより、システムの他の部分に影響を与えることなくリポジトリの実装を変更できるようになるため、システムがより柔軟でメンテナンスしやすくなります。また、テストで UserRepository の依存関係をモック化できるため、 UserService クラスのテストも容易になります。
まとめ
この記事では、 SOLID の5つの原則について説明しました。 各原則を満たすコードと共有コードスニペット。これらの原則に従うことで、より柔軟で、保守しやすく、拡張性の高いソフトウェアシステムを設計することができます。ただし、これらの原則は厳格なルールではなくガイドラインであり、特定のプロジェクトの文脈においていつどのように適用するかを決定するのは開発者次第であることを念頭に置くことが重要です。ブログをチェックして、より深く最新の記事や チュートリアル(クラウドコンピューティングやDevOps、ソフトウェアの設計と開発、注目すべきテクノロジートレンドなど)を読んで、学習を続けましょう。
Happy Coding!
コメント
コメントはまだありません。最初のコメントを投稿しましょう。