Edited at

Rubyに型を突っ込んでみよう!(RDL編)

More than 1 year has passed since last update.

Ruby Kaigi2016の基調講演(だったと思う)で


  • Rubyをこれまでの3倍高速化すること

  • Rubyに静的型付けを実装すること

これらを発表して暫く経ちますが、さて、これ実際にはどう行うのでしょうか?

rebuild.fmのゲストの方々の発言や、Ruby Kaigiの基調講演等で幾つかアイデアは出しているのですが、そもそも根本が動的に出来ているRubyなので、完全な静的な型のある状態には出来ません。

なので、何かしらの取捨選択を行うしかないのですが、実際に行うとしたら、それは何処で、どう行うのかのアイデアに関しては、Ruby開発者の間でもきっちり固まった案があるわけではないそうです。

そんな中Rubyの開発者向けのRedmineでも、議論は停滞していて、どの言語の書式を取るのかで(くだらねぇ)議論をしている人が散見されているのですが下の方でRDLというgemの紹介をしていました。

実際にRDLをいうgemを見てみると、それなりに思い描いた型チェックのシステムを実現していたので、ここで今RDL自体は何処まで問題を解決しているのか、実際には何処まで使えるのかを調べた結果を共有させていただこうと思っています。

実際にまつもとゆきひろさんの言葉などを総合すると次のような使用が望ましいと考えています。


  • ダックタイピングを保持している

  • コンパイラのための静的解析はしない

  • 後方互換性をほぼ維持できる


RDLの紹介

RDLの紹介

https://github.com/plum-umd/rdl

RDLですが、githubページのREADMEの下の方を見ると、Copyrightと論文へのリンクがあります。

主要な開発者であるT. Stephen Strickland, Brianna Ren, and Jeffrey S. Fosterはどうもアメリカのメリーランド大学の博士号持ちで、2013年ごろから連名で論文を出しています。おそらく同じ研究室のメンバーなのでしょう。


使い方

RDLは、既存のRubyに文法の拡張なく型のチェックを行うために、下の様なDSLを追加して、メソッドの前に宣言することで型のチェッカーを追加することにしています。

type '(Integer, Integer) -> String'

def m(x,y) ... end

外部のライブラリなどに対応するために、クラス名やメソッド名を省略しないで書くことも出来ますので、基本的に追加する事ができないものはほぼない感じです。追加できないパターンは後ほど紹介します。

type String, :insert, '(Integer, String) -> String'

このgemの目玉として、ダックタイピングに対しても対応していて、「to_sメソッドを持っていて、Stringを返す」などのチェックにも対応しています。

type IO, :puts, '(*[to_s: () -> String]) -> nil'

どうしても、型を推論しきれないパターンなどが存在(eval関数とかね!)しても、型をキャストしてくれるメソッドがあるので、型を付けられないパターンは実質存在していません。

# 変数aは、rdl上でnilと判断される

x = RDL.type_cast('a', 'nil', force: true)

DSL自体は結構充実しているので、複数の型を受け取る可能性がある場合、ポリモーフィズムへの対応などもされていて、もうなんだか本当に型チェックできてしまいそうなんですが、Ruby自体はそもそも、他の型のある言語よりも複雑な文法を持っているので、method_missingなどをした場合、どの程度チェックをしてくれるのか心配なところ。

ここも含めて総合的な評価をしながら、RDLの現在の限界を評価してみましょう。


Ruby固有の文法の対応度を調査

とはいえ、出来るだけキャストはしない方が良いです。そして、Rubyにはほかの言語には無い動的な性質を多く持っています。というか、そもそもdefでメソッドを定義してもそれ自体defne_methodの実質シンタックスシュガーで、すべてのメソッドは動的に作られています。

なので、そもそもRuby自体、最初から型の情報などを保持して動的に評価をしようとしても、まだ評価されてメモリ上に存在していないメソッドを参照して死ぬので、ソースコードを一通り読み終わって、ほぼ動的にメソッドなどが追加される状態でなくなった時から、参照を始める必要があります。

現実的には、Railsの様なフレームワークだと、フレームワーク自体が起動していて、autoloadされるクラスが一通り評価されている状態、というのは良い切り分け点でしょう。普通に考えても分かることなので、言語自体が判断してもよいですが、実際は、開発者自身がおおよそ揃ったといえるタイミングを判断して自分で型推論する箇所を指定するのが良いと思います。

RDLは、デフォルトでは動的に解析を行いますが、評価自体を遅延評価出来る様になっていて。

以下の様に指定することで、型推論の開始のタイミングを直接指定できます。

 type '() -> Integer', typecheck: :later

rspecの内側で、テスト開始するときだけ型推論したいなどは、これを指定することで実現できそうです。

とりあえず、Gemfileを作成してrdlをinstallしてテストのためのコードを実行してみます。

gem "rdl", git:"https://github.com/plum-umd/rdl.git", branch:"dev"

今回検証するのは以下の要素


  • 継承

  • autoload

  • define_method

  • ヒアドキュメント

  • mix-in

  • モンキーパッチング

  • super

  • Object#extend

  • refinement

  • method_missing

  • sendメソッド

  • eval


継承

継承している基底クラスのメソッドをチェックしてくれるかチェックしてみましたが、難なく動いています。

require 'rdl'

require 'types/core'
class Foo
extend RDL::Annotate
type '() -> Fixnum', typecheck: :later
def foo
10
end
end
class Bar < Foo
extend RDL::Annotate
type '() -> Fixnum', typecheck: :later
def bar
foo()
end
end
Bar.new.bar
RDL.do_typecheck :later


autoload

autoloadも問題なく動きました。


mage.rb

require 'rdl'

require 'types/core'
class Mage
extend RDL::Annotate
type '(Integer) -> Integer', typecheck: :later
def mage(num)
num
end
end

require 'rdl'

require 'types/core'
$:.unshift "./"
class Hoge
extend RDL::Annotate
type '(Integer) -> Integer', typecheck: :later
def hoge(num)
Mage.new.mage(num)
end
end
Hoge.autoload :Mage, "mage"
Hoge.new.hoge(10)
RDL.do_typecheck :later


define_method

define_methodの場合、何処のクラスに属しているか構文から解析が難しいので

引数に直接指定してあげることで、動くようになりました。

require 'rdl'

require 'types/core'
class Huga
[:huga,:hugahuga].each do |name|
# define_methodは何とか動かせる
type self, name, "()->Integer"
define_method name do
10
end
end
end
Huga.new.huga
RDL.do_typecheck :later


ヒアドキュメント

構文解析機泣かせで、IDEで対応できていなかったものもあったヒアドキュメントですがパーサー自体が対応していたので難なく通りました。

require 'rdl'

class Foo
extend RDL::Annotate
type '(Fixnum) -> String', typecheck: :later
def foo(num)
var_type :sql, 'String'
sql = <<-SQL
SELECT * from users;
SQL
sql
end
end
RDL.do_typecheck :later


mix-in

Object#includeでmix-inしてみましたが、これは無事に動いて見せました。

require 'rdl'

require 'types/core'
module Foo
extend RDL::Annotate
type '() -> Integer', typecheck: :later
def foo
10
end
end
class Bar
extend RDL::Annotate
include Foo
type '() -> Integer', typecheck: :later
def bar
foo()
end
end
Bar.new.bar
RDL.do_typecheck :later


Object#extend

去年チェックしていた時には動きませんでしたが、これも無事に動くようになっていました。

require 'rdl'

require 'types/core'
module Huga
extend RDL::Annotate
type '() -> String', typecheck: :later
def huga
"huga"
end
end
class Hoge
end
Hoge.new.extend(Huga).huga
RDL.do_typecheck :later


super

継承元をsuperメソッドで呼び出すのも無事に動いています。

これも去年は動かなかったのが無事に対応したものです。

require 'rdl'

require 'types/core'
class Foo
extend RDL::Annotate
type '() -> Integer', typecheck: :later
def foo
10
end
end
class Bar < Foo
extend RDL::Annotate
type '() -> Integer', typecheck: :later
def foo
super
end
end
Bar.new.foo
RDL.do_typecheck :later

ただ、オーバーライドした方で引数の数を増やしたりの変更は文法的に対応していません。

同じ動的な言語をベースとしたTypeScriptでは対応しているのを考えると実装不足と言わざる負えません。


refinement

Ruby2.1から導入されたrefinementですが、これは現在は非対応

require 'rdl'

require 'types/core'
module ArrayEx
extend RDL::Annotate
refine Array do
def bar
self.to_s
end
end
end
class Foo
extend RDL::Annotate
using ArrayEx
type '() -> String', typecheck: :later
def foo()
[].bar
end
end
Foo.new.foo
RDL.do_typecheck :later

型チェックはされなくて、エラーメッセージが表示されるだけでした。

./refinement.rb:18:5: error: no type information for instance method `Array#bar'

./refinement.rb:18: [].bar
./refinement.rb:18: ^~~~~~

これ、調べてみた感じ論理的に完全にチェックできるんじゃないかと思って自分なりに実装してpull request出してみたんですが、論文書いている方々のお仕事を邪魔している感じなのでだめかなーと思ったらその通りでした。実装の方法はもっと格好いい方法があるはずなので、色々提案してみると、研究が捗るんではないでしょうか。


sendメソッド

これは、sendメソッドの第1引数がはっきりしている場合は正しく動きました。これは意外でした。

しかしはっきりしない場合はエラーが出ていて、これは動的に決定されていました。

sendメソッドのユースケースを考えると、そもそもメソッド名が分かっている場合は考えずらいので対応できていないというのが正しい考えでしょう。

require 'rdl'

require 'types/core'
class Foo
extend RDL::Annotate
type '(Integer) -> Integer', typecheck: :later
def foo(num)
num
end
type '(String) -> String', typecheck: :later
def bar(num)
num.to_s
end
end
# fooメソッドを正しく推論していた
Foo.new.send(:foo, 10)
# 一旦変数に名前を叩き込んでも、正しくsendを判断していた
foo = :foo
Foo.new.send(foo, 10)
# この形式の場合は、:fooと:barのどちらかは場合によって変わる。
# 一見動いている様に見えて、:fooと:barを両方考慮して正しく推論はしていない
# retの型はStringの場合とFixnumの場合は動的に決定されてしまっている。
p ret = Foo.new.send([:foo,:bar].sample(1).first, 10)
RDL.do_typecheck :later


モンキーパッチング

モンキーパッチングは、メソッドに対して型を付与することは(当たり前ですが)出来ます。

問題は、モンキーパッチングで上書きをした場合ですが同じ名前の2つのメソッドのどちらが上書きされる方なのかというのは、requireされるものも含めて、コードをすべて解析しないと求められない問題です。

Rubocopの方でも同じ問題にぶち当たっていますね。

モンキーパッチングする側は自分がモンキーパッチングしている事を知っているので書式で回避できないかとか考えますが、さらにそれがモンキーパッチングされることを考えると知恵を絞らないといけなさそうな問題です。


method_missing

method_misingに関しては、AcitiveRecordのfind_by_XXXXメソッドなどは探せそうな気がしますが、外部のクラスなどへ処理を移譲する場合には、移譲先の引数や返り値を取ってくるのは動的にならざるをえないです。

結論から言うと、統計的にも過半数のものの推論は大変みたいです。


eval

これに関してはどうにもなりません。

もちろん完全なrubyコードの断片が入っているなら解析できないこともないでしょうが、わざわざ解析するコストを考えれば

素直にtypecaseをしてしまう方が賢いでしょう。

結果をまとめると下の様になります。

要素
動作状況
将来予測

継承
動く

autoload
動く

define_method
動く

ヒアドキュメント
動く

mix-in
動く

extend
動く

super
動く(引数の型や種類の変更不可能)

refinement
動かない
将来的には完全に動作するようになるはず

method_missing
動かない

method_missingで定義されていることは基本、解析可能。その後出来るパターンと出来ないパターンが混在している

モンキーパッチング
動く(引数の型や種類の変更不可能)
動かない(引数の型や種類の変更不可能)

sendメソッド
動かない(使うメソッド名が固定の場合だけできる)

sendメソッドに渡される、メソッド名と引数が分かるなら解析可能。現実的には、ほとんどできないので、どこかでキャストするのが適切と思われる。

eval
動かない

evalの引数の中が完全に静的解析出来るなら可能。実際にはほとんどが出来ない。

ちなみにテストのために書いたコードはgithubのリポジトリに置いておきます

すごい荒いコードなのでそのうちクリンナップします

https://github.com/baban/rdl_test


まとめ

RDL自体はまだ開発中のgemなので、すべてのRubyの型の問題を解決してくれているわけではないです。

ただ、Ruby3に型を実装する方法RDLの実装を見る限り、おおよそこの方針と似たような実装を行うしかないと思われます。

あと、実際使ってみると、extend RDL::Annotateをクラス毎に記述したり、type関数をメソッドの前に書く書式は「これでRubyでも型が付けられるならありっちゃありなんだけどな…」とメリットを納得しつつも、同時に面倒くさいと素直に感じる記述量です。ですが、Rubyの過去のバージョンでも動くようにするためには、既存のRubyの文法を拡張するわけにはいかないので、現状はこれを受け入れつつ、将来のバージョンではRuby本体の構文を拡張してScalaなどの様に文法に自然に組み込んだ書式で書けるようにしていくのではないでしょうか?

あと、evalの中身まで解析して型推論するのはナンセンスにしても、モンキーパッチングとmethod_missingに関しては、何かしらの対策を考えていかないとダメだと思います。

これに関してコミュニティでどう言う結論を出すのかが今後の研究課題ではないでしょうか?