Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

RBS に最近追加された構文

こんにちは。id:Pocke です。今年は3回ぐらいサンライズ瀬戸・出雲に乗っている気がします。

この記事では RBS に最近追加された構文を紹介します。 RBS は活発に開発をされており、ここ最近も多くの機能が追加されてきました。 一方でそれらの新機能は十分に知られていません。せっかくの新機能が知られず使われていないのはもったいないですね。

この記事ではそれらの新機能から、特に構文の変更に注目して紹介します。 比較的最近となる v2.0.0 以降の構文の変更を CHANGELOG からリストアップしました。 それら構文の変更の中から、主要なものを見ていきましょう。

github.com

v2.0.0 (2021-12-24)

https://github.com/ruby/rbs/blob/v3.2.1/CHANGELOG.md#200-2021-12-24

Bounded Generics

https://github.com/ruby/rbs/blob/v3.2.1/CHANGELOG.md#bounded-generics

RBS v2.0.0 での構文の変更は、Bounded Generics の1点のみでした。

Bounded Generics はその名の通り Generics の拡張で、型パラメータに上界を設けることができます。

たとえば次のような例を考えてみましょう。

class Formatter
  attr_reader :output

  def initialize(output:)
    @output = output
  end

  def format(value)
    output.write(convert(value))
  end
end

Formatterクラスは#initializeoutputを受け取り、#formatで値を変換した結果をoutputwriteメソッドを使って書き込みます。また、outputにはattr_readerを通して外からアクセスできます。

Bounded Generics がない場合(1)

ではこのクラスの RBS を考えてみましょう。まず、Bounded Generics がない場合の型定義の1つとして、次のようなコードが考えられます。1

class Formatter
  attr_reader output: _Writer

  def initialize: (output: _Writer) -> void
  def format: (untyped) -> void
  def convert: (untyped) -> String
end

これで問題なく定義できているような気がしますね。ですが、この型定義だと次のようなRubyコードを書いた場合に不都合が生じます。

f = Formatter.new(output: StringIO.new)
f.format something
f.output.string

この型定義ではf.outputの戻り値の型は_Writerであり、StringIOではありません。そのため、Steepでは次のように_Writer#stringメソッドが存在しないエラーとなってしまいます。

test.rb:15:9: [error] Type `::_Writer` does not have method `string`
│ Diagnostic ID: Ruby::NoMethod
│
└ f.output.string
           ~~~~~~

これを解決するにはf.outputの型を何らかの形でStringIOに変換する必要があります。たとえば Steep では次のように書くことで変換ができます。

f = Formatter.new(output: StringIO.new)
f.format 42
# @type var o: StringIO
o = f.output
o.string

ですがこの変換は安全ではなく、プログラマの責任で行う必要があります。そのためあまり良い方法とは言えないでしょう。

Bounded Generics がない場合(2)

先程のoutputの型が_Writerになってしまう問題は、 Bounded Generics がなくとも型パラメータを使うと解決できます。型定義を次のように変更してみましょう。

class Formatter[T]
  attr_reader output: T

  def initialize: (output: T) -> void
  def format: (untyped) -> void
  def convert: (untyped) -> String
end

これで先程の問題は解決しました。この型定義であればf変数の型はFormatter[StringIO]となります。そのためf.outputの型はStringIOとなり、f.output.stringは問題なく動作します。

一方でこのケースでは別の問題があります。まず、この型定義の場合はformatメソッドの定義で次のエラーが出てしまいます。

test.rb:9:11: [error] Type `T` does not have method `write`
│ Diagnostic ID: Ruby::NoMethod
│
└     output.write(convert(value))
             ~~~~~

型パラメータのTは任意の型を受け入れてしまうため、型がTであるoutputwriteメソッドを持っていないかもしれません。そのためこのようにエラーが出てしまいます。

またTに渡せる型には制限がないため、Formatter.new(output: Object.new)のように、outputにはなんでも渡せてしまう問題もあります。

Bounded Generics を使った型定義

Bounded Generics を使うとこれらの問題を解決できます。型定義を次のように変更してみましょう。

class Formatter[T < _Writer]
  attr_reader output: T

  def initialize: (output: T) -> void
  def format: (untyped) -> void
  def convert: (untyped) -> String
end

変更した箇所は1行目のみで、< _Writerを指定しました。これによってすべての問題が解決します。

まず型パラメータが使われているため、f.outputの型はStringIOとなり、f.output.stringは問題なく動作します。 そしてformatメソッド内のwriteメソッドの呼び出しも問題ありません。T_Writerのサブタイプであるため、Twriteメソッドを持っていることが保証されています。 またFormatter.new(output: Object.new)のように、writeメソッドを持たないオブジェクトを渡せてしまう問題も起きません。

実例

Bounded Generics を使った例はまだ多くないようです。簡単に検索したところ、protobuf gem の型定義や rbs gem の Environment関係の定義で使われているのを見つけることが出来ました。

v2.2.0 (2022-02-22)

https://github.com/ruby/rbs/blob/v3.2.1/CHANGELOG.md#220-2022-02-22

publicprivateがメソッドごとに書けるようになった

https://github.com/ruby/rbs/blob/v3.2.1/CHANGELOG.md#language-updates-4

RBS v2.2.0 からは、publicprivateがメソッドごとに書けるようになりました。

RBS v2.2.0 まではpublicprivateは以降のメソッドの可視性を変更する使い方しか出来ませんでした。

class C
  private
  def f: () -> untyped # メソッド f は private method

  public
  def g: () -> untyped # メソッド g は public method
end

一方 RBS v2.2.0 からはメソッド単位で可視性を変更できるようになりました。

class C
  private def f: () -> untyped # メソッド f は private method

  public def g: () -> untyped  # メソッド g は public mehtod
end

メソッドごとに可視性を明示することで、そのメソッドの可視性が何なのか明確になります。 この形式で書かれた RBS はまだ多くありませんが、このスタイルを好む場合には使ってみると良いでしょう。

2.7.0

https://github.com/ruby/rbs/wiki/Release-Note-2.7

Self type bindings in procs and blocks

https://github.com/ruby/rbs/wiki/Release-Note-2.7#self-type-bindings-in-procs-and-blocks

RBS v2.7.0 では、メソッドが受け取るブロックや Proc の中の self の型を変えられるようになりました。

馴染み深いところだと、ActionController::Basebefore_actionや、ActiveRecordscopeなどにこの機能が使えます。before_actionについて見てみましょう。

この機能が関係するのは、before_actionに Proc を渡すケースです。2 例えば以下のケースを考えてみます。

class ApplicationController < ActionController::Base
  # (1)
  before_action -> {
    # (2)
    redirect_to root_path unless current_user
  }
end

このコードに型を付ける際には、(1)(2)selfの型が異なることが問題となります。

(1)ではselfApplicationControllerクラスです。ここでメソッドを呼ぶと、ApplicationControllerクラスのクラスメソッドが呼ばれます。

一方で(2)ではselfApplicationControllerクラスのインスタンスです。そのためここでメソッドを呼ぶと、ApplicationControllerクラスのインスタンスメソッドが呼ばれます。

RBS では v2.7.0 までこのselfの違いをうまく表現できませんでした。そのため本来は(2)の位置でのselfの型はApplicationControllerクラスのインスタンスであるべきですが、ApplicationControllerクラスとなってしまっていました。 そのためこの例のようなコードがあると、redirect_toApplicationControllerのクラスメソッドとしては存在しないため、メソッドが見つからないエラーとなってしまっていました。

RBS v2.7.0 からはこの問題を解決するため、Proc やブロックのselfの型を明示する構文が導入されました。before_actionメソッドの型定義は(Proc を渡す形式だけに注目すると)次のようになります。3

class ActionController::Base
  def self.before_action: (^() [self: instance] -> void) -> void
end

[self: instance]の部分がこの構文です。これはこの Proc の中で、selfの型がinstance(このメソッドを呼び出したクラスのインスタンス)であることを示しています。 これによってselfの型が実態に沿ったものになり、型エラーも出なくなります。

3.0.0

https://github.com/ruby/rbs/wiki/Release-Note-3.0

v3.0.0 では、クラス及びモジュールのエイリアスを定義する構文と、useディレクティブが追加されました。

Class/module alias decl

https://github.com/ruby/rbs/wiki/Release-Note-3.0#add-classmodule-alias-syntax

1つ目に紹介する機能は、クラス及びモジュールのエイリアスを定義する構文です。

Ruby では以下のようにすることで、1つのクラスを複数の名前で参照できます。

class A
end

B = A

p A == B # => true

一方この定義を RBS でうまく表す方法は、RBS v3.0.0 まで存在しませんでした。次のようなワークアラウンドはありましたが、どれも問題がありました。

# RBS

class A
end

# B を A の子クラスとしてしまう方法。
# 定義が実態に沿っていないため、例えば `def f: (B) -> void`のようなメソッド定義があるとき、
# 本来渡せるはずの A クラスのインスタンスをこのメソッドに渡せなくなってしまう。
class B < A
end

# B 定数の型を A クラスと宣言する方法。
# この場合 B は RBS ではクラス名として扱われないため、そもそも `def f: (B) -> void`のようなメソッド定義ができない。
B: singleton(A)

この問題を解決するための機能として、クラスやモジュールのエイリアスを定義する構文が追加されました。この構文は次のように使います。

# RBS

class A
end

# B を A のエイリアスとして定義
class B = A

このように書くと、B クラスは A クラスと同じものとして使われます。def f: (B) -> voidには、当然AのインスタンスもBのインスタンスも渡せます。 またモジュールの場合も同様に、A, Bがモジュールであれば、module B = Aのようにモジュールのエイリアスも定義できます。

実例

クラスやモジュールに別名をつける実例は多くあります。 たとえばActiveSupportが提供するHashWithIndifferentAccessは、::ActiveSupport::HashWithIndifferentAccessとして定義されていますが、::HashWithIndifferentAccessとしてもアクセスできます。これは以下のファイルで定義されています。 https://github.com/ruby/gem_rbs_collection/blob/8149bc3fc0f720d935dc0592dc8886e03052f65f/gems/activesupport/6.0/activesupport-generated.rbs#L7956

またアプリケーションコードでもリファクタリングのためなどに別名をつけることはあるでしょう。そのようなケースにもこの機能は使えます。

use directive

https://github.com/ruby/rbs/wiki/Release-Note-3.0#add-use-syntax

v3.0.0 で追加されたもう1つの構文は、useディレクティブです。これはクラスやモジュールの名前空間を省略できる機能です。

この機能がない場合、次のように長い名前空間を何度も参照する時に RBS の書き方が冗長になってしまいます。

class C
  def f: (Very::Long::Namespace::User) -> Very::Long::Namespace::Result
end

このような場合にuseディレクティブが役立ちます。上の例は次のように短く書き換えられます。

use Very::Long::Namespace::*

class C
  def f: (User) -> Result
end

このように名前空間の後に*でワイルドカードを指定すると、このuseディレクティブを書いたファイルでは、その名前空間以下のすべての定数で名前空間を省略できます。

特定のクラス名のみを指定する

またuseディレクティブには他の使い方が2つあります。 1つはワイルドカードの代わりに特定のクラス名だけを指定する方法です。

use Very::Long::Namespace::User
use Very::Long::Namespace::Result

class C
  def f: (User) -> Result
end

この使い方では使いたいクラス名を個別に列挙する必要がある一方、どのクラスが指定されるかが明確で、不要なクラスまで省略されてしまうことがありません。

別名をつける

もう1つの使い方は、asを使って別名をつける方法です。

次の例では、Very::Long::NamespaceNSという別名をつけて参照しています。

use Very::Long::Namespace as NS

class C
  def f: (NS::User) -> NS::Result
end

また次のように個々のクラスに別名をつけることもできます。

use Very::Long::Namespace::User as U
use Very::Long::Namespace::Result as R

class C
  def f: (U) -> R
end

ワイルドカードを使ってすべての名前空間を省略すると分かりづらい場合などに、asを使うと良いでしょう。


なおこの機能は RBS の書き方のみに影響する機能であり、型定義の意味には影響しません。 またディレクティブのスコープはファイル単位です。あるファイルでuseディレクティブを使っていても、別ファイルから型名を参照するときにはそのディレクティブは適用されません。

最後に

この記事では比較的最近追加された RBS の構文を紹介しました。今まであまり紹介されていなかった構文も多いため、初めて知った構文がある方も多いかもしれません。 この記事がより快適に RBS を使うための一助になれば幸いです。


  1. ここで使っている_Writer は、writeメソッドを持つことを示すインターフェイスで組み込みで用意されています。 https://github.com/ruby/rbs/blob/v3.2.1/core/builtin.rbs#L77-L80
  2. ブロックを渡すケースも同様です。
  3. Symbolを渡せたりもするため、実際はより複雑な型になります。 実際の型は次のようになっています。 https://github.com/ruby/gem_rbs_collection/blob/8149bc3fc0f720d935dc0592dc8886e03052f65f/gems/actionpack/6.0/actioncontroller.rbs#L110-L116