Ruby: オブジェクトシェイプに優しいコードの書き方(翻訳)
Ruby 3.2には、オブジェクトシェイプ(Object Shape)と呼ばれるパフォーマンス最適化が含まれています。これによって、Ruby VMでインスタンス変数(@ivar
などのアットマークで始まる変数)の保存、検索、キャッシュの方法が変わります。YJITもオブジェクトシェイプを活用しており、今後のRuby 3.3ではオブジェクトシェイプのパフォーマンスをさらに向上させる改善が行われています。
本記事では、オブジェクトシェイプに最適化したRubyアプリケーションコードを書く方法について手短に説明します。Rubyにおけるオブジェクトシェイプの実装方法について詳しく知りたい場合は、以下のAaron PattersonによるRubyConf 2022動画か、Ayush Poddarによる解説記事をご覧ください。
本記事で解説したコーディング戦略にフィードバックしてくれた同僚のJohn HawthornとMatthew Draperに深く感謝いたします。また、Rails Performance SlackにおけるJohn Bachir、Nate Matykiewicz、Josh Nichols、Jean Boussierとのやりとりにも大いに触発されました。
🔗 一般原則: インスタンス変数は常に「同じ順序で」定義すること
自分が書いているRubyコードでオブジェクトシェイプの最適化を活かすには、シェイプの異なるオブジェクトの作成数を最小限に抑えることと、アプリケーションの実行中に発生するオブジェクトシェイプの変更回数も最小限に抑えることが目標となります。
- 同一クラスから生成されるインスタンスが、同一のオブジェクトシェイプを共有するように書く
- オブジェクトのシェイプが、頻繁に、または不必要に遷移したり変更されたりしないように書く
- 同一のオブジェクトシェイプを「共有可能」なオブジェクトであれば、可読性やメンテナンス性を損なわない範囲で、同一のオブジェクトシェイプを共有するように頑張って書く
Ayush Poddarの記事は、オブジェクトがシェイプを共有可能になる条件を簡潔に説明しています。
新しいオブジェクトのインスタンス変数が同じように遷移すると、最終的なシェイプも同じになる。この振る舞いはオブジェクトのクラスに依存しない。親クラスのシェイプの遷移も子クラスで再利用されるので、子クラスのシェイプについても同様。ただし、2つのオブジェクトが同じシェイプを共有可能になるのは、それらのインスタンス変数の設定順序が同じである場合に限られる。
これです、やるべきことはこれだけなのです。インスタンス変数の定義順序を常に同じにして、2つのオブジェクトが同一のシェイプを共有できるようにしましょう。
最初は悪例から見ていきます。
# Bad: オブジェクトシェイプに優しくない書き方
class GroceryStore
def fruit
@fruit = "apple"
end
def vegetable
@vegetable = "broccoli"
end
end
# The "Application"
alpha_store = GroceryStore.new
alpha_store.fruit # @fruitを最初に定義する
alpha_store.vegetable # @vegetableを次に定義する
beta_store = GroceryStore.new
beta_store.vegetable # @vegetableを最初に定義する
beta_store.fruit # @fruitを次に定義する
このコード例では、インスタンス変数の定義順序がアプリケーションでのメソッド呼び出しの順序に依存しているので、alpha_store
とbeta_store
のオブジェクトシェイプは同じになりません。このコードはオブジェクトシェイプに優しくないのです。
🔗 有用なパターン: インスタンス変数をinitialize
で定義する
インスタンス変数が常に同じ順序で定義されるようにする最も単純な方法は、#initialize
メソッドでインスタンス変数を定義することです。
# Good: オブジェクトシェイプに優しい書き方
class GroceryStore
def initialize
@fruit = "apple"
@vegetable = nil # 宣言されるが、後で代入される
end
def fruit
@fruit
end
def vegetable
@vegetable ||= "broccoli"
end
end
クラスの本体でattr_*
メソッドによる暗黙のインタンス変数を定義してもOKです。この場合もインスタンス変数の定義順序は常に同じになります。
更新: Ufuk Kayseriliogluによると、attr_*
メソッドは最初に呼び出されるまでインスタンス変数を定義しないので、attr_*
や、関連するインスタンス変数は引き続き#initialize
でも宣言する必要があります。
この例があきれるほど単純であることは私も承知してしています。しかし本当にたったこれだけでいいのです。
もっといい話をしましょう。私が働いているGitHubでは、200個を超えるインスタンス変数がさまざまなクラスで使われています。プロファイリングで見つけたホットコード(=実行頻度が極めて高いコード)では、こうしたインスタンス変数の定義順序を変えないように私たちが頑張っています。本当に大した効果なのです!
🔗 有用なパターン: nilではなくNULL定数でメモ化する
インスタンス変数を値のメモ化(memoization)に使う場合、nil
を有効な値として扱うのは問題になる可能性があります。この書き方はRubyでよく見かけますが、実はオブジェクトシェイプに優しくありません。
# Bad: オブジェクトシェイプに優しくない書き方
class GroceryStore
def fruit
return @fruit if defined?(@fruit)
@fruit = an_expensive_operation
end
end
上のコードを書き換えて、NULL
定数を作成し、このNULL
定数が存在するかどうかをチェックするようにすると、以下のようになります。
# Good: オブジェクトシェイプに優しい書き方
class GroceryStore
NULL = Object.new
NULL.freeze # 絶対に必要というほどではないが、Ractorセーフになる
def initialize
@fruit = NULL
end
def fruit
return @fruit unless @fruit == NULL
@fruit = an_expensive_operation
end
end
別の例: メタプログラミングを多用していて、メモ化された値の個数を可変長にする必要がある場合は、以下のようにハッシュとキーでチェックしましょう。
# Good: オブジェクトシェイプに優しい書き方
class GroceryStore
def initialize
@produce = {}
end
def produce(type)
return @produce[type] if @produce.key?(type)
@produce[type] = an_expensive_operation(type)
end
end
これでおしまい
オブジェクトシェイプに優しいコードを書くのは、大して難しくありません!
私が気づいていないパターンを他にも発見した方は、ぜひbensheldon@gmail.comまたはtwitter.com/@bensheldonまたはruby.social/@bensheldonまでお知らせください。
概要
原著者の許諾を得て翻訳・公開いたします。
なお、Kaigi on Rails 2023最後のキーノートスピーチでbyrootさんが発表していた2つ目のバグ(mastodonの#23644)が、ちょうど本記事の内容と関連していました。修正はRails 7.1の#47747で行われました。
参考: A Decade of Rails Bug Fixes by byroot - Kaigi on Rails 2023