こんにちは、サーバーサイドエンジニアの草分です。
先日のRubyKaigi 2023に参加された皆様お疲れ様でした!
"感想記事を書くまでがRubyKaigi" ということで、今回は1つのセッションを掘り下げた感想記事を投稿します。
このセッションではRubyのパターンマッチの機能を題材に、Rubyの機能の実装を「読んで理解する」そして「パフォーマンスを向上させる」といったプロセスを、いかにして進めていくのか。その方法について紹介されていました。
このセッションはRubyのパターンマッチやメタプログラミングを知った状態で聞くと、より深く理解することができます。 それらの前提知識を軽くおさらいしつつ、セッション内容を振り返っていきましょう。
パターンマッチとは
パターンマッチとは、「データ構造による条件分岐」「構成要素の取り出し」という要素を備えた機能です。
Rubyでは case/in 構文で記述します。
対象のオブジェクトとマッチさせるデータ構造(パターン)を比較し、マッチした場合処理が行われます。
また、マッチした場合はパターンに記載した各変数に値がバインドされ、内部の処理で利用することが可能となります。
User = Struct.new(:role, :name) def hello(user) case user in role: 'member', name: puts "ようこそ、#{name}さん" in role: 'guest' puts "ようこそ、ゲストユーザーさん" else puts "ようこそ" end end hello(User[:member, "太郎"]) # => ようこそ、太郎さん hello(User[:guest, "ゲスト"]) # => ようこそ、ゲストユーザーさん hello(nil) # => ようこそ
Arrayパターンについて
複数あるパターンマッチの型の内の1つで、オブジェクトの #deconstruct
が配列を返す場合、その配列と指定パターンがマッチするかを検査する機能です。
class Array def deconstruct self end end case [0, 1, 2] in [0, *a, 2] puts a #=> 1 end
※実際にはArray#deconstruct
はデフォルトで利用可能です
その他のパターンマッチの使い方については過去に解説記事を書いています。 こちらも是非ご覧ください。
Arrayパターンを改善していく
Rubyのソースコードは構文解析などによりAST(抽象構文木)に変換され、そこからYARV命令列に変換され実行されます。
実際にソースコードがどう変換されるかは RubyVM::InstructionSequence
を使うと簡単に表示することができます。
puts RubyVM::InstructionSequence.compile("puts 2 + 2").disasm == disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,10)> (catch: false) 0000 putself ( 1)[Li] 0001 putobject 2 0003 putobject 2 0005 opt_plus <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr] 0007 opt_send_without_block <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE> 0009 leave
パターンマッチのソースコードがどういうYARV命令に変換されるかを確認すると…
とてつもない量の命令が出力されてしまったとのこと。
(本当に量が多かったので本記事では省略)
落ち着いて、ASTをYARV命令列に変換しているcompile.cファイルを見てみましょう。とのこと。
パターンマッチの部分には素晴らしいコメントが記載されており、理解の手助けになるようです。果たして本当でしょうか。
compile.cは1万行をゆうに超える上に、ASTノードの変換を行う関数 iseq_compile_each0
には100近い分岐のswitch文が存在し、それぞれの処理を行っています。
いやはや、何の知識もない状態で読んでも全く理解できないことでしょう。 実際にファイルを開いてみます。 https://github.com/ruby/ruby/blob/8d242a33af19672977dcdcb8d32e9ad547bc0141/compile.c#L6363
おお!読めるぞ!?(一部分のみ)
さすがに実際のコードはすぐに読み取れる訳ではありませんが、 読む範囲が定まっており、読みやすいコメントが記載されていれば、なんだかいけそうな気になりますね!(気のせいかも)
改善方法について
Array patternでパターンマッチする場合、検査対象が#deconstruct
を持つかどうか、その戻り値がArrayかどうかのチェックが実行されます。
しかし対象がArrayの場合は#deconstruct
の検証は不要なはず、その処理をスキップすれば速度改善になるのではないか。
といった仮説から改善が行われました。
結果、ベンチマークでは速度が2倍に向上!やった!
しかしながら、Array#deconstruct
がオーバーライドされているケースで問題があったとのこと。
オーバーライドによる問題点
Rubyには「オープンクラス」という機能があります。 既存のクラスを任意の場所で再オープンし、メソッドの修正や追加を行うことができる機能です。 組み込みクラスのArrayも例外ではなく、既存メソッドの挙動を好き放題書き換えることができます。
オープンクラスについてはメタプログラミングRubyなどの書籍を読むと詳しく理解できるでしょう。
前述の改善方法は、Array#deconstruct
の内容の検証をスキップするものです。
オープンクラスによりメソッドが書き換えられていた場合、検証をスキップすると期待値と異なる動作をしてしまいますね。
さて、その問題に対してはどう対処するかというと……
Do not override Array#deconstruct !
といったオチに繋がるセッションでした。
感想
コーナーケースへの対処の難しさ
Array#deconstruct
の上書きですが、普通にRubyを使っている場合はおそらく書かないコードでしょう。
Rubyの利用者からすればあまり考慮する必要はなさそうです。
一方で、Ruby言語としては許されている実装です。
言語の改善のためにはそういったコーナーケースへの対処も必要なようです。
「これらを全て考慮するのは大変そうだぞ……」という気分になってしまいますね。
Array patternはArrayだけのものでない
パターンマッチのArray patternはArrayだけのものではありません。
以下のように、#deconstruct
が定義済の任意のオブジェクトで利用することが可能です。
Spot = Struct.new(:latitude, :longitude) def latlng(spot) case spot in [0.. => lat, 0.. => lng] puts "北緯#{lat.abs}度 東経#{lng.abs}度" in [..0 => lat, 0.. => lng] puts "南緯#{lat.abs}度 東経#{lng.abs}度" in [0.. => lat, ..0 => lng] puts "北緯#{lat.abs}度 西経#{lng.abs}度" in [..0 => lat, ..0 => lng] puts "南緯#{lat.abs}度 西経#{lng.abs}度" end end latlng(Spot.new(35, 135)) # => 北緯35度 東経135度 latlng(Spot.new(-35, -135)) # => 南緯35度 西経135度
この場合のパフォーマンスはどうなってしまうのでしょう。 チェック処理が増えた影響はあるのでしょうか。 手元での検証はできていませんが、ひとつの事柄を掘り下げていくと次の疑問点も見えてきますね。
おわりに
RubyKaigiのセッションはRubyの内部に踏み込んだ内容が多く、前提知識がないと理解すらままならないこともあります。 ということで今回は、1セッションの前提知識の内容も含めた記事を投稿いたしました。 今後のRubyKaigiのセッションを理解するための一助になれれば幸いです。
是非読者になってください!
メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!
■募集ポジションはこちら
■エンジニア紹介ページはこちら