前回 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]となってほしい気がする #=> 実際は常に []
うーん、ジェネリクスを使った場合は結構無理があるかも……?もちろん特別扱いは可能だが、特別扱いだらけになるのも歓迎しない。
どうしよう。