Ruby: SorbetにRBSのインラインコメント機能が追加された(翻訳)
Sorbetは、Shopifyにおけるコードの読み取りや理解、そしてメンテナンスを大きく改善してくれました。しかし率直に申し上げると、Sorbetそのものの構文は必ずしも読みやすいとは言えません。
本記事では、Sorbetに「RBS形式のインラインコメント」機能をどのようにして追加したかを解説します。Sorbetの強力な型安全性や型チェックの速度を損なわないようにしつつ、RBSの明快な構文を採用することで、Rubyによる開発をよりスムーズで、よりいっそう楽しいものにしてくれます。
🔗 Shopifyで活用されているSorbet
Sorbetは、Rubyコード用の型チェックツールです。開発者は静的な型アノテーションを段階的に追加することで、エラーを早い段階でキャッチしてリファクタリングを容易にし、コードの安定性と信頼性を高めることが可能です。Sorbetには言語サーバー(LSP)も備わっていて、コードナビゲーションやエラー診断などの機能によって開発エクスペリエンスを強化します。
Sorbetは、Shopifyの巨大なコードベースの可読性とメンテナンス性の向上に大きく貢献しています。現時点では、75,000件のファイルの99%がSorbetでチェックされており、150万個あるメソッドの71%で型シグネチャがチェックされ、メソッド呼び出し側の61%をカバーしています。Sorbetが広範囲に渡って利用されていることで、型安全性と明確さが大きく向上し、production環境のエラーも「大幅に」削減されています。
Sorbetの辿ってきた道すじについて詳しくは、以下のリソースを参照してください。
- 動画: Adopting Sorbet at Scale(RubyConf 2019)
- 記事: Static Typing for Ruby
- 記事: Adopting Sorbet at Scale
- 動画: Gradual Typing in Ruby - A Three Year Retrospective
🔗 Ruby開発者のエクスペリエンス
私のチームは、革新的なツールを用いて、ShopifyにおけるRuby開発者のエクスペリエンスを強化することに注力しています。
以下は、Ruby開発者をサポートするために開発したツールの一部です。
- Ruby LSP
- 「定義へジャンプ」機能、マウスオーバーでのAPIドキュメント表示、リファクタリングの自動化といった言語サーバー機能によってIDEのエクスペリエンスを向上させ、スムーズな開発ワークフローを実現します。
- Tapioca
- gemやDSL(ドメイン固有言語)を対象に型定義のアノテーション生成(RBIファイル)を自動化し、Sorbetとの型統合をシンプルにします。
- Spoom
- Rubyコードを静的に解析することでコード品質を向上させます(使われていないコードの検出機能など)。
私たちは、社内ツールの作成のみならず、Rubyデバッガの拡張やRDocの強化などによってRubyエコシステムにも広く貢献し、これらのオープンなツールがRuby開発者にとって有用なものであり続けるよう努めています。
ShopifyのRuby開発者コミュニティのニーズに応える目的で、洞察とフィードバックを得るためのアンケートを定期的に実施しています。これらのアンケートによって、有益な領域とともに改善の余地があることも明らかになり、時とともに明確な傾向が見えてきました。
- 開発者の80%は、もっと多くのコードを型付けしたいと考えており、型アノテーションが可読性やメンテナンス性に与える好影響に注目しています。
より多くのコードを型付けしたい
- 開発者の71%は、型チェックや強力なコードナビゲーションといったSorbetの機能をフル活用するために、型付けするコードベースを増やすことを支持しています。
SorbetをShopifyの他のコードベースにも適用したい
- しかし、開発者の75%近くは、既存の開発ワークフローとシームレスに統合できる、現在よりももっと使いやすく読みやすい構文を求めています。
もっと使いやすい構文が欲しい
🔗 Sorbetのお味はいかが?
以下は、SorbetをRubyコードベースで使う基本的な例です。
require "sorbet-runtime"
extend T::Sig
sig { params(names: T::Array[String]).returns(String) }
def greet(names)
"Hello, #{names.join(", ")}!"
end
class User
extend T::Sig
sig { returns(T.nilable(String)) }
attr_reader :nickname
sig { params(name: String, nickname: T.nilable(String)).void }
def initialize(name, nickname = nil)
@name = T.let(name, String)
@nickname = T.let(nickname, T.nilable(String))
end
end
Sorbetは強力ですが、型を表現するためにRubyをDSL(ドメイン固有言語)として使っていることで、コードが冗長・乱雑に見えてしまうことがあります。
上の例では、メソッドやアクセサのシグネチャにsig {}
を使い、変数の型付けにT.let
を使っています。Rubyの構文を使って型を表現すると、たとえばT::Array[String]
やT.nilable(String)
のように、Ruby構文からはみ出せないという本質的な制約のせいで、コードがごちゃごちゃして見えてしまう場合があります。
さらに、Sorbetがsorbet-runtime
gemに依存していることでパフォーマンス上のオーバーヘッドが発生するので、production環境ではランタイム型チェックを無効にすることになります。
開発者たちからのフィードバックによって、現状よりも簡潔で統一感のある構文が求められていることが浮き彫りになり、私たちも同じ意見でした。
🔗 Ruby RBSの構文
より簡潔な構文が求められていることがわかってきたので、私たちはRBSに注目しました。RBSはRuby 3.0から導入された型定義言語で、Rubyプログラムの型付けをより端的かつ簡潔に記述できます。RBSについて詳しくは、以下の公式リポジトリと、公式アナウンスを参照してください。
RBSでは、Rubyプログラムの型を以下のように記述します。
# ファイル: user.rbs
class Object
def greet: (Array[String]) -> String
end
class User
attr_reader nickname: String?
def initialize: (String name, String? nickname) -> void
end
# ファイル: user.rb
def greet(names)
"Hello, #{names.join(", ")}!"
end
class User
attr_reader :nickname
def initialize(name, nickname = nil)
@name = name
@nickname = nickname
end
end
RBSは型定義をコードから分離し、個別の.rb
ファイルごとに、それに対応する.rbs
ファイルも用意しなければなりません。この方法は、75,000件ものファイルにまたがるコードベースの型管理が重複する点が課題となります。さらに困ったことに、RBSではローカル変数の型付けがサポートされておらず、当初は大規模コードベースに対応したスケーラブルな型チェッカーがありませんでした。これは私たちにとって大きな制約でした。
しかし、私がRubyKaigi 2023で詳しく発表したように、Sorbetの多くの機能はRBSと整合しています。そういうわけで、RBSをSorbetに統合するのは自然な流れとなり、そのおかげで双方のアプローチの強みを活用できるようになりました。
🔗 RBSのインラインコメント
私たちのビジョンは、Rubyファイル内に直接型アノテーションを記述できる簡潔な構文を作り上げることでした。目指した結果は次のとおりです。
#: (Array[String]) -> String
def greet(names)
"Hello, #{names.join(", ")}!"
end
class User
#: String?
attr_reader :nickname
#: (String, String?) -> void
def initialize(name, nickname = nil)
@name = name #: String
@nickname = nickname #: String?
end
end
RBSを元にしたこの記法は、よりクリアな型アノテーションを直接的に表現する方法を提供し、コードの乱雑な印象を抑えて読みやすさを向上させます。
さらに、コメントベースなので実行時の依存が発生しません。
🔗 SorbetでRBSサポートを実装する
私たちのビジョンをどのように実現したかというと、RBS解析と書き換えをSorbetの型チェックパイプラインに組み込む形で実現しました。
最初に、パイプラインでRubyファイルをいくつかの段階に分けて処理します(ここでは簡略化しています)。
parser
- Rubyファイルを抽象構文木(AST)に変換します。
desugarer
- ASTからシンタックスシュガーを取り除いてシンプルにします。
(例:unless <cond>
をif !<cond>
に変換) rewriter
- 特定のRuby DSLやメタプログラミングを解析可能なコードに変換します。
(例:attr_reader
をdef
メソッドに変える) resolver
- 定数参照や名前、それらの関係を解決します。
(例: クラス継承、モジュールのinclude
) cfg
- ASTから制御フローグラフを構築します。
type_checker
- cfgからの型情報を元に型を推論して、型安全性に違反している場合にエラーをraiseします。
詳しくは、Sorbetの内部ドキュメントか、同僚のEmilyが書いた以下の記事を参照してください。
参考: Making Sorbet compatible with Ruby 3.2 | Rails at Scale
RBSのインラインコメントを実現するために、上述のparser
フェーズとdesugarer
フェーズの間に新しいフェーズを導入しました。
このフェーズでは、RBSコメントがあるかどうかを調べて、関連するASTノードに紐づけます。次に、RBSの内容を解析して、それと同等のSorbetのsig
やT.let
を、「あたかも」Rubyファイルに最初から書かれていたかのようにAST内で直接構成します。このプロセスによって、Sorbetパイプラインの残りの部分がシームレスに機能するようにします。
個別のフェーズを観察するには、Sorbetに--print <format>
フラグを付けて実行します。たとえば、desugarer
フェーズの直後にASTがどうなっているかを知りたい場合は、以下を実行します。
$ srb tc --print desugar-tree user.rb
以下は、RBS書き換えによって、RBSコメントのsig
ノードやT.let
ノードが挿入された直後のinitialize
メソッドの様子を抜粋したものです。
::<root>::<C Sorbet>::<C Private>::<C Static>.sig(::<root>::<C T>::<C Sig>::<C WithoutRuntime>) do ||
<self>.params(:name, <emptyTree>::<C String>, :nickname, ::<root>::<C T>.nilable(<emptyTree>::<C String>)).void()
end
def initialize(name, nickname = nil, &<blk>)
begin
@name = ::<root>::<C T>.let(name, <emptyTree>::<C String>)
@nickname = ::<root>::<C T>.let(nickname, ::<root>::<C T>.nilable(<emptyTree>::<C String>))
end
end
🔗 現在使えるRBSコメント
インラインRBSコメントに関する作業は継続中ですが、既にさまざまな機能が利用可能になっています。
Sorbetでは、インスタンスメソッドやシングルトンメソッドに以下のようなRBSシグネチャコメントを書けるようになりました。
#: (Array[String]) -> String
def greet(names)
"Hello, #{names.join(", ")}!"
end
新しいRBSシグネチャは、#:
記法ごとに導入されます。
以下のように、長いRBSシグネチャを#|
で複数行に分けて記述することも可能です。
#: (
#| String name,
#| String? nickname
#| ) -> void
def initialize(name, nickname = nil); end
attr_*
属性にも以下のようにRBSコメントを記述できます。
#: String?
attr_reader :nickname
以下のように末尾に#:
コメントを記述することで、「ローカル変数」「グローバル変数」「インスタンス変数」「クラス変数」「定数」にも型を指定できるようになりました。
local = ARGV.first #: String?
$global = ARGV.first #: String?
@instance = ARGV.first #: String?
@@class = ARGV.first #: String?
CONSTANT = ARGV.first #: String?
上の記法は、以下のような従来のT.let
と同等です。
local = T.let(ARGV.first, T.nilable(String))
$global = T.let(ARGV.first, T.nilable(String))
@instance = T.let(ARGV.first, T.nilable(String))
@@class = T.let(ARGV.first, T.nilable(String))
CONSTANT = T.let(ARGV.first, T.nilable(String))
型キャストは、#: as
コメントで実現できるようになりました。
name = ARGV.first #: as String
上の記法は、以下のような従来のT.let
と同等です。
name = T.cast(ARGV.first, String)
non-nilable(nil
になってはならない)型へのキャストには、#: as !nil
を利用できます。
name = ARGV.first #: as !nil
上の記法は、以下のような従来のT.must
と同等です。
name = T.must(ARGV.first)
RBSコメントによる型アサーションは、現在T.let
やT.cast
やT.must
を使っているあらゆる場所に適用可能です。
型アサーションを式の「途中」に導入したい場合は、以下のように式を複数行に分割することで実現できます。
greet(
[
ARGV.first, #: as !nil
"Alex"
] #: as Array[String]
) #: as String
上の記法は、以下のような従来の記法に相当します。
T.cast(
greet(
T.cast(
[
T.must(ARGV.first),
"Alex"
],
Array[String]
),
String
)
)
RBSの型アサーションコメントは、メソッド呼び出しのレシーバーにも適用できます。
ARGV #: as Array[String]
.first #: as !nil
.chars
上の記法は、以下と同等です。
T.must(T.cast(ARGV, Array[String]).first).chars
🔗 ぜひお試しください
SorbetのRBSサポートは現在も進化の途中であり、現時点では実験的な機能です。Sorbetでこの機能を有効にするには、--enable-experimental-rbs-signatures
オプションと--enable-experimental-rbs-assertions
オプションを渡すか、sorbet/config
ファイルにこれらのオプションを追加する必要があります。
詳しくは、RBSのサポートドキュメントを参照してください。
以下のPlaygroundでSorbetのRBS機能を試すこともできます。
🔗 RBSコメント記法への移行について
大規模なコードベースをRBS記法に手動で移行するのは複雑な作業になります。
この作業を簡単にするための自動化ツールSpoomを構築しました。これを用いることで、既存のSorbet型シグネチャやアサーションをRBS形式に手軽に変換できます。
$ spoom srb sigs translate
$ spoom srb assertions translate
Sorbetでは、RBSコメント記法と従来の sig {}
構文を両方サポートしていることにご注目ください。これにより、新構文への移行を段階的に進められます。
オープンソースのプロジェクトをRBSコメント形式に移行した例については、以下をご覧ください。
- shopify/ruby-lsp:
- Migrate to RBS signatures supported by Sorbet by Morriar · Pull Request #3222 · Shopify/ruby-lsp
- Translate all
T.let
to RBS inline comments supported by Sorbet by Morriar · Pull Request #3333 · Shopify/ruby-lsp - Replace T.must with comment based syntax by vinistock · Pull Request #3400 · Shopify/ruby-lsp
- shopify/spoom:
先日、RubyKaigi 2025でこの取り組みについて発表し、統合プロセスとRBSでサポートされる機能について詳しく議論しました。講演の動画は近日公開予定ですので、どうぞお楽しみに。詳しくは以下をご覧ください。
参考: Inline RBS comments for seamless type checking with Sorbet - RubyKaigi 2025
型付けを楽しみましょう!
概要
CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。