Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: SorbetにRBSのインラインコメント機能が追加された(翻訳)

概要

CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

Ruby: SorbetにRBSのインラインコメント機能が追加された(翻訳)

Sorbetは、Shopifyにおけるコードの読み取りや理解、そしてメンテナンスを大きく改善してくれました。しかし率直に申し上げると、Sorbetそのものの構文は必ずしも読みやすいとは言えません。

本記事では、Sorbetに「RBS形式のインラインコメント」機能をどのようにして追加したかを解説します。Sorbetの強力な型安全性や型チェックの速度を損なわないようにしつつ、RBSの明快な構文を採用することで、Rubyによる開発をよりスムーズで、よりいっそう楽しいものにしてくれます。

🔗 Shopifyで活用されているSorbet

sorbet/sorbet - GitHub

Sorbetは、Rubyコード用の型チェックツールです。開発者は静的な型アノテーションを段階的に追加することで、エラーを早い段階でキャッチしてリファクタリングを容易にし、コードの安定性と信頼性を高めることが可能です。Sorbetには言語サーバー(LSP)も備わっていて、コードナビゲーションやエラー診断などの機能によって開発エクスペリエンスを強化します。

Sorbetは、Shopifyの巨大なコードベースの可読性とメンテナンス性の向上に大きく貢献しています。現時点では、75,000件のファイルの99%がSorbetでチェックされており、150万個あるメソッドの71%で型シグネチャがチェックされ、メソッド呼び出し側の61%をカバーしています。Sorbetが広範囲に渡って利用されていることで、型安全性と明確さが大きく向上し、production環境のエラーも「大幅に」削減されています。

Sorbetの辿ってきた道すじについて詳しくは、以下のリソースを参照してください。

🔗 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の他のコードベースにも適用したい

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について詳しくは、以下の公式リポジトリと、公式アナウンスを参照してください。

ruby/rbs - GitHub

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_readerdefメソッドに変える)
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のsigT.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.letT.castT.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形式に手軽に変換できます。

Shopify/spoom - GitHub

$ spoom srb sigs translate
$ spoom srb assertions translate

Sorbetでは、RBSコメント記法と従来の sig {}構文を両方サポートしていることにご注目ください。これにより、新構文への移行を段階的に進められます。

オープンソースのプロジェクトをRBSコメント形式に移行した例については、以下をご覧ください。

先日、RubyKaigi 2025でこの取り組みについて発表し、統合プロセスとRBSでサポートされる機能について詳しく議論しました。講演の動画は近日公開予定ですので、どうぞお楽しみに。詳しくは以下をご覧ください。

参考: Inline RBS comments for seamless type checking with Sorbet - RubyKaigi 2025

型付けを楽しみましょう!

関連記事

Ruby: Shopifyによる新しい高速な静的型分析の実験(翻訳)

SorbetでRailsアプリの型シグネチャ作成とメンテを行ってみた(翻訳)


関連記事

該当する記事がありません。

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。
OSZAR »