契約プログラミングを試みる(Ruby)

最近読んだ本で、「DbC(Design by Contract):契約による設計」について言及が有った。

これをRubyで試してみます。

参考

実装

とりあえず試みる

やらないといけないことは、次の三つ。

  • 事前条件のチェック
  • 事後条件のチェック
  • クラス不変表明

クラス不変表明は、その処理実行の後に事前条件と事後条件を満たしていこと。
ということで、まずは事前条件と事後条件のチェックを試みてみます。

1
2
3
4
5
6
7
8
9
10
11
12
def add(a, b)
raise unless a > 0
raise unless b > 0

r = a + b

raise unless r > 1

return
end

p add(0, 1)

(書いての通りですが、)実行してみます。

1
2
3
$ bundle exec ruby app.rb
app.rb:2:in `add': unhandled exception
from app.rb:12:in `<main>'

raise unless a > 0 が条件に合致しないので、エラーになっています。
エラーの内容など見るとわかりにくくはありますが、やりたいことはこの通りです。

モジュール使ってみる

契約プログラミングを実現するモジュールを探してみるとありました。

というところで、こちらを試してみます。
サンプルを見つつ実装したのがこちらの通り。

1
2
3
4
5
6
7
8
9
10
11
12
13
require 'contracts'

class Sample
include Contracts::Core
include Contracts::Builtin

Contract Num, Num => Num
def add(a, b)
a + b
end
end

p Sample.new.add(0, "number")

実行すると次の通り。

1
2
3
4
5
6
7
8
9
10
$ bundle exec ruby app.rb
/usr/local/bundle/gems/contracts-0.17/lib/contracts.rb:51:in `block in <class:Contract>': Contract violation for argument 2 of 2: (ParamContractError)
Expected: Num,
Actual: "number"
Value guarded in: Sample::add
With Contract: Num, Num => Num
At: app.rb:8
from /usr/local/bundle/gems/contracts-0.17/lib/contracts.rb:197:in `failure_callback'
from /usr/local/bundle/gems/contracts-0.17/lib/contracts/method_handler.rb:144:in `block in redefine_method'
from app.rb:13:in `<main>'

とブロックしてくれていますが、オブジェクト型を見てくれていますが、具体的な値は見てくれていないものになっています。

拡張すると、具体的な値でチェックが可能になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
require 'contracts'

class CustomNum
def self.valid? val
val.is_a? Numeric
val > 0
end
end

class Sample
include Contracts::Core
include Contracts::Builtin

Contract CustomNum, CustomNum => Num
def add(a, b)
a + b
end
end

p Sample.new.add(0, 1)

実行すると次の通りです。

1
2
3
4
5
6
7
8
9
10
$ bundle exec ruby app.rb
/usr/local/bundle/gems/contracts-0.17/lib/contracts.rb:51:in `block in <class:Contract>': Contract violation for argument 1 of 2: (ParamContractError)
Expected: CustomNum,
Actual: 0
Value guarded in: Sample::add
With Contract: CustomNum, CustomNum => Num
At: app.rb:15
from /usr/local/bundle/gems/contracts-0.17/lib/contracts.rb:197:in `failure_callback'
from /usr/local/bundle/gems/contracts-0.17/lib/contracts/method_handler.rb:144:in `block in redefine_method'
from app.rb:20:in `<main>'

とできたわけですが、CustomNum の条件などが、コンソールからは読み取りにくいものになりました。

自前で作ってみる

公開されているモジュールで、少し満足できなかったので、簡単な処理をつくってみました。

1
2
3
.
├── app.rb
└── contract.rb
./contract.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class ContractError < StandardError
def initialize(backtrace, file, line, method, source_code, message)
text = "Contract Error!\n"
text += "BackTrace: #{backtrace}\n"
text += "File: #{file}\n"
text += "Line: #{line}\n"
text += "Method: #{method}\n"
text += "Against the args rules at \`#{source_code}\`\n"
text += "Message: #{message}\n" if message

super(text)
end
end

def contract(conditions, message: nil)
return if conditions

backtrace = caller.join(" <= ")
file = caller[0][/^([a-zA-Z0-9.\/]*):/, 1]
line = caller[0][/:([0-9]*):/, 1]
method = caller[0][/`([^']*)'/, 1]

source_code = File.open(file) do |f|
f.readlines[line.to_i-1].chomp.strip
end

raise ContractError.exception(backtrace, file, line, method, source_code, message)
end
app.rb
1
2
3
4
5
6
7
8
9
10
11
12
require './contract'

def add(a, b)
contract(a > 0, message: "aは0よりも大きい必要がある")
contract(b > 0)
r = a + b

contract(r > 1)
res
end

p add(0,1)

実行すると次のようになります。

1
2
3
4
5
6
7
8
9
10
$ bundle exec ruby app.rb
/usr/src/app/contract.rb:27:in `contract': Contract Error! (ContractError)
BackTrace: app.rb:73:in `add' <= app.rb:81:in `<main>'
File: app.rb
Line: 73
Method: add
Against the args rules at `contract(a > 0, message: "aは0よりも大きい必要がある")`
Message: aは0よりも大きい必要がある
from app.rb:73:in `add'
from app.rb:81:in `<main>'

何がまずかったのか、対象の呼び出し部分と条件とすべてを表示するようにできました。

メッセージの省略や、事後条件などの実行は次のようになります。

app.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
require './contract'

def add(a, b)
contract(a > 0, message: "aは0よりも大きい必要がある")
contract(b > 0)
r = a + b

contract(r > 1)
r
end

begin
p add(0, 1)
rescue ContractError => e
p e
end

begin
p add(1, 0)
rescue ContractError => e
p e
end

begin
p add(0.1, 0.1)
rescue ContractError => e
p e
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ bundle exec ruby app.rb
#<ContractError: Contract Error!
BackTrace: app.rb:44:in `add' <= app.rb:53:in `<main>'
File: app.rb
Line: 44
Method: add
Against the args rules at `contract(a > 0, message: "aは0よりも大きい必要がある")`
Message: aは0よりも大きい必要がある
>
#<ContractError: Contract Error!
BackTrace: app.rb:45:in `add' <= app.rb:59:in `<main>'
File: app.rb
Line: 45
Method: add
Against the args rules at `contract(b > 0)`
>
#<ContractError: Contract Error!
BackTrace: app.rb:48:in `add' <= app.rb:65:in `<main>'
File: app.rb
Line: 48
Method: add
Against the args rules at `contract(r > 1)`
>

省略もできるし、事後条件の確認もできる。
ただし、事後条件とは言いつつ返り値のチェックを処理を呼び出し元に返す前に行っている。
これについては、思想からは外れているものとも感じるところはある

自前で作った処理を クラス不変条件で適用してみる

app.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
require './contract'

class TwoNumbers
attr_reader :a, :b

def initialize(a, b)
@a = a
@b = b
validate
end

def add()
# @a を書き換えてしまう
validate
r = @a + @b
@a = 0
validate
r
end

private
def validate
contract(@a > 0)
contract(@b > 0)
end
end

begin
p TwoNumbers.new(0, 1)
rescue ContractError => e
p e
p e.backtrace
end

begin
p TwoNumbers.new(0.1, 1).add
rescue ContractError => e
p e
p e.backtrace
end

実行するとこんな感じ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ bundle exec ruby app.rb
#<ContractError: Contract Error!
BackTrace: app.rb:23:in `validate' <= app.rb:9:in `initialize' <= app.rb:29:in `new' <= app.rb:29:in `<main>'
File: app.rb
Line: 23
Method: validate
Against the args rules at `contract(@a > 0)`
>
#<ContractError: Contract Error!
BackTrace: app.rb:23:in `validate' <= app.rb:17:in `add' <= app.rb:35:in `<main>'
File: app.rb
Line: 23
Method: validate
Against the args rules at `contract(@a > 0)`
>

条件をすべてvalidateにまとめてしまったので発火した処理が何だったのか、少々見通しが悪いですができました。


契約プログラミングを試してみると、実装方針として取り入れていくのは良さそうでした。
特に、境界が意識されるものについて。

  • システムの境界
  • 開発者の境界

など。

ではでは。

とここまで書いて、RSpec 使えばいいなとも感じた

1
2
3
4
5
6
7
8
9
10
11
12
13
14
require "rspec/expectations"

include RSpec::Matchers

def add(a, b)
expect(a).to be > 0
expect(b).to be > 0
r = a + b

expect(r).to be > 1
r
end

p add(0, 1)

ではでは。