RBSがテストになるとおもしろいんじゃないか日記2

前回 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のものが出てくる。

しかし、Integernewインスタンスが作れない。これではレシピが役に立たない。こういう基礎的なclassでは、やはり特別扱いが必要だろう。

Integerの場合は特別に乱数をだすというロジックを実装することにする。

Float, Rational, Complex

FloatRationalComplexIntegerと同じく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_intRBSを元に定義してあげれば良さそうだ。

返り値もRBSから取得できるので、これまでに見た方法でなんらかのInteger型の乱数を用意して返してあげれば良さそうだ。

コードAPI

ここらで説明のためにも小さなAPIを考えたい。 CLIツールである以上、これは内向けの設計でしかないが、後々にカスタマイズ性を持たせてrspecのようにテストコードを書くことも考えているので、何らかのAPIは考えておきたいのだ。

QuickCheckproperなども参考にするためコードを読んでみたが、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]となってほしい気がする
#=> 実際は常に []

うーん、ジェネリクスを使った場合は結構無理があるかも……?もちろん特別扱いは可能だが、特別扱いだらけになるのも歓迎しない。

どうしよう。