RBSとSteepメモ(Ruby、Railsにおける型付け)
RBS3.1.0、Steep 1.4.0で確認。
簡単に説明するとRBSは型の構文で、Steepは型のチェックをするプログラム。
- https://github.com/ruby/rbs
- https://github.com/soutaro/steep
全てではないが、configure_code_diagnostics(D::Ruby.all_error)
の設定で動作確認している。
制限
rbファイルのコードを一切変更せずに、rbsファイルを書けるわけではない。適宜rbファイルのコードを変更したり、アノテーションコメントを追加したりしないといけないケースがある。ものによってはrubocopに指摘を受けるケースもあるかもしれない。
導入方法
Gemfileに以下を追加。Steep 1.3でためそうとすると'require': cannot load such file -- parallel/processor_count (LoadError)
になるので1.4を明示しておく。RBSはSteepが依存しているので勝手に入る。
gem 'steep', '~> 1.4'
bundle exec steep init
でSteepfileを作る。
初期導入は以上で完了。
Steepfile
# サンプル
D = Steep::Diagnostic
target :app do
signature 'sig'
check 'app'
ignore 'app/views/'
library 'pathname'
configure_code_diagnostics(D::Ruby.strict)
end
target
の引数は単なるラベルで特別な意味はなさそうなので、わかりやすいラベルを指定しておけば良いはず。
signature
はRBSファイルが格納されているディレクトリを指定する。signature 'sig', 'app'
のように複数指定可能で、rbファイルが存在するディレクトリも指定できる。なので、rbファイルと対応するrbsファイルを同じディレクトリに格納することも可能である。
check
は型チェック対象のrbファイルが存在しているディレクトリやファイルを指定する。app/controllers/**/*_controller.rb
のようなglobを指定することもできる。
ignore
はcheckの反対でチェック対象外にするディレクトリやファイル、globを指定する。
library
は型情報を読み込みたい標準ライブラリなどを指定する。
configure_code_diagnostics
は警告レベルなどを指定するようである。TODO: 動作確認。
チェックの実行
bundle exec steep check
でチェックを実行できる。
VSCodeの拡張機能
https://marketplace.visualstudio.com/items?itemName=soutaro.steep-vscode
一応存在しているようだが、ローカルにsteepをセットアップしなければならず、Dockerで開発している場合はそのままではうまくいかないようである。
Steepはlanguage_server-protocol
というgemを使ってVSCodeと通信しているようなので、その通信方式がわかればうまくやれる方法があるかもしれない。
文法
コメント
Rubyコメントと同じように#
以降がコメントになる。
モジュール、クラス定義
モジュールに含まれないクラスなら、rbファイル同様の以下の書き方でよい。
class A
end
# 継承する場合
class B < A
end
X::A
のようなモジュールに含まれるクラスの場合、rbファイルと同じようにrbsファイルでclass X::A
と書くとエラーになる。おそらく、Xがクラスなのかモジュールなのか判別できないのが原因なのだと思う。
したがって以下のいずれかの書き方をする必要がある。
module X
class A
end
end
# または
module X
end
class X::A
end
注意点としては、classをmoduleにしても大体動いてしまうのだが、classならnew
メソッドの定義が不要になるのでちゃんと使い分けたほうが良い。
また、オープンクラス同様、同じ名前のmoduleやclassの定義が複数あっても同じクラスに定義が追加されていく形になる。同じ名前のメソッドが複数定義された場合はエラーとなる。オーバーロードする方法は後述する。
コンストラクタ定義
他のインスタンスメソッド同様の定義でよい。ただし戻り値の型はvoidにする。voidでなくても良いのだが、指定しても意味がないので。
class A
def initialize: (String) -> void
end
インスタンスメソッド
インスタンスメソッドは以下のような形で、def メソッド名: (引数の型) -> 戻り値の型
という形で記述する。
class A
def hoge: (String) -> Integer
end
キーワード引数の場合は以下のようにする。
class A
def hoge: (a: String, b: Integer) -> Integer
end
複数値を返したい場合はrbファイルも気を付ける必要がある。通常、return :a, 1
のような形で書くと思うがreturn [:a, 1]
のように配列であることを明確にしなければならない。
class A
def hoge(val)
# return :a, val と書けるが配列であることを明示的に書かないといけない
return [:a, val] if val > 0
[:b, 0]
end
end
その上で、rbsは以下のように書く。
class A
def hoge:(Integer) -> [Symbol, Integer]
end
後述するが、[Symbol, Integer]
は型的には配列ではなくタプルになる。
もし、return :a, val
のままにしたいなら、rbsは以下のように変える必要がある。
class A
def hoge:(Integer) -> Array[Symbol | Integer]
end
しかし、この型は「要素がSymbolまたはIntegerのいずれかが入っている任意長の配列」という意味になるので、A.new.hoge(1)[0]
とした時、SteepはSymbolまたはIntegerまたはnilと判断する。実際には必要ない型チェックによる分岐が必須になるし、nilチェックも必須になってしまう(nilと判断するのは長さ0の配列の可能性があるため)。
なので、基本的に前者の書き方をしたほうが良い。
メソッドのオーバーロード
例えば、引数がIntegerだったらIntegerを返し、StringだったらStringを返すメソッドがある場合、以下のように書くことはできる。
def hoge:(Integer | String) -> (Integer | String)
しかし、この書き方だとhoge(100) + 100
はエラーになる。型的にhoge(100)
がStringを返す可能性があるためだ。
なので、以下のようにオーバーロードで型を定義する。
def hoge: (Integer) -> Integer
| (String) -> String
# 分割したい場合 `...`は省略の意味ではなくこのままの構文である
def piyo: (Integer) -> Integer
def piyo: (String) -> String | ...
こうすると、引数と戻り値の型の組み合わせを認識して、hoge(100) + 100
をInteger同士でのメソッド呼び出しと処理されるようになる。
なお、引数と同じ型の戻り値を返すメソッドを定義したいのであれば、オーバーロードではなくジェネリクスで実現できる。ジェネリクスを参照。
def hoge: [T](T) -> T
アクセス修飾子
rbファイルと同様にprivate
だけを書いた行以降はprivate扱いになる。また、メソッド定義の先頭にprivate
やpublic
を書くこともできる。
private def hoge: () -> void
private
def piyo: () -> void
引数
def hoge(a = 'default')
のようなデフォルト値のある引数の場合は以下のように型名の前に?
を記述する。
def hoge: (?String) -> void
キーワードの引数の場合は以下のように引数名の前に?
を書く。
def hoge: (?a: String) -> void
nullableな引数(nilを渡せる引数)の場合は以下のように型名の後に?
を書く。
# hoge(a: nil) が通る
def hoge: (a: String?) -> void
なので、デフォルト値を持っていてnullableなメソッドの場合は以下のような書き方になる。
def hoge: (?String?) -> void
def piyo: (?a: String?) -> void
他の引数については気が向いたら書く。
インスタンス変数
インスタンス変数は以下のように定義する。
class A
@val: String
end
以下のようなメモ化メソッドのインスタンス変数でも定義が必要になる。
class A
def hoge
@hoge ||= 100
end
end
class A
def hoge: () -> Integer
@hoge: Integer
end
クラスメソッド、クラスインスタンス変数
クラスメソッドとクラスインスタンス変数はインスタンスメソッド、インスタンス変数の先頭にself.
をつけただけで構文が基本的に同じである。
class A
def self.hoge
@hoge ||= 100
end
end
class A
def self.hoge: () -> Integer
self.@hoge: Integer
end
attr_accessor、attr_reader、attor_writer
難しいことはなく以下のように書く。
class A
attr_accessor field: Integer
end
警告レベルを変更していた場合、アノテーションコメントが必要になるかもしれない。少なくとも、Steepファイルにconfigure_code_diagnostics(D::Ruby.all_error)
を書いていると以下のアノテーションコメントが必要になる。
# @dynamic field, field=
attr_accessor :field
attr_accessorなどのメソッドは動的にメソッドが作成されるため、Steepがrbファイル内にメソッドがないと判定しまうので、動的にこれらのメソッドが生成されているということを明示するコメントになっている。
メソッドのエイリアス
def hoge(x)
x
end
alias_method :piyo, :hoge
のようなコードがある場合、rbsでもalias piyo hoge
という構文で型を省略できる。
ただし、attr_accessorなどと同じように動的にメソッドが追加されるので、警告レベル次第ではrbファイル側で# @dynamic piyo
のようなアノテーションコメントを追加しておく必要がある。
定数
そのまま書けば良い。
Hoge::CONST_VAL: String
ブロック
ブロックを持つメソッドの型は以下のように定義する。
# def メソッド名: (引数の型) { (ブロック引数の型) -> ブロックの戻り値の型 } -> メソッドの戻り値の型
def hoge: (Integer) { (String) -> Symbol } -> Symbol
Proc、lambda
以下のように定義する。
^(Integer) -> String
mixin
rbファイル同様、include、extend、prependでmixinすることができる。interfaceに対しても使える。
クラスそのものの型
以下のようにクラスのオブジェクトではなくクラスそのもののインスタンスを使うケースがある。
def hoge_class
HogeClass
end
この場合のrbsは以下のように書く。
def hoge_class: () -> singleton(HogeClass)
singleton(HogeClass)
ではなくHogeClass
だとHogeClassのインスタンスを返すという意味になってしまうので注意。
即値
CODE_TABLE = { a: 1, b: 2, c: 3 }
def code(key)
CODE_TABLE[key]
end
上記のようなコードに対応するrbsは以下のように書ける。
CODE_TABLE: { a: 1, b: 2, c: 3 }
def code:(:a | :b | :c) -> (1 | 2 | 3)
def code:(Symbol) -> Integer
とするよりも型を絞り込むことができる。
ハッシュとレコード型
ハッシュの型はHash[キーの型, 値の型]
という書き方をする。
def to_h: () -> Hash[Symbol, String]
上記の書き方は任意のキーを任意個数持つHashの型になる。キーを固定したい場合はレコード型を使う。
def record: () -> { id: Integer, name: String }
配列とタプル型
配列の型はArray[値の型]
という書き方をする。
def to_a: () -> Array[String]
上記の書き方は0個を含む任意個数の要素を持つ配列の型になる。要素を固定したい場合はタプル型を使う。
# [:xxx, 100, 'foo'] のような値にマッチする型
def tuple: () -> [Symbol, Integer, String]
キャスト
定数など、freezeメソッドを呼びたいことがある。
CONST = { a: 1, b: 2}.freeze
rbsファイルでは以下のように書きたいところだが、型不一致でエラーになってしまう。
CONST: { a: 1, b: 2}
この原因は、freezeがレシーバの即値やレコード型などを認識しないため、戻り値がより抽象的な型になってしまうことによる。上記の例だとfreezeはHash[Symbol, Integer]
を返すメソッドになっている。
このような場合に以下のようにRubyのコードを変更するとキャストして型を誤魔化すことができる。
CONST = _ = { a: 1, b: 2}.freeze
ユニオン型
StringまたはIntegerの要素を持つ配列はArray[String | Integer]
のように書く。メソッドの引数や戻り値も同じ。ただし、戻り値の場合はオーバーロードの構文と区別がつかなくなるので、カッコでくくる必要がある。
def hoge:(Symbol | String) -> (Integer | Float)
インターセクション型
ユニオン型はいずれかの型だったのに対して、インターセクション型はすべての型を満たす型になる。
# Gクラスと_Fインターフェースの両方を満たす型が必要(Hクラスが指定できる)
def aaa: (_F & G) -> String
interface _F
def to_s: () -> String
end
class G
def to_h: () -> Hash[Symbol, Integer]
end
class H < G
def to_s: () -> String
end
nullable
型の末尾に?
をつけるとnullableになる。
@a: String?
# 等価な別の書き方
@a: String | nil
型ガード
文字列かnilを受け取って文字列を返す以下のようなメソッドを考える。
def hoge(a)
return '' if a.nil?
a
end
末尾でaを返しているので型定義は
def hoge: (String?) -> String?
になるように思えるが、末尾のaはnilチェックでnilでないことが確定しているのでString以外あり得ない。したがって型定義を以下のように書くことができる。
# 戻り値がnullableでなくなった
def hoge: (String?) -> String
ただ、インスタンス変数やメソッド呼び出しに対してnilチェックや型チェックを行っても型ガードが機能しないので注意。型チェック以降に値が変更される可能性を考えれば妥当である。
# def hoge: () -> String?
# になる
def hoge
return '' if @a.nil?
# ここで別メソッドなどで @a = nil などがされる可能性を考えると妥当な判定
@a
end
# 一旦ローカル変数に代入すればOK
def piyo
a = @a
return '' if a.nil?
a
end
nil以外の型チェックを行う場合は、object.class == FooClass
ではだめで、FooClass === object
やkind_of?
、instance_of?
を使う必要がある。
interface
以下のようにinterfaceの定義と利用ができる。名前は_
ではじめ2文字目は大文字でなければならない。interfaceも別のinterfaceをincludeすることができる。
interface _Foo
def hoge: () -> void
end
class Bar
include _Foo
end
# モジュールに限っては以下の構文も使える
# 複数のinterfaceがある場合は、`,`区切りで並べられる
module M : _Foo
end
型のエイリアス
以下のように型のエイリアスを定義できる。名前はスネークケースで定義する。
type hash_key = String | Symbol
type code_val = 1 | 2 | 3
@hash: Hash[hash_key, code_val]
クラスとモジュールのエイリアス
以下のようにクラスやモジュールのエイリアスを定義できる。
class A
end
class X = A
ジェネリクス
ジェネリクスはclass、module、interface、型のエイリアス、メソッドで利用できる。
class A[T]
def hoge:(T) -> Integer
end
class B
def piyo: () -> A[String]
end
メソッドの場合は以下のように書く。主な用途は引数が複数あって同じ型の引数を受け取るとか、引数と同じ型の戻り値を返すといった感じだろう。
def hoge:[T](T)->T
型変数の型を制限することもできる。以下の例ではNumericを継承していることを要求している。
class A[T < Numeric]
end
共変性と反変性というものがある。
例えば、Array[Integer]
型の値をArray[Numeric]
型の変数に代入することはできる。これは配列から要素を取り出した時、多態性で親クラスの型として処理しても問題ないためである。
しかし、これを実現するためにはout
を使う必要がある。
class MyArray[out T]
def []:(Integer) -> T
end
class A
# find(list, 1) という呼び出しがあってもエラーにならない。outがないとエラーになる。
def list: () -> MyArray[Integer]
def find: (MyArray[Numeric], Integer) -> Numeric
end
逆に引数で渡されてくる値は子クラスのインスタンスでも良いことがある。内部的に多態性で処理されて問題ないためである。そういう場合はin
を使う。
class MyHash[out T, in U]
def []:(U) -> T
end
class A
# find(hash, 1) という呼び出しがあってもエラーにならない。inがないとエラーになる。
def hash: () -> MyHash[Integer, Numeric]
def find: (MyArray[Numeric, Integer], Integer) -> Numeric
end
out
が共変性でin
が反変性である。出ていく(戻り値)方がout
、入ってくる(引数)方がin
と覚えれば良い。
include?
のように引数にout
指定した型を指定したい場合がある。その場合、そのままだとエラーになってしまう。これを回避するには、unchecked
を使う。
class MyArray[unchecked out T]
def []:(Integer) -> T
def include?:(T) -> bool
end
ただ、[]=
メソッドなど破壊的メソッドがある場合は注意しないといけない。Arrayは class Array[unchecked out Elem] < Object
という定義になっているので、以下のようなコードでも通ってしまう。
# @list: Array[Integer]
# def list: () -> Array[Integer]
def list
@list ||= [1, 2, 3]
end
# def change: (Array[Numeric], Integer, Float)
def change(list, index, value)
list[index] = value
end
def exec
# これによって@listは [1, 2, 3, 1.1] となり型違反になるが問題を検知しない
change(list, 1, 1.1)
end
特殊な型
型 | 意味 |
---|---|
self | 自身の型 |
instance | そのクラスのインスタンス |
class | そのクラス自身のインスタンス |
bool | true | false のエイリアス |
untyped | 型チェックをしない。TypeScriptでいうところのany。 |
nil | nil |
top | 全ての型のスーパータイプ |
bot | 全ての型のサブタイプ |
void | topのエイリアス。値を使うべきでない時に使う。 |
boolish | topのエイリアス。findやselectメソッドのブロックの戻り値など真偽判定に使われる変数などで利用される。 |
型認識の強制
{a: 1}
のようなHashがあると型はHash[Symbol, Integer]
と認識される。しかし、Hash[Symbol, Numeric]
を想定している場合は困るので、型を強制する方法が用意されている。
def x
# @typeアノテーションコメントで型を強制する
# @type var y: Hash[Symbol, Numeric]
y = {a: 1}
y[:b] = 1.1 # @typeがないとここで型不一致のエラーになる
end
一応、ローカル変数以外にも指定する構文はあるようではある。 https://github.com/soutaro/steep/blob/master/manual/annotations.md
周辺のgem
rbs_railsというgemがあり、Railsでの片付けをサポートしてくれるものがある。
Gemfileに以下を追加。
gem 'rbs_rails', require: false
以下のコマンドを実行
bundle install
bin/rails g rbs_rails:install
bin/rails rbs_rails:all
最後のコマンドはrbsファイルを生成するコマンドで、現在は
- パスヘルパーのrbs
bin/rails rbs_rails:generate_rbs_for_path_helpers
- ActiveRecordモデルのrbs
bin/rails rbs_rails:generate_rbs_for_models
- 上記両方のrbs
bin/rails rbs_rails:all
の3コマンドがある。パスヘルパーのrbsはconfig/routes.rb
を元に作成し、ActiveRecordモデルはmigration済のカラム情報をもとに作成されるようである。なので、これらのコマンドは定期的に実行していく必要がある。
この状態だとRailsの型情報が不足しているようなので追加で以下を行う。
bundle exec rbs collection init
を実行するとrbs_collection.yaml
というファイルが作成されるので、このファイルを編集し、gems
にsteepを追加しignoreの設定をする。現状はsteep自身が型エラーを起こして報告されてしまうので除外している。
gems:
# Skip loading rbs gem's RBS.
# It's unnecessary if you don't use rbs as a library.
- name: rbs
ignore: true
- name: steep # ここを追加
ignore: true # ここを追加
その上で以下のコマンドを実行する。
bundle exec rbs collection install
この状態でbundle exec steep check
を実行すればエラーにならずに型チェックがされるはずである。
なお、bundle exec rbs collection install
で作成される.gem_rbs_collection
ディレクトリは.gitignore
に追加することを公式では推奨している。