😺

Sorbetを使ってRubyでインタフェース機能を活用する

2024/06/10に公開

この記事の概要

こんにちは。PharmaX でエンジニアをしている諸岡(@hakoten)です。

この記事では、Rubyの型チェックツール「Sorbet」のインタフェース機能に焦点を当てて解説しています。Sorbetの型チェックに興味がある方や、導入を検討している方の参考になれば幸いです。

Sorbetとは

Sorbet とはStripe社が開発を主導するRuby言語に後から導入するための型チェックツールです。

Sorbetでは、主に「静的チェック」「動的(Runtime)チェック」の2つの型チェック機能を提供していて、動的言語であるRubyの構文に対して、TypeScriptのtscコマンドのように「静的な型チェック」をかけることができるのが特徴です。

この記事では、Sorbetの全体的な詳細には触れません。Sorbetの基本について知りたい方は、ぜひ以下の記事もご覧ください。

https://zenn.dev/pharmax/articles/6e3acb8c10c87c

Sorbetを使うメリット

前述の通り、Sorbetには「ランタイム上で行われる動的チェック」と「コマンドから実行する静的チェック」という2つの主要な機能があります。特に、従来のRubyにはない「静的チェック」は、Sorbetの使用における大きなメリットの一つです。ここで静的チェックについて簡単に説明します。

例えば、以下のようなRubyコードがあるとします。

class Test
  def sum(a, b)
    a + b
  end
end

通常のRubyでは、もしab+メソッドを持たないインスタンスであれば、例えばnilが渡された場合、実行時にNoMethodErrorが発生します。つまり、エラーは「コードを実行するまで」検知されません。

しかし、Sorbetを使用すると、「コード実行前(例えばgitのコミット時やCI時)」にエラーを検知できるようになります。以下はSorbetを使用して型を明示した例です。

# typed: true

class Test
  extend T::Sig

  sig { params(a: Integer, b: Integer).returns(Integer) }
  def sum(a, b)
    a + b
  end
end
sorbet tc

静的チェックは、コマンドで実行できる

Sorbetでは、メソッドにデータ型のシグネチャを宣言することができます。このメソッドシグネチャを使用することで、TypeScriptや他の型付き言語のように、実行前に引数や戻り値の型が正しいかをチェックできます。これにより、実行前にバグを早期に発見し、より堅牢なコードを作成することが可能です。

Sorbetのインタフェース

Sorbetのインタフェース機能は、他言語のインタフェースと同様、クラスが提供すべきメソッドのセットを定義するために用います。この機能により、異なるクラスのインスタンスを同一のインタフェース型として扱うことが可能になり、多様性(ポリモーフィズム)を実現できます。

例として、Javaにおけるインタフェースの使用例を以下に示します。

public interface Animal {
    public void bark();
}

public class Dog implements Animal {
    public void bark() {
        System.out.println("Bowwow!");
    }
}

public class Cat implements Animal {
    public void bark() {
        System.out.println("Meow!");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal dog = new Dog();
        Animal cat = new Cat();

        dog.bark();
        cat.bark();
    }
}

Rubyでもポリモーフィズムを実現できますが、Rubyにはインタフェースという言語仕様はありません。しかし、Sorbetを使用すると、他の静的型付け言語に似た形でインタフェースを実装できるようになります。

Sorbetを使うことのもう一つの利点は、静的チェックを通じてより安全なコードを保証できる点です。次に、Sorbetのインタフェース機能の具体的な挙動と使い方を詳しく見ていきましょう。

Sorbetを使わずRubyだけで書いた例

まず、Sorbetを使わないRubyだけで書いた例を示します。

class Cat
  def bark
    puts 'Meow!'
  end
end

class Dog
  def bark
    puts 'Bowwow!'
  end
end

class Test
  def call_bark(animal)
    animal.bark
  end
end

Test.new.call_bark(Dog.new)
Test.new.call_bark(Cat.new)
Bowwow!
Meow!

この例では、CatとDogクラスが共通のメソッドbarkを実装しています。Rubyのような動的型付け言語では、ダックタイピングという手法が一般的です。ダックタイピングは、「同じ振る舞い(同じメソッドを持つ)をするオブジェクトなら、その型に関係なく扱うことができる」という考え方に基づいています。

この例で、CatとDogは異なるクラスですが、どちらもbarkメソッドを持っているため、Testクラスのcall_barkメソッド内で問題なく呼び出すことが可能です。Sorbetを使わない標準的なRubyのアプローチでは、このようなダックタイピングを利用してポリモーフィズムを実現します。

Sorbetでインタフェースを使用せずに書いた場合

次にSorbetのインタフェース機能は使わず、メソッドシグネチャのみ使った場合の例を示します。

# typed: true

class Animal
  extend T::Sig

  sig { void }
  def bark
    raise NotImplementedError, 'You must implement the bark method'
  end
end

class Cat < Animal
  extend T::Sig

  sig { void }
  def bark
    puts 'Meow!'
  end
end

class Dog < Animal
  extend T::Sig

  sig { void }
  def bark
    puts 'Bowwow!'
  end
end

class Test
  extend T::Sig

  # 関数シグネチャで型を付けることで、引数の型を明示的に宣言できる
  sig { params(animal: Animal).void }
  def call_bark(animal)
    animal.bark
  end
end

Test.new.call_bark(Dog.new)
Test.new.call_bark(Cat.new)

Testクラスのcall_barkメソッドにシグネチャをつけるため、基底クラスとしてAnimalクラスをDog、Catで継承しています。

Sorbetのメソッドシグネチャを定義することで「想定していないデータ型の引数が渡されてしまう」ことを静的にチェックが可能です。

例として、次のようなコードを試してみます。

# 本来はAnimal型のインスタンスが必要ですが、整数を渡す
Test.new.call_bark(1)

本来、call_barkにはAnimal型が渡されることを期待していますが、Integerを引数に渡しています。
この場合、Sorbetのsrb tcコマンドを使用すると、引数の型が不正であるとしてエラーが検出されます。

% bundle exec srb tc .

./app/test_interface/example.rb:7: Expected Animal but found Integer(1) for argument animal https://srb.help/7002
     7 |    Test.new.call_bark(1)
                           ^
  Expected Animal for argument animal of method Test#call_bark:
    ./app/test_interface/test.rb:6:
     6 |  sig { params(animal: Animal).void }
                       ^^^^^^
  Got Integer(1) originating from:
    ./app/test_interface/example.rb:7:
     7 |    Test.new.call_bark(1)
                           ^
Errors: 1

引数の事前チェックができるのであれば、このままでも問題ないのでは?と思うかもしれませんが、これだけでは不十分な場合があります。例えば、以下のようにDogクラスにbarkメソッドが意図的に実装されていない場合、現在のSorbetの設定では静的チェックでエラーを検出できません。

class Dog < Animal
  extend T::Sig

  # あえてbarkを実装しない
end
% bundle exec srb tc .
No errors! Great job.

静的チェックでエラーが発生しない

[1] pry(main)> Test.new.call_bark(Dog.new)
NotImplementedError: You must implement the bark method
from /myapp/app/test_interface/animal.rb:8:in `bark'

実行時には、メソッドが定義されていないため、エラーになる

この問題は、メソッドシグネチャを宣言しただけでは実装の存在が保証されないため発生します。

このような挙動を解決するために、次に説明する抽象メソッド(abstract method)やインタフェース(interface)を使います。

Sorbetでインタフェースを使う場合

ここからは、Sorbetの抽象メソッド(abstract method)とインタフェース(interface)機能を使った方法について記載します。

インタフェース・抽象メソッドの書き方

Sorbetでは、実装を伴わない抽象メソッドを宣言するために、メソッドシグネチャに abstract キーワードを使用します。また、モジュールをインタフェースとして扱うには、interface! キーワードを使用して宣言します。
※ Sorbetでは、別に抽象クラスとして定義も可能ですがここではinterface!のみ扱います。

構文としては次のようになります。

# typed: true

module Animal
  extend T::Sig
  # ① T::Helpersをextends
  extend T::Helpers
  # ② interface!を宣言する
  interface!

  # ③ abstractを付けたシグネチャを宣言
  sig { abstract.void }
  def bark
    # ④ 空実装にする
  end
end

まず、モジュールの定義としては、次の2点が必要です。

  • T::Helpers をextendsする
  • interface! を宣言する

抽象メソッドとしては「③ abstract を付けた関数のシグネチャを宣言する」必要があります。
abstractメソッドは、基本的に実装を許さないため、④ ※ メソッド中身は空である必要があります。

※ メソッドの中身を書くと静的チェックでエラーになります

抽象メソッドを実装する

# typed: true
class Dog
  extend T::Sig
  # ① インタフェースとして作成したモジュールをincludeする
  include Animal

  # ② overrideを付けたメソッドシグネチャを宣言
  sig { override.void }
  def bark
    puts 'Bowwow!'
  end
end

抽象メソッドを実装する側は、次のようにします。

  • ① インタフェースとして作成したモジュールをincludeする
  • abstractでメソッドシグネチャを宣言したメソッドに対して、 overrideを付けてメソッドシグネチャを宣言する

インタフェースとして宣言したモジュールをincludeしたクラスは、モジュールの持つ抽象メソッドに対してoverrideを付けた実装がないとエラーになります。(動的チェック及び静的チェック両方でエラーになります)

この制約のおかげで、「対象のインスタンスにメソッドが存在しているか?」 という実装保証を事前にチェックすることが可能になります。

公式ページ

その他使い方の詳細は次のSorbet公式ページを参照ください。

https://sorbet.org/docs/abstract#creating-an-abstract-method

インタフェースを使った完成例

最後にインタフェースを使った完成例は次のとおりです。

# typed: true
module Animal
  extend T::Sig
  extend T::Helpers

  interface!

  sig { abstract.void }
  def bark
  end
end

class Cat
  extend T::Sig
  include Animal

  sig { override.void }
  def bark
    puts 'Meow!'
  end
end

class Dog
  extend T::Sig
  include Animal

  sig { override.void }
  def bark
    puts 'Bowwow!'
  end
end

class Test
  extend T::Sig

  sig { params(animal: Animal).void }
  def call_bark(animal)
    animal.bark
  end
end

Test.new.call_bark(Dog.new)
Test.new.call_bark(Cat.new)

インタフェースを使わない場合にエラーを検知できなかった、Dogクラスにbarkメソッドを実装しないケースを試してみましょう。

class Dog
  extend T::Sig
  include Animal

  # あえてbarkを実装しない
end
bundle exec srb tc .
./app/test_interface/dog.rb:3: Missing definition for abstract method Animal#bark https://srb.help/5023
     3 |class Dog
        ^^^^^^^^^
    ./app/test_interface/animal.rb:9: bark defined here
     9 |  def bark
          ^^^^^^^^
  Autocorrect: Use -a to autocorrect
    ./app/test_interface/dog.rb:8: Insert sig { override.void }
      def bark; end
     8 |end
        ^
Errors: 1

このようにインタフェースを使うことで、srb tc コマンドにより、実行前に実装の不備を発見することができるようになります。

終わりに

以上、Sorbetのインタフェース機能について紹介でした。

PharmaXでは、様々なバックグラウンドを持つエンジニアの採用をお待ちしております。

Sorbetを使ったRubyのアプリケーション開発に興味がある方はぜひお声がけください。
弊社はAI活用にも力を入れていますので、LLM関連の開発に興味がある方もお待ちしております。

もし興味をお持ちの場合は、私のXアカウント(@hakoten)や記事のコメントにお気軽にメッセージいただければと思います。まずはカジュアルにお話できれば嬉しいです!

PharmaXテックブログ

Discussion