Ruby 3.1はエラー表示をちょっと親切にします

こんにちは、ruby-devチームの遠藤(@mametter)です。 Among Usというゲームをやってるのですが、友達が少なくてあまり開催できないのが悩みです。

今日は、Ruby 3.1に導入される予定のerror_highlightという機能を紹介します。

どんな機能?

NoMethodErrorが起きたとき、次のような表示が出るようになります。

f:id:ku-ma-me:20211201172801p:plain
error_highlightの動作例

どこのメソッド呼び出しで失敗したかが一目瞭然ですね。これだけの機能ですが、使ってみると意外と便利です。

もう少し詳しく

この機能が本領を発揮するのは、RailsのparamsやJSONデータの取り扱いなどのときです。 たとえばjson[:articles][:title]みたいなコードを書いて、undefined method '[]' for nil:NilClassという例外が出たとします。 このとき、変数jsonnilだったのか、json[:articles]の返り値がnilだったのかは、残念ながらコードだけ見ても判断できません。 特定するには、デバッグ出力を挟んで再実行する必要がありました。

error_highlightがあると、これがひと目で判別できます。

$ ruby test.rb
test.rb:2:in `<main>': undefined method `[]' for nil:NilClass (NoMethodError)

title = json[:articles][:title]
            ^^^^^^^^^^^

↑は、jsonnilだったケースです。

$ ruby test.rb
test.rb:2:in `<main>': undefined method `[]' for nil:NilClass (NoMethodError)

title = json[:articles][:title]
                       ^^^^^^^^

↑は、json[:articles]nilを返したことがわかります。

実装について

error_highlightはRubyインタプリタの実装に深く関わってます。ざっくりイメージで紹介します。

Rubyは、プログラムを抽象構文木に変換し、それをバイトコードにコンパイルした上で実行しています。 たとえば json[:articles][:title] というコードは、次のような抽象構文木(イメージ)に変換されます。

f:id:ku-ma-me:20211201163014p:plain
Rubyの抽象構文木(イメージ)

それぞれの四角は抽象構文木のノードを表します。ノードは、メソッド名などの付加情報や、レシーバや引数などの子ノードへの参照を持ちます。それに加え、ノードは"ID"と"column"という情報を持っています。"ID"はノードを一意に特定する番号です。"column"は、そのノードに対応するコードの範囲を表しています*1

それから、この抽象構文木をおおよそ次のようなバイトコード(イメージ)にコンパイルします。RubyのVMはスタックマシンで、まあなんとなく読めるかと思います*2

1: getlocal  :json     # ID: 3
2: putobject :articles # ID: 4
3: send :[]            # ID: 2
4: putobject :title    # ID: 5
5: send :[]            # ID: 1

このとき、各命令が由来となったノードのIDを持っているのがRuby 3.1で新たに実装したところで、error_highlightの肝になります。

json[:articles]nilを返し、nilに対して[]メソッドを呼んでしまった場合、5番目のsend命令が失敗します。このとき、5番目の命令は"ID: 1"という参照を持っているので、どのノードで実行失敗したのかがわかります。そして、抽象構文木のノードには"column"情報があるので、コード中のどの範囲でエラーが起きたかという情報を得ることができます。

ID 1のノードのcolumnは 0...23 となっていて、これは json[:articles][:title] という文字列全体の範囲に対応しています。この全体に下線を引くとどこでエラーが起きたかわかりにくいので、error_highlightはなるべくメソッド名の位置を特定して線を引くようにしています。大まかに言えば、レシーバの子ノードである ID 2のノードの終端より後、つまり [:title] の下にだけ下線を引くようになっています。

クレジットと裏話

ノードにカラム情報をもたせるようにしたのはyui-knkさんです(RubyKaigi 2018の発表)。 error_highlightの原型もyui-knkさんが作っていたのですが、放置状態になっていたので、今回遠藤が引き取って完成させました。

なぜ引き取ったかと言うと、RubyKaigi Takeout 2021で遠藤が発表したTypeProf for IDEの実装のために必要だったからです。 IDEではエラー箇所をカラム単位で下線を引いて示すのが普通なので、ほぼ同じ機構が必要でした。 そのために必要な拡張を実装すると、そのおまけとしてerror_highlightが実現可能になりました。

命令ごとに由来となったノードIDを記録するためには、多少メモリを消費してしまいます*3 *4。 ここは、開発体験向上とのトレードオフでした。 各命令にノードIDではなくカラム位置自体をもたせてしまう方法もあるのですが、よりメモリ消費量が大きくなるので、抽象構文木を経由して位置を特定する現在の方法になりました。

なお、抽象構文木自体はメモリに保存されておらず、必要になった時(つまりエラーが発生したとき)に、ファイルにあるソースコードを再度読み込んでパースし直しています。 このアプローチでは、ソースファイルが書き換えられた場合などはノードを正しく同定できなくなる可能性があります。 また、evalで実行されているソースコードについてはerror_highlightは動きません(このため、現在のところirbでは動きません)。 現在のところ隠しコマンドですが、RubyVM.keep_script_lines = trueとすると、インタプリタがソースコードを保持し続けるようになり、ソースコードの再読み込みは行われなくなり、evalについてもerror_highlightが動くようになります。

まとめ

Ruby 3.1では、NoMethodErrorのエラー表示がちょっと親切になります。 ささやかな改良なので過剰に期待されると困りますが、「Ruby 3.1.0-preview1を実際に使ってみたら予想外に開発体験がよくなった」という声をちらほら頂いているので、ほどほどにご期待ください。

*1:実際にはノードはカラム番号だけでなく、行番号も持っていますが、省略しています。

*2:getlocalはローカル変数読み出し、putobjectは即値オブジェクトのpush、sendはメソッド呼び出しです。

*3:機能提案時点の実験では、rails newして作ったWebアプリのメモリ消費量が97 MBから100 MBくらいに増えました。

*4:余談ですがノードIDの記録方法は、ちょっと工夫されています。以前書いた記事『簡潔ビットベクトルでRubyをlog N倍速くした』をご参照ください。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/