class Foo def bar: (Integer) -> String end
みたいなRBSがあったとして、このRBSを使って勝手にテストしてくれるツールがあるとおもしろいんじゃない? というアイデアでプロダクトを作ってみている。
プロジェクト名は適当にRaaP(RBS as a Property)とした。
先ほどのRBSをRaaPで実行すると、
foo = Foo.new assert_kind_of String, foo.bar(0) assert_kind_of String, foo.bar(-1) assert_kind_of String, foo.bar(3) assert_kind_of String, foo.bar(-4) assert_kind_of String, foo.bar(100)
みたいなテストを実行してくれるというものだ。
例が簡単すぎたので実践的な例を出すと、Integer#pow
がある。
Integer#pow
の例
少し前まで、Integer#pow
のRBSはこうだった。
def pow: (Integer other, ?Integer modulo) -> Integer | (Float) -> Float | (Rational) -> Rational | (Complex) -> Complex
RBSを少し説明しておくと、このRBSはInteger#pow
には引数と返り値の型の組み合わせが4パターンあるという意味になる。
- 引数が
(Integer other, ?Integer modulo)
だったら返り値はInteger
になる - 引数が
(Float)
だったら返り値はFloat
になる - 引数が
(Rational)
だったら返り値はRational
になる - 引数が
(Complex)
だったら返り値はComplex
になる
2.pow(2) #=> 4[Integer] 2.pow(2.0) #=> 4.0[Float] 2.pow(2r) #=> (4/1)[Rational] 2.pow(2i) #=> (0.18345697474330172+0.9830277404112437i)[Complex]
なるほど確かにそうなった。
しかし、実は以下のようにパターンがあることがわかった。
-2.pow(-1) #=> (-1/2)[Rational] -9.pow(0.5) #=> (0.0+3.0i)[Complex] 2.pow(1/2r) #=> 1.4142135623730951[Float] -1.pow(1/2r) #=> (0.0+1.0i)[Complex]
調査してみると、より正しい型としては以下のようになることが分かった。
def pow: (Integer other) -> (Integer | Rational) | (Integer other, Integer modulo) -> Integer | (Float) -> (Float | Complex) | (Rational) -> (Float | Rational | Complex) | (Complex) -> Complex
- 引数が
(Integer other)
なら返り値はInteger
もしくはRational
になる - 引数が
(Integer other, Integer modulo)
なら返り値はInteger
になる - 引数が
(Float)
だったら返り値はFloat
もしくはComplex
になる - 引数が
(Rational)
だったら返り値はFloat
もしくはRational
もしくはComplex
になる - 引数が
(Complex)
だったら返り値はComplex
になる
PRはすでにmerge済みだ。
https://github.com/ruby/rbs/pull/1706/files
私は実を言うとInteger#pow
が何なのか全くわかっていない。
数学もよく分からない。
そんな私がどうやってこんなパターンを発見できたのだろうか?
ものすごく簡単なモデルで示すと、 ランダムなIntegerのレシーバーに対して、100回ランダムな値を入れてみたのだ。
100.times do receiver = Random.rand(-10..10) arg = Random.rand(-10.0..10.0) result = receiver.pow(arg) unless result.kind_of?(Float) p "Fail #{receiver}.pow(#{arg}) -> #{result}" end end #=> "Fail -6.pow(-2.3382898246090544) -> 0.007370540726186491-0.01323798966778574i" #=> "Fail -1.pow(-0.2237157347318508) -> 0.7630200574290098-0.6463748076472586i" #=> "Fail -9.pow(6.264880066044544) -> 640350.5389016571+703203.5975286782i" #=> "Fail -5.pow(9.672387114373539) -> 2971132.3702771068-4938997.434107209i" #=> "Fail -1.pow(-9.99330836294536) -> 0.9997790375330122+0.021020849401575725i" #=> "Fail -1.pow(6.7379598219388726) -> -0.6798608195419452+0.7333411662055762i" #=> "Fail -8.pow(6.374746550914516) -> 219100.4837162987+527766.3899187194i" ...(snip)...
簡単に想定外のパターンを見つけることができた。
変更前の型を書いた人は誰か。 mame さんである。早まらないでほしい。私が言いたいのは、 「あのmameさんが型を間違えるなら、全人類が間違える。」という点だ。(ここで全人類がうなずく)
ゆえにこの手法には価値がある。
このような手法をファジングとかプロパティベーステストとか言ったりするようだ。
プロパティベーステスト
この辺は実践プロパティベーステストが詳しい。
『実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう』 – 技術書出版と販売のラムダノート
プロパティベーステストはHaskellから生まれたもので、型を利用してランダムな値を生成するらしい。
ではRubyならRBSを利用してランダムな値を生成できるんじゃないか?
テストケースをRBSから生成できたらいいのではないか?
何ならそのまま実行しちゃえばいいのでは?
と、いうわけで今回のアイデアを閃いた。
RBS as a Propertyというわけだ。
Integer#pow
の例も、RBSを元にランダムな値を生成して、返り値をチェックする簡単なスクリプトを作って、coreのRBSでいろいろ試して見た結果発見したケースだった。
RBSを実行する
RBSをテストケースとして実行して、間違っていたらRBSを直す。あるいは実装を直す。 これを繰り返していくと、RBSが完成していくと言うわけだ。
入念にケースを確認して、動作が保証された、新鮮なRBSが保たれる。
これって結構いいのでは……?
例えば以下のようなイメージだ。
# 失敗する場合 $ raap 'Integer#pow' (Integer other, ?Integer modulo) -> Integer ........F Fail in case of `-2.pow(-1) #=> (-1/2)[Rational]` # 成功する場合 $ raap 'Integer#pow' (Integer other, ?Integer modulo) -> Integer ............. 👌
おもしろい気がする。