RBSとSteepメモ(Ruby、Railsにおける型付け)

RBS3.1.0、Steep 1.4.0で確認。

簡単に説明するとRBSは型の構文で、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扱いになる。また、メソッド定義の先頭にprivatepublicを書くこともできる。

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 === objectkind_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
untyped型チェックをしない。TypeScriptでいうところのany。
nilnil
top全ての型のスーパータイプ
bot全ての型のサブタイプ
voidtopのエイリアス。値を使うべきでない時に使う。
boolishtopのエイリアス。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に追加することを公式では推奨している。

RBS Collection manager