Tbpgr Blog

Employee Experience Engineer tbpgr(てぃーびー) のブログ

Ruby | Template Method Pattern

概要

Template Method Pattern

詳細

Template Method Patternは、アルゴリズムの大部分は同じだが
一部だけが異なる部分があるような場合に

・変わらない部分を基底クラスに
・変わる部分を複数の具象クラスに

する設計手法です。
(変わる部分を個別のクラスに実装するために基底クラスにフックメソッドを用意します。
基底クラスでは該当メソッドに NotImplementedError を上げるコードのみ実装します。
継承側で実装を忘れると、NotImplementedError が発生することになります。)

これにより、同じようなロジックをいくつものクラスに記述したり、
一つのクラスないが分岐だらけになってしまうような事態を避けることができます。

結果として、共通部の修正は基底クラスに閉じ
固有の修正は具象クラスに閉じるため修正しやすく、新たな具象クラスも追加しやすくなっています。

静的言語との違い

Javaを例にとると、Template Methodパターンを実装する場合は抽象クラスを利用します。
基底クラスでは共通部を具体的なメソッドとして実装し、固有部は抽象メソッドをフックメソッドとしてインターフェースだけ決めておきます。
具象クラスでは、抽象クラスを継承することでフックメソッドの実装を強要されます。
具象クラスですべてのフックメソッドを実装すると、Template Methodパターンが完成します。

Ruby動的言語で特別なインターフェースの定義などは不要でメソッド名さえ合っていれば動作します。(ダックタイピングの特性)
ただし、フックメソッドが正しく実装されているかチェックするために NotImplementedError を実装しておくことが好ましいです。

サンプル仕様

株式会社HogeHogeの社内レポートを作成する。
レポートはHTMLとMarkdownの2種類です。
(HTMLはMarkdownから出せばいいような気もしますが気にしない。)

サンプル修正前その1

全て1メソッドに記述している場合

require 'test_toolbox'

module HogeHogeInc
  module REPORT_TYPE
    HTML = "HTML"
    MARKDOWN = "Markdown"
  end

  class Report
    attr_reader :title, :contents, :type
    def initialize(title, contents, type = REPORT_TYPE::HTML)
      @title, @contents, @type = title, contents, type
    end

    def output_report
      if type == REPORT_TYPE::HTML
        title = <<-EOS
<html>
<head>
  <title>#{@title}</title>
</head>
        EOS
        body = <<-EOS
<body>
#{contents}
</body>
</html>
        EOS
      else
        title = "# #{@title}\n"
        body = <<-EOS
## Report
#{@contents}
        EOS
      end
      title + body
    end
  end
end

html_report = HogeHogeInc::Report.new("html title", "html contents" , HogeHogeInc::REPORT_TYPE::HTML)
puts html_report.output_report

dp_line __LINE__

markdown_report = HogeHogeInc::Report.new("html title", "html contents" , HogeHogeInc::REPORT_TYPE::MARKDOWN)
puts markdown_report.output_report

__END__
dp_line は tbpgr_utils gem の機能
詳しくは
https://github.com/tbpgr/tbpgr_utils
を参照。

出力

<html>
<head>
  <title>html title</title>
</head>
  <body>
  html contents
  </body>
</html>
--------------------|filename=|line=45|--------------------
# html title
## Report
html contents

サンプル修正前その2

メソッドを分割した場合。
サンプル1をリファクタリングしてメソッドを分割し、フックメソットの元となるメソッドを用意します。
これは次のTemplate Method適用への準備段階になります。

require 'test_toolbox'

module HogeHogeInc
  module REPORT_TYPE
    HTML = "HTML"
    MARKDOWN = "Markdown"
  end

  class Report
    attr_reader :title, :contents, :type
    def initialize(title, contents, type = REPORT_TYPE::HTML)
      @title, @contents, @type = title, contents, type
    end

    def output_report
      report_title + report_contents
    end

    private
      def report_title
        if type == REPORT_TYPE::HTML
          <<-EOS
<html>
<head>
  <title>#{@title}</title>
</head>
          EOS
        else
          "# #{@title}\n"
        end
      end

      def report_contents
        if type == REPORT_TYPE::HTML
          <<-EOS
<body>
#{contents}
</body>
</html>
          EOS
        else
          <<-EOS
## Report
#{@contents}
          EOS
        end
      end
  end
end

html_report = HogeHogeInc::Report.new("html title", "html contents" , HogeHogeInc::REPORT_TYPE::HTML)
puts html_report.output_report

dp_line __LINE__

markdown_report = HogeHogeInc::Report.new("html title", "html contents" , HogeHogeInc::REPORT_TYPE::MARKDOWN)
puts markdown_report.output_report

__END__
dp_line は tbpgr_utils gem の機能
詳しくは
https://github.com/tbpgr/tbpgr_utils
を参照。

出力

<html>
<head>
  <title>html title</title>
</head>
  <body>
  html contents
  </body>
</html>
--------------------|filename=|line=55|--------------------
# html title
## Report
html contents

サンプルTemplate Methodパターン適用1

共通部を HogeHogeInc::Report クラスに抽出。
個別部を HogeHogeInc::HtmlReport, HogeHogeInc::MarkdownReport クラスに抽出。
また、実装ミスの確認用にわざとフックメソッドを実装しない HogeHogeInc::NotImplementedReport も作成します。

require 'test_toolbox'

module HogeHogeInc
  module REPORT_TYPE
    HTML = "HTML"
    MARKDOWN = "Markdown"
  end

  class Report
    attr_reader :title, :contents, :type
    def initialize(title, contents)
      @title, @contents = title, contents
    end

    def output_report
      report_title + report_contents
    end

    private
      def report_title
        fail NotImplementedError.new
      end

      def report_contents
        fail NotImplementedError.new
      end
  end

  class HtmlReport < Report
    def initialize(title, contents)
      @type = REPORT_TYPE::HTML
      super(title, contents)
    end

    private
      def report_title
        <<-EOS
<html>
<head>
  <title>#{@title}</title>
</head>
        EOS
      end

      def report_contents
        <<-EOS
  <body>
  #{contents}
  </body>
</html>
        EOS
      end
  end

  class MarkdownReport < Report
    def initialize(title, contents)
      @type = REPORT_TYPE::MARKDOWN
      super(title, contents)
    end

    private
      def report_title
        "# #{@title}\n"
      end

      def report_contents
        <<-EOS
## Report
#{@contents}
        EOS
      end
  end

  class NotImplementedReport < Report
    def initialize(title, contents)
      @type = REPORT_TYPE::HTML
      super(title, contents)
    end
  end
end

html_report = HogeHogeInc::HtmlReport.new("html title", "html contents")
puts html_report.output_report

dp_line __LINE__

markdown_report = HogeHogeInc::MarkdownReport.new("html title", "html contents")
puts markdown_report.output_report

dp_line __LINE__

begin
  not_implemented_report = HogeHogeInc::NotImplementedReport.new("not_implemented_ title", "not_implemented_ contents")
  puts not_implemented_report.output_report
rescue NotImplementedError => e
  puts e
end

__END__
dp_line は tbpgr_utils gem の機能
詳しくは
https://github.com/tbpgr/tbpgr_utils
を参照。

出力

<html>
<head>
  <title>html title</title>
</head>
  <body>
  html contents
  </body>
</html>
--------------------|filename=|line=85|--------------------
# html title
## Report
html contents
--------------------|filename=|line=90|--------------------
NotImplementedError

サンプルTemplate Methodパターン適用2

HogeHogeInc::Report クラスにtbpgr_utils gemのTemplateMethodableをincludeし、must_implに実装が必要なフックメソッドを列挙することで

def report_title(*args)
  fail NotImplementedError.new
end

def report_contents(*args)
  fail NotImplementedError.new
end

と同等の動作をします。
ここではサンプルのため2メソッドしかフックメソッドがありませんが、実際にコーディングする際は大量のメソッドを定義することもよくあります。
そのような際に簡単にフックメソッドを定義できます。

フックメソッド + NotImplementedErrorの実装は退屈なボイラーテンプレートです。
メソッド名の宣言とNotImplementedErrorを投げるコードを延々定義するだけです。
この作業を簡単にするために TemplateMethodable は must_impl を利用して宣言的にフックメソッドを定義できるようになっています。
※ただし、汎用的な引数に対応できるように *args 限定の機能にしています。厳密に任意の数の引数に適用したい場合は利用できません。

また、同様にコンストラクタの定義 + メンバ変数への設定もボイラーテンプレートです。
これも、tbpgr_utils gemのAttributesInitializableをincludeし、attr_reader_initに実装がメンバ変数を列挙することで

attr_reader :title, :contents

def initialize(options)
  @title = options[:title]
  @contents = options[:contents]
end

と同等の動作をします。

以上を踏まえて実装内容は以下になります。

require 'test_toolbox'
require "template_methodable"
require 'attributes_initializable'

module HogeHogeInc
  module REPORT_TYPE
    HTML = "HTML"
    MARKDOWN = "Markdown"
  end

  class Report
    include TemplateMethodable
    include AttributesInitializable
    must_impl :report_title, :report_contents
    attr_reader_init :title, :contents

    def output_report
      report_title + report_contents
    end
  end

  class HtmlReport < Report
    def initialize(title, contents)
      @type = REPORT_TYPE::HTML
      super({title: title, contents: contents})
    end

    private
      def report_title
        <<-EOS
<html>
<head>
  <title>#{@title}</title>
</head>
        EOS
      end

      def report_contents
        <<-EOS
  <body>
  #{contents}
  </body>
</html>
        EOS
      end
  end

  class MarkdownReport < Report
    def initialize(title, contents)
      @type = REPORT_TYPE::MARKDOWN
      super({title: title, contents: contents})
    end

    private
      def report_title
        "# #{@title}\n"
      end

      def report_contents
        <<-EOS
## Report
#{@contents}
        EOS
      end
  end

  class NotImplementedReport < Report
    def initialize(title, contents)
      @type = REPORT_TYPE::HTML
      super({title: title, contents: contents})
    end
  end
end

html_report = HogeHogeInc::HtmlReport.new("html title", "html contents")
puts html_report.output_report

dp_line __LINE__

markdown_report = HogeHogeInc::MarkdownReport.new("html title", "html contents")
puts markdown_report.output_report

dp_line __LINE__

begin
  not_implemented_report = HogeHogeInc::NotImplementedReport.new("not_implemented_ title", "not_implemented_ contents")
  puts not_implemented_report.output_report
rescue NotImplementedError => e
  puts e
end

__END__
dp_line は tbpgr_utils gem の機能
詳しくは
https://github.com/tbpgr/tbpgr_utils
を参照。

出力

<html>
<head>
  <title>html title</title>
</head>
  <body>
  html contents
  </body>
</html>
--------------------|filename=|line=80|--------------------
# html title
## Report
html contents
--------------------|filename=|line=85|--------------------
NotImplementedError

補足

列挙

Reportの型定義にmodule+定数を利用しています。
これは、Javaでいうenumの代替です。

  module REPORT_TYPE
    HTML = "HTML"
    MARKDOWN = "Markdown"
  end