Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: オブジェクトシェイプに優しいコードの書き方(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。


なお、Kaigi on Rails 2023最後のキーノートスピーチでbyrootさんが発表していた2つ目のバグ(mastodonの#23644)が、ちょうど本記事の内容と関連していました。修正はRails 7.1の#47747で行われました。

参考: A Decade of Rails Bug Fixes by byroot - Kaigi on Rails 2023

Ruby: オブジェクトシェイプに優しいコードの書き方(翻訳)

Ruby 3.2には、オブジェクトシェイプ(Object Shape)と呼ばれるパフォーマンス最適化が含まれています。これによって、Ruby VMでインスタンス変数(@ivarなどのアットマークで始まる変数)の保存、検索、キャッシュの方法が変わります。YJITもオブジェクトシェイプを活用しており、今後のRuby 3.3ではオブジェクトシェイプのパフォーマンスをさらに向上させる改善が行われています。

本記事では、オブジェクトシェイプに最適化したRubyアプリケーションコードを書く方法について手短に説明します。Rubyにおけるオブジェクトシェイプの実装方法について詳しく知りたい場合は、以下のAaron PattersonによるRubyConf 2022動画か、Ayush Poddarによる解説記事をご覧ください。

参考: Object shapes - how this under-the-hood change in Ruby 3.2.0 will improve your code performance | Poddar, Ayush

本記事で解説したコーディング戦略にフィードバックしてくれた同僚の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_storebeta_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までお知らせください。

関連記事

Rubyオブジェクトの未来をつくる「シェイプ」とは(翻訳)

Ruby: 私がメモ化を暗黙で使わない3つの理由(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。