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
.............

👌

おもしろい気がする。

aws-sdk-ruby配下すべてのgemにRBSが含まれた状態でリリースされました

みなさまに、RBSに関する重要なニュースを発表できることを嬉しく思います。

私の目標の一つにはRBSを当たり前の世界にするというものがあります。

この目標に対して大きなインパクトを残せたことに大変興奮しています。*1

aws-sdk-ruby配下すべてのgemにRBSが含まれた状態でリリースされました

こちらは公式blogからのアナウンスです。

aws.amazon.com

aws-sdk-rubyrubygemsでの累計ダウンロードランキング2位に乗るほどの人気gemです。(aws-sdk-core)

aws-sdk-rubyは現状370以上のgemのあつまりです。 このすべてのgemにRBSが含まれた状態でリリースされました。 そうです。すべてです。

rbs v3.4.0以上でご利用いただけます。

steep + vscodeの例。etagがStringであることがわかる

え、なにがどうなるの?

具体的には配信されるgemのディレクトリーにsigというディレクトリーが生えて、中にRBSファイルが配置されるだけです。ライブラリーの挙動は何も変わりません。productionでも安心してご利用いただけます。

RBSをプロダクトに導入していない場合

何も変わりません。RBSを導入しましょう!

RBSをプロダクトに導入している場合

まずrbsをv3.4.0以上にあげましょう。それ以前だとgem_rbs_collectionのaws-sdk-coreが優先して読み込まれてしまうので、最悪RBS読み込みに失敗してしまいます。

まさかとは思いますが、万が一rbs collectionを使用していない場合は全く問題ないです。

たくさんのaws-sdk-ruby gemを使用している場合、RBSの読み込みが遅くなるかもしれません。 使わなそうなものはrbs_collection.yamlignore: true指定すると良いかもしれません。

なんでそんなに詳しいの?

私が実装を担当しました。何かおかしかったら直すので教えてください。

https://github.com/aws/aws-sdk-ruby/pull/2961

なにがおこったの?

2年くらい前

RBS利用当初から、ネットワーク等のI/Oを使用するプログラムと型は相性がいいとにらんでいました。 テストコードだと、外部通信だのモックだので複雑になったり、そもそもおろそかになりがちだったりするところですが、型ならコードを書いた瞬間からレスポンス形式の不一致が分かったりします。そんなわけでaws-sdk-rubyとは相性が良いだろうと考えていました。

そこで、私はgem_rbs_collectionというリポジトリーでaws-sdk-rubyに対するRBSを生成するという取り組みを行なっていました。

aws-sdk-rubyRubyコードのほとんどはJSONから自動生成されており、その仕組みに乗っかってRBSも出力してみようというアイデアです。

初期実装に2ヶ月くらいかかりました。※毎日の寝る前の1時間とかでやってる趣味活動です

これ自体はaws-sdk-rubyの中の人は知らず、私が勝手にやっている活動でした。

https://github.com/ruby/gem_rbs_collection/pull/118

2ヶ月くらい前

RubyConf2023 in San DiegoでRBSの作者である id:soutaro さんの元にaws-sdk-rubyのメンテナが「aws-sdk-rubyRBSを入れたいんだよね」と声をかけたそうです。

soutaroさんは私の活動を知っていたので、すでにやってる人がいるよとgem_rbs_collectionを紹介したそうです。

という話を私がsoutaroさんから聞き、「それなら、ようし ひとつ やってみるかな」とissueを立てました。

https://github.com/aws/aws-sdk-ruby/issues/2950

やるぞ宣言です。

2週間くらい前まで

既存の実装を乗せ替えるだけでしょ。と思っていたら既存の実装は先方の理想の基準に達しておらず、長い開発の日々が始まりました。※毎日の寝る前の1時間とかでやってる趣味活動です

2ヶ月の間コードを書く→レビューを受けるを繰り返しました。

95のコメントと57ファイルの変更、1,657行の追加と22行の削除が行われました。

RBSの整合性を確認し、実際のライブラリーの挙動とRBSの表現で乖離がないか確認するテストも追加して、CIとしてタスクも追加しました。

メンテナがメンテナンスしやすいようにコードの分離関係も気を使いました。

もちろん全世界で使われる重要なライブラリーなので相当なプレッシャーでした。

ドキュメントひとつとっても、先方と議論を交わしました。

タイムゾーンが違いすぎるので、議論も1コメントしては次の日まで返事を待つというサイクルでした。

rbs本体側の問題もあったのでいくつか修正しました。※そのためrbs v3.4.0以上でないと利用できません。

おかげさまで満足できる完成度に至りました。

S3のget_objectのRBS(左)と公式ドキュメント(右)の比較

伝わりますか?オプションの順番までドキュメントと一緒インデントもあって読みやすい。狂気の完成度と言えるでしょう。(言えない)

ちょっと前

https://github.com/aws/aws-sdk-ruby/commit/5b831e8eaa27b453fe6c1ca2e38b69ec39c58326

かくして、4,276 changed files with 983,107 additions and 2,101 deletions.という豪快なcommitによって、全サービス370gemで同時リリースが行われました。

RBS界隈へ与えたインパクトは大きいと思っていて、RBSがgemに含まれるのが当たり前の世界に近づいたとさえ思っています。最近RBSを同梱しているgemが増えたと耳にします。そこに世界トップクラスのgem 370個にもRBSが入ったとなれば、さらにRBSをgemに同梱する流れは加速するんじゃないかと考えています。

RBSの工夫点

RBSで型をつける上でのTipsを記憶があるうちに残しておきます。 型チェッカーはsteepを想定しています。RubyMineでは大きな問題はないですが、ちょっと違う挙動らしいです。

RBS::Testを使用

RBSの正しさを検証するためにRBS::Testを使用しました。

RBS::Testが何なのかは以前に記事を書いたのでこちらも合わせてどうぞ。 RBSのテスト方法4パターン

RBS::Testを使ってaws-sdk-coreとaws-sdk-s3のrspec上でメソッドの引数と返り値を監視して、定義通りの挙動かチェックしています。 RBS::Testはあんまり使用例がないらしく、大きな使用例を世界に示せたと思います。

実装は短いrake taskになっているので参考にどうぞ。

https://github.com/aws/aws-sdk-ruby/blob/5b831e8eaa27b453fe6c1ca2e38b69ec39c58326/tasks/rbs.rake

rspecのタグを使って、後述するレアケースはスキップするようにしていますが、レスポンス構造の正しさなど基本的な部分は十分保証できています。

ほとんどのgemは自動生成された少量のテストケースしかないのですが、aws-sdk-s3は特別にテストケースが多くユーザーも多いので、これさえ通れば他のサービスもカバレッジ的にほぼカバーできているはずです。

引数の型

aws-sdk-rubyの特にClient系メソッドは以下のように書けます。※名前は私が適当につけたものです

client = Aws::S3::Client.new

# keyword argument style
client.get_object(bucket: 'bucket', key: 'key')

# record argument style
client.get_object({ bucket: 'bucket', key: 'key' })

# hash argument style
args = { bucket: 'bucket', key: 'key' }
client.get_object(args)

そのため、次のようなRBSを書くと、record argument styleとhash argument styleでエラーになります。

def get_object: (bucket: String, key: String) -> Response

かといって、こう書いてもhash argument styleでエラーになります。 Hashオブジェクトは変数宣言時にrecord型からHash型になるっぽい?のです。※将来的にSteepの挙動が変わるかもしれません。

def get_object: ({ bucket: String, key: String }) -> Response

そして次のように書くと全パターン通りますが、引数の型情報がなくなってしまいます。

def get_object: (Hash[Symbol, untyped]) -> Response

最終的には次のように、keyword argument styleとhash argument styleをまぜました。

def get_object: (bucket: String, key: String) -> Response
              | (Hash[Symbol, untyped]) -> Response

最終的にはhash argument styleで受けるのでエラーは発生しません。しかしながらkeyword argument styleがコードを書いているときにサジェストされるのでコーディング時のサポートになります。 しかもRubyMineではkeyword argument styleの方が採用されて引数の型チェックができるっぽいです。すごい。本当かどうか確かめていません。

返り値の型

Delegatorの表現

RBSでは、同じclassなのに使えるメソッドが変わるDelegator class系の表現は難しいです。 aws-sdkの各Clientレスポンスデータは#dataで取得でき、かつ#dataを省略すると#datadelegateされます。

resp = Aws::S3::Client.new.get_object(...)
resp.class #=> Seahorse::Client::Response
resp.data #=> Types::GetObjectOutput (レスポンスデータ)
resp.data.etag #=> String
resp.etag #=> String (dataへdelegateされている)
resp.successful? #=> bool (各レスポンス共通で使えるメソッドもある)

こういうclassのRBSはどう書けばいいのでしょうか?そのままSeahorse::Client::Responseを返しても、Seahorse::Client::Response#etagメソッドを持っていません。class GetObjectOutput < Seahorse::Client::ResponseのようにRBSを定義しても、現実とclassが異なるのでRBS::Testは落ちてしまいます。

今回はinterfaceを使って解決してみました。 イメージとしてはこうです。

interface _ResponseSuccess[DATA]
  def data: () -> DATA # レスポンスデータ
  def successful?: () -> bool # 共通メソッド
end
interface _GetObjectResponseSuccess
  include ::Seahorse::Client::_ResponseSuccess[Types::GetObjectOutput] #=> 共通メソッドをとりこみつつ、dataでレスポンスデータを取得できるように
  def etag: () -> ::String #=> delegateするメソッドはinterfaceのメソッドとして生成しちゃう
end
def get_object: (...) -> _GetObjectResponseSuccess

RBS::Testでは、interfaceならメソッド名が存在しているかと、メソッドの引数の個数ぐらいしかみません。 かつsteepなどの型チェッカーでも正しい型が扱えます。

SuccessとErrorでレスポンスの型をわけるという割り切り

Clientのレスポンスでは、問題があると普通は例外をraiseするのですが、raiseしないオプションを使用することもできます。こうなると#etagなどは呼び出せないので使えるメソッドが変わってしまいます。よってRBSとしては正しくなくなってしまいます。

この問題へは、raiseしない場合はレアケースと割り切って、レスポンスは全て成功する前提で型をつけています。 もしレアケースを使いたい場合は、型アノテーションを使ってエラーパターンを指定してあげてください。

client = Client.new(raise_response_errors: false)
# @type var response: ::Seahorse::Client::_ResponseError
response = client.operation()
response.error.message

レアケースなのでちょっと難しい対応にはなりますが、多くの場合でメリットを享受できるので実践的な割り切りといえます。

Resourceの型

Resource系は型付けと相性が良いようで、画像のように複雑なメソッドチェインしても型情報を失いません。

aws-sdk-rubyの人たち

GitHub上では3名で管理しているっぽくて、少人数で大きなプロジェクトを管理するという責任あるお仕事をされていました。 しかしながら、そもそもRBSを導入したいと思っていたことや、PRでも好意的にサポートしていただいてレビュー体験は非常に良いものでした。みんなプログラミングが好きなんだろうな。 最初は1サービスに導入して様子を見るのかな?と思っていたら、いきなり全サービスに導入する話になっていて、豪快さも持っています。さすが世界トップクラスのライブラリーのメンテナという感じでした。感謝。

さいごに

aws-sdk-rubyの型は、利用頻度が高そうなものに絞って型をつけているので、結構主観が入っています。 例えばAsyncClientはまだ対応していませんし、coreは全て手書きなので、まだまだ型が足りないでしょう。 つまりはコントリビュートチャンスです。みんなでこのビッグウェーブに乗っていきましょう!正直一人は心細い!

*1:言ってみたかっただけですすみません

RBS v3.3.0のリリースノートを読む

RBS v3.3.0がリリースされたのでリリースノートを読んでみたいと思います。

https://github.com/ruby/rbs/wiki/Release-Note-3.3

Add rbs diff command

新コマンド追加です。

https://github.com/ruby/rbs/issues/1448

にPR作者の意気込みが書かれていますね。

例えばgem_rbs_collectionへPRを出す時に、RBSの自動生成をしたものだと差分が大きくて何が変わったのか、なにか抜け落ちてないか見抜くのは困難になります。そこで、結局何が変わったのかメソッド単位で差分を突き合わせて、「増えたもの」「減ったもの」「変わったもの」を抜き出して表示する。しかもPRに貼り付けることを意識してのmarkdown table形式や、CLI出力を意識しての色付きdiff形式等、出力形態も選べるようです。しかもこういう調査は結果さえわかればいいので手軽に実行したい。なのでいちいちスクリプトを書いて実行するのはしんどい。よって新コマンド追加というわけですかね。

なるほど、現実的な課題に向かい合った結果の機能追加というわけですね。

Add --todo option to rbs prototype runtime

これもPR作者の意気込みが書かれた文章が見つかりました。

https://github.com/ruby/rbs/issues/1449

内容をエスパーすると、最初の動機は上記のrbs diffコマンドを実験していた時に実際に実行したときのinstance_methodsなりとRBS定義とをdiffで比較してみると、意外とRBSの定義漏れを発見できて便利なんじゃないか?と思ったようですね。 そこからrbs diffコマンドとは分離して、rbs todoコマンドを想像したようですが、pockeさんから「rbs prototype runtime と絡めるといいんじゃないか」という助言をもらい、rbs prototype runtimeのオプションとする案に落ち着いたようです。あくまでエスパーです。

このオプションを使うと、実際には存在するんだけど、RBSはまだ無いものだけをプロトタイプとして生成してくれるもののようです。 実際のデモでもIOのような組み込みクラスでも、まだ実装されていないメソッドが見つかっています。 「RBSになにか貢献したいなあ」と思っている方も、これを使ってまだ無いメソッドを探してPRで追加することができそうですね。

Add __todo__ type

これは名前から察するに、上記の--todoオプションと関連するかと思いきや、実は関係ない変更です。たまたま名前が被ったようですね。 __todo__は、ほぼuntypedと同じですが、"なんでもいいよ"という意味のuntypedと、"まだ決めれてないよ"という意味のuntypedを分けて管理できるようにしたんじゃないかと思います。

prototypeの出力こそまさに__todo__向きかも?

Additional syntactic validation with rbs validate

rbs validateでチェックする項目が増えました。引数にvoidを使ったり、定数にselfを使ったり等、型の使い方を間違えていると教えてくれるようになったようです。間違うとどう動くんだろ。 新しく追加されたエラーはwarning的な扱いですが、将来的には文法エラーとして実装予定らしいのでこのエラーを見たらすぐに修正するとよさそうです。

Delete sources: section from rbs_collection.lock.yaml

rbs_collection.lock.yamlファイルのsources:セクションは実はもう使ってないので削除されたようです。

ついでのCHANGELOGも読む

Signature updates

coreの型が数多く修正されています。ほとんど同じ人がやっていて、数がすごい。

Library changes

バグ修正が多いですが、少しピックアップしてみましょう。

Show location of type by method command (#1537)

rbs methodコマンドの出力として、型情報が記述されているファイルの場所が出力されるようになったようです。型情報を直したい時にすぐに見つけれて便利かも。

Better support for inherited class of Struct or Data by prototype runtime #1571

StructDataを継承したclassの場合にいい感じにプロトタイプが出力されるようになったようです。

This will be one of the most important improvements in RBS 3.3. 🎉

なんかすごそうですね。

StructDataによって作られたclassの型をどう書くかという問題は度々議論に上がっていましたが、この変更によってある程度の回答とできそうですね。

[prototype runtime] Add --autoload option (#1561)

rbs prototype runtimeはautoloadの壁を越えれないという弱点があったのですが、--autoloadというオプションをつければ限界を超えれるようです。将来的にデフォルトで有効になりそうな感じで書かれています。

[prototype runtime] Optimize performance (#1495)

マイクロベンチマークによると、classが1万個ある場合だと、100sから1sと100倍早くなってます。Railsアプリケーションとかだとそれぐらいいくので嬉しそうです。

[Collection] Simple colorize collection text like Bundler (#1558)

rbs collectionコマンドでBundlerのように色がつくようになったみたいです。色がつくとなんかちゃんとしてる感があっていいですね。

Kaigi on Rails 2023に登壇した。

kaigionrails.org

2日目の最初だったので、オープニング担当だなと勝手に解釈した。

多分100人ぐらいはいた?50人くらいかもしれない。分からないけど1人でもお客さんがいるならライブをやるとdir en greyの京も言ってたしがんばった。

客観的にどういう公演になったかは分からないけど、主観的には出し切れてスッキリした気持ち。いいライブができた。

もうちょっとネタを盛り込みたかったけど社名を背負っているので自重した。「LUUPで来た」ぐらいは言いっとけばよかった。

周りに与えた影響として一番大きいのはrakeのリリースを急かすことに成功したことだと思う。rakeは影響力が大きいgemなので大変だとは思います。リリースお疲れ様です。

これからはとりあえずプロダクト側の改善を続けて、RubyKaigi 2024に向けてのネタを探したい。

関係者の皆様お疲れ様でした。

rakeのエラー表示がちょっとだけ便利になりました。

rake v13.1.0がリリースされました 🎉

https://rubygems.org/gems/rake/versions/13.1.0

このリリースには私が実装した改善が含まれているので紹介します。 というか書かないと環境変数とか誰にも気づかれなさそう。

Support `#detailed_message` when task failed by ksss · Pull Request #486 · ruby/rake · GitHub

rake task実行時に何らかのエラーによって失敗したとき、error_highlightやdid_you_meanが効くようになりました。

before

$ bundle exec rake demo
rake aborted!
NoMethodError: undefined method `opject_id' for main:Object

after

$ bundle exec rake demo
rake aborted!
NoMethodError: undefined method `opject_id' for main:Object (NoMethodError)

  p self.opject_id
        ^^^^^^^^^^
Did you mean?  object_id

これまでは発生したエラークラスの#messageメソッドを表示していたのですが、#detailed_messageというメソッドが呼べるならこっちを呼び出すようになります。

ruby v3.2.0からException#detailed_messageが導入されており、これを利用しています。

エラークラスに#detailed_messageメソッドさえあればv3.1以前のrubyでも動くっちゃ動くのですが、稀なケースだと思われます。

rbsはv.3.1でも#detailed_messageを呼べるようにしたのでこの施策は有効です。

Debug at stop when task fail by ksss · Pull Request #489 · ruby/rake · GitHub

環境変数RAKE_DEBUGに何かしらの値が入っていると、rake taskが失敗したときにdebug gemによるデバッガーが起動します。

$ RAKE_DEBUG=1 bundle exec rake demo
undefined local variable or method `aaa' for main:Object
[30, 36] in ~/src/github.com/ksss/rake/Rakefile
    30| end
    31|
    32| task default: :test
    33|
    34| task :demo do
=>  35|   aaa
    36| end
=>#0 block in <top (required)> at ~/src/github.com/ksss/rake/Rakefile:35
  #1    [C] Kernel.load at ~/.rbenv/versions/3.2.1/lib/ruby/3.2.0/bundler/cli/exec.rb:58
  # and 14 frames (use `bt' command for all frames)
(rdbg:postmortem)

これまでrdbgコマンドでrakeタスクに対してブレイクポイントを仕込む事はできましたが、エラー発生時にデバッガーを起動するpostmortem機能を使っても最後に起きたexceptionに対してデバッガーが起動するので、本来調べたかったコンテキストが失われた状態になっていました。

そこで rspec-debug gemをヒントにRake::Task#execute実行中に発生したエラーに対してpostmortemを起動することで、本来得たかったコンテキストを得ることができるようになりました。

もちろんdebug gemが使える状況でなければ環境変数をつけても起動しないのでご注意ください。

なにか問題があれば直すので教えて下さい。

まとめ

rake便利。