はじめに
オブジェクト指向設計の基本原則であるSOLID原則を、Rubyのコード例を交えつつ、違反例と解決策を示しながらまとめました。
SOLID原則とは
SOLID原則は、「変更に強い」・「理解しやすい」・「再利用しやすい」といった性質を持つソフトウェア設計を目指すための以下5つの原則から構成されます。
- 単一責任の原則(SRP: Single Responsibility Principle)
- オープン・クローズドの原則(OCP: Open-Closed Principle)
- リスコフの置換原則(LSP: Liskov Substitution Principle)
- インターフェース分離の原則(ISP: Interface Segregation Principle)
- 依存性逆転の原則(DIP: Dependency Inversion Principle)
単一責任の原則(SRP)
「モジュールはたったひとつのアクターに対して責務を負うべきである」という原則です。
ここでのモジュールとは、クラスやコンポーネントなどを指します。
また、アクターとはシステムの変更を望むユーザーやステークホルダーなどの人たちをひとまとめにしたグループを指します。
違反例
給与システムにおけるEmployeeクラスがあり、経理部門と人事部門という異なるアクターからの変更を受ける構造になっています。
calculate_payは経理部門の要求によって変更される可能性があります。
report_hoursは人事部門の要求によって変更される可能性があります。
regular_hoursは両方から利用されているため、片方の変更がもう片方に影響を与えてしまう可能性があります。
たとえば、経理部門の要請でregular_hoursの算出方法を変更した場合、人事部門が想定していた労働時間の計算ロジックまで変わってしまう恐れがあります。
class Employee
def initialize(name)
@name = name
end
# 給与を計算
def calculate_pay
# 時給
hourly_wage = 1500
pay = hourly_wage * regular_hours
puts "#{@name}さんの給与は#{pay}です"
end
# 労働時間を報告
def report_hours
puts "#{@name}さんの労働時間は#{regular_hours}です"
end
private
# 労働時間を算出
def regular_hours
# 省略
end
end
# 使用例
employee = Employee.new("山田太郎")
employee.calculate_pay
employee.report_hours
解決策
経理部門が使用するメソッドと、人事部門が使用するメソッドを別のクラスに分けます。
class Employee
attr_reader :name
def initialize(name)
@name = name
end
end
class PayCalculator
def initialize(employee)
@employee = employee
end
# 給与を計算
def calculate_pay
# 時給
hourly_wage = 1500
pay = hourly_wage * regular_hours
puts "#{@employee.name}さんの給与は#{pay}です"
end
private
# 労働時間を算出(経理部門の算出方法)
def regular_hours
# 省略
end
end
class HourReporter
def initialize(employee)
@employee = employee
end
# 労働時間を報告
def report_hours
puts "#{@employee.name}さんの労働時間は#{regular_hours}です"
end
private
# 労働時間を算出(人事部門の算出方法)
def regular_hours
# 省略
end
end
# 使用例
employee = Employee.new("山田太郎")
pay_calculator = PayCalculator.new(employee)
pay_calculator.calculate_pay
hour_reporter = HourReporter.new(employee)
hour_reporter.report_hours
オープン・クローズドの原則(OCP)
「ソフトウェアの振る舞いは、既存の成果物を変更せず拡張できるようにすべきである」という原則です。
これにより、ソフトウェアは拡張には開かれていながら、修正に対して閉じている状態を維持できます。
違反例
以下のDocumentクラスは、新しいレンダラー(例: pdf)を追加するたびにcase文を修正する必要があるため、「拡張に対して開かれているが、修正に対して閉じていない」状態になっています。
これはオープン・クローズドの原則に違反しています。
class Document
def initialize(renderer)
@renderer = renderer
end
def render
case @renderer
when :screen
puts "画面表示"
when :print
puts "印刷"
end
end
end
# 使用例
document1 = Document.new(:screen)
document1.render
document2 = Document.new(:print)
document2.render
解決策
Rendererクラスを基底クラス(またはインターフェース)にして、各Rendererクラスの振る舞いを定義します。
Documentクラスはrenderメソッドを呼ぶだけで、新しいレンダラー(例: PdfRenderer)が追加されても変更不要になります。
つまり、拡張(新しいレンダラー追加)に対して開かれており、修正(既存コード変更)に対して閉じているため、オープン・クローズドの原則を満たしています。
class Document
def initialize(renderer)
@renderer = renderer
end
def render
@renderer.render
end
end
class Renderer
def render
raise NotImplementedError
end
end
class ScreenRenderer < Renderer
def render
puts "画面表示"
end
end
class PrintRenderer < Renderer
def render
puts "印刷"
end
end
# 使用例
document1 = Document.new(ScreenRenderer.new)
document1.render
document2 = Document.new(PrintRenderer.new)
document2.render
リスコフの置換原則(LSP)
「派生型(サブクラス)は上位型(スーパークラス)と置換可能でなければならない」という原則です。
つまり、サブクラスをスーパークラスの代わりとして置き換えても、期待される振る舞いが維持される設計にすべきということを意味します。
違反例
以下のコードでは、Square(正方形)クラスがRectangle(長方形)クラスを継承してwidth=とheight=をオーバーライドしています。
Squareではwidthとheightを個別に変更できないため、Rectangleの期待される振る舞いが壊れ、リスコフの置換原則に違反します。
class Rectangle
attr_accessor :height, :width
def area
height * width
end
end
class Square < Rectangle
def height=(height)
super(height)
@width = height
end
def width=(width)
super(width)
@height = width
end
end
# 使用例
rectangle = Rectangle.new
rectangle.height = 10
rectangle.width = 5
rectangle.area # 50(期待通り)
square = Square.new
square.height = 10
square.width = 5
square.area # 25(本来期待していた 10 * 5 = 50 にならない)
解決策
RectangleクラスをSquareクラスのスーパークラスにせず、共通のShapeクラスを作成し、それぞれのクラスを独立させることで、違反しないようにします。
これにより、Shape型のオブジェクトとしてRectangleやSquareを扱う場合でも、それぞれのareaメソッドが適切に動作し、期待される振る舞いが維持されるため、リスコフの置換原則を満たします。
class Shape
def area
raise NotImplementedError
end
end
class Rectangle < Shape
def initialize(height, width)
@height = height
@width = width
end
def area
@height * @width
end
end
class Square < Shape
def initialize(side)
@side = side
end
def area
@side * @side
end
end
# 使用例
rectangle = Rectangle.new(5, 10)
rectangle.area # 50
square = Square.new(5)
square.area # 25
インターフェース分離の原則(ISP)
「クライアントは、利用しないメソッドへの依存を強制されるべきではない」という原則です。
つまり、不要なメソッドを含む大きなインターフェースを提供するのではなく、必要なメソッドだけを持つ小さなインターフェースに分割するべきという考え方です。
なお、RubyにはJavaやTypeScriptのようなインターフェースという概念がなく、「このメソッドを必ず実装しなければならない」といった制約をクラスに強制しません。そのため、不要なメソッドを含む大きなインターフェースが問題になることは基本的にありません。
しかし、Rubyでもクラスの責務を適切に分けることは大切です。そこで、インターフェース分離の原則の考え方に反するケースを考えてみます。
違反例
以下のコードでは、UserActionableモジュールが「投稿する(post_content)」と「ユーザー管理をする(manage_users)」の両方の機能を提供しています。
しかし、通常のユーザー(RegularUser)はmanage_usersを必要とせず、管理者(AdminUser)はpost_content を必要としません。それにもかかわらず、モジュールをインクルードすることで、どちらのクラスも不要なメソッドを持つことになり、インターフェース分離の原則に違反しています。
module UserActionable
def post_content
raise NotImplementedError
end
def manage_users
raise NotImplementedError
end
end
class RegularUser
include UserActionable
def post_content
puts "コンテンツを投稿しました"
end
def manage_users
raise "一般ユーザーはユーザー管理できません"
end
end
class AdminUser
include UserActionable
def post_content
raise "管理者はコンテンツを投稿しません"
end
def manage_users
puts "ユーザーを管理しました"
end
end
# 使用例
regular_user = RegularUser.new
regular_user.post_content
regular_user.manage_users
admin_user = AdminUser.new
admin_user.manage_users
admin_user.post_content
解決策
UserActionableモジュールを「投稿する(Postable)」と「ユーザー管理をする(UserManageable)」に分けることで、不要なメソッドを持たずに済み、必要な責務だけを持つ設計になります。
module Postable
def post_content
raise NotImplementedError
end
end
module UserManageable
def manage_users
raise NotImplementedError
end
end
class RegularUser
include Postable
def post_content
puts "コンテンツを投稿しました"
end
end
class AdminUser
include UserManageable
def manage_users
puts "ユーザーを管理しました"
end
end
# 使用例
regular_user = RegularUser.new
regular_user.post_content
admin_user = AdminUser.new
admin_user.manage_users
依存性逆転の原則(DIP)
以下の2つの要点を持つ原則です。
- 上位モジュール(ビジネスロジックを持つ部分)はいかなるものも下位モジュール(具体的な実装)から持ち込んではならない。双方とも抽象(インターフェースなど)に依存するべきである
- 抽象は詳細に依存してはならない。詳細(具体的な実装)が抽象に依存するべきである
違反例
以下のコードでは、OrderServiceクラス(上位モジュール)がEmailNotifierクラス(下位モジュール)に直接依存しています。
この設計では、OrderServiceに新しい通知手段(例: SlackNotifier)を追加したい場合に、コードを変更する必要があり、変更に弱い設計になっています。
class EmailNotifier
def send_notification(message)
puts "Email通知: #{message}"
end
end
class OrderService
def initialize
@notifier = EmailNotifier.new
end
def process_order
puts "注文を処理しました"
@notifier.send_notification("注文が完了しました")
end
end
# 使用例
order_service = OrderService.new
order_service.process_order
解決策
Notifiableという抽象的な役割を持つモジュールを作成し、OrderServiceはNotifiableに依存するようにします。
この設計にすることで、OrderServiceは特定の通知手段に依存せずに通知処理を実行できるようになります。
module Notifiable
def send_notification(message)
raise NotImplementedError
end
end
class EmailNotifier
include Notifiable
def send_notification(message)
puts "Email通知: #{message}"
end
end
class OrderService
def initialize(notifiable)
@notifiable = notifiable
end
def process_order
puts "注文を処理しました"
@notifiable.send_notification("注文が完了しました")
end
end
# 使用例
email_notifier = EmailNotifier.new
order_service = OrderService.new(email_notifier)
order_service.process_order
さいごに
クリーンアーキテクチャなどを学ぶ中で、オブジェクト指向の基礎理解の大切さを改めて感じ、Rubyを使ってSOLID原則を整理しました。
この記事が誰かの参考になれば嬉しいです!