契約による設計

呼び出し側の責任の事前条件、呼び出され側の責任の事後条件、オブジェクトの一生を通じて守られるクラス不変表明の3つからなるテクニック。

必ずしもコードで表現されるわけでなく、コメントなどによって指示されるケースもある。

事前条件は、メソッドを呼び出す時に呼び出し側が守らなければならないルールで、違反した場合にはどのような結果になるか保証されないことを意味する。

# 事前条件: pathは/files/からの相対パスで指定され、存在していること
def import_file(path)
  # ...
end

通常は以下のように例外を発生させることになる。

# 事前条件: pathは/files/からの相対パスで指定され、存在していること
def import_file(path)
  path = Pathname.new('/files/') / "./#{path}"
  unless path.exist?
    raise "指定されたファイルが/files/に存在しない: path => #{path}"
  end
  # ...
end

事後条件は、事前条件が守られてメソッドが呼び出された時に保証される結果である。

# 事前条件: pathは/files/からの相対パスで指定され、存在していること
# 事後条件: 処理されたファイルは削除されていること
def import_file(path)
  path = Pathname.new('/files/') / "./#{path}"
  unless path.exist?
    raise "指定されたファイルが/files/に存在しない: path => #{path}"
  end
  # ...
end

後述するが、事後条件はテストコードでチェックすることができるので通常はプロダクトコードにはチェック処理を組み込まない。

クラス不変表明は、オブジェクトが生成されてから消滅するまで維持されていなければならない性質である。

# クラス不変表明:  0 <= @collection.length <= 10
# 10個の要素を持てるコレクション
class Collection
  def initialize
    @collection = []
  end

  # 事後条件: 追加できた場合は@collectionの最後にitemが追加される
  # 追加できた場合はtrueを、追加できなかった場合はfalseを返す
  def try_add(item)
    return false if 10 < @collection.length
    @collection << item

    true
  end
end

クラス不変表明もテストコードで工夫すればチェックすることができるので通常はプロダクトコードにはチェック処理を組み込まない。

ではどのように、事後条件とクラス不変表明をテストコードで表現するかだが、以下のようにすれば良い。

RSpec.describe Collection, type: :model do
  let(:target) { described_class.new }
  # インスタンス変数をメタプログラミングを使って取得している
  let(:collection) { target.instance_variale_get(:@collection) }

  # クラス不変表明テスト
  def class_invariant_spec
    expect(collection.length).to be >= 0
    expect(collection.length).to be <= 10
  end

  describe '#try_add' do
    # 事後条件テスト
    def postcondition_spec(item)
      expect(last_added_item).to eq item
    end
    let(:last_added_item) { collection.last }

    it '10回目の実行までtrueを返す' do
      10.times do |item|
        expect(target.try_add(item)).to eq true
        postcondition_spec(item)
        class_invariant_spec
      end
    end

    it '11回目以降の実行ではfalseを返す' do
      10.times { |item| target.try_add(item) }
      last_item = last_added_item
      10.times do |item|
        expect(target.try_add(item)).to eq false
        postcondition_spec(last_item)
        class_invariant_spec
      end
    end
  end
end

事後条件はメソッドに紐づくものなので常にそのメソッドのテストにおいて記述するべきだが、クラス不変表明はテスト対象のメソッドに関連するものに限るかすべてのクラス不変表明をチェックするかは言語とプロジェクトの性質によって判断されるところだろう。

上記のようにRSpecの場合、メソッドとしてくくり出すことができるので、class_invariant_specpostcondition_specのようにクラス不変表明と事後条件をメソッドにして呼び出すことでテスト記述をシンプルにできる。

事前条件については、呼び出し時にチェックしなければならないため、テストコードでチェックすることは不可能であり、チェックするのであればプロダクトコードに記述しなければならない。手放しでチェックロジックを組み込めば、本番環境においても実行されてしまいパフォーマンスに悪影響がでる可能性がある。あるいは、記録だけ残して本番環境ではそのまま動いて欲しいケースもある。

そのため、事前条件のチェックを組み込むのであれば、C言語のプリプロセッサのような仕組みを導入して環境ごとに実行するチェックを指定できるようにした方が良い。

Rails環境なら以下のようなコードを用意すればよいだろう。

# 各メソッド名は本番環境でどのように振る舞うかをイメージしている
class Precondition
  class << self
    # 本番環境でチェックする
    def check
      yield
      self
    end

    # 本番環境でチェックし問題があればログに記録する
    def logging
      yield
      self
    rescue StandardError => e
      Rails.logger.fatal(e.message)
      raise unless Rails.env.production?

      self
    end

    # 本番環境では忘れる=実行しない
    def forget
      return self if Rails.env.production?

      yield
      self
    end
  end
end

def import_file(path)
  path = Pathname.new('/files/') / "./#{path}"
  # 利用例。本番環境以外では例外が呼び出し元に投げられるが、本番環境ではログに記録されるだけ
  Precondition.logging do
    unless path.exist?
      raise "指定されたファイルが/files/に存在しない: path => #{path}"
    end
  end
  # ...
end

継承における契約による設計の取り扱いはリスコフの置換原則を参照。

参考文献:オブジェクト指向入門(Bertrand Meyer著 1990年出版) 7章 ソフトウェア構築への体系的アプローチ、Code Complete 第2版 上 完全なプログラミングを目指して 第8章 防御的プログラミング