こんにちは。id:Pocke です。今年は3回ぐらいサンライズ瀬戸・出雲に乗っている気がします。
この記事では RBS に最近追加された構文を紹介します。 RBS は活発に開発をされており、ここ最近も多くの機能が追加されてきました。 一方でそれらの新機能は十分に知られていません。せっかくの新機能が知られず使われていないのはもったいないですね。
この記事ではそれらの新機能から、特に構文の変更に注目して紹介します。 比較的最近となる v2.0.0 以降の構文の変更を CHANGELOG からリストアップしました。 それら構文の変更の中から、主要なものを見ていきましょう。
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
クラスは#initialize
でoutput
を受け取り、#format
で値を変換した結果をoutput
のwrite
メソッドを使って書き込みます。また、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
であるoutput
はwrite
メソッドを持っていないかもしれません。そのためこのようにエラーが出てしまいます。
また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
のサブタイプであるため、T
はwrite
メソッドを持っていることが保証されています。
またFormatter.new(output: Object.new)
のように、write
メソッドを持たないオブジェクトを渡せてしまう問題も起きません。
実例
Bounded Generics を使った例はまだ多くないようです。簡単に検索したところ、protobuf gem の型定義や rbs gem の Environment
関係の定義で使われているのを見つけることが出来ました。
- https://github.com/ruby/gem_rbs_collection/blob/267dd270bb5aabcc1e21c87f44360f0680a8501c/gems/protobuf/3.10.3/interface.rbs#L3-L5
- https://github.com/ruby/rbs/blob/v3.2.1/sig/environment.rbs#L21-L22
v2.2.0 (2022-02-22)
https://github.com/ruby/rbs/blob/v3.2.1/CHANGELOG.md#220-2022-02-22
public
とprivate
がメソッドごとに書けるようになった
https://github.com/ruby/rbs/blob/v3.2.1/CHANGELOG.md#language-updates-4
RBS v2.2.0 からは、public
とprivate
がメソッドごとに書けるようになりました。
RBS v2.2.0 まではpublic
とprivate
は以降のメソッドの可視性を変更する使い方しか出来ませんでした。
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::Base
のbefore_action
や、ActiveRecord
のscope
などにこの機能が使えます。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)
ではself
はApplicationController
クラスです。ここでメソッドを呼ぶと、ApplicationController
クラスのクラスメソッドが呼ばれます。
一方で(2)
ではself
はApplicationController
クラスのインスタンスです。そのためここでメソッドを呼ぶと、ApplicationController
クラスのインスタンスメソッドが呼ばれます。
RBS では v2.7.0 までこのself
の違いをうまく表現できませんでした。そのため本来は(2)
の位置でのself
の型はApplicationController
クラスのインスタンスであるべきですが、ApplicationController
クラスとなってしまっていました。
そのためこの例のようなコードがあると、redirect_to
はApplicationController
のクラスメソッドとしては存在しないため、メソッドが見つからないエラーとなってしまっていました。
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::Namespace
にNS
という別名をつけて参照しています。
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 を使うための一助になれば幸いです。
-
ここで使っている
_Writer
は、write
メソッドを持つことを示すインターフェイスで組み込みで用意されています。 https://github.com/ruby/rbs/blob/v3.2.1/core/builtin.rbs#L77-L80↩ - ブロックを渡すケースも同様です。↩
- Symbolを渡せたりもするため、実際はより複雑な型になります。 実際の型は次のようになっています。 https://github.com/ruby/gem_rbs_collection/blob/8149bc3fc0f720d935dc0592dc8886e03052f65f/gems/actionpack/6.0/actioncontroller.rbs#L110-L116↩