Ruby3の静的型解析をVSCodeでみる

2023-02-19
#ruby

小ネタ

個人的にぱっと書く用に、静的型解析のできるスクリプト言語を探していたのですが、意外と選択肢がない。

  • Ruby3 + Typeprof
  • Python3 + Type Hints
  • TypeScript (Deno)

Typeprofがいつの間にかLanguage Serverにも対応しており、
個人的に思い入れのあるRubyで静的解析出来たら理想だったので、どのくらいできるか調査しました。

上の記事を参考にさせていただきました。

現状、Rubyで型解析を行う場合、TypeProfSteepという選択肢があるようですが、標準ライブラリに統合されていそうなTypeProfを選択

記事の通りVSCodeでセットアップしました。
手順はリンク先に任せて、はまったポイントだけメモします。

はまりポイント

1. 拡張機能がtypeprofを実行できない

rbenvを使用してRuby環境を構築していましたが、どうも拡張機能がrbenvを検知できず、bundlerがないと怒っていました。
単純に設定を変更するだけで対応できました。

which bundle
#> /home/xxx/.rbenv/shims/bundle

でbundlerの位置を調べて、vscodeのsettings.jsonに以下を追加するだけ

{
    "typeprof.server.path": "/home/xxx/.rbenv/shims/bundle exec typeprof"
}

2. Typeprof内部でExceptionを吐く

VSCodeの出力タブを確認すると、TypeprofのLanguage Serverが途中で落ちてました。

/home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/iseq.rb:824:in `check_send_branch': Unknown insn: #<struct TypeProf::ISeq::Insn insn=:objtostring, operands=[], lineno=42, code_range=(42,16)-(42,19), definitions=nil> (RuntimeError)
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/iseq.rb:573:in `block in unify_instructions'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/iseq.rb:567:in `times'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/iseq.rb:567:in `unify_instructions'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/iseq.rb:57:in `block in compile_core'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/iseq.rb:56:in `each'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/iseq.rb:56:in `compile_core'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/iseq.rb:19:in `compile'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/builtin.rb:627:in `file_load'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/builtin.rb:726:in `kernel_require_relative'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/method.rb:320:in `[]'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/method.rb:320:in `do_send'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/analyzer.rb:2383:in `block (2 levels) in do_send'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/utils.rb:101:in `each_key'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/utils.rb:101:in `each'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/analyzer.rb:2382:in `block in do_send'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/type.rb:90:in `each_child'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/analyzer.rb:2358:in `do_send'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/analyzer.rb:1503:in `step'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/analyzer.rb:1050:in `type_profile'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/lib/typeprof/config.rb:123:in `analyze'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/gems/typeprof-0.20.2/exe/typeprof:9:in `<top (required)>'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/bin/typeprof:25:in `load'
        from /home/xxx/projects/rbswiki/vendor/bundle/ruby/3.2.0/bin/typeprof:25:in `<top (required)>'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/cli/exec.rb:58:in `load'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/cli/exec.rb:58:in `kernel_load'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/cli/exec.rb:23:in `run'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/cli.rb:491:in `exec'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/vendor/thor/lib/thor/command.rb:27:in `run'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/vendor/thor/lib/thor/invocation.rb:127:in `invoke_command'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/vendor/thor/lib/thor.rb:392:in `dispatch'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/cli.rb:34:in `dispatch'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/vendor/thor/lib/thor/base.rb:485:in `start'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/cli.rb:28:in `start'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/exe/bundle:45:in `block in <top (required)>'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/lib/bundler/friendly_errors.rb:117:in `with_friendly_errors'
        from /home/xxx/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/bundler-2.4.6/exe/bundle:33:in `<top (required)>'
        from /home/xxx/.rbenv/versions/3.2.1/bin/bundle:25:in `load'
        from /home/xxx/.rbenv/versions/3.2.1/bin/bundle:25:in `<main>'

https://github.com/ruby/typeprof/issues/73

原因はTypeprofの古めのバージョン(0.20.4)にバグが混入していたため。
最新版にアップデートしたら動きました。

Gemfileを以下のように更新

- gem "typeprof"
+ gem "typeprof" , '~> 0.21.0'

感想

それなりにエディタ上で型解析が行われ、エラーも確認できました。
ただ、数点気になることがあり、結局Rubyを使うことはありませんでした。

変数の型がわからない

最近のIDEだとカーソルを変数に合わせるとその変数の型がわかったりしますが、そのような機能がないため今どのようなデータを触っているか把握しずらいです。

個人的にはデータ構造がわかることが、静的型解析のいいところの一つと考えているので、その点が微妙だなと思ってしまいました。

抽象解釈のため、想定していない型の引数が渡されてもエラーにならない場合がある

これは型注釈を書かないこととトレードオフですが、渡す引数によっては想定していない型も受けてしまいます。
例えば、以下のようなコードの時

# def foo: (Integer | String x, Integer | String y) -> (Integer | String)
def foo(x, y)
    (x + y).to_s()
end

# パターン1
p foo(1, 2) #=> "3"
# パターン2
p foo("1", "2") #=> "12"
# パターン3
p foo("1", 2) #=> `+': no implicit conversion of Integer into String (TypeError)

この場合、foo()は数字しか許容したくなかったときでも、文字列が入ってしまってもエラーになりません。
またパターン3の場合、型的には問題ないですが実行時エラーになってしまいます。

これは受け入れるしかないですが、依存ライブラリが増えて、Untypedが多くなった時に問題になりそうです。
きちんとテストコードを書けば避けられる問題でもありますが、気楽に書きたいという個人的欲求と真逆になってしまいます。

おわり

いったんは Deno+TypeScriptでスクリプトの欲求は満たしつつ、Ruby3の型解析はウォッチを続けようと思います。

基本的に私の思想と相性悪いだけで、Rubyで型解析は型注釈なしの夢のツールになりえると思います。