前回 RBSがテストになるとおもしろいんじゃないか日記1 - スペクトラム
前回で型からランダムな値を作ってテストに使うと言うアイデアについて考えた。
では型からランダムな値を作るというパーツについて考えていこう。
型からインスタンスを作る作戦
例えば次のようなRBSを持つclass Fooについて考えてみよう。
class Foo def initialize: (Integer) -> void def bar: () -> String end
このFooについてテストする場合、レシーバーとしてFooのインスタンスを作ることは明らかに必要だろう。
ではFooのインスタンス化はどうやるかというと、initializeという立派なレシピが用意されている。
このinitializeを使ってFooインスタンスを作るには、引数にIntegerが必要なようだ。
つまり
Foo.new(0) Foo.new(-3) Foo.new(123)
みたいな感じでFooを作ればいい。そのうえで#barなりをテストすることができそうだ。
もしFooを作るのにBarが必要だとしても、再帰的にBarもインスタンス化してFoo#initializeに与えればいい。
こんな感じでinitializeを見てインスタンス化させると、インスタンスを作るレシピがあるし、もしできなかったらそれはRBSが間違っていると言うことになる。つまりinitializeもテストできているということだ。うーん、方針はよさそう。
型からインスタンスを作る、とは
まずは土台から考えていこう。
BasicObject
$ bundle exec rbs method BasicObject initialize
とすると、() -> nilと出る。引数が無い1パターンしかないようだ。これは簡単そう。
BasicObject.new
だけでインスタンスが作れる。
Object
$ bundle exec rbs method Object initialize
とするとBasicObjectのものが出てくる。これも問題なさそう。
Integer
$ bundle exec rbs method Integer initialize
とすると、やはりBasicObjectのものが出てくる。
しかし、Integerはnewでインスタンスが作れない。これではレシピが役に立たない。こういう基礎的なclassでは、やはり特別扱いが必要だろう。
Integerの場合は特別に乱数をだすというロジックを実装することにする。
Float, Rational, Complex
Float、Rational、ComplexもIntegerと同じくnewがない。基本的にnewが無いclassなら特別扱いが必要そうだ。
NilClass, FalseClass, TrueClass
これらもnewはないので特別扱いだが、インスタンスが一つしかないとされているので、それぞれnil, false, trueを使えば良さそうだ。
interface
_ToInt等のインターフェースはどうインスタンス化したらよいだろう。
interfaceは特定のメソッド群を持ったオブジェクトという意味合いで、定義は次のようになっている。
interface _ToInt def to_int: () -> Integer end
to_intという1つのメソッドを持ち、かつ引数なしで呼び出すと、Integer型のインスタンスを返す。という意味だ。
何らかのObjectを用意して、#to_intをRBSを元に定義してあげれば良さそうだ。
返り値もRBSから取得できるので、これまでに見た方法でなんらかのInteger型の乱数を用意して返してあげれば良さそうだ。
コードAPI
ここらで説明のためにも小さなAPIを考えたい。 CLIツールである以上、これは内向けの設計でしかないが、後々にカスタマイズ性を持たせてrspecのようにテストコードを書くことも考えているので、何らかのAPIは考えておきたいのだ。
QuickCheckやproperなども参考にするためコードを読んでみたが、haskellは型アノテーションから生成しているし、properはヘルパー関数を組み合わせてランダムな値を作る。もちろんQuickCheckを参考にした既出のライブラリーも調べた。
Rubyらしいインターフェースはなんだろうと色々考えた。RaaPではRBSを軸とする以上、どれも参考にはなるが同じようにはならないだろうと考えた。
たどり着いたのは次のような感じだ。
Type.new("Integer").pick #=> 3
Typeというclassの引数にRBSの型を与える。これを型つまり集合と考えて、その中から1つだけつまみ出してくるイメージだ。なんとなくオブジェクティブでいいんじゃないだろうか。いったんこれで行ってみよう。
Type.new("Array[Integer]").pick #=> [1, -3, 123]
うーん、何となく意味は分かるんじゃない?
ただジェネリクスの場合は問題がある。newを使っても中身に型引数のものが入っているとは限らないのだ。
例えばArrayだと、次のような場合がある。
Array.new(3) #=> [nil, nil, nil]
単純にnewの引数パターンを見てしまうと、Array[Integer]を作ったつもりがnilが入ってしまっているので間違った型になる。
次のような単純なclassですらnewを使う作戦だけではジェネリクスを使ったclassは対応できない。
class List def initialize @array = [] end def add(i) @array << i end def to_a @array end end
class List[T] def initialize: () -> void def add: (T) -> T def to_a: () -> Array[T] end
Type.new("List[Integer]").pick.to_a #=> [1, -4, 0]となってほしい気がする #=> 実際は常に []
うーん、ジェネリクスを使った場合は結構無理があるかも……?もちろん特別扱いは可能だが、特別扱いだらけになるのも歓迎しない。
どうしよう。