Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

RBS徹底解説:Ruby 3で導入された新しい型定義言語の使い方とTypeProfとの連携

待ち望まれていたRuby 3.0.0がついにリリースされました。以前のバージョン比で約3倍の高速化や、実験的な並行・並列処理機能など、数多くの改善とともに、Rubyチームは動的型付けのための新しい構文言語「RBS」を導入しました。

RBSは、Sorbetのような静的型検査ツールがコミュニティで成功を収めたことを受けて、チームが何年もかけて議論してきたものです。

SorbetはStripeが支援する強力な型チェッカーで、アノテーションやRBIファイルの定義によってコードを検査します。RBIファイルは、静的な要素と動的な要素(定数、継承関係、メタプログラミングコードなど)をつなぐインターフェースとして機能します。

ここで疑問が浮かびます。Sorbetが主に静的な検査を扱うのに対し、RBSは動的型付けに対応するために作られた——では、両者の違いとは何でしょうか? 両者はどのように共存するのでしょうか? どちらをいつ使えばよいのでしょうか?

これらはRBSの役割について非常によくある質問です。そこで本記事では、RBSが実際に何をできるのかを明確にし、採用すべき理由を解説していきます。それでは始めましょう!

基本から押さえる:静的型付けと動的型付けの違い

まずは静的型付け動的型付けの違いを正しく理解することから始めましょう。基本的な内容ですが、RBSの役割を理解するためには欠かせない重要な概念です。

静的型付け言語のコード例を見てみましょう:

➜
String str = "";
str = 2.4;

ご存知の通り、このような言語はオブジェクトや変数の型を厳密に扱います。そのため、上記のようなコードはエラーになります。

一方、RubyはJavaScript、Python、Objective-Cなどと同じく、オブジェクトの型に対してはそれほど厳格ではありません。

同じコードをRubyで書くと、以下のように問題なく実行されます:

➜  irb
str = ""
str = 2.4
puts str # 2.4と出力される

これは、Rubyのインタープリタがある型から別の型へ動的に切り替える方法を知っているためです。

しかし、インタープリタにも限界があります。例えば、次のようなコード変更を見てみましょう:

➜  irb
val = "6.0"
result = val + 2.0
puts result

これを実行すると、次のエラーが発生します:

Error: no implicit conversion of Float into String

ちなみに、同じコードをJavaScriptで実行すると問題なく動作します。

教訓:Rubyは確かに型を動的に推論しますが、他の主要な動的言語とは異なり、何でも許容するわけではありません。この点には注意が必要です。

そして、ここで型チェッカー(静的・動的を問わず)の出番となります。

RBSとSorbetの違い

動的型付けと静的型付けの話は分かりました。では、Sorbetはどうなるのでしょうか? 非推奨になるのでしょうか?

いいえ、決してそんなことはありません。RBSとSorbetの最大の(そしておそらく最も重要な)違いは、前者が単なる「言語」であるのに対し、後者はそれ自体が完全な型チェッカーであるという点です。

Rubyチームによると、RBSの主な目的はコードの構造を記述することです。RBS自身は型検査を行わず、Sorbetなどの型チェッカーが利用できる構造を定義します。コードの構造は、新しいファイル拡張子「.rbs」に保存されます。

実際に確認してみましょう。次のRubyクラスを例に取ります:

class Super
    def initialize(val)
      @val = val
    end
 
 
    def val?
      @val
    end
end
 
class Test < Super
  def initialize(val, flag)
    super(val)
    @flag = flag
  end
 
  def flag?
    @flag
  end
end

これはシンプルなRubyの継承を表しています。注目すべき点は、flagを除いて、クラス内で使われている各属性の型を推測できないということです。

flagはデフォルト値で初期化されているため、開発者も型チェッカーも型を推論でき、以降の誤用を防ぐことができます。

上記のクラスをRBS形式で表現すると、次のようになります:

class Super
  attr_reader val : untyped
 
  def initialize : (val: untyped) -> void
end
 
class Test < Super
  attr_reader flag : bool
 
  def initialize : (val: untyped, ?flag: bool) -> void
  def flag? : () -> bool
end

少し時間をかけて消化してみてください。RBSは宣言言語なので、RBSファイルにはシグネチャのみが記述されます。シンプルですね。

CLIツールによる自動生成(後述)であれ手書きであれ、型を推測できない場合はuntypedとアノテーションを付けるのが安全です。

例えば、valの型が確実に分かるのであれば、RBSのマッピングは次のように変更できます:

class Super
  attr_reader val : Integer
 
  def initialize : (val: Integer) -> void
end

また重要な点として、RubyチームとSorbetチームは両方ともRBSの作成と改善に取り組んできました(現在も続いています)。長年の型検査での経験を持つSorbetチームの知見が、このプロジェクトの多くの部分の調整に役立ちました。

RBSとRBIファイルの相互運用性はまだ開発中ですが、目標はSorbetおよびその他のチェッカーツールが従うべき公式かつ一元的な基盤を提供することです。

RBS CLIツールの使い方

RubyチームがRBSを開発する際に重視した点の一つは、開発者が試したり学んだりできるようにCLIツールを同梱することでした。このツールはrbsと呼ばれ、Ruby 3にはデフォルトで付属しています。まだRubyのバージョンをアップグレードしていない場合は、gemとしてプロジェクトに直接追加することもできます:

➜  gem install rbs

rbs helpコマンドを実行すると、使用方法と利用可能なコマンドの一覧が表示されます。

利用可能なコマンド一覧

これらのコマンドのほとんどは、Rubyコードの構造をパース・解析することに焦点を当てています。例えば、ancestorsコマンドは指定したクラスの階層構造を走査し、祖先クラスを確認できます:

➜  rbs ancestors ::String
::String
::Comparable
::Object
::Kernel
::BasicObject

methodsコマンドは、指定したクラスのすべてのメソッド構造を表示します:

➜  rbs methods ::String
! (public)
!= (public)
!~ (public)
...
Array (private)
Complex (private)
Float (private)
...
autoload? (private)
b (public)
between? (public)
...

特定のメソッドの構造を見たい場合は、methodコマンドを使います:

➜  rbs method ::String split
::String#split
  defined_in: ::String
  implementation: ::String
  accessibility: public
  types:
      (?::Regexp | ::string pattern, ?::int limit) -> ::Array[::String]
    | (?::Regexp | ::string pattern, ?::int limit) { (::String) -> void } -> self

今日からRBSを始める人にとって特に便利なのが、prototypeコマンドです。既存のクラスの型定義のひな形を作成でき、RBSファイルのプロトタイプを生成してくれます。

先ほどのTest < Superの継承の例を使ってみましょう。コードをappsignal.rbというファイルに保存し、次のコマンドを実行します:

➜  rbs prototype rb appsignal.rb

このコマンドはrbrbiruntimeの3種類のジェネレーターに対応しているため、prototypeコマンドの直後にファイルの種類を指定し、続けてファイルのパスを渡す必要があります。

実行結果は以下の通りです:

class Super
  def initialize: (untyped val) -> untyped
 
  def val?: () -> untyped
end
 
class Test < Super
  def initialize: (untyped val, ?flag: bool flag) -> untyped
 
  def flag?: () -> untyped
end

最初に作ったRBSのバージョンとかなり似ていますね。前述の通り、このツールは推測できない型をuntypedとしてマークします。

メソッドの戻り値にも同じことが当てはまります。flag定義の戻り値の型に注目してください。開発者としては、このメソッドが常に真偽値を返すことは分かっていますが、Rubyの動的な性質上、ツールはそれを100%断言することができません。

そして、ここでRuby 3のもう一つの新兵器「TypeProf」が登場します。

TypeProfツールによる型解析

TypeProfは、構文木の解釈をベースに構築されたRuby向けの型解析ツールです。

まだ実験的な段階ではありますが、コードが何をしようとしているのかを理解するという点で非常に強力であることが証明されています。

Ruby 3をまだ導入していない場合は、gemを追加するだけでOKです:

➜  gem install typeprof

それでは、先ほどと同じappsignal.rbファイルをTypeProfにかけてみましょう:

➜  typeprof appsignal.rb

出力結果はこちらです:

# Classes
class Super
  @val: untyped
  def initialize: (untyped) -> untyped
  def val?: -> untyped
end
 
class Test < Super
  @val: untyped
  @flag: true
 
  def initialize: (untyped, ?flag: true) -> true
  def flag?: -> true
end

flagのマッピングのされ方に注目してください。これは、RBSのプロトタイプ生成とは異なり、TypeProfがメソッド本体をスキャンして、その変数に対してどんな操作が行われているのかを理解しようとするため可能になります。この変数への直接的な変更を検出できなかったため、TypeProfはメソッドの戻り値を安全に真偽値としてマッピングしました。

例えば、TypeProfがTestクラスをインスタンス化して使用する他のクラスにもアクセスできる場合を考えてみましょう。そうすれば、さらに深くコードを解析し、予測を精密化できます。appsignal.rbファイルの末尾に次のコードを追加してみます:

testSub = Test.new("My value", "My value" == "")
testSup = Super.new("My value")

そして、initializeメソッドのシグネチャを次のように変更します:

def initialize(val, flag)

コマンドを再実行すると、出力はこう変わります:

# Classes
class Super
  @val: String
 
  def initialize: (String) -> String
  def val?: -> String
end
 
class Test < Super
  @val: String
  @flag: bool
 
  def initialize: (String val, bool flag) -> bool
  def flag?: -> bool
end

素晴らしいですね!

TypeProfは継承された属性の扱いがあまり得意ではありません。そのため、ここでは新しいSuperオブジェクトをインスタンス化しています。そうしないと、valStringであることを検出できないのです。

TypeProfの最大の利点は安全性です。確実な判断ができない場合は常にuntypedを返します。

RBS仕様の限界と部分的な対応範囲

公式ドキュメントからの重要な注意点として、TypeProfは非常に強力ではありますが、RBSコードとして何を生成でき、何を生成できないのか、その限界を認識しておく必要があります。

例えば、Ruby開発者の間で一般的な手法にメソッドオーバーロードがあります。これは引数に応じてメソッドの異なる振る舞いを呼び出すものです。

Superクラスに、パラメータの型に基づいてIntegerまたはStringを返す新しいメソッドspellを追加したとしましょう:

def spell(val)
  if val.is_a?(String)
    ""
  else
    0
  end
end

RBSはユニオン型(複数の可能性のある型を表す値)の構文により、このようなオーバーロードに対応できます:

def spell: (String) -> String | (Integer) -> Integer

しかし、TypeProfはメソッド本体の解析だけではこれを推論できません。補助するには、RBSファイルに手動でこの定義を追加します。TypeProfは常にまずRBSファイルの指示を確認するためです。

そのためには、コマンドの末尾にRBSファイルのパスを追加します:

typeprof appsignal.rb appsignal.rbs

新しい出力は以下のようになります:

class Super
  ...
  def spell: (untyped val) -> (Integer | String)
end

さらに、Kernel#pを使って実行時に実際の型を確認し、オーバーロードが正しく機能しているかテストすることもできます。appsignal.rbファイルの末尾に次の2行を追加してみましょう:

p testSup.spell(42)
p testSup.spell("str")

出力は次のようになります:

# Revealed types
#  appsignal.rb:11 #=> Integer
#  appsignal.rb:12 #=> String
 
...

詳細については、特にTypeProfの制限に関するセクションを中心に、公式ドキュメントを必ず参照してください。

ダックタイピングとインターフェース型

聞いたことがあるでしょう。「もしRubyのオブジェクトがアヒルのように振る舞うなら、それはアヒルだ」という考え方です。

これまで見てきたように、Rubyはオブジェクトが何であるべきかを気にしません。型は動的に変化し、オブジェクトの参照も同様です。

ダックタイピングは便利ですが、扱いが難しいこともあります。例を見てみましょう。

Superクラスで宣言したval属性(String型)が、今後は常に整数に変換可能でなければならないとします。

開発者が常に変換を保証してくれると信頼するのではなく(そうでなければエラーを投げるかもしれません)、インターフェースを作成してその要件を明示できます:

interface _IntegerConvertible
   def to_int: () -> Integer
end

インターフェース型は、具体的なクラスやモジュールから切り離された1つ以上のメソッドを提供します。このようにして、特定の型をSuperのインスタンス化時に渡したい場合は、次のように記述するだけです:

class Super
  attr_reader val : _IntegerConvertible
 
  def initialize : (val: _IntegerConvertible) -> void
end

このインターフェースを実装する具体的なクラスやモジュールは、適切な検証が行われることを保証しなければなりません。

メタプログラミングへの対応状況

Rubyの最も動的な機能の一つが、ランタイム中にコードが自分自身でコードを生成する能力、すなわちメタプログラミングです。

その不確実な性質のため、RBS CLIツールはメタプログラミングコードからRBSを生成することができません。

次のスニペットを例に見てみましょう:

class Test
    define_method :multiply do |*args|
        args.inject(1, :*)
    end
end
 
p Test.new.multiply(2, 3, 5)

このクラスは、ランタイム時にmultiplyというメソッドを定義し、引数を受け取ってそれぞれを掛け合わせるよう指示しています。

RBSのprototypeコマンドを実行すると、出力はこうなります:

class Test
end

メタプログラミングコードの複雑さにもよりますが、TypeProfは依然として何らかの情報を抽出しようと最善を尽くします。ただし、常に成功するとは限りません。

覚えておいてほしいのは、自分で型マッピングをRBSファイルに追加すれば、TypeProfはそれを優先的に尊重するということです。これはメタプログラミングの場合でも有効です。

また、チームが継続的に新機能をリリースしており、メタプログラミング対応の更新が含まれる可能性もあるため、最新のリポジトリの変更をフォローしておくことも重要です。

とはいえ、コードベースにメタプログラミングが含まれている場合は、これらのツールの使用に注意してください。盲目的に使わないこと!

まとめ

ここまで取り上げた内容には、さらに多くの詳細や、RBSとTypeProf双方のエッジケースが存在します。

詳細については、ぜひ公式ドキュメントを参照してください。

RBSはまだ登場したばかりですが、他のツールでコードベースの型検査を行ってきたRubyistたちにすでに大きなインパクトを与えています。

あなたはいかがですか? もう試してみましたか? RBSについての感想をぜひ聞かせてください!


  1. Ruby 2.6の新機能9選|コード例でわかる注目ポイントを徹底解説

    Ruby 2.6には、開発者の生産性を高める新しい機能やパフォーマンス改善が多数盛り込まれています。 本記事では、Ruby 2.6で導入された9つの注目新機能を、実際のコード例とともにわかりやすく紹介します。最新のRuby動向をキャッチアップしたい方は、ぜひ最後までご覧ください。 1. 無限Range(Endless Range) Ruby 2.5以前でもFloat::INFINITYを使えば終端のない範囲を表現できましたが、Ruby 2.6ではさらに直感的な記法が使えるようになりました。 新しい無限Rangeは次のように書きます。 (1..) 通常のRangeが(1..10)のように終端

  2. キーボードが間違った文字を入力するときの直し方|初心者向け8つの対処法

    キーボードの「P」を押したのに、画面にはまったく別の文字や記号が表示される——そんな経験はありませんか?長年安定して使えていたキーボードでも、経年劣化や何らかの不具合によって誤作動することがあります。特定のキーから異音がしたり、反応しなくなったりすることもあるでしょう。この記事では、キーボードが間違った文字を入力してしまう問題の直し方を、初心者にもわかるように順番に解説します。 考えられる原因は、言語設定の誤り、NumLockの有効化、マルウェア感染、古いキーボードドライバーなど多岐にわたります。最悪の場合はキーボード自体の交換が必要になりますが、その前に試せる対処法はたくさんあります。ひとつ