Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby:継承を使って良い感じにコードを共通するメモ

オブジェクト志向設計実践ガイドを読んで継承を使ったソースコードの共有化手法を学んだので内容を整理してみましたφ(..)

はじめに

今回は、下記のような社員を表すクラスのコードを元に実際に継承によるコード共有を行ってみようと思いますφ(..)

class Staff
  attr_reader :name, :group
  
  def initialize(args)
    @name  = args[:name]
    @group = args[:group]
  end
  
  def work
    do_preparation
    do_clean_up
  end
# ...
end

社員だけでなく管理者も追加して、独自のプロパティとメソッドを定義したいんだけど。。。

あまり考えたくないですが、このような仕様変更は結構ありそうですね・・・!! このような要求に関してどのようにコードを修正しますか??
ぱっと思いつくのはgradeを追加して、その値によって振る舞いを定義する方法でしょうか?

class Staff
  attr_reader :name, :group, :grade, :assistant
  
  def initialize(args)
    @name  = args[:name]
    @group = args[:group]
    @grade = args[:grade]
    # 管理者の場合、assistantを持つ
    @assistant = args[:assistant]
  end
  
  def work
    def do_preparation
    if grade == :admin
      # 管理者の場合はチェックを行う
      do_check
    else 
      do_clean_up
    end
  end
# ...
end

一見良さそうに見えますが、このコードは下記のような問題を含んでいます。

  • 新しいgradeが追加された場合、workにif文が追加されメンテナンス負荷が高い。
  • 新しいgradeが追加される度に、プロパティが追加され、使用するプロパティと使用しないプロパティが混在する恐れがある。

クラスは原則として単一の役割を持つ(単一責任)であることが良いと言われています。しかし今回の改修では、Staffクラスが純粋なスタッフとしての定義と管理者としての定義という2つの役割を持ってしまっているのが原因のようですね。。。

継承とはなんぞや

継承を使ってコードを整理する前に、そもそも継承とは何なのかを少しおさらいしましょう。

継承とは、根本的に「メッセージの自動移譲」の仕組みにほかなりません。
オブジェクト指向実践ガイドより抜粋

継承とはスーパークラス(継承元クラス)からサブクラス(継承先クラス)へ、プロパティやメソッドを共有化する仕組みです。

サブクラスでは、スーパークラスのメソッドとプロパティが使えるだけでなく、スーパークラスへ定義したメソッドの上書き(オーバーライド)及び、独自のメソッドも定義することも出来ます!!

イメージとしては師匠(スーパークラス)から弟子(サブクラス)へ、礼儀や技を受け継ぎ、弟子は師匠から受け継いだ技だけでなく、独自に編み出した技も使えるイメージでしょうか(._.)

継承を使ってコードを整理してみる

では本題の継承を使ってコードを整理してみますφ(..)
現状のコードは、Staffクラスの中に一般社員と管理者、2つの役割が存在してしまっているために煩雑になってしまっていましたね。

より抽象的なクラスを作成し、継承させてみる。

まずは、一般社員や管理者よりも抽象的な労働者Employeeクラスを作成してみましょう。その後、管理者Managerのクラスを作成し、労働者クラスを管理者や一般社員に継承させてみましょう。

class Employee
end

class Staff < Employee
  attr_reader :name, :group, :grade, :assistant
  
  def initialize(args)
    @name  = args[:name]
    @group = args[:group]
    @grade = args[:grade]
    # 管理者の場合、assistantを持つ
    @assistant = args[:assistant]
  end
  
  def work
    do_preparation
    if grade == :admin
      # 管理者の場合はチェックを行う
      do_check
    else 
      do_clean_up
    end
  end
# ...
end

class Manager < Employee
end

一見この状態では、なにも変わっていないように見えますが、ここからStaffクラスの抽象化可能な処理をEmployeeに移譲していきます。
※処理を全てEmployeeに移譲してから、StaffManagerに振り分けるよりも、Staffから抽象化出来そうな処理を吟味して、Employeeに移譲していった方が、抽象的で無い処理がEmployeeに残るリスクを回避することが出来ます。

まずは、プロパティから労働者Employeeに移譲してみましょう。 @name@group@gradeは、労働者であれば誰でも持っていると言えそうなので、Employeeに移譲しても良さそうです。また、@assistantは管理者のみが持つ項目なので、Managerに移します。

class Employee
  attr_reader :name, :group, :grade
  def initialize(args)
    @name  = args[:name]
    @group = args[:group]
    @grade = args[:grade]
  end
end

class Staff < Employee
  def work
    do_preparation
    if grade == :admin
      # 管理者の場合はチェックを行う
      do_check
    else 
      do_clean_up
    end
  end
# ...
end

class Manager < Employee
  attr_reader :assistant
  initialize(args)
    # superを書くことで、サブクラス(Employee)の同名のメソッドを呼び出せる
    super 
    @assistant = args[:assistant]
  end
end

次にStaffworkメソッドに取り組んでみます。メソッド内の作業前の準備do_preparationは、Employeeに移して、他は個別にサブクラスに実装した方が良さそうですね。

class Employee
  attr_reader :name, :group, :grade
  def initialize(args)
    @name  = args[:name]
    @group = args[:group]
    @grade = args[:grade]
  end

  def work
    do_preparation
  end
  # ...
end

class Staff < Employee
  def work
    super
    do_clean_up
  end
# ...
end

class Manager < Employee
  attr_reader :assistant
  def initialize(args)
    super # Employeeのinitializeメソッドを実行
    @assistant = args[:assistant]
  end
  
  def work
    super
    do_check
  end
  # ...
end

最初に比べるとStaffが純粋な社員としての役割となっていて大分良い感じになってきた気がします!!

しかし、この段階でも問題を含んでいます。。。

  • StaffなのにgradeadminManagerなのにgradestaff等のチグハグな状態が起こる。
  • 新しいサブクラスを追加する際にsuperを記載忘れると上手く動作しなくなってしまう。

このような問題を解決するために「テンプレートメソッド」、「フックメソッド」を使って更にリファクタリングしていきます。

テンプレートメソッドをつかってサブクラスにメソッド実装を促す。

「StaffなのにgradeadminManagerなのにgradestaff等のチグハグな状態が起こる。」をテンプレートメソッドというデザインパターンで対応してみたいと思います!!

テンプレートメソッドは、スーパークラスにメソッドを作っておいて、サブクラスでのオーバーライドを促すというものです。

実際にテンプレートメソッドを用いてリファクタリングしたのが下記コードです。Employeedefault_gradeメソッドを実装し、その中にERRORを発生処理を実装し、initializeメソッドで@gradeの設定に使用することでサブクラスにdefault_gradeの実装を促しています。

これによって、StaffManagerにて適切なgradeが設定されるようになりましたφ(..)

class Employee
  attr_reader :name, :group, :grade
  def initialize(args)
    @name  = args[:name]
    @group = args[:group]
    @grade = default_grade
  end
  
  def default_grade
    # ERRORを投げるメソッドを定義
    raise NotImprementedError, 
    "#{self.class}では、default_gradeメソッドを実装してください。"
  end

  def work
    do_preparation
  end
  # ...
end

class Staff < Employee
  def work
    super
    do_clean_up
  end
  
  def default_grade
    :staff
  end
# ...
end

class Manager < Employee
  attr_reader :assistant
  def initialize(args)
    super # Employeeのinitializeメソッドを実行
    @assistant = args[:assistant]
  end

  def default_grade
    :admin
  end
  
  def work
    super
    do_check
  end
  # ...
end

フックメソッドを使ってスーパークラスからsuperを除外する。

「新しいサブクラスを追加する際にsuperを記載忘れると上手く動作しなくなってしまう。」をフックメソッドを使って対応していきます。

フックメソッドを使うとサブクラスでsuperを実装させずに、サブクラスの処理を引き継ぐことが出来ます!!

実際のコードを見たほうがわかりやすいと思います、フックメソッドを用いてリファクタリングしたのが下記コードです。

Employeepost_initializesubsequent_workを実装し、initializework内で呼び出すことで、StaffManager内でpost_initializesubsequent_workメソッドを実装すれば、superを使わなくも自動的に元メソッド内で実行されるようになりました!!

class Employee
  attr_reader :name, :group, :grade
  def initialize(args)
    @name  = args[:name]
    @group = args[:group]
    @grade = default_grade
    post_initialize(args) # フックメソッド
  end
  
  def post_initialize(args)
    nil
  end
  
  def default_grade
    # ERRORを投げるメソッドを定義
    raise NotImprementedError, 
    "#{self.class}では、default_gradeメソッドを実装してください。"
  end

  def work
    do_preparation
    subsequent_work # フックメソッド
  end
  
  # ...
end

class Staff < Employee  
  def subsequent_work
    do_clean_up
  end
  
  def default_grade
    :staff
  end
# ...
end

class Manager < Employee
  attr_reader :assistant
  def post_initialize(args)
    @assistant = args[:assistant]
  end

  def default_grade
    :admin
  end
  
  def subsequent_work
    do_check
  end
  # ...
end

継承を使ったコードをテストする

最後に継承を使ったコードのテストについて整理していこうとおもいますφ(..)

検証のポイント

継承を使ったコードの検証のポイントは下記の通りです。

スーパークラス

  • メソッドが定義されているか
  • テンプレートメソッドが適切にエラーを吐くか
    • サブクラスでdefault_grade未実装時にエラーとなるか

サブクラス

  • スーパークラスのメソッドが定義されているか
  • サブクラス独自の振る舞いが定義されているか
    • post_initializesubsequent_workdefault_gradeがサブクラスに実装されているか
    • サブクラスで定義したdefault_gradeの設定値の確認

テストコード

それでは実際にテストコードを書いて検証していきます!(._.)

メソッドが定義されているか、スーパークラスのメソッドが定義されているか

スーパークラスにメソッドが定義されており、それが適切にサブクラスに継承されているかを検証するにはassert_respond_toを使います。また、メソッド定義の確認は汎用性を高めるために、moduleを使ってまとめておきます。
moduleにまとめておくことにより、新規のサブクラスが追加になっても容易にスーパークラスのメソッド追加出来ますφ(..)

require 'minitest/autorun'

# スーパークラスのメソッド定義を検証するモジュール
module EmployeeInterfaceTest
  def test_respond_to_name
    assert_respond_to(@object, :name)
  end
  def test_respond_to_group
    assert_respond_to(@object, :group)
  end
  def test_respond_to_grade
    assert_respond_to(@object, :grade)
  end
  def test_respond_to_work
    assert_respond_to(@object, :work)
  end
  def test_respond_to_default_grade
    assert_respond_to(@object, :default_grade)
  end
  def test_respond_to_do_preparation
    assert_respond_to(@object, :do_preparation)
  end
end

# スーパークラスの検証
class EmployeeTest
  # モジュールをincludeし、メソッド定義の検証を実施
  include EmployeeInterfaceTest
  def setup
    @employee = @object = Employee.new(geade: :worker)
  end
end

# サブクラス(Staff)の検証
class Staff
  # モジュールをincludeし、メソッド定義の検証を実施
  include EmployeeInterfaceTest
  def setup
    @staff = @object = Staff.new
  end
end

# サブクラス(Manager)の検証
class Manager
  # モジュールをincludeし、メソッド定義の検証を実施
  include EmployeeInterfaceTest
  def setup
    @manager = @object = Manager.new
  end
end

テンプレートメソッドが適切にエラーを吐くか

スーパークラスに定義されたテンプレートメソッドが適切にエラーを出力するかを検証するのは、単純です。assert_raiseを使用して、NotImprementedErrorが発生するかどうかを検証します。

また、エラーが発生することだけではなくオーバーライドされていたらエラーとならず、サブクラスで定義した値が設定されることをスタブを使い確認しています。今回は、サブクラスとしてStaffManagerが定義されていますが、実際の業務等ではスーパークラスだけで検証する必要がある場合は、このようにスタブを作成することでスーパークラスの検証を行うことが出来ますφ(..)

# 検証用のサブクラス(スタブ)
class StubbedEmployee < Employee
  def default_grade
    :stubbed
  end
end

# スーパークラスの検証
class EmployeeTest
  # モジュールをincludeし、メソッド定義の検証を実施
  include EmployeeInterfaceTest
  def setup
    @employee = @object = Employee.new(geade: :worker)
    # スタブ用のインスタンスを作成
    @stubbed_employee = StubbedEmployee.new
  end
  
  # スーパークラスで呼び出したときにエラーとなることを検証
  def test_forces_subclasses_to_implement_default_grade
    assert_raises(NotImprementedError){ @employee.default_grade }
  end
  
  # スタブを用いて、オーバーライド時に値が設定されることを検証
  def test_default_grade_overraid_success
    assert_equal @stubbed_employee.default_grade, :stubbed
  end
end

サブクラス独自の振る舞いが定義されているか

これも最初に説明したケースと同様にサブクラス固有音メソッド定義を検証するmoduleを作成して検証していきます。また、固有の固有の振る舞いdefalt_gradeの設定値については、テストメソッドとして、個別のテストクラスに記載していきますφ(..)

こうすることによって、新しいサブクラスが発生したときにEmployeeSubclassTestをincludeすれば、サブクラスに必要な役割の検証を行うことができ、あとは個別の振る舞いのみの検証に集中することが出来ます!

module EmployeeSubclassTest
  def test_respond_to_post_initialize
    assert_respond_to(@object, :post_initialize)
  end
  def test_respond_to_subsequent_work
    assert_respond_to(@object, :subsequent_work)
  end
  def test_respond_to_default_grade
    assert_respond_to(@object, :default_grade)
  end
end

# サブクラス(Staff)の検証
class Staff
  # モジュールをincludeし、メソッド定義の検証を実施
  include EmployeeInterfaceTest
  # moduleをinclude
  include EmployeeSubclassTest
  def setup
    @staff = @object = Staff.new
  end
  # default_gradeの値の検証
  def test_default_grade_value_staff
    assert_equal @manager.default_grade, :staff
  end
end

# サブクラス(Manager)の検証
class Manager
  include EmployeeInterfaceTest
  # モジュールをincludeし、サブクラスのメソッド定義の検証を実施
  include EmployeeSubclassTest
  def setup
    @manager = @object = Manager.new
  end
  # default_gradeの値の検証
  def test_default_grade_value_admin
    assert_equal @manager.default_grade, :admin
  end
end

おわりに

今回は少し長くなってしまいましたが、継承によるコードの共有化と、継承を使ったテンプレートメソッドフックメソッドを使ったリファクタリングについて整理してみましたφ(..)

継承を使うとコードを共有してコード量を削減出来るだけでなく、 コードの変更にも強くなるので積極的に使っていきたいですが、安易に継承を使い過ぎると具象的な内容を残してしまったり、処理が各所に散らばり可読性が下がってしまう恐れもあるので、注意して使っていきたいですね・・・!