Rubyのコードで考えるSOLID原則

Ruby
記事内に広告が含まれています。

はじめに

オブジェクト指向設計の基本原則である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原則を整理しました。
この記事が誰かの参考になれば嬉しいです!

最後までお読みいただきありがとうございました。

皆さんからのコメントやSNSでのシェア、嬉しい投稿をいただくと本当に励みになります。

もしこの記事が気に入ったら感想をコメントやSNSでシェアしていただけると嬉しいです。

皆さんの声を聞かせてくださいね!

Ruby
tetsuをフォローする
簿記はじめるってよ

コメント

タイトルとURLをコピーしました