仕事で利用しているライブラリがSorbetを使っており、sorbet-runtime
をupgradeするdependabotのpull requestsがバリバリ供給されてくるのでもう少し中身を知っておきたいと思って公式ドキュメントを拾い読みしたりしたメモと所感。
ドキュメントではStripe社内の実態の話がちょいちょい出てくるのと、Ruby3との関わりあたりが興味深かった。
また、Sorbet関連のプロジェクトを少し触ってみてパッチを送ってみる過程でも型をブッ込んだプロジェクトってこんな感じなのか〜と思うところがあった。
大きくは2つの構成要素
sorbet
gem
- コードの静的解析を行うCLIツール
- 実行時には必要ない
sorbet-runtime
gem
- Rubyのコードにtype annotationを加えられるようにする
T
namespace とsig
methodによるsignatureの記述- 実行時に動的に型検査を行う
漸進的型付け
Gradual Type Checking & Sorbet · Sorbet
型の恩恵を受けるためだけにすでに存在している巨大なコードベースをrewriteしたり、言語が持つ大きな資産を捨てて別言語に乗り換えるのは現実的ではない。特にRubyにはものすごい量と質の資産がある。
既存資産と型の恩恵の両方を活かすにはTypeScriptが実践して成功を収めたような漸進的型付けが有効となる。
Sorbetでは妥協点として、メソッド呼び出し・メソッド定義・クラス・ファイルそれぞれの単位で型検査をオプトアウトできるようにした。
T.untyped
Sorbetの型システムにおける型の一つ。漸進的型付けという観点でいえばもっとも重要な型。
- すべての値が
T.untyped
型であることを主張することができる - また、
T.untyped
型のすべての値は他の型であることを主張することができる - 型付けを始めたタイミングではほぼすべてが
untyped
である
漸進的型付けに短期的には役立つが長期的にはなくしていく型。
sigil
Enabling Static Checks · Sorbet
ファイルの先頭に記述するマジックコメントのこと。sigilによりsrb tc
の型検査のレポート内容を5段階に分けることができる。TypeScriptでstrictnessを調整できるのに似ている。
true
ないしstrict
あたりが目指すところ。
# typed: ignore
- Sorbetからはファイルが読まれない
- Stripeではignoreしているファイルは、無い!
# typed: false
- syntax、定数名の解決、
sig
の正しさのみレポートされる。sig
は記述しなくてもよい - sigilを記述しない場合はデフォルトでこのレベルになる
# typed: true
- type errorがレポートされるようになる
- 存在しないメソッド呼び出し、メソッドの不正な呼び出し(引数間違い)、型とマッチしない変数の使い方
- Stripeではもうすぐ99%のファイルがこのレベルに到達する!*1
# typed: strict
sig
の記述がすべてのメソッドに必要- 定数、変数は明示的な型注釈が必要
- TypeScriptでいえば
noImplicitAny
に相当する
# typed: strong
T.untyped
を許容しない- ファイル内のすべてのメソッド呼び出しについて静的に型を決定できている
- Stripeでもほぼ使われない
- RBIファイルか、ほぼ空のファイルぐらい
- Sorbetが機能を足すと
T.untyped
がもたらされることがあるのでstrongのサポートは最小限にとどめておく
実行時検査
Enabling Runtime Checks · Sorbet
sig
によりシグネチャが付与されたメソッド呼び出しはラップされ、前後で以下を検査される。
- 宣言にマッチする引数で呼び出されたか
- 結果が宣言された戻り値にマッチするか
sig
が間違っていたらリファクタリング対象の検出はできない。到達できるコードに到達できないとSorbetは判断してしまうことがある。また、将来的には型情報を利用したRubyの高速化もありえるが、誤った型情報はそのために使えないし、sigで嘘をつくと実際には遅くなることも十分ある。
なぜRuntime checkをするか
エコシステム含めてだいぶ参考にしているであろうTypeScriptとの大きな違いの1つがここだと思うので気になっていた。*2
SorbetがRuntime checkを重視するのは以下のため:
- 型注釈に信頼が増す
- 自動テストが型注釈のテストになる
- ステージングや本番での型検査結果をモニタリングすることにより間違った型がコードベース全体に伝播する前に早期に検出できる
- 型を付けたコードを型のないコードから守り、型の採用や型付けを促進する
型注釈と実装の一致をランタイムで検証することで乖離を防ぎ、結果として型の恩恵を受けやすくして型付けのインセンティブを増やす、という戦略のようだ。
実行時の挙動をコントロールする方法
.on_failure
sorbet-runtime
のT::Configuration
moduleを使ってType error発生時にはロギングするだけ、などの制御が可能。個別のsigにsig {params(argv: T::Array[String]).void.on_failure(:log)}
のように定義することもできる。
.checked
実行時に型検査するかどうかを選べる。ただ、これを多用するとそもそもランタイムでやる意義が薄れるので推奨はしていないようす。
sig {params(argv: T::Array[String]).void.checked(:never)}
型定義ファイル
インラインでRubyファイルに型を書くこともできるがRBIファイル.rbi
にも記述できる。文法上はRubyと全く同じように記述でき、メソッドの中身が空なだけ。
Cで書かれたライブラリに型を与えるためにもこの仕様は必要。
すべてのライブラリ作者が型注釈を書くのは現実的ではないのでDefinitelyTypedのように https://github.com/sorbet/sorbet-typed にライブラリの型を置くことができる。
Ruby3との関連
Types in Ruby 3, RBS, and Sorbet · Sorbet
SorbetはRuby本体の上に構築してきたしこれからもそうする方針であり、Ruby3 or Sorbetではない。
標準ライブラリの型定義を用意するのはとても大事。現在、Sorbetが標準ライブラリの型定義を持っているおかげで、ユーザーがSorbetを使い始めた瞬間にメソッド呼び出しのうち約25%が型情報を持つほどのカバレッジになる。
RBSの生成にはSorbetのRBIが使われるはずだし、Ruby本体が型定義のRBSを持つことでSorbetにもメリットがある。共存前提。
型付けの練習をするには
ちょっと触ってみたいと思ってやったこと
1. Sorbet playgroundで遊ぶ
雰囲気はわかるがリアルワールドの例が見たくなった。
2. 型追加のcontributionを求めているプロジェクトで試す
Add more Sorbet typing to the gem · Issue #178 · Vonage/vonage-ruby-sdk · GitHubとか。
sigilのレベルを引き上げたり、ライブラリのコードを読みつつsigを書いてみることでだいぶ理解が深まる。srb tc -a
により推論されたシグネチャを自動付与したり、生成された定義を見てみるのも面白い。
うまくいったらpull requestを送れて一石二鳥。
- Add sorbet types to some modules by ohbarye · Pull Request #180 · Vonage/vonage-ruby-sdk · GitHub
- Increase file level typedness at least `typed: true` by ohbarye · Pull Request #183 · Vonage/vonage-ruby-sdk · GitHub
3. Sorbet本体を触る
Sorbet本体でも標準ライブラリの型定義が一部足りなかったりする。
C++で書かれていたりビルド・テストツールにBazelを使っていたりとRubyist的にあまり馴染みがないところもあるが.rbi
に定義を足したりテストしたりするのはRubyでもなんとかなりそうだったのでちょっとしたパッチを送ってみた。
最初のビルドには30分かかった。
所感
TypeScriptを時折書いている身としては型注釈を書く体験自体に違和感はなかった。めっちゃ書きたいわけじゃないけど書いてるプロジェクトに入ったなら普通に書ける、みたいな。
ただ、複雑なRubyコードに対する型をまだ書いてないのでリアルワールドでtyped: strict
レベルで戦っていける感じはまだしていない。
Vonageのgemを読んでいて思ったのはcontributionのハードルを下げてくれるということ。
- ライブラリのコードを読むうえで実装者の意図がわかる
- 静的検査、テストでの実行時型検査でしょぼいミスを防げる
Stripe社内でもこんな感じらしいのでライブラリに限らず、ではあるがOSSプロジェクト的にも嬉しいポイントではありそう。
Runtime checkで受けられる恩恵の本質は実際にSorbetを導入したプロジェクトを運用してみないとなんともいえないかも。
*1:https://sorbet.org/blog/2020/07/30/ruby-3-rbs-sorbet より
*2:TypeScriptの場合はランタイムがJSなので、JSでできないことはできないという制約は別途あるが…。いちおうTypeScriptにもRuntime checkのライブラリがあるけどメインストリームとはいえないと思うのでスルー。Denoだとランタイムの型検査があるのだっけ