この記事はRuby 3.0 Advent Calendar 2020の14日目の記事です。Rubyに導入される型定義でProcがどのように書かれるのかについてまとめています。
はじめに
Ruby3.0ではコードに型定義を与えるRBSという仕組みが加わります。Rubyで動いているプロダクトの可読性や保守性を高めるためにこの型定義の導入を考えている方も多いのではないでしょうか。
RBSによってRubyのコードに型情報を与えるための手助けをしてくれるツールとして静的解釈に基づく型解析ツールであるTypeprofがあります。RBSやTypeprofの基本的な解説は色々な記事が出ているため、本記事ではドキュメントなどにあまり載っていないTypeprofを用いたBlockやProcの型推論に関してまとめました。
使用した環境は以下の通りです
ruby 3.0.0preview1 (2020-09-25 master 0096d2b895) [x86_64-darwin18]
typeprof (0.9.0)
steep (0.37.0)
rbs (0.20.1)
準備
Procを用いた簡単なコードを準備します。
# lib/app.rb
class App
def foo(n)
n.to_s
end
def boo(fn)
fn.call
end
def baz(fn)
fn.call(5)
end
def bar(&fn)
fn.call(0)
end
end
proc1 = proc { "ブロック" }
proc2 = Proc.new { |n| n.to_s }
app = App.new
p app.foo(5)
p app.boo(proc1)
p app.baz(proc2)
p app.bar { |a| a.to_s }
まず実行してみます。
$ ruby lib/app.rb
"5"
"ブロック"
"5"
"0"
問題なく実行できました。それではtypeprofで型解析を行います。
$ typeprof lib/app.rb
# Revealed types
# lib/app.rb:24 #=> String
# lib/app.rb:25 #=> String
# lib/app.rb:26 #=> untyped
# lib/app.rb:27 #=> String
# Classes
class App
def foo: (Integer) -> String
def boo: (^-> String) -> String
def baz: (Proc) -> untyped
def bar: { (Integer) -> String } -> String
end
型解析結果が出てきました。おおよその型をコードを実行することで判断してくれます、自分で一からRBSを書く手間が省けてありがたいですね。TypeprofのドキュメントによるとProcに関しては抽象化せず基本的に渡される引数と返される値を元に具体的な型を出力するようです。
Procオブジェクトは、ラムダ式(-> { ... })やブロック仮引数(&blk)で作られるクロージャです。 これらは抽象化されず、コード片と結びついた具体的な値として扱われます。 これらに渡された引数や返された値によってRBS出力されます。
Typeprocではコードを実行することで型情報の解析をするため、どのような分析結果が出るかはコードの使用例によって異なります。今回のコードではbooには引数なしでStringを返すprocを与えているため(^-> String) -> Stringと分析されていますが、例えば以下のようにこれをintを返すprocに変更すると、typeprofの分析結果は変わります。
proc1 = proc { 1 }
p app.boo(proc1)
#=> def boo: (^-> Integer) -> Integer
また、これら両方を与えると、出入力両方がユニオンタイプの型シグネチャになります。
proc1 = proc { "ブロック" }
proc2 = proc { 1 }
p app.boo(proc1)
p app.boo(proc2)
#=> def boo: (^-> (Integer | String)) -> (Integer | String)
Procやblockの型シグネチャは以下のように行います。詳細はRBSのsyntaxドキュメントを参照してください。
# Proc
^(Integer) -> String
^(?String, size: Integer) -> bool
#Block
{ (Integer) -> (Integer | String) }
booに関してはうまく入出力の型の分析ができていますが、bazに関しては入力がProcであることしか分析できていませんね。ドキュメントを調べていくと以下のような文を見つけました。
Class.newは対応されません(untypedを返します)。
これが直接の原因かは検証できていませんが、上記のbazに与えるproc2もproc関数で書いてみます。
proc2 = proc { |n| n.to_s }
p app.baz(proc2)
# => def baz: (^(Integer) -> String) -> String
今度はちゃんと型が分析されましたね。
それでは、この結果をそのままRBSファイルにしていきます。RBSファイルの拡張子は.rbsです。
# sig/app.rbs
class App
def foo: (Integer) -> String
def boo: (^-> String) -> String
def baz: (Proc) -> untyped
def bar: { (Integer) -> String } -> String
end
ではこの型情報を元に実行ファイルの型情報のチェックを行っていきます。型シグネチャを用いた静的チェックにはsteepというgemを使います。以下のコマンドでsteepのための設定ファイルを作成します。
$ steep init
生成されたSteepfileを以下のように変更します。signatureでRBSの型シグネチャファイルのあるディレクトリを、checkでチェックしたいrbファイルのディレクトリを指定します。
target :lib do
signature "sig"
check "lib"
end
型情報の静的チェックを行います。
$ steep check
lib/app.rb:25:10: ArgumentTypeMismatch: receiver=::App, expected=^() -> ::String, actual=::Proc (proc1)
lib/app.rb:26:10: ArgumentTypeMismatch: receiver=::App, expected=^(::Integer) -> ::String, actual=::Proc (proc2)
型チェックのエラーが出ましたね。どうやらsteepではまだProcの引数のチェックまではできず、Procとプロック型シグネチャを不一致と見なしてエラーを吐いてしまうようなので、以下のように書き換えます。
# sig/app.rbs
class App
def foo: (Integer) -> String
def boo: (Proc) -> String #<=ここを編集
def baz: (Proc) -> String #<=ここを編集
def bar: { (Integer) -> String } -> String
end
もう一度型チェックを行います。
$ steep check
今度はエラーが出ませんでした。steepがprocの引数の型までチェックしてくれるようになるといいですね。
最終的なフォルダ構成は以下のようになっています。また、使用したコードはhttps://github.com/TomeHirata/typeprof_testに上がっています。
まとめ
Ruby3.0で標準となるRBSやTypeprofを用いてrubyコードの型プロファイリングを行なってみました。自動で既存のRubyコードからRBSの型定義を分析してくれるTypeprofとても便利そうですね!Typeprofでおおよその型定義を自動生成して一部手直しすることで簡単に型定義が用意できるようになりそうです。
RBSではProcとBlockはそれぞれ以下のように書きますが、本記事執筆時点ではTypeprofでClass.newをおってくれなかったり、steepでprocの型不一致エラーが起きたりしました。
# Proc
^(Integer) -> String
#Block
{ (Integer) -> (Integer | String) }
まだ、型シグネチャの書き方や解析ツールの推論部分などにわかりにくい部分はありますが、今後改善されていくと思います。皆さんもぜひこの機会に型を持ったRubyライフを始めてみてください。また、このように書くとsteepやTypeprofでProcの定義がうまくいくよと言ったTipsがあればぜひ教えてください!!
最後に
現在 estie では, JavaScript や Ruby に強いエンジニアを積極採用中です!!
不動産のデータを使ってデータプラットフォームを構築したい、分析したい、プロダクトを作ってみたいという方はぜひ!