Rubyで学ぶSOLID設計原則:変更に強いコードを書くための5つの原則
すべてのソフトウェアは時間とともに変化します。そして、その変更は予期しない連鎖的な問題を引き起こすことがあります。しかし、変化しないソフトウェアを作ることはできないため、変更そのものは避けられません。ソフトウェアが成長するにつれて要件も変わり続けるからです。私たちにできるのは、変更に強い(レジリエントな)設計をすることです。適切な設計には最初に時間と労力がかかりますが、長期的には多大な時間と手間を節約してくれます。密結合なソフトウェアは壊れやすく、変更によって何が起きるかを予測できません。
設計の悪いソフトウェアには、次のような悪影響があります。
- コードが移動・再利用できない(不動性)。
- コードの変更コストが高くなる。
- シンプルにするよりも、複雑さを追加する方が簡単になってしまう。
- コードが管理不能になる。
- 開発者が機能の仕組みを理解するのに多くの時間を要する。
- 一部を変更すると他の部分が壊れることが多く、変更がどんな問題をもたらすか予測できない。
Robert C. Martinの論文「Design Principles and Design Patterns」では、腐敗したソフトウェアの症状として以下が挙げられています。
- 硬直性(Rigidity):ある部分を変更すると他の部分も変更する必要が生じ、問題を起こさずにコードを変更することが非常に難しい。
- 脆さ(Fragility):コードを変更するとソフトウェアの動作が壊れやすく、変更と直接関係ない部分まで壊れることさえある。
- 不動性(Immobility):似た動作をする部分があるにもかかわらず、コードを再利用できず、複製せざるを得ない。
- 粘着性(Viscosity):ソフトウェアの変更が難しいため、改善する代わりに複雑さを追加し続けてしまう。
こうした問題を避けるには、変更を制御可能で予測できるものにするような設計が必要です。
SOLID設計原則は、ソフトウェアを疎結合にすることでこれらの問題を解決する助けになります。この概念はRobert C. Martinが論文「Design Principles and Design Patterns」で提唱し、頭字語「SOLID」はMichael Feathersによって考案されました。
SOLIDは次の5つの原則で構成されています。
- Single Responsibility Principle(単一責任の原則)
- Open/Closed Principle(開放閉鎖の原則)
- Liskov Substitution Principle(リスコフの置換原則)
- Interface Segregation Principle(インターフェース分離の原則)
- Dependency Inversion Principle(依存性逆転の原則)
以降、それぞれの原則がRubyでの優れたソフトウェア設計にどのように役立つのかを見ていきましょう。
単一責任の原則(SRP)
人事管理ソフトウェアで、「ユーザーの作成」「従業員の給与の登録」「給与明細の生成」という機能が必要だとしましょう。構築する際、これらの機能を1つのクラスにまとめることもできますが、このアプローチは機能同士に不要な依存関係を生み出します。始めはシンプルでも、要件が変わったときに、その変更がどの機能を壊すのか予測できなくなります。
クラスが変更される理由は、ひとつでなければならない ― Robert C. Martin
すべての機能が1つのクラスに入っているサンプルコードを見てみましょう。
class User
def initialize(employee, month)
@employee = employee
@month = month
end
def generate_payslip
# データベースから読み込み、
# 給与明細を生成し、
# ファイルに書き出すコード
self.send_email
end
def send_email
# メール送信のコード
employee.email
month
end
end
給与明細を生成してユーザーに送信するには、クラスを初期化してgenerate_payslipメソッドを呼び出します。
month = 11
user = User.new(employee, month)
user.generate_payslip
ここで新しい要件が発生しました。給与明細は生成したいが、メールは送信したくないというケースです。既存の機能はそのまま維持しつつ、メール送信なしの社内報告用の給与明細生成機能を追加する必要があります。この際、従業員に送信される既存の給与明細機能が壊れないことも保証しなければなりません。
この要件に対して、既存のコードは再利用できません。generate_payslipメソッドにフラグを追加し、「trueならメールを送る、そうでなければ送らない」という対応も考えられますが、既存コードを変更するため、既存機能を壊す恐れがあります。
壊れるリスクを避けるには、これらのロジックを別々のクラスに分離(デカップリング)します。
class PayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate_payslip
# データベースから読み込み、
# 給与明細を生成し、
# ファイルに書き出すコード
end
end
class PayslipMailer
def initialize(employee)
@employee = employee
end
def send_mail
# メール送信のコード
employee.email
month
end
end
次に、この2つのクラスを初期化してメソッドを呼び出します。
month = 11
# 一般的な給与明細の生成
generator = PayslipGenerator.new(employee, month)
generator.generate_payslip
# メール送信
mailer = PayslipMailer.new(employee, month)
mailer.send_mail
このアプローチにより責務が分離され、変更が予測可能になります。メール送信機能だけを変更したい場合、明細生成ロジックに触れることなく対応できます。機能変更の影響範囲も予測しやすくなります。
たとえば、メール内の月の表示形式を11からNovに変更したい場合、PayslipMailerクラスだけを修正すればよく、PayslipGeneratorの機能には何も影響しません。
コードを書くたびに、自問してみてください。「このクラスの責務は何か?」答えに「〜と〜」という接続詞が入るようなら、クラスを複数に分割しましょう。小さなクラスは、大きく汎用的なクラスよりも常に優れています。
開放閉鎖の原則(OCP)
開放閉鎖の原則は、Bertrand Meyerが著書『Object-Oriented Software Construction』の中で提唱したものです。
この原則は「ソフトウェアのエンティティ(クラス、モジュール、関数など)は拡張に対して開かれているべきであり、修正に対して閉じているべきである」と述べています。つまり、エンティティ自体を変更することなく、振る舞いを変更できるようにすべきだということです。
先ほどの例では、従業員向けの給与明細送信機能がありましたが、これは全従業員に対して非常に汎用的なものでした。ここで新しい要件として、「従業員の種別に応じた給与明細の生成」が求められました。正社員と業務委託者(コントラクター)では異なる給与計算ロジックが必要です。この場合、既存のPayslipGeneratorを修正して機能を追加できます。
class PayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate_payslip
# データベースから読み込み、
# 給与明細を生成するコード
if employee.contractor?
# 業務委託者用の給与明細を生成
else
# 通常の給与明細を生成
end
# ファイルに書き出すコード
end
end
しかし、これは悪いパターンです。既存のクラスを修正しているためです。今後、雇用契約の種類に応じてさらに生成ロジックを追加するたびに、既存クラスの修正が必要になり、開放閉鎖の原則に違反します。クラスを修正すると、意図しない変更が発生するリスクがあります。何かを変更・追加するたびに、既存コードに未知の問題が生じる可能性があるのです。しかも、このようなif-elseは同じクラス内の複数箇所に散らばりがちです。新しい従業員タイプを追加するとき、if-elseが存在する箇所を見逃すかもしれません。それらをすべて見つけて修正するのはリスクが高く、問題を引き起こしかねません。
そこで、エンティティを変更せずに機能を拡張できるようにリファクタリングします。それぞれ専用のクラスを作り、共通のgenerateメソッドを持たせましょう。
class ContractorPayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate
# データベースから読み込み、
# 給与明細を生成し、
# ファイルに書き出すコード
end
end
class FullTimePayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate
# データベースから読み込み、
# 給与明細を生成し、
# ファイルに書き出すコード
end
end
必ず同じメソッド名を持たせます。次に、PayslipGeneratorクラスをこれらのクラスを使うように変更します。
GENERATORS = {
'full_time' => FullTimePayslipGenerator,
'contractor' => ContractorPayslipGenerator
}
class PayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate_payslip
# データベースから読み込み、
# 給与明細を生成するコード
GENERATORS[employee.type].new(employee, month).generate()
# ファイルに書き出すコード
end
end
ここでは、従業員タイプに応じて呼び出すクラスをマッピングするGENERATORS定数を用意しています。これにより、どのクラスを呼び出すべきかを判定できます。新しい機能を追加する場合は、新しいクラスを作成してGENERATORS定数に追加するだけです。既存ロジックを壊したり意識したりすることなくクラスを拡張でき、任意の種類の給与明細ジェネレーターを簡単に追加・削除できます。
リスコフの置換原則(LSP)
リスコフの置換原則は、「SがTのサブタイプであるならば、型Tのオブジェクトは型Sのオブジェクトで置き換え可能であるべきである」と述べています。
この原則を理解するために、まず問題点を整理しましょう。開放閉鎖の原則に基づいて、拡張可能なようにソフトウェアを設計しました。特定の役割を持つサブクラスの給与明細ジェネレーターを作成し、呼び出し側から見れば、実際にどのクラスが呼ばれているかは不明です。これらのクラスは同じ振る舞いを持つ必要があり、呼び出し側が違いを感じられないようにしなければなりません。ここでいう振る舞いとは、クラス内のメソッドの一貫性のことです。これらのクラスのメソッドは次の特徴を満たす必要があります。
- 同じ名前であること
- 同じデータ型の引数を同じ数だけ受け取ること
- 同じデータ型を返すこと
給与明細ジェネレーターの例で見てみましょう。正社員用と業務委託者用の2つのジェネレーターがあります。これらの給与明細処理の一貫性を保証するには、基底クラスを継承させる必要があります。ここではUserという基底クラスを定義します。
class User
def generate
end
end
開放閉鎖の原則の例で作成したサブクラスには基底クラスがありませんでした。これをUserを基底クラスとするように修正します。
class ContractorPayslipGenerator < User
def generate
# 給与明細を生成するコード
end
end
class FullTimePayslipGenerator < User
def generate
# 給与明細を生成するコード
end
end
次に、Userクラスを継承するすべてのサブクラスに必要なメソッド群を定義します。これらのメソッドは基底クラスに定義します。今回のケースでは、必要なのはgenerateという1つのメソッドだけです。
class User
def generate
raise "NotImplemented"
end
end
ここではraise文を含むgenerateメソッドを定義しました。これにより、基底クラスを継承するすべてのサブクラスはgenerateメソッドを持たなければなりません。実装されていない場合はエラーが発生します。こうすることでサブクラスの一貫性を保証でき、呼び出し側は常にgenerateメソッドが存在することを確信できます。
この原則により、多くの変更を加えることなく、壊れる心配なくサブクラスを容易に置き換えられるようになります。
インターフェース分離の原則(ISP)
インターフェース分離の原則は静的型付け言語に適用される原則であり、Rubyは動的言語であるためインターフェースという概念自体がありません。インターフェースはクラス間の抽象化ルールを定義するものです。
この原則は次のように述べています。
クライアントは、自分が使わないインターフェースへの依存を強制されてはならない ― Robert C. Martin
つまり、あらゆるクラスが使える汎用的なインターフェース1つよりも、多数の専用インターフェースに分けた方が良いということです。汎用的なインターフェースを定義すると、クラスは自分が使わない定義に依存することになります。
Rubyにはインターフェースがありませんが、クラスとサブクラスの概念を使って似た仕組みを作ってみましょう。
リスコフの置換原則の例では、サブクラスFullTimePayslipGeneratorが汎用クラスUserを継承していました。しかしUserは非常に汎用的なクラスで、他のメソッドも含む可能性があります。もしLeave(休暇管理)のような別の機能が必要になった場合、それもUserのサブクラスにしなければなりません。Leaveにgenerateメソッドは不要なのに、このメソッドに依存することになってしまいます。そこで、汎用クラスではなく、目的に特化したクラスを用意します。
class Generator
def generate
raise "NotImplemented"
end
end
class ContractorPayslipGenerator < Generator
def generate
# 給与明細を生成するコード
end
end
class FullTimePayslipGenerator < Generator
def generate
# 給与明細を生成するコード
end
end
このGeneratorは給与明細の生成に特化しており、サブクラスは汎用的なUserクラスに依存する必要がなくなりました。
依存性逆転の原則(DIP)
依存性逆転は、ソフトウェアモジュールを疎結合にするための原則です。
上位レベルのモジュールは下位レベルのモジュールに依存してはならない。どちらも抽象に依存すべきである。
ここまで説明してきた原則を使った設計は、自然と依存性逆転の原則へと導いてくれます。単一責任を持つクラスであっても、動作のためには他のクラスの機能が必要です。給与計算を生成するにはデータベースへのアクセスが必要で、レポート生成後はファイルへの書き出しが必要です。単一責任の原則では1つのクラスに1つの役割のみを持たせようとしますが、データベースからの読み込みやファイルへの書き出しといった処理は、同じクラス内で行われる必要があります。
重要なのは、これらの依存を取り除き、主要なビジネスロジックを疎結合にすることです。そうすることでコードは変更に柔軟になり、変更が予測可能になります。依存関係を逆転させ、モジュールの呼び出し側が依存をコントロールできるようにするのです。給与明細ジェネレーターの場合、依存関係とはレポートのデータソースのことです。呼び出し側がデータソースを指定できるようにコードを整理すべきです。依存のコントロールを逆転させれば、呼び出し側が簡単に変更できるようになります。
先ほどの例では、どこからデータを読み込み、どう出力を保存するかがContractorPayslipGeneratorモジュール自身によって制御されていました。これを逆転させるため、ユーザーデータを読み込むUserReaderクラスを作成します。
class UserReader
def get
raise "NotImplemented"
end
end
Postgresからデータを読み込みたい場合は、そのためのUserReaderのサブクラスを作成します。
class PostgresUserReader < UserReader
def get
# Postgresからデータを読み込むコード
end
end
同様に、FileUserReaderやInMemoryUserReaderなど、任意の種類のリーダーを用意できます。次に、FullTimePayslipGeneratorクラスを修正し、PostgresUserReaderを依存として受け取るようにします。
class FullTimePayslipGenerator < Generator
def initialize(datasource)
@datasource = datasource
end
def generate
# 給与明細を生成するコード
data = datasource.get()
end
end
呼び出し側はPostgresUserReaderを依存として渡せるようになりました。
datasource = PostgresUserReader.new()
FullTimePayslipGenerator.new(datasource)
呼び出し側が依存をコントロールでき、必要に応じてデータソースを簡単に変更できます。
依存性の逆転はクラスだけでなく、設定にも適用する必要があります。たとえばPostgresサーバーに接続する際には、DBURL、ユーザー名、パスワードなどの固有の設定が必要です。これらの設定をクラス内にハードコードするのではなく、呼び出し側から渡すようにしましょう。
class PostgresUserReader < UserReader
def initialize(config)
config = config
end
def get
# 設定を使ってDBを初期化
self.config
# Postgresからデータを読み込むコード
end
end
呼び出し側からconfigを渡します。
config = { url: "url", user: "user" }
datasource = PostgresUserReader.new(config)
FullTimePayslipGenerator.new(datasource)
これで呼び出し側が依存を完全にコントロールでき、変更管理が容易で苦痛の少ないものになります。
まとめ
SOLID設計はコードを疎結合にし、変更の痛みを軽減してくれます。疎結合で再利用可能な、変化に応答できるプログラムを設計することが重要です。SOLIDの5つの原則は互いに補完し合っており、共存させるべきものです。よく設計されたコードベースは柔軟で、変更が容易で、作業していて楽しいものです。新しい開発者でもすぐに参加し、コードを容易に理解できます。
SOLIDがどんな種類の問題を解決するのか、なぜこれを行うのかを理解することが非常に重要です。問題を理解することで、設計原則を受け入れ、より良いソフトウェアを設計できるようになるのです。
-
コーディング不要!初心者からプロまで使える無料ゲーム制作ソフトおすすめ9選
かつてゲーム開発者を目指すなら、JAVA、ActionScript、C++といった複数のプログラミング言語を習得することが必須でした。これらの言語は2D・3Dゲームの開発に欠かせないものだったからです。しかし現在では、コーディング不要で本格的なゲーム制作ができるソフトウェアが数多く登場しています。 ツールの進化のおかげで、専門教育を受けていなくてもゲーム業界への扉を開ける時代になりました。最新のゲーム制作ソフトはシンプルで直感的なインターフェースを備え、グラフィックエディタ、イベントシステム、レベルエディタ、特殊効果ツールなどを一体化しているため、開発の手間を大幅に削減できます。 優れたゲーム
-
2022年版 おすすめブックデザインソフト10選【無料・有料】
ブックデザインソフトは、作家や著者がオンラインで書籍を作成するために活用できるツールです。多くのアプリケーションは、初心者からプロフェッショナルまで幅広く利用できます。この記事では、表紙や内ページのデザイン、そしてデジタル形式での保存が可能な有料・無料のブックデザインソフトを厳選してご紹介します。自分用あるいは他者向けに書籍を作りたい方にとって、ソフト選びの悩みはここで解決できるはずです。紹介するすべての製品には、実用的な機能が多数搭載されています。 おすすめブックデザインソフト一覧 1. Adobe InDesign まず最初にご紹介するのは、多用途ツールとして定評のあるAdobe InD