Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyの#freezeを使うべきタイミングとは?Railsの実例で学ぶ不変性とパフォーマンス改善

近年、Rubyのコードで#freezeを目にする機会がぐっと増えました。しかし、そのfreezeがなぜ使われているのか、理由まで明確に説明できる方は意外と少ないかもしれません。この記事では、開発者が変数をfreezeする最も一般的な理由を解説します。それぞれの理由を具体的に示すため、Railsのコードベースや人気のオープンソースプロジェクトから実際のコード例を抜粋して紹介します。

不変(イミュータブル)な定数を作る

Rubyでは、実は定数もミュータブル(変更可能)です。やや紛らわしい仕様ですが、コード自体はシンプルで理解しやすいでしょう。ここでは文字列の定数を作成し、そこへ別の文字列を追加してみます。

MY_CONSTANT = "foo"
MY_CONSTANT << "bar"
puts MY_CONSTANT.inspect # => "foobar"

#freezeを使えば、名前だけでなく動作としても本当に「定数」である定数を作れます。今度は文字列を変更しようとした瞬間にRuntimeErrorが発生します。

MY_CONSTANT = "foo".freeze
MY_CONSTANT << "bar" # => RuntimeError: can't modify frozen string

ActionDispatchのコードベースには、この手法の実例があります。Railsはログ上の機密データを「[FILTERED]」というテキストで置き換えることで隠蔽しますが、このテキストはfreezeされた定数として格納されています。

module ActionDispatch
  module Http
    class ParameterFilter
      FILTERED = '[FILTERED]'.freeze
      ...

オブジェクトの割り当てを減らして高速化する

Rubyアプリケーションを高速化するために最も効果的な施策のひとつが、生成されるオブジェクトの数を減らすことです。その厄介な発生源のひとつが、ほとんどのアプリケーションに散りばめられた文字列リテラルです。

log("foobar")のようなメソッド呼び出しを行うたびに、新しいStringオブジェクトが1つ生成されます。もしこのような呼び出しが毎秒数千回発生するコードであれば、毎秒数千個の文字列が生成され、ガベージコレクションの対象になるわけです。これは相当なオーバーヘッドになります。

幸い、Rubyには打開策があります。文字列リテラルをfreezeすると、RubyインタプリタはStringオブジェクトを一度だけ生成し、それをキャッシュして再利用します。以下は、freezeした文字列とそうでない文字列を引数に渡した場合の性能を比較するシンプルなベンチマークです。約50%の高速化が確認できます。

require 'benchmark/ips'

def noop(arg)
end

Benchmark.ips do |x|
  x.report("normal") { noop("foo") }
  x.report("frozen") { noop("foo".freeze)  }
end

# MRI 2.2.2での結果:
# Calculating -------------------------------------
#               normal   152.123k i/100ms
#               frozen   167.474k i/100ms
# -------------------------------------------------
#               normal      6.158M (± 3.3%) i/s -     30.881M
#               frozen      9.312M (± 3.5%) i/s -     46.558M

この効果が実際に活きているのがRailsのルーターです。ルーターはすべてのWebリクエストで経由するため、高速性が強く求められます。だからこそ、大量のfreeze済み文字列リテラルが使われているのです。

# 出典: https://github.com/rails/rails/blob/f91439d848b305a9d8f83c10905e5012180ffa28/actionpack/lib/action_dispatch/journey/router/utils.rb#L15
def self.normalize_path(path)
  path = "/#{path}"
  path.squeeze!('/'.freeze)
  path.sub!(%r{/+\Z}, ''.freeze)
  path.gsub!(/(%[a-f0-9]{2})/) { $1.upcase }
  path = '/' if path == ''.freeze
  path
end

Ruby 2.2以降の組み込み最適化

Ruby 2.2以降(MRI)では、ハッシュのキーとして使われる文字列リテラルは自動的にfreezeされます。

user = {"name" => "george"}

# Ruby 2.2以降では
user["name"]

# ...これはRuby 2.1以前では次のコードと同等
user["name".freeze]

さらにMatz(まつもとゆきひろ氏)は、Ruby 3ではすべての文字列リテラルが自動的にfreezeされる予定だと発言していました。ただし最終的には互換性への配慮から、デフォルトでの自動freezeは見送られています。現在の実務では、ファイルの先頭に# frozen_string_literal: trueというマジックコメントを記述することで、そのファイル内の全文字列リテラルをfreezeできるのが一般的な使い方です。

値オブジェクトと関数型プログラミング

Rubyは関数型プログラミング言語ではありませんが、関数型スタイルで書くことの価値を実感するRubyistは年々増えています。このスタイルの重要な原則のひとつが「副作用を避ける」こと。つまり、オブジェクトは初期化された後は決して変更されるべきではない、という考え方です。

コンストラクタ内でfreezeを呼び出せば、オブジェクトが二度と変更されないことを保証できます。仮に意図しない副作用が起きようものなら、例外が即座に発生するため、バグにすぐ気づけるのです。

class Point
  attr_accessor :x, :y
  def initialize(x, y)
    @x = x
    @y = y
    freeze
  end

  def change
    @x = 3
  end
end

point = Point.new(1,2)
point.change # RuntimeError: can't modify frozen Point

まとめ

#freezeは単なるおまじないではなく、①定数の不変性の保証、②文字列リテラルによる不要なオブジェクト生成の削減、③値オブジェクトの安全性確保――といった明確な目的を持った強力なツールです。自分のコードでも、これらの場面に応じて適切に活用し、より堅牢で高速なRubyアプリケーションを目指しましょう。

  1. RubyのStructとOpenStructの使い方を徹底解説!値オブジェクトを簡単に作る方法

    Rubyには、関連する属性をまとめて保持する「値オブジェクト」を手軽に作れるStructという組み込みクラスが用意されています。本記事では、Structの基本的な使い方から注意点、そしてOpenStructとの違いまで詳しく解説します。 そもそもStructとは何か? StructはRubyに組み込まれたクラスで、新しいクラスを動的に生成し、値オブジェクト(Value Object)を作成するために使われます。値オブジェクトとは、関連性のある複数の属性をひとまとめにして扱うためのオブジェクトです。 具体例を挙げてみましょう。 2つの座標(xとy)を持つPointというデータを表したい場合、この

  2. Rubyの文字列フォーマット入門:%演算子と書式指定子の使い方

    この記事では、Rubyで文字列をフォーマットする方法について詳しく解説します。 なぜ文字列のフォーマットが必要なのでしょうか?例えば、10未満の数値にも先頭にゼロを付けて表示したい場合(01、02、03など)、あるいはコンソール出力を見やすいように桁を揃えて整形したい場合などが挙げられます。 他の言語ではprintf関数を使って文字列をフォーマットできます。C言語を使ったことがある人なら馴染み深いでしょう。printfでは、書式指定子(フォーマット指定子)のリストと、変数や値のリストを定義して使用します。 Rubyでの文字列フォーマットの基本 Rubyでもsprintfメソッドが利用可能ですが