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

RBS徹底解説:Rubyの新しい型定義言語を理解する

RBSは、Ruby向けに新しく設計された型定義構文のフォーマット言語です。RBSを使うと、.rbsという新しい拡張子を持つファイルに型注釈を記述できます。記述例は以下の通りです。

class MyClass
  def my_method : (my_param: String) -> String
end

RBSで型注釈を提供することにより、次のようなメリットが得られます。

  • コードベースの構造をクリーンかつ簡潔に定義できる。
  • クラス本体を直接変更せずに済むため、レガシーコードへより安全に型を導入できる。
  • 静的・動的な型チェッカーとの普遍的な統合が期待できる。
  • メソッドオーバーロード、ダックタイピング、動的インターフェースなどに対応する新機能を利用できる。

「でも待ってください。SorbetやSteepのような静的型チェッカーはすでにあるのでは?」と思う方もいるでしょう。

その通りです。それらは素晴らしいツールです。しかし、4年間に及ぶ議論とコミュニティ製の型チェッカーの登場を経て、Rubyコミッターチームは型チェッカーツールを構築するための標準規格を定めるべき時が来たと判断しました。

RBSは正式な言語として位置づけられており、Ruby 3とともに誕生しつつあります。

さらに、Rubyが動的型付け言語であること、そしてダックタイピングやメソッドオーバーロードといった一般的なパターンが存在することを考慮し、最良のアプローチを確保するための配慮も盛り込まれています。詳細は後ほど見ていきましょう。

Ruby 3のインストール

この記事のサンプルコードを実行するには、Ruby 3をインストールする必要があります。

公式の手順に従ってインストールしてもよいですし、複数のRubyバージョンを管理したい場合はruby-build経由でのインストールも可能です。

あるいは、現在のプロジェクトにrbs gemを直接インストールすることもできます。

gem install rbs

静的型付けと動的型付けの違い

先へ進む前に、この概念を整理しておきましょう。動的型付け言語と静的型付け言語はどう違うのでしょうか?

RubyやJavaScriptのような動的型付け言語では、データ型が事前に定義されていません。そのため、実行時に不正な操作が発生した場合、インタープリターはそれを事前に把握できません。

これは静的型付けとは正反対の挙動です。JavaやCのような静的型付け言語では、コンパイル時に型の検証が行われます。

以下のJavaコードを見てみましょう。

int number = 0;
number = "Hi, number!";

静的型付けでは2行目でエラーが発生するため、このようなコードは成立しません。

error: incompatible types: String cannot be converted to int

同じ例をRubyで書くとこうなります。

number = 0;
number = "Hi, number!";
puts number // 正常に "Hi, number!" が出力される

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

なお、この概念は強い型付け弱い型付けという概念と混同されがちです。

Rubyは動的型付けであるだけでなく、強い型付けの言語でもあります。つまり、変数が実行中に型を変えることは許容されますが、無茶苦茶な型の混合操作は許されません。

先ほどの例を応用した次のコード(Ruby)を見てください。

number = 2;
sum = "2" + 2;
puts sum

今回は異なる型(IntegerString)に属する値同士の足し算を試みています。Rubyは次のエラーを投げます。

main.rb:2:in `+': no implicit conversion of Integer into String (TypeError)
  from main.rb:2:in `<main>'

つまりRubyは、「異なる型が絡む複雑な計算を行うのはあなた自身の仕事だ」と言っているわけです。

一方、弱い型付けかつ動的型付けであるJavaScriptでは、同じコードがまったく異なる結果になります。

> number = 2
  sum = "2" + 2
  console.log(sum)
> 22 // 両方の値が連結される

RBSはSorbetと何が違うのか?

まず大きな違いは、コードへの型注釈のアプローチです。Sorbetはコード全体に明示的に注釈を追加していく方式ですが、RBSは単に.rbs拡張子の新しいファイルを作成するだけで済みます。

この方式の最大の利点は、レガシーコードベースの移行を考えるときに発揮されます。元のファイルに一切手を加えずに済むため、プロジェクトへのRBSファイルの導入ははるかに安全です。

開発者たちによると、RBSの主な目的はRubyプログラムの構造を記述することです。焦点はクラスやメソッドのシグネチャの定義のみに置かれています。

RBS自体には型チェックの機能はありません。その役割はあくまで構造の定義にとどまり、それを基盤としてSorbetやSteepなどの型チェッカーが本来の仕事を行います。

簡単なRubyの継承の例を見てみましょう。

class Badger
    def initialize(brand)
      @brand = brand
    end

    def brand?
      @brand
    end
end

class Honey < Badger
  def initialize(brand: "Honeybadger", sweet: true)
    super(brand)
    @sweet = sweet
  end

  def sweet?
    @sweet
  end
end

いくつかの属性と推論された型を持つ、シンプルな2つのクラスです。

これをRBSで表現すると、次のようになります。

class Brand
  attr_reader brand : String

  def initialize : (brand: String) -> void
end

class Honey < Brand
  @sweet : bool

  def initialize : (brand: String, ?sweet: bool) -> void
  def sweet? : () -> bool
end

かなり似ていますよね。最大の違いは型の明示です。たとえばHoneyクラスのinitializeメソッドは、String1つとboolean1つのパラメータを受け取り、戻り値はありません。

一方、Sorbetチームは、RBI(Sorbet独自の型定義拡張子)とRBSの相互運用を可能にするツールの開発にも取り組んでいます。

その狙いは、Sorbetをはじめとするあらゆる型チェッカーが、RBSの型定義ファイルを活用できる土台を整備することです。

スキャフォールディングツール

言語を使い始めたばかりで、すでに進行中のプロジェクトがある場合、どこからどのように型を付ければよいか迷うかもしれません。

そこでRubyチームは、既存のクラスの型をスキャフォールディング(雛形生成)してくれる便利なCLIツールrbsを提供しています。

利用可能なコマンドの一覧を表示するには、コンソールでrbs helpと入力するだけです。

RBS徹底解説:Rubyの新しい型定義言語を理解する CLIツールで利用可能なコマンド一覧。

おそらくリストの中で最も重要なのがprototypeコマンドです。パラメータとして渡されたソースファイルのAST(抽象構文木)を解析し、「おおよそ」のRBSコードを生成します。

「おおよそ」というのは、100%正確ではないからです。レガシーコードは基本的に型情報を持たないため、生成される内容もそのまま曖昧になりがちです。たとえば明示的な代入がない場合、RBSは一部の型を推測できません。

別の例として、3つのクラスが段階的に継承するケースを見てみましょう。

class Animal
    def initialize(weight)
      @weight = weight
    end

    def breathe
      puts "Inhale/Exhale"
    end
end

class Mammal < Animal
    def initialize(weight, is_terrestrial)
      super(weight)
      @is_terrestrial = is_terrestrial
    end

    def nurse
      puts "I'm breastfeeding"
    end
end

class Cat < Mammal
    def initialize(weight, n_of_lives, is_terrestrial: true)
        super(weight, is_terrestrial)
        @n_of_lives = n_of_lives
    end

    def speak
        puts "Meow"
    end
end

属性とメソッドだけを持つシンプルなクラス群です。そのうち1つにはデフォルトの真偽値が設定されています。これは後ほど、RBSがどれだけ自動で型を推測できるかを示すために重要になります。

それでは、これらの型をスキャフォールディングしてみましょう。次のコマンドを実行します。

rbs prototype rb animal.rb mammal.rb cat.rb

渡せるRubyファイルの数に制限はありません。実行結果は以下の通りです。

class Animal
  def initialize: (untyped weight) -> untyped

  def breathe: () -> untyped
end

class Mammal < Animal
  def initialize: (untyped weight, untyped is_terrestrial) -> untyped

  def nurse: () -> untyped
end

class Cat < Mammal
  def initialize: (untyped weight, untyped n_of_lives, ?is_terrestrial: bool is_terrestrial) -> untyped

  def speak: () -> untyped
end

予想した通り、RBSはクラス作成時に意図していた型の大半を理解できませんでした。

実際の作業の多くは、untypedの参照を本当の型へ手動で書き換えることになるでしょう。より良い方法を模索する議論は、現在もコミュニティで活発に行われています。

メタプログラミング

メタプログラミングに関しては、その動的な性質上、rbsツールはあまり役に立ちません。

次のクラスを例に考えてみましょう。

class Meta
    define_method :greeting, -> { puts 'Hi there!' }
end

Meta.new.greeting

この型をスキャフォールディングすると、結果は次のようになります。

class Meta
end

ダックタイピング

Rubyはオブジェクトの本質(型)よりも、そのオブジェクトが何をできるか(振る舞い)を重視します。

ダックタイピングは、次の格言に従う有名なプログラミングスタイルです。

「オブジェクトがアヒルのように振る舞う(鳴く、歩く、飛ぶ等)なら、それはアヒルである」

つまり、元の定義や型がアヒルを表していなくても、Rubyは常にそれをアヒルとして扱うのです。

しかし、ダックタイピングはコードの実装詳細を隠してしまうことがあり、それが原因で問題が複雑化したり、発見や読解が困難になったりすることがあります。

そこでRBSはインターフェース型という概念を導入しました。これは特定の具象クラスやモジュールに依存しないメソッドの集合です。

先ほどの動物の継承の例に戻り、Catが継承する陸生動物のための新しい階層を追加するとしましょう。

class Terrestrial < Animal
    def initialize(weight)
        super(weight)
    end

    def run
        puts "Running..."
    end
end

陸生以外の子オブジェクトが走らないようにするには、そのアクションに対して特定の型を検証するインターフェースを作成します。

interface _CanRun
  # `Terrestrial` オブジェクトを受け取る `<<` 演算子が必要。
  def <<: (Terrestrial) -> void
end

RBSコードをrunメソッドにマッピングすると、シグネチャは次のようになります。

def run: (_CanRun) -> void

Terrestrialオブジェクト以外をこのメソッドに渡そうとした瞬間、型チェッカーが確実にエラーを記録してくれます。

ユニオン型

Rubyistの間では、異なる型の値を保持する式を書くこともよくあります。

def fly: () -> (Mammal | Bird | Insect)

RBSは、パイプ演算子で型をつなぐだけでユニオン型を表現できます。

メソッドオーバーロード

多くのプログラミング言語で一般的なもう一つの慣習が、メソッドオーバーロードです。これは、同じ名前を持ちながらシグネチャ(引数の型や数、順序など)が異なる複数のメソッドをクラス内に持てるようにするものです。

ある動物が直近の進化的な親戚を返す例を見てみましょう。

def evolutionary_cousins: () -> Enumerator[Animal, void] | { (Animal) -> void } -> void

このようにRBSを使えば、ある動物が単一の進化的親戚を持つのか、あるいは複数の親戚を持つのかを明示的に指定できます。

TypeProf

並行して、RubyチームはTypeProfという新しいプロジェクトも立ち上げました。これはRBSの内容を解析し、(可能な限り)生成することを目指す実験的な型レベルのRubyインタープリターです。

抽象解釈によって動作しており、まだ改良の途上にあるため、本番環境での使用には注意が必要です。

インストールは、gemをプロジェクトに追加するだけです。

gem install typeprof

なお、Ruby 2.7以上のバージョンが必要です。

Animalクラスの次のバージョンを見てください。

class Animal
    def initialize(weight)
      @weight = weight
    end

    def die(age)
      if age > 50
        true
      elsif age <= 50
        false
      elsif age < 0
        nil
      end
    end
end

Animal.new(100).die(65)

TypeProfは、dieメソッド内部の処理とその後の呼び出しをもとに、コード内で扱われる型を賢く推論できます。

typeprof animal.rbコマンドを実行すると、次のような出力が得られます。

## Classes
class Animal
  @weight: Integer

  def initialize: (Integer weight) -> Integer
  def die: (Integer age) -> bool?
end

すでに大量のコードが存在するプロジェクトにとって、非常に力強い味方となるツールです。

VS Codeとの統合

現時点では、RBSのフォーマットや構造チェックなどに対応したVS Codeプラグインはまだ多くありません。RBSが比較的新しい技術であることが主な理由です。

ただし、ストアで「RBS」と検索すると、ruby-signatureというプラグインが見つかるかもしれません。以下のように、シンタックスハイライトをサポートしてくれます。

RBS徹底解説:Rubyの新しい型定義言語を理解する VS CodeでのRBSシンタックスハイライト。

まとめ

RBSは誕生したばかりですが、より安全なRubyコードベースへ向けて重要な一歩を踏み出しています。

今後は、Railsアプリケーション向けのRBSファイルを生成するRBS Railsのように、これを支える新しいツールやオープンソースプロジェクトが続々と登場していくでしょう。

より安全でバグの少ないアプリケーションが生まれる、Rubyコミュニティの明るい未来が待っています。楽しみですね!

  1. Rubyで学ぶ挿入ソート:仕組みから計算量まで徹底解説

    ※本記事は、Rubyでさまざまなソートアルゴリズムを実装するシリーズの第4回です。第1回ではバブルソート、第2回では選択ソート、第3回ではマージソートを取り上げました。データのソート手法をさまざまな角度から探っていくシリーズもいよいよ折り返し地点。今回は挿入ソート(Insertion Sort)に焦点を当てます。挿入ソートには魅力的な特徴がたくさんあります。まず、挿入ソートは安定(stable)なアルゴリズムです。つまり、同じキーを持つ要素同士の相対的な順序が入れ替わることがありません。また、インプレース(in-place)アルゴリズムでもあるため、ソート結果を保存するための新しい配列を作成す

  2. 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)のように終端