エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

「Specification は述語である」という話

勉強不足を晒すことになりますが。 仕事で "Specification" なるオブジェクトが出てきたときに、しばらくは漠然としたイメージしか持てませんでした。

当面は直接関わることがなかったのでそのままにしていたのですが、原典に当たったことですっきりしました。

仕様とは、あるオブジェクトが何らかの基準を満たしているかどうかを判定する述語である。

www.shoeisha.co.jp

なるほど、述語のオブジェクトということのようです。

その応用については Martin Fowler のサイトで公開されている論文に詳しく書かれています。

そこで、この論文に出てくる CompositeSpecificationRuby で書いてみました。

module CompositeSpecification
  class AndSpecification
    include CompositeSpecification

    def initialize(left, right)
      @left = left
      @right = right
    end

    def satisfied_by?(object)
      @left.satisfied_by?(object) && @right.satisfied_by?(object)
    end
  end

  class OrSpecfication
    include CompositeSpecification

    def initialize(left, right)
      @left = left
      @right = right
    end

    def satisfied_by?(object)
      @left.satisfied_by?(object) || @right.satisfied_by?(object)
    end
  end

  class NotSpecification
    include CompositeSpecification

    def initialize(specification)
      @specification = specification
    end

    def satisfied_by?(object)
      !@specification.satisfied_by?(object)
    end
  end

  def and(another)
    AndSpecification.new(self, another)
  end

  def or(another)
    OrSpecfication.new(self, another)
  end

  def !
    NotSpecification.new(self)
  end
end

使い方の例です。

まず、 1 から 30 の数値のうち、仕様を満たす値のみを出力する関数を用意します。

def satisfy(spec)
  puts (1..30).select { |i| spec.satisfied_by?(i) }.join(' ')
end

次に 3 の倍数であることを FizzSpecification 、5 の倍数であることを BuzzSpecification と定義します。

require_relative './composite_specification'

class FizzSpecification
  include CompositeSpecification

  def satisfied_by?(n)
    (n % 3).zero?
  end
end

class BuzzSpecification
  include CompositeSpecification

  def satisfied_by?(n)
    (n % 5).zero?
  end
end

それぞれの Specification オブジェクトを適用してみます。

fizz_spec = FizzSpecification.new
satisfy(fizz_spec)
#=> 3 6 9 12 15 18 21 24 27 30

buzz_spec = BuzzSpecification.new
satisfy(buzz_spec)
#=> 5 10 15 20 25 30

CompositeSpecification を include したこれらの Specification は合成することができます。

FizzSpecificationBuzzSpecification を合成して、新しい Specification オブジェクトを作ります。

fizz_buzz_spec = fizz_spec.and(buzz_spec)
satisfy(fizz_buzz_spec)
#=> 15 30

どんどん合成できます。

satisfy(fizz_spec.or(buzz_spec))
#=> 3 5 6 9 10 12 15 18 20 21 24 25 27 30

satisfy(!fizz_spec.or(buzz_spec))
#=> 1 2 4 7 8 11 13 14 16 17 19 22 23 26 28 29

satisfy((!fizz_spec).and(!buzz_spec))
#=> 1 2 4 7 8 11 13 14 16 17 19 22 23 26 28 29

「エリック・エヴァンスのドメイン駆動設計」にはこうも書かれています。

論理プログラミングは、 「述語」と呼ばれる、独立した結合可能なルールオブジェクトの概念を提供するが、この概念をオブジェクトで完全に実装するのは面倒である。

なるほど。 それでは論理プログラミングなら簡単なのかも。

試してみます。

そんなわけで。 久々の Prolog プログラミングです。

fizz/1buzz/1 を定義して specification.prolog というファイル名で保存します。

fizz(N) :- Rem is N mod 3, Rem == 0.
buzz(N) :- Rem is N mod 5, Rem == 0.

REPL を起動します。 使うのはいつものように GNU Prolog です。

$ gprolog

プログラムを読み込みます。

| ?- [specification].       

1から30までの間で fizz/1 を満たす N をすべて見つけます。

| ?- findall(N, (between(1, 30, N), fizz(N)), Ns).
Ns = [3,6,9,12,15,18,21,24,27,30]

1から30までの間で buzz/1 を満たす N をすべて見つけます。

| ?- findall(N, (between(1, 30, N), buzz(N)), Ns).
Ns = [5,10,15,20,25,30]

Prolog ではコンマ( (',')/2 )は論理積を、セミコロン( (;)/2 )は論理和を表す演算子です。

ちなみに否定を表す演算子(\+)/1 です。

ですので fizz_buzz/1 はこのように定義できます。

fizz_buzz(N) :- fizz(N), buzz(N).
| ?- findall(N, (between(1, 30, N), fizz_buzz(N)), Ns).
Ns = [15,30]

他にも:

fizz_or_buzz(N) :- fizz(N); buzz(N).
| ?- findall(N, (between(1, 30, N), fizz_or_buzz(N)), Ns).
Ns = [3,5,6,9,10,12,15,15,18,20,21,24,25,27,30,30]
not_fizz_or_buzz(N) :- \+fizz_or_buzz(N).
| ?- findall(N, (between(1, 30, N), not_fizz_or_buzz(N)), Ns).
Ns = [1,2,4,7,8,11,13,14,16,17,19,22,23,26,28,29]

確かに合成は簡単、…というかこれは論理プログラミングそのものですね、確かに。

合成の部分に関しては。 例えば次のように演算をオブジェクトして遅延評価したいときに顔をだす構造。

module Calculable
  class Add
    include Calculable

    def initialize(left, right)
      @left = left
      @right = right
    end

    def eval
      @left.eval + @right.eval
    end
  end

  class Sub
    include Calculable

    def initialize(left, right)
      @left = left
      @right = right
    end

    def eval
      @left.eval - @right.eval
    end
  end
  
  def add(another)
    Add.new(self, another)
  end

  def sub(another)
    Sub.new(self, another)
  end
end

class Int
  include Calculable

  def initialize(n)
    @n = n
  end

  def eval
    @n
  end
end

three = Int.new(3)
two = Int.new(2)

puts (three.add(two)).sub(three.sub(two)).eval
#=> 4

これは Ruby on RailsActiveRecord のクエリメソッドで、普段からお世話になっている仕組みですね。

述語の詳細をオブジェクトで表現しなければならい状況に遭遇しないと、なぜこれが有意義なのかわかりにくいですが、よくよく調べてみると確かにそういった状況があると納得し、その解決方法の一つが Specification なのだとようやく腑に落ちたのでした。