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

前回までのあらすじ

RBSがテストになるとおもしろいんじゃないか日記1 - スペクトラム

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

RBSがテストになるとおもしろいんじゃないか日記3 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記4 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記5 - スペクトラム

追加コードなしにこだわる理由

RaaPでは基本的な姿勢として、テストコードを書かなくても動くことを目指しています。(もちろんRBSは書く) その理由を言語化してみます。

そもそもここで言うコードとは

プロパティベーステストはプロパティというルールをコードで記述して、このルールがどんな入力でも成立するかを検証するテスト手法です。 QuickCheckやproper・propcheckを読んだなら、当然以下のようなコードを思いつくでしょう。

it "2回reverseしたら元に戻るはず" do
  forall(list(integer())) do |ary|
    expect(ary.reverse.reverse).to eq(ary)
  end
end

RaaPではこういうことをできなくはない(内部のテストでは使用している)のですが、あえてファーストサポートとしないように意識しており、あくまでCLIツールとしての利用をファーストサポートとして考えています。

利用しやすさ

利用に関して、障壁は少なければ少ないほど良いです。コード書かなくていいなら最も利用しやすく、lsコマンドやtopコマンドのようにカジュアルに使うことができます。

利用される数が多ければ多いほど、良いフィードバックも増えてプロダクトの成長にもつながります。

互換性の問題からの解放

rspecではv2からv3で大きな非互換性があったことが記憶に新しいと思いますが、、、10年以上前(2013年)のことでした……。まじか。

ともかく、コードあるところに互換性の問題は常にあります。一度決めたAPIはなかなか変えれません。

しかしながらコードがなければ互換性に気を揉まなくても良くなります。よりより設計を思いついたらすぐに取り入れることができます。これはプロダクトの成長スピードに関わってくると思います。

CLIの挙動の非互換はもちろんありえますが、コードの非互換よりは小さな問題でしょう。

縛り

縛りを設けることで呪力の総量を上げるのはプログラマーの基本中の基本です。

目的を絞りコンセプトを一貫させることによって、より深いプロダクトになるはずです。

ほんとに何とかなるの?

当然全てのパターンでは成立しません。例えばArray.newはマイナスの値を入れるとエラーになるし、RBSとしてパースできる文字列しか::RBS::Parser.parse_typeは受け付けません。このような場合ではカスタムコードを書かざるをえないでしょう。なんならRaaP本体ではRubyの基本的なclassに対して結構カスタムコードを書いてしまっています。

しかしながら、書いてしまうとこれまで否定してきた問題に直面することになります。

この辺の問題とどうバランスを取っていくのかが、今後の見どころです。

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

前回までのあらすじ

RBSがテストになるとおもしろいんじゃないか日記1 - スペクトラム

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

RBSがテストになるとおもしろいんじゃないか日記3 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記4 - スペクトラム

続シンボリックコール

シンボリックコールは、前回紹介したようにメソッドの呼び出しを遅延評価するデータ構造である。

ザックリとしか紹介できていなかったので、ここで深掘りしておく。

シンボリックコールは次のようなシンプルなデータ構造だ。

[:call, Integer, :sqrt, [4], {}, nil]

この構造は次のRubyコードをシンボリックコールで表現したものだ。

Integer.sqrt(4)

最初に:callがあることで、ありえそうなArrayデータと区別しやすくしている。

次のIntegerはレシーバーだ。

次の:sqrtはレシーバーから呼び出すメソッド名。

次の[4]は位置引数。

次の{}はキーワード引数。

次のnilはブロック引数を表した実際の値となっている。

Rubyにおけるオブジェクト指向的にはclassにしたほうがいいのだろうが、シンボリックコールの発祥であろうQuickCheckをリスペクトした形だ。また、単純なタプルだとinspectで単純に表示できるし、パターンマッチでマッチするように書いても自然だ。

余談だが、raapを作るにあたってerlangとelixirとhaskellを少し学んだので、raapではこれらをリスペクトしてパターンマッチやメソッドの再起呼び出しを多用している。

ネスト

このシンボリックコールはネストできる。例えば前の例を拡張してみよう。

i = "4".to_i
Integer.sqrt(i)

上のコードをシンボリックコードで表現するとこうなる。

[:call,
  Integer, :sqrt, [
    [:call, "4", :to_i, [], {}, nil]
  ],
{}, nil]

位置引数部分でネストしていることがわかる。

さらに複雑な例をraapのテストコードから引用する。

test_a = Test::A.new()
test_b = Test::B.new()
test_c = Test::C.new(a: test_a, b: test_b)
test_c.run()

を表現すると次のようになる。

[:call,
  [:call, Test::C, :new, [], {
    a: [:call, Test::A, :new, [], {}, nil],
    b: [:call, Test::B, :new, [], {}, nil],
  }, nil],
:run, [], {}, nil]

このように、メソッド呼び出しの結果を利用しなければならないという制限はあるものの、raapでイメージしている型からの値生成では十分に柔軟なものだと思われる。

なににつかうの

さて、このシンボリックコールが何の役に立つかというと、"ランダムな値"の生成時にこの値を生成してしまうのではなく、シンボリックコールで生成するのだ。

単純な例えだと、あるFloatの値が欲しいとしたら、

Random.rand

とするのではなく、

[:call, Random, :rand, [], {}, nil]

としておくのだ。

"ランダムな値"を取得すると、このシンボリックコールが手に入り、このシンボリックコールを実際に実行することで"ランダムな値"を取得する。

なんでこんな回りくどいことを?

シンボリックコールはinspectするだけでも、なんとなく何がしたいのかわかるが、そこまでフレンドリーでもない。 そこで、このシンボリックコールからRubyのコードに戻すことを考えた。これはQuickCheckでもproperでもやっていない。

つまり、これを

[:call, Random, :rand, [], {}, nil]

こうする。

Random.rand

これを、

[:call,
  [:call, Test::C, :new, [], {
    a: [:call, Test::A, :new, [], {}, nil],
    b: [:call, Test::B, :new, [], {}, nil],
  }, nil],
:run, [], {}, nil]

こうするのだ。

test_a = Test::A.new()
test_b = Test::B.new()
test_c = Test::C.new(a: test_a, b: test_b)
test_c.run()

こうすることによって、もしテストが失敗した場合に、かなり実際に近い"再現コード"を手に入れることができる。

"ランダムな値"を手に入れるために、さまざまな選択肢が存在する。

例えば Integer? なら Integernil かをランダムに選択している。

Integer | Stringだったら、 IntegerString かでランダムに選択している。

選択肢があると、引数に使われた42はどうやって作られた42なのかわからなくなる。

再現コードがあれば、問題の再現はもちろん、raapがどういう選択をして"ランダムな値"を選択したのかもわかる。

これはさすがに便利なんじゃない?

どうやってRubyコードにするの?

"実装を見てくれ"の一言だが、軽く説明する。

まずこのシンボリックコールを再起的に渡り歩いていく。

トップレベルがシンボリックコールでないならその値が最終的な値。

シンボリックコールなら、レシーバー、位置引数、キーワード引数の順に見ていき、これがシンボリックコールなら、またレシーバー、位置引数、キーワード引数と見ていく。

再起的に見ていくと、もうこれ以上深ぼれないシンボリックコールにたどり着くので、これから文字列化していく。 文字列化したら最終結果にためておき、"変数名"を戻す。

変数

この変数は現状かなりザックリつくっており、module/class名をdowncaseしただけというものになっている。名前が被ってしまうことがあるが、あんまりいい解決策は見つかっていない(なんとかしたい)。

変数が返ってきたらこの変数をRubyコードに文字列として組み込んでやる。再起が戻っていけば、自然と上からRubyコードを実行したかのように文字列が組み上がるという寸法だ。

つなみに"ランダムな値"は、変数を返すのではなく、シンボリックコードを実行した結果を返す。

[:call, Random, :rand, [], {}, nil]

だったら、

Random.rand

にして、

0.2585089800916408

を得るのだ。これを再起的に戻していって、レシーバーなり引数に利用して、コードを実行していく。

つまり?

シンボリックコールを使えば使うほど、再現コードが手に入る確率が上がり、便利になっていくというわけだ。

まとめ

シンボリックコールはいいぞ。

あんまり読み返してなくて眠いので読みづらいと思うが、書いて出しが長続きするのでここで一旦publishする。

RBSをテストコードにする

DALL-Eより: Imagine a scene where the abstract concepts of Ruby programming and property-based testing blend harmoniously. Picture a large, glowing ruby crystal

まいどお馴染み、作ってみたシリーズです。

今回は、RaaP(ラープ)というツールを作りました。RBS as a PropertyでRaaPです。

github.com

RaaPはテスティングツールの一種で、RBSをそのままテストコードにみたてて実行してくれるツールです。

次のようなRBSがあったとして

class Foo
end

class Bar
  def initialize: (foo: Foo) -> void
  def f2s: (Float) -> String
end

つぎのようなテストコードを自動的に作って実行してくれるイメージです。

describe Bar do
  let(:foo) { Foo.new }
  let(:bar) { Bar.new(foo: foo) }

  it "#f2s" do
    100.times do |size|
      float = Random.rand * size
      expect(bar.f2s(float)).to be_a(String)
    end
  end
end

プロパティベーステストにおけるプロパティとは、どんなランダムな値が来ても共通で成功するテストケースのことです。

RBSをプロパティとして考える、"RBS as a Property"というわけです。

実際は追加のテストコードの記述は一切必要ありません。RBSを書くだけでテストコードとしても使えるし、書いたRBSはもちろんSteep等で利用できます。これが基本的なコンセプトです。

着想

最初の着想は、golangからでした。

私はrgotという誰も使っていないテスティングツールを愛用しているのですが、これはgolangの標準テスティングライブラリーであるtesting PackageRubyでそのまま書いてみたものです。RubyKaigiのLTでも発表したことがあるので、このときにテストの考え方については自分なりのモデルを作れたと思います。

久しぶりにrgotのメンテをしようとgolangのtesting Packageをのぞいてみると、rgot実装時にはなかったFuzzingという機能が追加されていました。なんの機能かもわからずとりあえずポートしてみると、なるほど、型を指定するとランダムな値を生成してテストを実行する機能だったようで、そういう考え方もあるのかと感心しました。

実践プロパティベーステスト

そこからさらに「実践プロパティベーステスト」を読んだことで新しいテストパラダイムがあることが非常に面白く感じました。

『実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう』www.lambdanote.com

この本を読んでいる時に、いろいろな考えが浮かびます。

「これをRubyで表現したらどんな感じかなあ」

「QuickCheckは型が重要なHaskellでうまれてる。RBSを使って何かできないかなあ」

「このinteger()っていうジェネレーター、RBSから作れないかなあ」

RBSを書いたらテストコードを自動生成するのはどうだろう?」

「っていうかそのまま実行したらいいんじゃない?」

「そうすればテストコードの互換性とかで悩まなくても済むし、コードを書かなくてもいきなり使える手軽さもあるし、コードのテストもRBSのテストもできるし、最悪ツールを使わなくなってもRBSは残るんじゃない?」

「これやばくない?」

とそんな感じで開発してみたものになります。

実績からみる使用例

このツールは開発中でも多くの成果を出しています。

Symbol#=~

簡単な例から紹介しましょう。

https://github.com/ruby/rbs/pull/1704

ここではSymboldef =~: (untyped obj) -> Integer?を例に見てみます。

untypedはいわゆるany型で、全ての型がありえることを表します。

しかしながら、実際はなんでもいいわけではありません。

:sym =~ 1
# => undefined method `=~' for an instance of Integer (NoMethodError)

Integerを引数に渡しても絶対に成功しません。このままではSteepでチェックするとIntegerを渡しても型エラーになりません。このRBSは不十分であることがわかります。

これまでruby/rbsリポジトリーでは、RBSの記述の確かさはどのように確認されていたのでしょうか?

もちろん、テストが全く無かったわけではありません。

Symbol#=~のためには、以下のテストが用意されていました。

    assert_send_type "(Regexp) -> Integer",
                     :a, :=~, /a/
    assert_send_type "(nil) -> nil",
                     :a, :=~, nil

https://github.com/ruby/rbs/blob/03e1ad0d9925c4e107bed8859f26739cfd848ce8/test/stdlib/Symbol_test.rb#L40-L45

おそらくRBS明瞭期、大量にあるcoreメソッド一つ一つ確認していくことは難しく、メソッド名だけでもと型が用意されたのでしょう。それくらい正確なRBSを考えるのは人間には大変な作業なのです。

しかしながらこれでは、RBSの表現に対してテストパターンが足りなかったわけです。

RaaPで試してみましょう。

$ bundle exec raap 'Symbol#=~'
# Symbol

## def =~: (untyped obj) -> ::Integer?
SE, [2024-03-22T22:45:57.257849 #92819] ERROR -- : [TypeError] type mismatch: String given
F
Failed in case of `:v.=~(:r) -> nil[NilClass]`

### call stack:

```
:v.=~(:r)
```

success: 0, skip: 1, exception: 0

Fail:
def =~: (untyped obj) -> ::Integer?

:v.=~(:r)というケースで、TypeErrorが起きたと報告しています。

untypedという表現から、様々なオブジェクトで呼び出しを試み、TypeErrorが起きるパターンを、再現コード付きで教えてくれました。

機械的にテストケースを生成して試すことで、人間には大変だったテストケースの生成を大量に行うことができ、RBSの不十分な点がCLIコマンド一発で見つかりました。

Integer#pow

もう少し大きい例を紹介します。

https://github.com/ruby/rbs/pull/1706

これは以前の開発日記でも紹介したように、型の専門家であっても気付けない難しい型です。

RaaPなら簡単に再現コード付きで型の間違いを教えてくれます。

$ bundle exec raap 'Integer#pow'
# Integer

## def pow: (::Integer other, ?::Integer modulo) -> ::Integer
EI, [2024-03-22T22:53:31.836090 #93160]  INFO -- : Exception: [ZeroDivisionError] divided by 0
..EI, [2024-03-22T22:53:31.836288 #93160]  INFO -- : Exception: [ZeroDivisionError] divided by 0
..F
Failed in case of `2.pow(-1) -> (1/2)[Rational]`

### call stack:

```
2.pow(-1)
```

success: 4, skip: 0, exception: 2

## def pow: (::Float) -> ::Float
...F
Failed in case of `-2.pow(-4.25) -> (0.037162722343835025-0.037162722343835025i)[Complex]`

### call stack:

```
-2.pow(-4.25)
```

success: 3, skip: 0, exception: 0

## def pow: (::Rational) -> ::Rational
..F
Failed in case of `3.pow((3/11)) -> 1.3493480275940617[Float]`

### call stack:

```
rational = Rational(-3, -11)
3.pow(rational)
```

success: 2, skip: 0, exception: 0

## def pow: (::Complex) -> ::Complex
....................................................................................................
success: 100, skip: 0, exception: 0

Fail:
def pow: (::Integer other, ?::Integer modulo) -> ::Integer
Fail:
def pow: (::Float) -> ::Float
Fail:
def pow: (::Rational) -> ::Rational

3つのケースで間違いがあることを、再現コード付きで教えてくれました。 Rationalを使用したケースではcall stackという再現コードが2行に分かれています。 これをそのまま実行すれば、型がおかしかったパターンを再現できるわけです。

String#initialize

https://bugs.ruby-lang.org/issues/20292

開発途中でRubyのバグも見つけ報告しています。こんなコード人間はまず書かないと思いますが、ランダムな値によるテストならみつけ出せたので可能性を感じます。現在のRaaPでは、publicメソッドのみ確認するように変更したので再現コードは作り出せません。privateメソッドはオプショナルで実行できてもいいのかな……?

どうやって見つけたの?

RaaPではルートとなるmodule/class名を指定すれば、その名前で定義されているメソッド全てでテストできるようになっています。

出力が小さそうな既知のclassで試してみます。

$ bundle exec raap 'TrueClass'
# ::TrueClass#!

## def !: () -> false
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#===

## def ===: (true) -> true
....................................................................................................
success: 100, skip: 0, exception: 0

## def ===: (untyped obj) -> bool
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#inspect

## def inspect: () -> "true"
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#to_s

## def to_s: () -> "true"
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#to_json

## def to_json: (?::JSON::State state) -> ::String
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#&

## def &: (false | nil) -> false
....................................................................................................
success: 100, skip: 0, exception: 0

## def &: (untyped obj) -> bool
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#^

## def ^: (false | nil) -> true
....................................................................................................
success: 100, skip: 0, exception: 0

## def ^: (untyped obj) -> bool
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#|

## def |: (untyped obj) -> true
....................................................................................................
success: 100, skip: 0, exception: 0

複数のメソッドを横断的に確認することができました。

他にもいくつかruby/rbsRBSでの間違いをみつけています。

これから

このツールには可能性を感じているので、さらなる有用性を探究していきたいと思います。

RBSからのランダム生成だと、どうしてもカスタムなコードがないと無理なケースも多く存在します。 そのため、あくまで第一優先はCLIからの利用と考えつつも、既存のテストコードに組み込める表現も考えています。

これまではCRubyのcoreなclassに対して実験してきたので、実装はかなり信頼におけるケースが多かったのですが、 実際のアプリケーションでどのような有用性を発揮できるか確認できていないので、その辺も探っていきたいと思います。

無理矢理いいところを探すんじゃなくて課題ベースで考えたらって?

はは。

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

前回までのあらすじ

RBSがテストになるとおもしろいんじゃないか日記1 - スペクトラム

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

RBSがテストになるとおもしろいんじゃないか日記3 - スペクトラム

間が開いた

結構間が開いてしまったがまだあきらめずに開発している。 サイドオーダーは全てのパレットをクリアして虹バッチとりました。

サイズ

プロパティベーステストの重要な概念にサイズがある。

サイズは簡単にいうと乱数に対する係数である。

Integerのサイズ0は0、Arrayのサイズ0は[]となる。

経験的にバグは0[]の場合に起きることが多いことから、サイズは0から始めて、少しずつ大きくしていく手法をQuickCheckをはじめとした多くのプロパティベーステストツールでとっている。簡単に示すとこんな感じだ。

100.times do |size|
  float = Random.rand * size
  integer = float.to_i
  array = Array.new(integer)
end

こうすることで単純なランダムよりも、不具合の起きやすいデータに集中してテストケースを作り出すことを目的としている。

このサイズを、これまで考えてきたものに実装するとどうなるか考えていく。

なんの引数?

これまで、Type.new("Integer").pickというAPIを考えてきた。「Integer型から、1つ具体的な値をとりだすよ」という意味を表している。

サイズの概念を導入した時、最初はこう考えていた。

Type.new("Integer", size: 10).pick

コンストラクターにサイズを設定した方が、内部のコード的には簡潔になるからだ。 しかしながらこれは間違いだと気がついた。概念的におかしいからだ。「サイズ10のInteger型から1つ具体的な値をとりだすよ」と読めるが、「サイズ10のInteger型」とはなんだろう?サイズは増加させてテストケースを作る。「サイズ11のInteger型」を作るにはまた別のオブジェクトが必要になるだろうか?なんかしっくりこない。

変更してこうした。

Type.new("Integer").pick(size: 10)

違いはわずかだが、「Integer型からサイズ10で具体的な値を取ってくる。」と考えれる。サイズが増加しても「Integer型からサイズ11で値を取ってくる。」と考えれるのでオブジェクトは使いまわせる。サイズは取得時の引数と考える方がしっくりきそうだ。

どこからやってくるのか

サイズはどこからやってくるのだろうか。RaaPでは、1つのメソッドのオーバーロード毎にこの0〜100とサイズを変化させてテストを実行させることにした。 このオーバーロード毎に0~100のサイズを発生させればよさそうだ。この単位を MethodProperty とした。

MethodProperty

MethodPropertyではこのサイズの変化を受け持つ他にも様々な役割を持たせた。ジェネレータからの値の具体化。メソッドの実行。返り値の型チェック。エラーハンドリング。タイムアウト。ループのスキップなどだ。

Result

ではMethodPropertyの実行結果について考える。 成功は考える余地がないほど単純だ。また、バグや型記述の不一致を見つけたいのだから当然失敗はほしい。

  • 成功: RBSに記述された型を引数に与えると期待した型が返ってきた。
  • 失敗: RBSに記述された型を引数に与えると期待しない値が返ってきた。

またなんらかの理由でどうしようもない場合がある。例えばNotImplementedErrorが出た時は、型も何もないと考えられるので、スキップしたい。

他にもInteger.sqrtの定義は(::int n) -> ::Integerとなっているが、実際はマイナスの数字を入力するとMath::DomainErrorとなる。

こういう場合はカスタムコードが必要となってくるが、現段階ではCLIだけの利用を考えているので、簡単に止まってしまっては困る。

というわけで、なんらかのエラーが出ても止まらず進めたい。この状態を例外としたい。

  • スキップ: 型に関係なくどうしようもない場合。
  • 例外: どうしようもなく例外が発生する場合。

TypeError

ではおもしろい考察として、TypeErrorはどう扱うかという問題がある。

例外だと単純に考えていいだろうか?しかしながらTypeErrorはたいてい引数の型が間違っている時に発生する。Integerを期待している箇所にStringがきた場合などだ。これって型エラーなのでRaaP的には検知したい型エラー、つまり失敗のケースなんじゃないかと私は考えた。

よってTypeErrorは例外の例外的に失敗に含めている。

bot

Kernel#raiseのような、絶対に返り値が発生しないメソッドも存在する。この場合はRBSではbotを使用する。 逆にいうと、botが設定されている場合は例外が発生することを期待しているとも言える。 MethodPropertyでは、このbotが返り値だったら例外が発生したら成功、しなければ失敗として扱っている。

再起型

RBSでは再起的な型を許可している。例えばリストにRBSを書いてみる。多分こんな感じ。

class List[T]
  attr_accessor head: T
  attr_accessor tail: List[T]
  def initialize: (head: T?, tail: List[T]?) -> void
end

このclassList[T]インスタンスを生成したい時、tailList[T]インスタンスが欲しい。 ではこのtail用のList[T]インスタンスを生成する時にもまたList[T]インスタンスが必要になってくる。 このような再起的な循環が簡単に起こり得るのでなんとかしたい。 今のところはアイデアはいくつかあるものの、実装に落とし込めてはいない。

ではどうするかというと、SystemStackErrorが発生したら「どうしようもない場合」としてスキップすることにした。

シンボリックコール

これは最近実装した概念で、QuickCheckをはじめとしたプロパティベーステストで使われているデータ構造である。 レシーバー、メソッド名、引数のタプルでできていて、メソッドを呼び出す直前の状態をひとまとめにしている。 シンボリックコールはネストしてよく、コードのメソッド呼び出しそのものの構造を作ることができる。

シンボリックコールの嬉しいところは、メソッドの呼び出し状況の可視化が簡単になる点だ。 AをつくるにはBが必要で、BをつくるにはCが必要で……といったような複雑なオブジェクトの場合、テストが落ちた時のデータの再現が難しかったりする。シンボリックコールを残しておけば、用意に再現手順が作れるし、最悪シンボリックコール自体をdumpすればやりたいことがわかるようになっている。

基本的にデータはこのシンボリックコールで組み立てて、最後にevalつまりはシンボリックコールを実行するようにした。

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

前回までのあらすじ

RBSがテストになるとおもしろいんじゃないか日記1 - スペクトラム

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

メソッド型

前回までで、1つの型について生成できる道筋をつけた。

今回の目標は(Integer) -> Integerを検証したいので、単一の型だけではどうしようもできない。

RBSでは型は大きく3つに分類できる

  • Type
  • Method type
  • Signature

それぞれ対応するパースメソッドが存在するのが分類基準だ。

  • RBS::Parser.parse_type
  • RBS::Parser.parse_method_type
  • RBS::Parser.parse_signature

Type

これは前回で考えた単一の型の話だ。例としてはObject, Integer, Set[String]などはこれに入る。

MethodType

これが今回の主役で、(Integer) -> Integerに相当するものだ。MethodTypeは3つの要素に分解できる

  • 型変数
  • 引数
  • ブロック
  • 返り値

型引数

これはいきなり難しいのだが、重要な要素だ。例えば、引数としてわかされたものをそのまま返すメソッドだとすると、

[T] (T) -> T

と書くことができる。このメソッドの引数をIntegerで呼べばIntegerが返るし、Stringで呼べばStringが返るという挙動をこれで表せる。

今回の問題は、この型引数のランダムな値は何か?というものになる。

名前解決できる場合

例えばArraydef first: () -> Elem?を見てみる。このElemArrayclassの定義にあるclass Array[unchecked out Elem] < ObjectのElemである。ようするにArray[Integer]Integerだ。

このようにclass定義に存在する名前がメソッド定義に出てきた場合は解決可能に見える。Array[Integer]#firstでは、ElemIntegerに置き換えて() -> Integer?とすればいい。

よって名前解決できるなら単におきかえれば良い。

名前解決できない場合

では

[T] (T) -> T

のTが初めて出てくる名前であることも当然あるだろう。Steepでは、未解決のTはあらゆるメソッドの呼び出しも許していない。触れてはいけないものなのだ。あらゆるメソッド呼び出しを塞いだオブジェクトは結構取り扱いが面倒である。いったんはBasicObject.newとかでいいか……?

引数

引数もまあまあ難しい要素だが、今回はRBSに従っていればいいのでそこまで難しいことはしないはずだ。

例えば

(Integer, foo: String) -> void

なメソッドがあったとしたら、

foo(*[123], **{foo: 'aaa'})

というランダムな値を生成してメソッドに私てやるイメージだ。

Required positionals

ふつうの位置引数というやつで、(Integer)のように定義も単純だ。

このランダム値を単純にRBS定義通りの個数用意すればいい。

Optional positionals

デフォルト値がついている、つけてもつけなくてもいいオプショナルな位置引数だ。

RBSでは(?Integer)のようになる。

この場合のランダムな値はなんだろう。確率的に引数があったりなかったりすればいい気がする。

Rest positionals

0〜無限?個指定できる位置引数だ。これは0〜3個くらいをランダムで位置引数として用意すれば大抵のパターンを網羅できる気がする。

Trailing positionals

忘れがちなのがこれ。Rest positionalsの最後のいくつかを、この指定で取得できるという優れものだ。これはRequired positionalsと同じ扱いでよさそうだ。

Required keywords

必須キーワード引数だ。必須なのでRBSの定義通りランダム値を用意すればいい。

Optional keywords

オプションのキーワード引数だ。これもオプションということで、ランダムにあったりなかったりにしよう。

Rest keywords

なんでもキーワード引数だ。keyは何でもいいので、というか何でもいいことを確認したいので、適当な長さのSymbolを0〜3個ぐらい用意してkeyとし、valueRBSの型指定から生成すればよさそう。

ブロック

RBSではブロックの型定義も書ける。() { () -> void } -> voidのように{ (ARG) -> RET }で表現する。

今回はRBSからランダムな値を生成してメソッドを呼び出したい。

よって、このブロックは一旦Procオブジェクトにしてブロック引数として渡してあげる必要がある。

必須かどうかを指定できるが、必須でないならランダムにあったりなかったりにしよう。

ブロックの引数も生成する必要がある。Setではブロックを実行して中の要素を決定する。ブロックにも引数があるのかよ。。。

しかしご安心を。我々はこれまでに引数の生成方法について考えてきた。これをそのまま使えばよいだろう。

RBSの構造

ここでちょっとRBSの構造をおさらいしておこう。

RBSではブロックはRBS::Types::Blockclassで表現されている。RBS::Types::Blockでは、引数と返り値の型のセットであるRBS::Types::Functionや、必須かどうか、selfが何かという情報を持っている。

class RBS::Types::Block
  attr_reader type: RBS::Types::Function
  attr_reader required: bool
  attr_reader self_type: t?
end

ではメソッドはというと、RBS::MethodTypeで表現されていてRBS::MethodTypeは、型引数、引数と返り値の型のセットであるRBS::Types::Function、ブロック型を要素に持っている。

class RBS::MethodType
  attr_reader type_params: Array[AST::TypeParam]
  attr_reader type: RBS::Types::Function
  attr_reader block: RBS::Types::Block?
end

ちなみにRBS::Types::ProcRBS::Types::Functionを持っている。Proc型の生成にも使えそうだ。

class RBS::Types::Proc
  attr_reader :type
  attr_reader :block
  attr_reader :self_type
end

つまり、RBS::Types::Functionという引数と返り値のセットを表す型は非常に重要であることがわかる。

というわけで、RBS::Types::Function用にロジックを用意しておけば、メソッドでもブロックでも使えて便利というわけだ。

返り値

これまでで用意した引数を使ってメソッドを呼び出し、その返り値が返り値の型と一致していればRBS通りの挙動であると言えるし、逆なら言えない。

このチェックロジック自体はrbs本体にRBS::Test::TypeCheckというclassがあるのでこれを利用すればよい。

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]となってほしい気がする
#=> 実際は常に []

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

どうしよう。

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

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#powRBSはこうだった。

  def pow: (Integer other, ?Integer modulo) -> Integer
         | (Float) -> Float
         | (Rational) -> Rational
         | (Complex) -> Complex

RBSを少し説明しておくと、このRBSInteger#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
.............

👌

おもしろい気がする。